From e513558b3212225b24683b3348591c0cf12b5e12 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 09:57:20 +0100 Subject: [PATCH 1/8] upgrade alpine base image version --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 45639b5..fb4bc17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM alpine:3.23.2 +FROM alpine:3.23.3 LABEL maintainer="Michael Oberdorf IT-Consulting " -LABEL site.local.program.version="1.4.1" +LABEL site.local.program.version="2.0.0" RUN apk upgrade --available --no-cache --update \ && apk add --no-cache --update \ From 1222c3eda537d48ee1be87c3efdea9524e03f196 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 09:57:40 +0100 Subject: [PATCH 2/8] increase copyight year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 196fe55..2186675 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020-2024 Michael Oberdorf IT-Consulting +Copyright (c) 2020-2026 Michael Oberdorf IT-Consulting Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 165b536ec29efbc353d88e7a46d4a3f089846ae5 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 09:58:39 +0100 Subject: [PATCH 3/8] add instructions --- .github/copilot-instructions.md | 132 ++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d6aa9ea --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,132 @@ +# Copilot Instructions for Modbus Server + +## Project Overview + +This is a **Modbus TCP Server** written in Python that serves as a mock Modbus slave system for testing Modbus master implementations. It allows predefined register values via JSON configuration files and supports both TCP and UDP protocols, with optional TLS encryption. + +## Build, Test, and Lint Commands + +### Python Version +- **Required**: Python 3.10, 3.11, or 3.12 (see `.tool-versions` for pinned version) +- **Pre-commit hooks**: Python 4.0.1 + +### Install Dependencies +```bash +cd src/ +pip install -r requirements.txt +``` + +### Run All Tests +```bash +pytest +``` + +### Run Single Test +```bash +pytest tests/test_server.py::test_prepare_register -v +``` + +### Linting +The repository uses multiple linting tools configured in `.pre-commit-config.yaml`: +```bash +# Lint with ruff (syntax errors, style) +ruff check --target-version=py37 . + +# Format with black +black --line-length=120 . + +# Sort imports with isort +isort --profile black --filter-files . + +# Remove unused imports/variables +autoflake --in-place --remove-unused-variables --remove-all-unused-imports + +# Lint Dockerfile +hadolint Dockerfile +``` + +### Pre-commit Hook +```bash +pre-commit run --all-files +``` + +## Architecture + +### Key Components + +1. **`src/app/modbus_server.py`** - Main application + - Server initialization and startup logic + - Register preparation and data block management + - Support for TCP, UDP, and TLS protocols + - Logging configuration + +2. **`tests/`** - Test suite + - `test_server.py` - Core server functionality tests + - `test_utils.py` - Utility function tests + - Run against Python 3.10, 3.11, and 3.12 via GitHub Actions + +3. **Configuration** + - `src/app/modbus_server.json` - Default server configuration (embedded in container) + - `examples/` - Additional configuration examples for different use cases + +### Data Stores + +The server manages four types of Modbus registers: +- **Discrete Inputs** - Read-only single-bit registers +- **Coils** - Read-write single-bit registers +- **Input Registers** - Read-only 16-bit registers +- **Holding Registers** - Read-write 16-bit registers + +Register initialization uses `ModbusSparseDataBlock` (sparse registers) or `ModbusSequentialDataBlock` (full 0-65535 range). + +## Key Conventions + +### Configuration Files + +Register values are defined in JSON with these conventions: +- **Bit registers** (Discrete Inputs, Coils): Use `true`/`false` values +- **Word registers** (Input/Holding): Use hex notation (`"0xAA00"`) or integers (0-65535) +- Register numbers must be strings in JSON, converted to integers at runtime +- Example: `"42": true` or `"9": "0xAA00"` + +### Function Naming + +- `prepare_register()` - Takes register config dict and returns Modbus data block +- Server startup functions accept `config_file` parameter (defaults to `/app/modbus_server.json`) +- Functions include docstrings with parameter descriptions and return types + +### Docker Builds + +- **Dockerfile**: Alpine-based (3.23.2), Python 3.12.12, deterministic version pinning +- **Exposed ports**: 5020/tcp, 5020/udp (default Modbus port) +- **Entry point**: `python -u /app/modbus_server.py` (unbuffered output for logging) +- **User**: Non-root UID 1434 for security + +### CI/CD Workflow + +- **Test workflow** (`.github/workflows/test.yaml`): + - Runs on pull requests and pushes to `main` + - Tests against Python 3.10, 3.11, 3.12 + - Runs `ruff check` for linting, then `pytest` + - Install deps in `src/` directory before running tests +- **Pre-commit hooks** enforce code quality before commits +- Bitbucket Pipelines config available for legacy CI/CD + +### Code Style + +- Line length: 120 characters +- Formatter: Black (via pre-commit) +- Import sorting: isort with Black profile +- Code quality: Ruff with default rules +- Unused imports/variables: auto-removed by autoflake + +## MCP Server Configuration + +### Python Language Server + +For improved code navigation, type checking, and intelligent code completion, configure a Python language server: + +**Recommended**: Pylance or Pyright via MCP +- Provides real-time type checking across the codebase +- Supports Python 3.10, 3.11, 3.12 +- Helps catch issues with Modbus data types and register handling From 16d7b00236e4bd057b7615762acc2d75181a416b Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 09:59:14 +0100 Subject: [PATCH 4/8] remove unsupported zero_mode --- src/app/modbus_server.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/modbus_server.json b/src/app/modbus_server.json index 2cc5bb0..ef59dbf 100644 --- a/src/app/modbus_server.json +++ b/src/app/modbus_server.json @@ -15,7 +15,6 @@ }, "registers": { "description": "initial values for the register types", - "zeroMode": false, "initializeUndefinedRegisters": true, "discreteInput": {}, "coils": {}, From 7707d6dd9bcd2880babbcce576249718f2859a53 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 09:59:41 +0100 Subject: [PATCH 5/8] remove zero_mode and update copyright year --- README.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1ba0ce9..2c79588 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,10 @@ Container image: [DockerHub](https://hub.docker.com/r/oitc/modbus-server) # Supported tags and respective `Dockerfile` links -* [`latest`, `1.4.1`](https://github.com/cybcon/modbus-server/blob/v1.4.1/Dockerfile) +* [`latest`, `2.0.0`](https://github.com/cybcon/modbus-server/blob/v2.0.0/Dockerfile) +* [`1.4.1`](https://github.com/cybcon/modbus-server/blob/v1.4.1/Dockerfile) * [`1.4.0`](https://github.com/cybcon/modbus-server/blob/v1.4.0/Dockerfile) * [`1.3.2`](https://github.com/cybcon/modbus-server/blob/v1.3.2/Dockerfile) -* [`1.3.1`](https://github.com/cybcon/modbus-server/blob/v1.3.1/Dockerfile) -* [`1.3.0`](https://github.com/cybcon/modbus-server/blob/v1.3.0/Dockerfile) -* [`1.2.0`](https://github.com/cybcon/modbus-server/blob/v1.2.0/Dockerfile) # What is Modbus TCP Server? @@ -123,7 +121,6 @@ The `/app/modbus_server.json` file comes with following content: }, "registers": { "description": "initial values for the register types", - "zeroMode": false, "initializeUndefinedRegisters": true, "discreteInput": {}, "coils": {}, @@ -150,7 +147,6 @@ The `/app/modbus_server.json` file comes with following content: | `server.logging.logLevel` | String | Defines the maximum level of severity to log to std out. Possible values are `DEBUG`, `INFO`, `WARN` and `ERROR`. | | `registers` | Object | Configuration parameters to predefine registers. | | `registers.description` | String | No configuration option, just a description of the parameters. | -| `registers.zeroMode` | Boolean | By default the modbus registers starts at 1 (`false`) but some implementation requires to start at 0 (`true`). | | `registers.initializeUndefinedRegisters` | Boolean | If `true` the server will initialize all not defined registers with a default value of `0`. | | `registers.discreteInput` | Object | The pre-defined registers of the register type "Discrete Input". | | `registers.coils` | Object | The pre-defined registers of the register type "Coils". | @@ -223,7 +219,7 @@ I would appreciate a small donation to support the further development of my ope # License -Copyright (c) 2020-2024 Michael Oberdorf IT-Consulting +Copyright (c) 2020-2026 Michael Oberdorf IT-Consulting Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 4bdf2b9a876911734df9ab165c0c268d72a06ed4 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 10:00:03 +0100 Subject: [PATCH 6/8] adding requirements especially for tests --- tests/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/requirements.txt diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..d06b682 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +pytest>=9.0.0 +ruff>=0.14.0 From 936d90ae3776a03a943fcf1f65af22791a80f47d Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 10:01:46 +0100 Subject: [PATCH 7/8] change install dependencies by using the requirements.txt in tests --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5ec7b16..dfe3d35 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,8 +22,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff pytest - cd src/ && pip install -r requirements.txt + pip install -r tests/requirements.txt + pip install -r src/requirements.txt - name: Lint with ruff run: | # stop the build if there are Python syntax errors or undefined names From 2ca26d1dedffbbf9df7d562850781d6ffcede3cd Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 10:02:20 +0100 Subject: [PATCH 8/8] upgrade to PyModbus v3.11 --- src/app/modbus_server.py | 91 ++++++++++++++++++++++++++-------------- src/requirements.txt | 2 +- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/src/app/modbus_server.py b/src/app/modbus_server.py index b02a37a..288ed7f 100644 --- a/src/app/modbus_server.py +++ b/src/app/modbus_server.py @@ -4,7 +4,7 @@ Author: Michael Oberdorf IT-Consulting Datum: 2020-03-30 Last modified by: Michael Oberdorf -Last modified at: 2025-12-28 +Last modified at: 2026-02-07 *************************************************************************** """ import argparse import json @@ -14,18 +14,20 @@ import sys from typing import Literal, Optional +import pymodbus from pymodbus.datastore import ( + ModbusDeviceContext, ModbusSequentialDataBlock, ModbusServerContext, - ModbusSlaveContext, ModbusSparseDataBlock, ) -from pymodbus.device import ModbusDeviceIdentification -from pymodbus.server.sync import StartTcpServer, StartTlsServer, StartUdpServer +from pymodbus.pdu.device import ModbusDeviceIdentification +from pymodbus.server import StartTcpServer, StartTlsServer, StartUdpServer # default configuration file path -default_config_file = "/app/modbus_server.json" -VERSION = "1.4.1" +__script_path__ = os.path.dirname(__file__) +default_config_file = os.path.join(__script_path__, "modbus_server.json") +VERSION = "2.0.0" log = logging.getLogger() @@ -39,8 +41,11 @@ def get_ip_address() -> str: """ - get_ip_address is a small function that determines the IP address of the outbound ethernet interface - @return: string, IP address + Small function that determines the IP address of the outbound ethernet interface + + :return IP address + :rtype: str + :raises Exception: if the IP address cannot be determined (will be passed silently and empty string will be returned) """ ipaddr = "" try: @@ -66,16 +71,27 @@ def run_server( ): """ Run the modbus server(s) - @param listener_address: string, IP address to bind the listener (default: '0.0.0.0') - @param listener_port: integer, TCP port to bin the listener (default: 5020) - @param protocol: string, defines if the server listenes to TCP or UDP (default: 'TCP') - @param tls_cert: boolean, path to certificate to start tcp server with TLS (default: None) - @param tls_key: boolean, path to private key to start tcp server with TLS (default: None) - @param zero_mode: boolean, request to address(0-7) will map to the address (0-7) instead of (1-8) (default: False) - @param discrete_inputs: dict(), initial addresses and their values (default: dict()) - @param coils: dict(), initial addresses and their values (default: dict()) - @param holding_registers: dict(), initial addresses and their values (default: dict()) - @param input_registers: dict(), initial addresses and their values (default: dict()) + + :param listener_address: IP address to bind the listener (default: '0.0.0.0') + :type listener_address: str + :param listener_port: TCP port to bin the listener (default: 5020) + :type listener_port: int + :param protocol: defines if the server listenes to TCP or UDP (default: 'TCP') + :type protocol: str + :param tls_cert: path to certificate to start tcp server with TLS (default: None) + :type tls_cert: str + :param tls_key: path to private key to start tcp server with TLS (default: None) + :type tls_key: str + :param zero_mode: request to address(0-7) will map to the address (0-7) instead of (1-8) (default: False) + :type zero_mode: bool + :param discrete_inputs: initial addresses and their values (default: dict()) + :type discrete_inputs: Optional[dict] + :param coils: initial addresses and their values (default: dict()) + :type coils: Optional[dict] + :param holding_registers: initial addresses and their values (default: dict()) + :type holding_registers: Optional[dict] + :param input_registers: initial addresses and their values (default: dict()) + :type input_registers: Optional[dict] """ # initialize data store @@ -123,10 +139,10 @@ def run_server( log.debug("set all registers to 0x00") ir = ModbusSequentialDataBlock.create() - store = ModbusSlaveContext(di=di, co=co, hr=hr, ir=ir, zero_mode=zero_mode) + store = ModbusDeviceContext(di=di, co=co, hr=hr, ir=ir) log.debug("Define Modbus server context") - context = ModbusServerContext(slaves=store, single=True) + context = ModbusServerContext(devices=store, single=True) # ----------------------------------------------------------------------- # # initialize the server information @@ -136,11 +152,17 @@ def run_server( log.debug("Define Modbus server identity") identity = ModbusDeviceIdentification() identity.VendorName = "Pymodbus" + log.debug(f"Set VendorName to: {identity.VendorName}") identity.ProductCode = "PM" - identity.VendorUrl = "http://github.com/riptideio/pymodbus/" + log.debug(f"Set ProductCode to: {identity.ProductCode}") + identity.VendorUrl = "https://github.com/pymodbus-dev/pymodbus/" + log.debug(f"Set VendorUrl to: {identity.VendorUrl}") identity.ProductName = "Pymodbus Server" + log.debug(f"Set ProductName to: {identity.ProductName}") identity.ModelName = "Pymodbus Server" - identity.MajorMinorRevision = "2.5.3" + log.debug(f"Set ModelName to: {identity.ModelName}") + identity.MajorMinorRevision = pymodbus.__version__ + log.debug(f"Set MajorMinorRevision to: {identity.MajorMinorRevision}") # ----------------------------------------------------------------------- # # run the server @@ -152,7 +174,7 @@ def run_server( if start_tls: log.info(f"Starting Modbus TCP server with TLS on {listener_address}:{listener_port}") StartTlsServer( - context, + context=context, identity=identity, certfile=tls_cert, keyfile=tls_key, @@ -161,12 +183,12 @@ def run_server( else: if protocol == "UDP": log.info(f"Starting Modbus UDP server on {listener_address}:{listener_port}") - StartUdpServer(context, identity=identity, address=(listener_address, listener_port)) + StartUdpServer(context=context, identity=identity, address=(listener_address, listener_port)) else: log.info(f"Starting Modbus TCP server on {listener_address}:{listener_port}") - StartTcpServer(context, identity=identity, address=(listener_address, listener_port)) + StartTcpServer(context=context, identity=identity, address=(listener_address, listener_port)) # TCP with different framer - # StartTcpServer(context, identity=identity, framer=ModbusRtuFramer, address=(listener_address, listener_port)) + # StartTcpServer(context=context, identity=identity, framer=ModbusRtuFramer, address=(listener_address, listener_port)) def prepare_register( @@ -176,15 +198,21 @@ def prepare_register( ) -> dict: """ Function to prepare the register to have the correct data types - @param register: dict(), the register dictionary, loaded from json file - @param init_type: str(), how to initialize the register values 'boolean' or 'word' - @param initialize_undefined_registers: boolean, fill undefined registers with 0x00 (default: False) - @return: dict(), register with correct data types + + :param register: the register dictionary, loaded from json file + :type register: dict + :param init_type: how to initialize the register values 'boolean' or 'word' + :type init_type: Literal["boolean", "word"] + :param initialize_undefined_registers: fill undefined registers with 0x00 (default: False) + :type initialize_undefined_registers: bool + :return: register with correct data types + :rtype: dict + :raises ValueError: if the input register is not a dictionary """ out_register = dict() if not isinstance(register, dict): log.error("Unexpected input in function prepareRegister") - return out_register + raise ValueError("Unexpected input in function prepareRegister") if len(register) == 0: return out_register @@ -326,7 +354,6 @@ def prepare_register( protocol=CONFIG["server"]["protocol"], tls_cert=CONFIG["server"]["tlsParams"]["privateKey"], tls_key=CONFIG["server"]["tlsParams"]["certificate"], - zero_mode=CONFIG["registers"]["zeroMode"], discrete_inputs=configured_discrete_inputs, coils=configured_coils, holding_registers=configured_holding_registers, diff --git a/src/requirements.txt b/src/requirements.txt index fee656c..7a3c9f3 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1 +1 @@ -pymodbus >= 2, < 3 +pymodbus >= 3, < 4