Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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 <file>

# 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
4 changes: 2 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM alpine:3.23.2
FROM alpine:3.23.3

LABEL maintainer="Michael Oberdorf IT-Consulting <info@oberdorf-itc.de>"
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 \
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 3 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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": {},
Expand All @@ -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". |
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion src/app/modbus_server.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
},
"registers": {
"description": "initial values for the register types",
"zeroMode": false,
"initializeUndefinedRegisters": true,
"discreteInput": {},
"coils": {},
Expand Down
91 changes: 59 additions & 32 deletions src/app/modbus_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pymodbus >= 2, < 3
pymodbus >= 3, < 4
2 changes: 2 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest>=9.0.0
ruff>=0.14.0