commit 9466936bc3bfe9020ca22a567abdf684440187d4 Author: zihluwang Date: Wed Jun 10 19:53:55 2026 +0800 Initial commit: add Modbus slave simulator - TCP/RTU/TLS server support with pymodbus - Configurable data store sizes and initial values - CLI with argument parsing for port, slave-id, etc. - GitHub Actions CI/CD for Docker build and push diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..35df131 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.venv +.idea +.claude +*.md +uv.lock diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..a02d06d --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,53 @@ +name: Build and Push to Docker Hub + +on: + push: + branches: [main] + tags: + - 'v*' + pull_request: + branches: [main] + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/modbus-slave + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix= + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b3eeb23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY pyproject.toml uv.lock ./ +RUN pip install uv && uv sync --frozen + +# Copy application +COPY main.py ./ + +# Create non-root user +RUN useradd -m modbus && chown -R modbus:modbus /app +USER modbus + +EXPOSE 5020 + +CMD ["python", "main.py"] diff --git a/main.py b/main.py new file mode 100644 index 0000000..675c75b --- /dev/null +++ b/main.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Modbus Slave Simulator - A configurable Modbus TCP/RTU slave server for testing. +""" + +import argparse +import logging +import signal +import sys +from dataclasses import dataclass +from typing import Optional + +from pymodbus.datastore import ( + ModbusSequentialDataBlock, + ModbusServerContext, + ModbusSlaveContext, +) +from pymodbus.device import ModbusDeviceIdentification +from pymodbus.server import StartTcpServer, StartTlsServer, StartSerialServer +import pymodbus + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("modbus-slave") + + +@dataclass +class ServerConfig: + """Configuration for the Modbus slave server.""" + + host: str = "127.0.0.1" + port: int = 5020 + slave_id: int = 1 + type: str = "tcp" + + # RTU/Serial specific + serial_port: str = "/dev/ttyUSB0" + serial_baudrate: int = 9600 + serial_bytesize: int = 8 + serial_parity: str = "N" + serial_stopbits: int = 1 + + # Data store sizes + coils_size: int = 100 + discrete_inputs_size: int = 100 + holding_registers_size: int = 100 + input_registers_size: int = 100 + + # Initial values + coils_default: bool = False + discrete_inputs_default: bool = False + holding_registers_default: int = 0 + input_registers_default: int = 0 + + # TLS + certfile: Optional[str] = None + keyfile: Optional[str] = None + + # Identity + vendor_name: str = "ModbusSimulator" + product_code: str = "MBS" + model_name: str = "Simulator" + version: str = "1.0" + + +def create_data_store(config: ServerConfig) -> ModbusSlaveContext: + """Create and configure the Modbus data store.""" + def make_block(start: int, size: int, default) -> ModbusSequentialDataBlock: + return ModbusSequentialDataBlock(start, [default] * size) + + return ModbusSlaveContext( + co=make_block(0, config.coils_size, config.coils_default), + di=make_block(0, config.discrete_inputs_size, config.discrete_inputs_default), + hr=make_block(0, config.holding_registers_size, config.holding_registers_default), + ir=make_block(0, config.input_registers_size, config.input_registers_default), + ) + + +def create_server_context(slave_id: int, store: ModbusSlaveContext) -> ModbusServerContext: + """Create the server context with single slave.""" + return ModbusServerContext(slaves={slave_id: store}, single=False) + + +def create_identity(config: ServerConfig) -> ModbusDeviceIdentification: + """Create Modbus device identification.""" + return ModbusDeviceIdentification( + info_name={ + "VendorName": config.vendor_name, + "ProductCode": config.product_code, + "ModelName": config.model_name, + "MajorMinorRevision": config.version, + } + ) + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Modbus Slave Simulator - A configurable Modbus server for testing.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s # TCP on 127.0.0.1:5020 + %(prog)s --port 5020 --holding-registers 1000 + %(prog)s --type rtu --serial-port /dev/ttyUSB0 --slave-id 2 + %(prog)s --coils-size 50 --holding-registers-default 100 + """, + ) + + parser.add_argument("--type", "-t", choices=["tcp", "rtu", "tls"], default="tcp", + help="Server type (default: tcp)") + parser.add_argument("--host", default="127.0.0.1", help="TCP bind host (default: 127.0.0.1)") + parser.add_argument("--port", "-p", type=int, default=5020, help="TCP port (default: 5020)") + parser.add_argument("--slave-id", "-s", type=int, default=1, help="Modbus slave ID (default: 1)") + + ser = parser.add_argument_group("Serial (RTU)") + ser.add_argument("--serial-port", default="/dev/ttyUSB0", help="Serial port") + ser.add_argument("--baudrate", type=int, default=9600, help="Baudrate (default: 9600)") + ser.add_argument("--bytesize", type=int, choices=[5, 6, 7, 8], default=8) + ser.add_argument("--parity", choices=["N", "E", "O"], default="N") + ser.add_argument("--stopbits", type=int, choices=[1, 2], default=1) + + tls = parser.add_argument_group("TLS") + tls.add_argument("--certfile", help="Certificate file") + tls.add_argument("--keyfile", help="Key file") + + size = parser.add_argument_group("Data store sizes") + size.add_argument("--coils-size", type=int, default=100) + size.add_argument("--discrete-inputs-size", type=int, default=100) + size.add_argument("--holding-registers-size", type=int, default=100) + size.add_argument("--input-registers-size", type=int, default=100) + + init = parser.add_argument_group("Initial values") + init.add_argument("--coils-default", type=lambda x: x.lower() == "true", default=False) + init.add_argument("--discrete-inputs-default", type=lambda x: x.lower() == "true", default=False) + init.add_argument("--holding-registers-default", type=int, default=0) + init.add_argument("--input-registers-default", type=int, default=0) + + ident = parser.add_argument_group("Device identity") + ident.add_argument("--vendor", default="ModbusSimulator") + ident.add_argument("--product-code", default="MBS") + ident.add_argument("--model", default="Simulator") + ident.add_argument("--version", default="1.0") + + parser.add_argument("--verbose", "-v", action="count", default=0, help="Increase verbosity") + return parser + + +def get_log_level(verbose: int) -> int: + levels = [logging.WARNING, logging.INFO, logging.DEBUG] + return levels[min(verbose, len(levels) - 1)] + + +def run_server(config: ServerConfig): + """Create and start the server based on configuration.""" + store = create_data_store(config) + context = create_server_context(config.slave_id, store) + identity = create_identity(config) + + logger.info(f"Starting Modbus {config.type.upper()} server") + logger.info(f" Address: {config.host if config.type != 'rtu' else config.serial_port}") + if config.type != "rtu": + logger.info(f" Port: {config.port}") + logger.info(f" Slave ID: {config.slave_id}") + logger.info(f" Data store: coils={config.coils_size}, di={config.discrete_inputs_size}, " + f"hr={config.holding_registers_size}, ir={config.input_registers_size}") + + if config.type == "tcp": + StartTcpServer(context=context, identity=identity, address=(config.host, config.port)) + elif config.type == "tls": + if not config.certfile or not config.keyfile: + raise ValueError("TLS requires --certfile and --keyfile") + StartTlsServer(context=context, identity=identity, address=(config.host, config.port), + certfile=config.certfile, keyfile=config.keyfile) + elif config.type == "rtu": + StartSerialServer(context=context, identity=identity, port=config.serial_port, + baudrate=config.serial_baudrate, bytesize=config.serial_bytesize, + parity=config.serial_parity, stopbits=config.serial_stopbits) + + +def main(): + parser = create_parser() + args = parser.parse_args() + logger.setLevel(get_log_level(args.verbose)) + + config = ServerConfig( + host=args.host, port=args.port, slave_id=args.slave_id, type=args.type, + serial_port=args.serial_port, serial_baudrate=args.baudrate, + serial_bytesize=args.bytesize, serial_parity=args.parity, serial_stopbits=args.stopbits, + coils_size=args.coils_size, discrete_inputs_size=args.discrete_inputs_size, + holding_registers_size=args.holding_registers_size, input_registers_size=args.input_registers_size, + coils_default=args.coils_default, discrete_inputs_default=args.discrete_inputs_default, + holding_registers_default=args.holding_registers_default, input_registers_default=args.input_registers_default, + certfile=args.certfile, keyfile=args.keyfile, + vendor_name=args.vendor, product_code=args.product_code, + model_name=args.model, version=args.version, + ) + + try: + logger.info(f"Modbus Slave Simulator v{pymodbus.__version__}") + run_server(config) + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error(f"Server error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ceca055 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "modbus-slave" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [ + "pymodbus>=3.8.6", +]