From 67c0218d6a55c17e7e8069f970e580ef03bb1d75 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 10:26:09 +0100 Subject: [PATCH 01/25] change python versions --- .github/workflows/test.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dfe3d35..a1e0d39 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -27,9 +27,9 @@ jobs: - name: Lint with ruff run: | # stop the build if there are Python syntax errors or undefined names - ruff check --select=E9,F63,F7,F82 --target-version=py37 . + ruff check --select=E9,F63,F7,F82 --target-version=py312 . # default set of ruff rules with GitHub Annotations - ruff check --target-version=py37 . + ruff check --target-version=py312 . - name: Test with pytest run: | pytest From ab0261d5417d40c3824037b08deb325c03ea2a3d Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 10:26:39 +0100 Subject: [PATCH 02/25] prepare for register persistene --- Dockerfile | 9 +++++++-- Dockerfile.test | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb4bc17..ccb64ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,17 @@ FROM alpine:3.23.3 LABEL maintainer="Michael Oberdorf IT-Consulting " -LABEL site.local.program.version="2.0.0" +LABEL site.local.program.version="2.1.0" RUN apk upgrade --available --no-cache --update \ && apk add --no-cache --update \ python3=3.12.12-r0 \ py3-pip=25.1.1-r1 \ # Cleanup APK - && rm -rf /var/cache/apk/* /tmp/* /var/tmp/* + && rm -rf /var/cache/apk/* /tmp/* /var/tmp/* \ + # Prepare persistant storage + && mkdir -p /data \ + && chown 1434:1434 /data COPY --chown=root:root /src / @@ -19,5 +22,7 @@ EXPOSE 5020/udp USER 1434:1434 +VOLUME [ "/data" ] + # Start Server ENTRYPOINT ["python", "-u", "/app/modbus_server.py"] diff --git a/Dockerfile.test b/Dockerfile.test index aad3c08..3897a26 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,14 +1,17 @@ FROM alpine:3.23.2 LABEL maintainer="Michael Oberdorf IT-Consulting " -LABEL site.local.program.version="1.4.1" +LABEL site.local.program.version="2.1.0" RUN apk upgrade --available --no-cache --update \ && apk add --no-cache --update \ python3=3.12.12-r0 \ py3-pip=25.1.1-r1 \ # Cleanup APK - && rm -rf /var/cache/apk/* /tmp/* /var/tmp/* + && rm -rf /var/cache/apk/* /tmp/* /var/tmp/* \ + # Prepare persistant storage + && mkdir -p /data \ + && chown 1434:1434 /data COPY --chown=root:root /src / COPY --chown=root:root /examples/test.json /test.json @@ -20,5 +23,7 @@ EXPOSE 5020/udp USER 1434:1434 +VOLUME [ "/data" ] + # Start Server ENTRYPOINT ["python", "-u", "/app/modbus_server.py"] From ba597f31c67cdcc0b6192a537d6853ad07f648f6 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 10:27:46 +0100 Subject: [PATCH 03/25] remove zeroMode from examples --- examples/abb_coretec_example.json | 1 - examples/test.json | 1 - examples/udp.json | 1 - 3 files changed, 3 deletions(-) diff --git a/examples/abb_coretec_example.json b/examples/abb_coretec_example.json index 744b745..6752712 100644 --- a/examples/abb_coretec_example.json +++ b/examples/abb_coretec_example.json @@ -15,7 +15,6 @@ }, "registers": { "description": "initial values for the register types", - "zeroMode": true, "initializeUndefinedRegisters": true, "discreteInput": {}, "coils": {}, diff --git a/examples/test.json b/examples/test.json index 78d245a..2dea152 100644 --- a/examples/test.json +++ b/examples/test.json @@ -15,7 +15,6 @@ }, "registers": { "description": "initial values for the register types", - "zeroMode": false, "initializeUndefinedRegisters": true, "discreteInput": { "1": true, diff --git a/examples/udp.json b/examples/udp.json index eee8ce3..d35c30d 100644 --- a/examples/udp.json +++ b/examples/udp.json @@ -15,7 +15,6 @@ }, "registers": { "description": "initial values for the register types", - "zeroMode": false, "initializeUndefinedRegisters": true, "discreteInput": { "1": true, From 90388f1ad8a17f48db5bceacebf2aeb92125fa5d Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 11:59:11 +0100 Subject: [PATCH 04/25] ignore envrc files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 773239e..3b28a75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Created by https://www.gitignore.io/api/osx,java,linux,eclipse,windows,netbeans,java-web,intellij +.envrc ### Eclipse ### From 5fc937eb86e4c23894dd5da58811bef1f0664e06 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 11:59:49 +0100 Subject: [PATCH 05/25] change loglevel and add persistence configuration --- examples/test.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/test.json b/examples/test.json index 2dea152..2a08a8f 100644 --- a/examples/test.json +++ b/examples/test.json @@ -10,9 +10,14 @@ }, "logging": { "format": "%(asctime)-15s %(threadName)-15s %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s", - "logLevel": "DEBUG" + "logLevel": "INFO" } }, + "persistence": { + "enabled": true, + "file": "/data/modbus_registers.json", + "saveInterval": 30 + }, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, From de4b515820f309c0ebbe9c8c27300acc1b9f69cb Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 11:59:57 +0100 Subject: [PATCH 06/25] add persistence configuration --- src/app/modbus_server.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/modbus_server.json b/src/app/modbus_server.json index ef59dbf..c1d4df8 100644 --- a/src/app/modbus_server.json +++ b/src/app/modbus_server.json @@ -13,6 +13,11 @@ "logLevel": "INFO" } }, + "persistence": { + "enabled": false, + "file": "/data/modbus_registers.json", + "saveInterval": 30 + }, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, From bb452c0f2b119b3aa0dbbb53abbfcce4e9d9da14 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 12:00:35 +0100 Subject: [PATCH 07/25] adding new library that handles the persistence --- src/app/lib/register_persistence/__init__.py | 198 +++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/app/lib/register_persistence/__init__.py diff --git a/src/app/lib/register_persistence/__init__.py b/src/app/lib/register_persistence/__init__.py new file mode 100644 index 0000000..31d18ea --- /dev/null +++ b/src/app/lib/register_persistence/__init__.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +""" +############################################################################### +# Library to make register writes persistent across restarts of the modbus server. +# seeAlso: https://github.com/cybcon/modbus-server/issues/28 +#------------------------------------------------------------------------------ +# Author: Michael Oberdorf +# Date: 2026-02-07 +# Last modified by: Michael Oberdorf +# Last modified at: 2026-02-07 +###############################################################################\n +""" + +__author__ = "Michael Oberdorf " +__status__ = "production" +__date__ = "2026-02-07" +__version_info__ = ("1", "0", "0") +__version__ = ".".join(__version_info__) + +__all__ = ["RegisterPersistence"] + +import json +import logging +import os +import threading +from typing import Optional + +from pymodbus.datastore import ( + ModbusSequentialDataBlock, + ModbusServerContext, + ModbusSparseDataBlock, +) + + +class RegisterPersistence: + """ + Handles saving and loading of register data to/from disk + """ + + def __init__(self, persistence_file: str, context: ModbusServerContext, save_interval: int = 30): + """ + Initialize the persistence layer + + :param persistence_file: path to the JSON file for storing register data + :type persistence_file: str + :param context: the ModbusServerContext to monitor + :type context: ModbusServerContext + :param save_interval: how often to save data in seconds (default: 30) + :type save_interval: int + """ + self.logger = logging.getLogger(__name__) + self.persistence_file = persistence_file + self.context = context + self.save_interval = save_interval + self._stop_event = threading.Event() + self._save_thread = None + self._last_data = None + if not os.path.exists(os.path.dirname(self.persistence_file)): + self.logger.info(f"Persistence file directory does not exist: {os.path.dirname(self.persistence_file)}") + os.makedirs(os.path.dirname(self.persistence_file), exist_ok=True) + self.logger.info(f"Created directory for persistence file: {os.path.dirname(self.persistence_file)}") + + def load_registers(self) -> Optional[dict]: + """ + Load register data from persistence file + + :return: Register data or None if file doesn't exist + :rtype: Optional[dict] + :raises: RuntimeError if file exists but cannot be read or parsed + """ + if not os.path.isfile(self.persistence_file): + self.logger.info(f"No persistence file found at {self.persistence_file}, using initial configuration") + return None + + try: + with open(self.persistence_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.logger.info(f"Successfully loaded register data from {self.persistence_file}") + return data + except Exception as e: + self.logger.error(f"Failed to load persistence file: {e}") + raise RuntimeError("Error loading persistence file") from e + + def save_registers(self) -> bool: + """ + Save current register data to persistence file + + :return: True if successful, False otherwise + :rtype: bool + :raises: RuntimeError if saving fails but does not raise exceptions (errors are logged and False is returned) + """ + try: + # Get the slave context (we use single=True, so slaves is the context directly) + slave_context = self.context[0] # Unit ID 0 for single context + + # Extract current register values + data = { + "discrete_inputs": self._extract_register_values(slave_context, "d"), + "coils": self._extract_register_values(slave_context, "c"), + "holding_registers": self._extract_register_values(slave_context, "h"), + "input_registers": self._extract_register_values(slave_context, "i"), + } + + # Only save if data has changed + if data == self._last_data: + return True + + # Save to file with atomic write (write to temp file, then rename) + temp_file = self.persistence_file + ".tmp" + with open(temp_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Atomic rename + os.replace(temp_file, self.persistence_file) + + self._last_data = data + self.logger.debug(f"Register data saved to {self.persistence_file}") + return True + + except Exception as e: + self.logger.error(f"Failed to save register data: {e}") + return False + + def _extract_register_values(self, slave_context: ModbusServerContext, register_type: str) -> dict: + """ + Extract register values from the datastore + + :param slave_context: the slave context + :type slave_context: ModbusServerContext + :param register_type: 'd' (discrete inputs), 'c' (coils), 'h' (holding), 'i' (input) + :type register_type: str + :return: dict with address: value mappings + :rtype: dict + :raises: Exception if register type is invalid or datastore access fails but does not raise exceptions (errors are logged and empty dict is returned) + """ + result = {} + try: + # Get the appropriate datastore + if register_type == "d": + store = slave_context.store["d"] + elif register_type == "c": + store = slave_context.store["c"] + elif register_type == "h": + store = slave_context.store["h"] + elif register_type == "i": + store = slave_context.store["i"] + else: + return result + + # Check if it's a sparse or sequential block + if isinstance(store, ModbusSparseDataBlock): + # Sparse blocks have a values dict + result = dict(store.values) + elif isinstance(store, ModbusSequentialDataBlock): + # Sequential blocks: iterate and save non-zero values + # This saves space in the JSON file + for addr in range(0, 65536): + values = store.getValues(addr, 1) + if values and values[0] != 0: + result[addr] = values[0] + + return result + + except Exception as e: + self.logger.error(f"Error extracting {register_type} register values: {e}") + return result + + def start_auto_save(self): + """ + Start background thread for automatic periodic saving + """ + if self._save_thread is not None: + self.logger.warning("Auto-save thread already running") + return + + def save_loop(): + self.logger.info(f"Auto-save thread started (interval: {self.save_interval}s)") + while not self._stop_event.wait(self.save_interval): + self.save_registers() + # Final save on shutdown + self.logger.info("Performing final save before shutdown...") + self.save_registers() + self.logger.info("Auto-save thread stopped") + + self._save_thread = threading.Thread(target=save_loop, daemon=True) + self._save_thread.start() + + def stop_auto_save(self): + """ + Stop the background save thread + """ + if self._save_thread is None: + return + + self.logger.info("Stopping auto-save thread...") + self._stop_event.set() + self._save_thread.join(timeout=5) + self._save_thread = None From 34fb6074861202bacbde397a5a5d1b84947407fb Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 12:00:54 +0100 Subject: [PATCH 08/25] add persistence feature --- src/app/modbus_server.py | 245 +++++++++++++++++++++++---------------- 1 file changed, 147 insertions(+), 98 deletions(-) diff --git a/src/app/modbus_server.py b/src/app/modbus_server.py index 288ed7f..1733b5d 100644 --- a/src/app/modbus_server.py +++ b/src/app/modbus_server.py @@ -15,6 +15,7 @@ from typing import Literal, Optional import pymodbus +from lib.register_persistence import RegisterPersistence from pymodbus.datastore import ( ModbusDeviceContext, ModbusSequentialDataBlock, @@ -26,8 +27,10 @@ # default configuration file path __script_path__ = os.path.dirname(__file__) +__persistence_path__ = os.path.join(os.path.dirname(__script_path__), "data") default_config_file = os.path.join(__script_path__, "modbus_server.json") -VERSION = "2.0.0" +default_persistence_file = os.path.join(__persistence_path__, "modbus_registers.json") +VERSION = "2.1.0" log = logging.getLogger() @@ -57,42 +60,129 @@ def get_ip_address() -> str: return ipaddr -def run_server( - listener_address: str = "0.0.0.0", - listener_port: int = 5020, - protocol: str = "TCP", - tls_cert: str = None, - tls_key: str = None, - zero_mode: bool = False, - discrete_inputs: Optional[dict] = None, - coils: Optional[dict] = None, - holding_registers: Optional[dict] = None, - input_registers: Optional[dict] = None, -): +def run_server(persistence_file: Optional[str] = None, persistence_interval: int = 30): """ Run the modbus server(s) - :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] + :param persistence_file: path to file for register persistence (default: None) + :type persistence_file: Optional[str] + :param persistence_interval: interval in seconds to save the registers to the persistence file (default: 30) + :type persistence_interval: int """ + # Check if we should load from persistence + persistence = None + loaded_data = None + + if persistence_file: + log.info("Persistence enabled") + temp_context = ModbusServerContext(devices=ModbusDeviceContext(), single=True) + persistence = RegisterPersistence( + persistence_file=persistence_file, context=temp_context, save_interval=persistence_interval + ) + loaded_data = persistence.load_registers() + + deviceContext = _get_modbus_device_context(persistence_data=loaded_data) + + log.debug("Define Modbus server context") + context = ModbusServerContext(devices=deviceContext, single=True) + + # Set up persistence with the actual context + if persistence_file: + persistence = RegisterPersistence( + persistence_file=persistence_file, context=context, save_interval=persistence_interval + ) + persistence.start_auto_save() + + log.debug("Define Modbus server identity") + identity = _define_server_identity() + + # ----------------------------------------------------------------------- # + # run the server + # ----------------------------------------------------------------------- # + listener_address = CONFIG["server"].get("listenerAddress", "0.0.0.0") + listener_port = int(CONFIG["server"].get("listenerPort", 5020)) + protocol = (CONFIG["server"].get("protocol", "TCP"),) + + tls_cert = CONFIG["server"]["tlsParams"].get("certificate", None) + tls_key = CONFIG["server"]["tlsParams"].get("privateKey", None) + start_tls = False + if tls_cert and tls_key and os.path.isfile(tls_cert) and os.path.isfile(tls_key): + start_tls = True + + if start_tls: + log.info(f"Starting Modbus TCP server with TLS on {listener_address}:{listener_port}") + StartTlsServer( + context=context, + identity=identity, + certfile=tls_cert, + keyfile=tls_key, + address=(listener_address, listener_port), + ) + else: + if protocol == "UDP": + log.info(f"Starting Modbus UDP server on {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=context, identity=identity, address=(listener_address, listener_port)) + # TCP with different framer + # StartTcpServer(context=context, identity=identity, framer=ModbusRtuFramer, address=(listener_address, listener_port)) + + +def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> ModbusDeviceContext: + """ + Generates the Modbus Device Context with the defined registers based on the configuration file and the persistence file (if enabled) + + :param persistence_data: the data loaded from the persistence file (default: None) + :type persistence_data: Optional[dict] + :return: the generated ModbusDeviceContext with the defined registers + :rtype: ModbusDeviceContext + """ + # Check if we should load from persistence or from configuration file + if persistence_data: + discrete_inputs = _prepare_register( + register=persistence_data.get("discrete_inputs", {}), + init_type="boolean", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + coils = _prepare_register( + register=persistence_data.get("coils", {}), + init_type="boolean", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + holding_registers = _prepare_register( + register=persistence_data.get("holding_registers", {}), + init_type="word", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + input_registers = _prepare_register( + register=persistence_data.get("input_registers", {}), + init_type="word", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + log.info("Loaded registers from persistence file") + else: + discrete_inputs = _prepare_register( + register=CONFIG["registers"].get("discreteInput", {}), + init_type="boolean", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + coils = _prepare_register( + register=CONFIG["registers"].get("coils", {}), + init_type="boolean", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + holding_registers = _prepare_register( + register=CONFIG["registers"].get("holdingRegister", {}), + init_type="word", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + input_registers = _prepare_register( + register=CONFIG["registers"].get("inputRegister", {}), + init_type="word", + initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], + ) + log.info("Loaded registers from configuration file") # initialize data store log.debug("Initialize discrete input") @@ -139,17 +229,16 @@ def run_server( log.debug("set all registers to 0x00") ir = ModbusSequentialDataBlock.create() - store = ModbusDeviceContext(di=di, co=co, hr=hr, ir=ir) + return ModbusDeviceContext(di=di, co=co, hr=hr, ir=ir) - log.debug("Define Modbus server context") - context = ModbusServerContext(devices=store, single=True) - # ----------------------------------------------------------------------- # - # initialize the server information - # ----------------------------------------------------------------------- # - # If you don't set this or any fields, they are defaulted to empty strings. - # ----------------------------------------------------------------------- # - log.debug("Define Modbus server identity") +def _define_server_identity() -> ModbusDeviceIdentification: + """ + Defines the server identity based on the configuration file + + :return: the defined ModbusDeviceIdentification + :rtype: ModbusDeviceIdentification + """ identity = ModbusDeviceIdentification() identity.VendorName = "Pymodbus" log.debug(f"Set VendorName to: {identity.VendorName}") @@ -164,34 +253,10 @@ def run_server( identity.MajorMinorRevision = pymodbus.__version__ log.debug(f"Set MajorMinorRevision to: {identity.MajorMinorRevision}") - # ----------------------------------------------------------------------- # - # run the server - # ----------------------------------------------------------------------- # - start_tls = False - if tls_cert and tls_key and os.path.isfile(tls_cert) and os.path.isfile(tls_key): - start_tls = True - - if start_tls: - log.info(f"Starting Modbus TCP server with TLS on {listener_address}:{listener_port}") - StartTlsServer( - context=context, - identity=identity, - certfile=tls_cert, - keyfile=tls_key, - address=(listener_address, listener_port), - ) - else: - if protocol == "UDP": - log.info(f"Starting Modbus UDP server on {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=context, identity=identity, address=(listener_address, listener_port)) - # TCP with different framer - # StartTcpServer(context=context, identity=identity, framer=ModbusRtuFramer, address=(listener_address, listener_port)) + return identity -def prepare_register( +def _prepare_register( register: dict, init_type: Literal["boolean", "word"], initialize_undefined_registers: bool = False, @@ -318,27 +383,18 @@ def prepare_register( log.info(f"Starting Modbus Server, v{VERSION}") log.debug(f"Loaded successfully the configuration file: {config_file}") - # be sure the data types within the dictionaries are correct (json will only allow strings as keys) - configured_discrete_inputs = prepare_register( - register=CONFIG["registers"]["discreteInput"], - init_type="boolean", - initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], - ) - configured_coils = prepare_register( - register=CONFIG["registers"]["coils"], - init_type="boolean", - initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], - ) - configured_holding_registers = prepare_register( - register=CONFIG["registers"]["holdingRegister"], - init_type="word", - initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], - ) - configured_input_registers = prepare_register( - register=CONFIG["registers"]["inputRegister"], - init_type="word", - initialize_undefined_registers=CONFIG["registers"]["initializeUndefinedRegisters"], - ) + # Check for persistence configuration + persistence_file = None + persistence_interval = 30 # default: save every 30 seconds + if "persistence" in CONFIG: + if CONFIG["persistence"].get("enabled", False): + persistence_file = CONFIG["persistence"].get("file", default_persistence_file) + persistence_interval = CONFIG["persistence"].get("saveInterval", 30) + log.info(f"Persistence enabled: {persistence_file} (save interval: {persistence_interval}s)") + else: + log.info("Persistence disabled in configuration") + else: + log.info("No persistence configuration found, persistence disabled") # add TCP protocol to configuration if not defined if "protocol" not in CONFIG["server"]: @@ -349,13 +405,6 @@ def prepare_register( if local_ip_addr != "": log.info(f"Outbound device IP address is: {local_ip_addr}") run_server( - listener_address=CONFIG["server"]["listenerAddress"], - listener_port=CONFIG["server"]["listenerPort"], - protocol=CONFIG["server"]["protocol"], - tls_cert=CONFIG["server"]["tlsParams"]["privateKey"], - tls_key=CONFIG["server"]["tlsParams"]["certificate"], - discrete_inputs=configured_discrete_inputs, - coils=configured_coils, - holding_registers=configured_holding_registers, - input_registers=configured_input_registers, + persistence_file=persistence_file, + persistence_interval=persistence_interval, ) From d19bdd1856ee39b95e8f408b8eb9c8cc4c42b073 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 12:08:21 +0100 Subject: [PATCH 09/25] update documentation to reflect persistence feature --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 2c79588..1b62cbf 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ The `/app/modbus_server.json` file comes with following content: "logLevel": "INFO" } }, + "persistence": { + "enabled": false, + "file": "/data/modbus_registers.json", + "saveInterval": 30 + }, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, @@ -145,6 +150,10 @@ The `/app/modbus_server.json` file comes with following content: | `server.logging` | Object | Log specific configuration. | | `server.logging.format` | String | The format of the log messages as described here: https://docs.python.org/3/library/logging.html#logrecord-attributes | | `server.logging.logLevel` | String | Defines the maximum level of severity to log to std out. Possible values are `DEBUG`, `INFO`, `WARN` and `ERROR`. | +| `server.persistence` | Object | Configuration for the persistence layer to automatically saved and restored after the server is restarted. | +| `server.persistence.enabled` | Boolean | If `true` the persistence will be enabled. | +| `server.persistence.file` | String | The file to store the persistent data (if enabled). | +| `server.persistence.saveInterval` | Integer | The interval in seconds when to save the registers (this will be only done if there are changes). | | `registers` | Object | Configuration parameters to predefine registers. | | `registers.description` | String | No configuration option, just a description of the parameters. | | `registers.initializeUndefinedRegisters` | Boolean | If `true` the server will initialize all not defined registers with a default value of `0`. | @@ -209,6 +218,7 @@ services: - 5020:5020 volumes: - ./server.json:/server_config.json:ro + - ./data:/data:rw ``` # Donate From 7115b53e55d038829b7a0e5cca1accc76d165d28 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 14:31:16 +0100 Subject: [PATCH 10/25] enhance python library path --- tests/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..f90426b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +import os +import sys + +__application_lib_path__ = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "app") +sys.path.append(__application_lib_path__) From 42b0609e67d201fbf64c7e19ed37d4008a908f32 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 14:31:36 +0100 Subject: [PATCH 11/25] fix renamed function --- tests/test_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 8f2e2e9..175dfcd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from src.app.modbus_server import prepare_register +from src.app.modbus_server import _prepare_register def test_prepare_register(): @@ -13,12 +13,12 @@ def test_prepare_register(): "8": "0x0074", "9": "0x0032", } - register = prepare_register(register=register_example_data, init_type="word", initialize_undefined_registers=False) + register = _prepare_register(register=register_example_data, init_type="word", initialize_undefined_registers=False) assert len(register) == 8 assert register[9] == 0x0032 # full register should contain all entries as normal registered, but all other memory addresses set to 0 - full_register = prepare_register( + full_register = _prepare_register( register=register_example_data, init_type="word", initialize_undefined_registers=True ) for key in register: From 9076108b8493845b54decb436d9bef46651f83a0 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sat, 7 Feb 2026 14:31:51 +0100 Subject: [PATCH 12/25] add tests for new library --- tests/test_register_persistence.py | 395 +++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 tests/test_register_persistence.py diff --git a/tests/test_register_persistence.py b/tests/test_register_persistence.py new file mode 100644 index 0000000..d2e1320 --- /dev/null +++ b/tests/test_register_persistence.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for the RegisterPersistence library +""" + +import json +import os +import shutil +import tempfile +import time +from unittest.mock import MagicMock, patch + +import pytest +from pymodbus.datastore import ( + ModbusSequentialDataBlock, + ModbusServerContext, + ModbusSparseDataBlock, +) + +from src.app.lib.register_persistence import RegisterPersistence + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for test files""" + temp_dir = tempfile.mkdtemp() + yield temp_dir + shutil.rmtree(temp_dir) + + +@pytest.fixture +def temp_persistence_file(temp_dir): + """Create a temporary persistence file path""" + return os.path.join(temp_dir, "test_persistence.json") + + +@pytest.fixture +def mock_modbus_context(): + """Create a mock ModbusServerContext for testing""" + context = MagicMock(spec=ModbusServerContext) + + # Create mock slave context with register stores + slave_context = MagicMock() + + # Create mock data stores for each register type + discrete_inputs = MagicMock(spec=ModbusSparseDataBlock) + discrete_inputs.values = {0: True, 5: False} + + coils = MagicMock(spec=ModbusSparseDataBlock) + coils.values = {1: True, 3: True} + + holding_registers = MagicMock(spec=ModbusSparseDataBlock) + holding_registers.values = {0: 100, 5: 200, 10: 300} + + input_registers = MagicMock(spec=ModbusSparseDataBlock) + input_registers.values = {2: 50, 7: 75} + + slave_context.store = {"d": discrete_inputs, "c": coils, "h": holding_registers, "i": input_registers} + + context.__getitem__ = MagicMock(return_value=slave_context) + + return context + + +class TestRegisterPersistenceInit: + """Test RegisterPersistence initialization""" + + def test_init_with_existing_directory(self, temp_persistence_file, mock_modbus_context): + """Test initialization when directory already exists""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + assert persistence.persistence_file == temp_persistence_file + assert persistence.context == mock_modbus_context + assert persistence.save_interval == 30 + assert persistence._stop_event is not None + assert persistence._save_thread is None + + def test_init_with_nonexistent_directory(self, temp_dir, mock_modbus_context): + """Test initialization creates directory if it doesn't exist""" + nested_path = os.path.join(temp_dir, "nested", "dir", "persistence.json") + persistence = RegisterPersistence(nested_path, mock_modbus_context) + assert persistence.save_interval == 30 + assert os.path.isdir(os.path.dirname(nested_path)) + + def test_init_with_custom_save_interval(self, temp_persistence_file, mock_modbus_context): + """Test initialization with custom save interval""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=60) + assert persistence.save_interval == 60 + + +class TestLoadRegisters: + """Test RegisterPersistence.load_registers() method""" + + def test_load_registers_file_not_exists(self, temp_persistence_file, mock_modbus_context): + """Test loading when persistence file doesn't exist""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + result = persistence.load_registers() + assert result is None + + def test_load_registers_file_exists(self, temp_persistence_file, mock_modbus_context): + """Test loading when persistence file exists with valid JSON""" + test_data = { + "discrete_inputs": {"0": True, "5": False}, + "coils": {"1": True}, + "holding_registers": {"0": 100, "5": 200}, + "input_registers": {"2": 50}, + } + + with open(temp_persistence_file, "w") as f: + json.dump(test_data, f) + + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + result = persistence.load_registers() + assert result == test_data + + def test_load_registers_invalid_json(self, temp_persistence_file, mock_modbus_context): + """Test loading when persistence file contains invalid JSON""" + with open(temp_persistence_file, "w") as f: + f.write("invalid json {{{") + + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + with pytest.raises(RuntimeError, match="Error loading persistence file"): + persistence.load_registers() + + def test_load_registers_file_read_error(self, temp_persistence_file, mock_modbus_context): + """Test loading when file cannot be read (permission denied, etc.)""" + # Create file + with open(temp_persistence_file, "w") as f: + f.write("{}") + + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + + with patch("builtins.open", side_effect=IOError("Permission denied")): + with pytest.raises(RuntimeError, match="Error loading persistence file"): + persistence.load_registers() + + +class TestSaveRegisters: + """Test RegisterPersistence.save_registers() method""" + + def test_save_registers_success(self, temp_persistence_file, mock_modbus_context): + """Test successful saving of registers""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + result = persistence.save_registers() + assert result is True + assert os.path.isfile(temp_persistence_file) + + # Verify saved data + with open(temp_persistence_file, "r") as f: + saved_data = json.load(f) + + assert "discrete_inputs" in saved_data + assert "coils" in saved_data + assert "holding_registers" in saved_data + assert "input_registers" in saved_data + + def test_save_registers_no_changes(self, temp_persistence_file, mock_modbus_context): + """Test that repeated saves without changes don't write to file""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + + # First save + result1 = persistence.save_registers() + assert result1 is True + stat1 = os.stat(temp_persistence_file) + + # Wait a bit + time.sleep(0.1) + + # Second save without changes + result2 = persistence.save_registers() + assert result2 is True + stat2 = os.stat(temp_persistence_file) + + # File should not have been modified (timestamp should be the same) + assert stat1.st_mtime == stat2.st_mtime + + def test_save_registers_file_write_error(self, temp_persistence_file, mock_modbus_context): + """Test handling of file write errors""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + + with patch("builtins.open", side_effect=IOError("No space left")): + result = persistence.save_registers() + assert result is False + + def test_save_registers_atomic_write(self, temp_persistence_file, mock_modbus_context): + """Test that atomic write doesn't leave temp files on success""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + persistence.save_registers() + + temp_file = temp_persistence_file + ".tmp" + assert not os.path.exists(temp_file) + + def test_save_registers_preserves_data_on_error(self, temp_persistence_file, mock_modbus_context): + """Test that existing data is preserved if save fails""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + + # Save initial data + persistence.save_registers() + with open(temp_persistence_file, "r") as f: + initial_data = json.load(f) + + # Try to save with error + with patch("os.replace", side_effect=OSError("Cannot replace")): + persistence.save_registers() + + # Original file should still exist with original data + with open(temp_persistence_file, "r") as f: + current_data = json.load(f) + + assert current_data == initial_data + + +class TestExtractRegisterValues: + """Test RegisterPersistence._extract_register_values() method""" + + def test_extract_sparse_discrete_inputs(self, temp_persistence_file, mock_modbus_context): + """Test extracting discrete input values from sparse block""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + slave_context = mock_modbus_context[0] + + result = persistence._extract_register_values(slave_context, "d") + assert result == {0: True, 5: False} + + def test_extract_sparse_coils(self, temp_persistence_file, mock_modbus_context): + """Test extracting coil values from sparse block""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + slave_context = mock_modbus_context[0] + + result = persistence._extract_register_values(slave_context, "c") + assert result == {1: True, 3: True} + + def test_extract_sparse_holding_registers(self, temp_persistence_file, mock_modbus_context): + """Test extracting holding register values from sparse block""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + slave_context = mock_modbus_context[0] + + result = persistence._extract_register_values(slave_context, "h") + assert result == {0: 100, 5: 200, 10: 300} + + def test_extract_sparse_input_registers(self, temp_persistence_file, mock_modbus_context): + """Test extracting input register values from sparse block""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + slave_context = mock_modbus_context[0] + + result = persistence._extract_register_values(slave_context, "i") + assert result == {2: 50, 7: 75} + + def test_extract_sequential_registers(self, temp_persistence_file): + """Test extracting values from sequential data block""" + context = MagicMock(spec=ModbusServerContext) + slave_context = MagicMock() + + # Create a mock sequential block + sequential_block = MagicMock(spec=ModbusSequentialDataBlock) + sequential_block.getValues = MagicMock(side_effect=lambda addr, count: [100] if addr in [5, 10] else [0]) + + slave_context.store = {"h": sequential_block} + context.__getitem__ = MagicMock(return_value=slave_context) + + persistence = RegisterPersistence(temp_persistence_file, context) + result = persistence._extract_register_values(slave_context, "h") + + # Sequential blocks save non-zero values + assert isinstance(result, dict) + + def test_extract_invalid_register_type(self, temp_persistence_file, mock_modbus_context): + """Test extracting with invalid register type""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + slave_context = mock_modbus_context[0] + + result = persistence._extract_register_values(slave_context, "x") + assert result == {} + + def test_extract_on_datastore_error(self, temp_persistence_file): + """Test extraction handles datastore errors gracefully""" + context = MagicMock(spec=ModbusServerContext) + slave_context = MagicMock() + + # Create a mock store that raises an error + error_store = MagicMock(spec=ModbusSparseDataBlock) + slave_context.store = {"h": error_store} + context.__getitem__ = MagicMock(return_value=slave_context) + + # Mock the isinstance check to not match, causing an exception + with patch("src.app.lib.register_persistence.isinstance", side_effect=Exception("Store error")): + persistence = RegisterPersistence(temp_persistence_file, context) + result = persistence._extract_register_values(slave_context, "h") + + # Should return empty dict on error + assert result == {} + + +class TestAutoSave: + """Test RegisterPersistence auto-save threading""" + + def test_start_auto_save(self, temp_persistence_file, mock_modbus_context): + """Test starting auto-save thread""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=1) + persistence.start_auto_save() + + assert persistence._save_thread is not None + assert persistence._save_thread.is_alive() + + # Cleanup + persistence.stop_auto_save() + + def test_auto_save_doesnt_start_twice(self, temp_persistence_file, mock_modbus_context): + """Test that starting auto-save twice doesn't create multiple threads""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=1) + persistence.start_auto_save() + thread1 = persistence._save_thread + + persistence.start_auto_save() + thread2 = persistence._save_thread + + assert thread1 is thread2 + + # Cleanup + persistence.stop_auto_save() + + def test_stop_auto_save(self, temp_persistence_file, mock_modbus_context): + """Test stopping auto-save thread""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=1) + persistence.start_auto_save() + + assert persistence._save_thread is not None + persistence.stop_auto_save() + + # Thread should be None after stopping + assert persistence._save_thread is None + + def test_stop_auto_save_when_not_running(self, temp_persistence_file, mock_modbus_context): + """Test stopping auto-save when it's not running""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + # Should not raise an error + persistence.stop_auto_save() + assert persistence._save_thread is None + + def test_auto_save_periodic_writes(self, temp_persistence_file, mock_modbus_context): + """Test that auto-save periodically writes data""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=1) + persistence.start_auto_save() + + # Wait for at least one save cycle + time.sleep(1.5) + + assert os.path.isfile(temp_persistence_file) + + # Cleanup + persistence.stop_auto_save() + + def test_auto_save_final_save_on_shutdown(self, temp_persistence_file, mock_modbus_context): + """Test that auto-save performs final save on shutdown""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=100) + persistence.start_auto_save() + persistence.stop_auto_save() + + # File should exist after shutdown due to final save + assert os.path.isfile(temp_persistence_file) + + +class TestIntegration: + """Integration tests for RegisterPersistence""" + + def test_full_workflow(self, temp_persistence_file, mock_modbus_context): + """Test complete workflow: save, load, verify""" + # Save data + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context) + save_result = persistence.save_registers() + assert save_result is True + + # Load data + loaded_data = persistence.load_registers() + assert loaded_data is not None + assert "discrete_inputs" in loaded_data + assert "coils" in loaded_data + assert "holding_registers" in loaded_data + assert "input_registers" in loaded_data + + def test_auto_save_with_manual_save(self, temp_persistence_file, mock_modbus_context): + """Test combining auto-save with manual save""" + persistence = RegisterPersistence(temp_persistence_file, mock_modbus_context, save_interval=1) + + # Manual save + persistence.save_registers() + assert os.path.isfile(temp_persistence_file) + + # Start auto-save + persistence.start_auto_save() + time.sleep(1.5) + + # Manual save again + persistence.save_registers() + + persistence.stop_auto_save() + assert os.path.isfile(temp_persistence_file) From 72c0c1f83a88fb231112faeffb84dccbe9b3b6e4 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:32:02 +0100 Subject: [PATCH 13/25] remove irrenelvant file --- src/app/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/app/__init__.py diff --git a/src/app/__init__.py b/src/app/__init__.py deleted file mode 100644 index e69de29..0000000 From 4d48ea186222965c612e0b5281a42f173c6e801d Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:32:23 +0100 Subject: [PATCH 14/25] enable DEBUG mode by default in test --- examples/test.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/test.json b/examples/test.json index 2a08a8f..bab41ff 100644 --- a/examples/test.json +++ b/examples/test.json @@ -10,7 +10,7 @@ }, "logging": { "format": "%(asctime)-15s %(threadName)-15s %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s", - "logLevel": "INFO" + "logLevel": "DEBUG" } }, "persistence": { From c3bab0b2530eb8d73923ab913a880be0448acbd0 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:33:16 +0100 Subject: [PATCH 15/25] add new version number and add persistence documentation --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b62cbf..4e43b2b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Container image: [DockerHub](https://hub.docker.com/r/oitc/modbus-server) # Supported tags and respective `Dockerfile` links -* [`latest`, `2.0.0`](https://github.com/cybcon/modbus-server/blob/v2.0.0/Dockerfile) +* [`latest`, `2.1.0`](https://github.com/cybcon/modbus-server/blob/v2.1.0/Dockerfile) +* [`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) @@ -204,6 +205,83 @@ Example configuration of pre-defined registers from type "Holding Registers" or - [examples/udp.json](https://github.com/cybcon/modbus-server/blob/main/examples/udp.json) +# Data persistence + +The persistence layer enables all register changes (made by Modbus write accesses) to be automatically saved and restored after the server is restarted. + +## Functionality + +### When starting up +- The server checks whether a persistence file exists. +- **If YES**: Loads all registry values from the file (initial configuration is skipped) +- **If NO**: Use the initial configuration from `modbus_server.json` + +### During operation +- A background thread periodically saves the register data (default: every 30 seconds). +- Only changed data is saved (optimized for performance) +- Uses atomic writes (prevents data loss in case of crashes) + +### When shutting down +- A final save is performed. +- All current registry values are backed up. + +## Configuration + +### Enable persistence + +Add the following section to your `modbus_server.json`: + +```json +{ + "server": { ... }, + "persistence": { + "enabled": true, + "file": "/app/modbus_registers.json", + "saveInterval": 30 + }, + "registers": { ... } +} +``` + +## Persistence file format + +The persistence file is saved as JSON: + +```json +{ + "discrete_inputs": { + "0": false, + "1": true, + "100": true + }, + "coils": { + "0": true, + "1": false, + "50": true + }, + "holding_registers": { + "0": 1234, + "1": 5678, + "100": 42 + }, + "input_registers": { + "0": 100, + "1": 200 + } +} +``` + +**Hint:** Only registers with values ≠ 0 are stored (space-saving). + +## Backup + +For critical applications, you should create regular backups. When using Docker, you need to mount a local directory as volume to `/data` inside the container first. + +```bash +# Cron-Job for daily backup +0 2 * * * cp /local/path/to/modbus_registers.json /local/backuppath/to/modbus_registers_$(date +\%Y\%m\%d).json +``` + # Docker compose configuration From 5a0a94ecdc125b7008538c051d952cadcfd98397 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:33:48 +0100 Subject: [PATCH 16/25] optimize storage --- src/app/lib/register_persistence/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/lib/register_persistence/__init__.py b/src/app/lib/register_persistence/__init__.py index 31d18ea..b490084 100644 --- a/src/app/lib/register_persistence/__init__.py +++ b/src/app/lib/register_persistence/__init__.py @@ -150,14 +150,22 @@ def _extract_register_values(self, slave_context: ModbusServerContext, register_ # Check if it's a sparse or sequential block if isinstance(store, ModbusSparseDataBlock): # Sparse blocks have a values dict - result = dict(store.values) + for key, value in store.values.items(): + if value != 0: + if register_type in ["d", "c"]: + result[key] = True + else: + result[key] = value elif isinstance(store, ModbusSequentialDataBlock): # Sequential blocks: iterate and save non-zero values # This saves space in the JSON file for addr in range(0, 65536): values = store.getValues(addr, 1) if values and values[0] != 0: - result[addr] = values[0] + if register_type in ["d", "c"]: + result[addr] = True + else: + result[addr] = values[0] return result From 8ac8178fc3fb451980dc084b7c5a11ea27068951 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:34:40 +0100 Subject: [PATCH 17/25] fixing graceful stop persistence save --- src/app/modbus_server.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/app/modbus_server.py b/src/app/modbus_server.py index 1733b5d..9f93ae7 100644 --- a/src/app/modbus_server.py +++ b/src/app/modbus_server.py @@ -109,24 +109,29 @@ def run_server(persistence_file: Optional[str] = None, persistence_interval: int if tls_cert and tls_key and os.path.isfile(tls_cert) and os.path.isfile(tls_key): start_tls = True - if start_tls: - log.info(f"Starting Modbus TCP server with TLS on {listener_address}:{listener_port}") - StartTlsServer( - context=context, - identity=identity, - certfile=tls_cert, - keyfile=tls_key, - address=(listener_address, listener_port), - ) - else: - if protocol == "UDP": - log.info(f"Starting Modbus UDP server on {listener_address}:{listener_port}") - StartUdpServer(context=context, identity=identity, address=(listener_address, listener_port)) + try: + if start_tls: + log.info(f"Starting Modbus TCP server with TLS on {listener_address}:{listener_port}") + StartTlsServer( + context=context, + identity=identity, + certfile=tls_cert, + keyfile=tls_key, + address=(listener_address, listener_port), + ) else: - log.info(f"Starting Modbus TCP server on {listener_address}:{listener_port}") - StartTcpServer(context=context, identity=identity, address=(listener_address, listener_port)) - # TCP with different framer - # StartTcpServer(context=context, identity=identity, framer=ModbusRtuFramer, address=(listener_address, listener_port)) + if protocol == "UDP": + log.info(f"Starting Modbus UDP server on {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=context, identity=identity, address=(listener_address, listener_port)) + # TCP with different framer + # StartTcpServer(context=context, identity=identity, framer=ModbusRtuFramer, address=(listener_address, listener_port)) + finally: + # Ensure we stop auto-save and perform final save on shutdown + if persistence: + persistence.stop_auto_save() def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> ModbusDeviceContext: From a29486b606a00433c18945bbc0c14630878d2dcc Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:56:41 +0100 Subject: [PATCH 18/25] increase version --- src/app/lib/register_persistence/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/lib/register_persistence/__init__.py b/src/app/lib/register_persistence/__init__.py index b490084..a5938da 100644 --- a/src/app/lib/register_persistence/__init__.py +++ b/src/app/lib/register_persistence/__init__.py @@ -7,14 +7,14 @@ # Author: Michael Oberdorf # Date: 2026-02-07 # Last modified by: Michael Oberdorf -# Last modified at: 2026-02-07 +# Last modified at: 2026-02-08 ###############################################################################\n """ __author__ = "Michael Oberdorf " __status__ = "production" -__date__ = "2026-02-07" -__version_info__ = ("1", "0", "0") +__date__ = "2026-02-08" +__version_info__ = ("1", "0", "1") __version__ = ".".join(__version_info__) __all__ = ["RegisterPersistence"] From cb3415250c16847e054d6d86f1ef48eda4aaa245 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 18:56:56 +0100 Subject: [PATCH 19/25] change logger configuration --- examples/test.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/test.json b/examples/test.json index bab41ff..90a8aa2 100644 --- a/examples/test.json +++ b/examples/test.json @@ -9,7 +9,7 @@ "certificate": null }, "logging": { - "format": "%(asctime)-15s %(threadName)-15s %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "logLevel": "DEBUG" } }, From 7be202a594729c808983165a0d5fd9d4201ff47e Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 20:27:29 +0100 Subject: [PATCH 20/25] add arc42 --- arc42/architecture.md | 76 ++++++++++++++++++ .../diagrams/modbus_master_slave_sequence.mmd | 20 +++++ .../diagrams/modbus_master_slave_sequence.png | Bin 0 -> 77213 bytes .../diagrams/modbus_master_slave_sequence.svg | 1 + 4 files changed, 97 insertions(+) create mode 100644 arc42/architecture.md create mode 100644 arc42/diagrams/modbus_master_slave_sequence.mmd create mode 100644 arc42/diagrams/modbus_master_slave_sequence.png create mode 100644 arc42/diagrams/modbus_master_slave_sequence.svg diff --git a/arc42/architecture.md b/arc42/architecture.md new file mode 100644 index 0000000..eab77b1 --- /dev/null +++ b/arc42/architecture.md @@ -0,0 +1,76 @@ +# arc42 Architecture Documentation for Modbus TCP Server + +This document follows the arc42 template and maps the project's repository to the arc42 sections. + +**1. Introduction and Goals** +- **Purpose:** Provide a mock Modbus TCP/UDP server for testing Modbus masters and for use in CI. +- **Key stakeholders:** Test engineers, integrators, maintainers. +- **Main goals:** reliable Modbus protocol emulation, configurable registers via JSON, optional TLS, simple persistence. + +**2. Constraints** +- **Language/runtime:** Python 3.10–3.12 (see `src/requirements.txt`). +- **Ports:** Default Modbus listening port is 5020 (TCP/UDP). +- **Environment:** Runs in container (Dockerfiles present) and as a standalone script. + +**3. Context and Scope** +- **System Context:** The server listens for Modbus clients over TCP/UDP (and optionally TLS). It exposes configured registers and supports persistence to a JSON file. +- **External systems:** Modbus clients, Docker host, optional TLS clients. + +**4. Solution Strategy** +- Use a compact Python server implemented in `src/app/modbus_server.py`. +- Configuration-driven: initial registers and behavior from JSON files in `examples/` or `src/app/modbus_server.json`. +- Persistence handled by a lightweight JSON persistence layer under `src/app/lib/register_persistence`. + +**5. Building Block View (Static Structure)** +- **Entry point:** `src/app/modbus_server.py` — server startup, config parsing, logging. +- **Persistence module:** `src/app/lib/register_persistence/__init__.py` — save/load register state. +- **Configuration files:** `src/app/modbus_server.json`, `examples/*.json` — register definitions. +- **Tests:** `tests/test_server.py`, `tests/test_utils.py`, `tests/test_register_persistence.py` — unit tests and examples. +- **Dependencies:** `src/requirements.txt` (project dependencies used at runtime and in tests). + +**6. Runtime View (Key Scenarios)** +- **Start server:** `modbus_server.py` reads config, prepares Modbus data blocks, optionally loads persistence, starts listening on configured interfaces. +- **Register access:** Incoming Modbus requests are served from in-memory data blocks; write operations update in-memory state and (optionally) trigger persistence. +- **Persistence loop:** Background thread periodically saves register state to `/data/modbus_registers.json` (or configured path). + +**7. Deployment View** +- **Container image:** `Dockerfile` and `Dockerfile.test` provide containerized deployment with pinned Python runtime. +- **Ports:** Expose 5020/tcp and 5020/udp by default. +- **Volumes:** Recommended mount for persistence: host path → `/data` inside container to retain `modbus_registers.json`. + +**8. Cross-cutting Concepts** +- **Configuration:** JSON-driven; register numbers are JSON string keys; values can be booleans (bit registers) or hex/integer for word registers (see docs in repo). +- **Logging:** Module-level logging configured in `modbus_server.py`. +- **Security:** Optional TLS support (configuration-controlled). When deployed in untrusted networks, run behind appropriate network controls. + +**9. Architecture Decisions (ADRs) — short list** +- Use JSON for register configuration: simple, human-editable, easy to mount into containers. +- Provide both TCP and UDP Modbus endpoints for maximal compatibility with test scenarios. + +**10. Quality Requirements** +- **Availability:** Server must start reliably and gracefully handle shutdowns (server already implements graceful shutdown logging). +- **Performance:** Designed for low-to-medium throughput testing scenarios (not a production-grade high-performance Modbus gateway). +- **Maintainability:** Small code base, test coverage in `tests/` directory. + +**11. Risks & Technical Debt** +- Single-threaded request handling or dependency limitations could limit concurrency; evaluate if high-load testing is required. +- Persistence format is JSON; binary/large-scale persistence not optimized. + +**12. Mapping: Files → arc42 Sections** +- **Startup & runtime behavior:** `src/app/modbus_server.py` → sections 4, 5, 6. +- **Persistence implementation:** `src/app/lib/register_persistence/__init__.py` → sections 5, 6, 8. +- **Configuration/examples:** `src/app/modbus_server.json`, `examples/*.json` → sections 3, 4. +- **Tests & verification:** `tests/*` → section 10 (quality requirements and validation). +- **Containerization:** `Dockerfile`, `Dockerfile.test` → section 7. + +**13. Next Steps & Suggestions** +- Create ADRs for TLS choices and persistence path conventions. +- Expand Building Block diagrams (drawn diagrams) and runtime sequence diagrams for main scenarios. +- Add explicit security section describing TLS cert management and required firewall rules. + +**Diagrams** +**Modbus Master-Slave Communication Sequence** +![modbus_master_slave_sequence.png](./diagrams/modbus_master_slave_sequence.png) + +--- +Generated by project maintainer guidance — review and refine details for project-specific decisions. diff --git a/arc42/diagrams/modbus_master_slave_sequence.mmd b/arc42/diagrams/modbus_master_slave_sequence.mmd new file mode 100644 index 0000000..9442ea5 --- /dev/null +++ b/arc42/diagrams/modbus_master_slave_sequence.mmd @@ -0,0 +1,20 @@ +```mermaid +sequenceDiagram + participant Master as Modbus Master (Client) + participant Network as TCP/UDP + participant Slave as Modbus Slave (Server) + participant Persist as Persistence Thread + + Master->>+Network: Open connection / send packet + Network->>+Slave: TCP/UDP: Modbus Request (MBAP + PDU / UDP frame) + note right of Slave: Validate header, parse PDU, access register model + Slave-->>-Network: Modbus Response (PDU / exception) + Network-->>-Master: Return response + + alt Write operation + Slave->>+Persist: Update in-memory registers + Persist-->>-Slave: Ack (persist scheduled / background save) + end + + note over Master,Slave: Retries/timeouts handled at transport layer (TCP retransmission / UDP retries) +``` diff --git a/arc42/diagrams/modbus_master_slave_sequence.png b/arc42/diagrams/modbus_master_slave_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..66eefe5e1bb0f2fe80339afd995bc5a007190cf5 GIT binary patch literal 77213 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 ThreadModbus Slave (Server)TCP/UDPModbus Master (Client)Persistence ThreadModbus Slave (Server)TCP/UDPModbus Master (Client)Validate header, parse PDU, access register modelalt[Write operation]Retries/timeouts handled at transport layer (TCP retransmission / UDP retries)Open connection / send packetTCP/UDP: Modbus Request (MBAP + PDU / UDP frame)Modbus Response (PDU / exception)Return responseUpdate in-memory registersAck (persist scheduled / background save) From ae5f90a05deaddaa784a3887d5ba86e451d4e5bc Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 22:14:13 +0100 Subject: [PATCH 21/25] add some community standards --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 46 ++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5706de2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +e-mail notification. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..152b062 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +## How to contribute to Modbus Server + +#### **Did you find a bug?** + +* **Do not open up a GitHub issue if the bug is a security vulnerability + in modbus-server**, and instead write me a mail to [info@oberdorf-itc.de](mailto:info@oberdorf-itc.de). + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/cybcon/modbus-server/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cybcon/modbus-server/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. + +#### **Did you write a patch that fixes a bug?** + +* Open a new GitHub pull request with the patch. + +* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. + +* All commits needs to have a valid GPG signature + +* All pull request checks have to pass successfully + +* If using 3rd party components, the licence needs to be compatible with the license of this project + +#### **Did you fix whitespace, format code, or make a purely cosmetic patch?** + +Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of the application will generally not be accepted. + +#### **Do you intend to add a new feature or change an existing one?** + +* Open a new GitHub pull request with the patch. + +* Ensure the PR description clearly describes the new feature or the change. Include the relevant issue number if applicable. + +* All commits needs to have a valid GPG signature + +* All pull request checks have to pass successfully + +* If using 3rd party components, the licence needs to be compatible with the license of this project + +#### **Do you have questions about the source code?** + +* Write me a mail to [info@oberdorf-itc.de](mailto:info@oberdorf-itc.de). + +Thanks! :heart: :heart: :heart: + +Michael From 156799cd462af1afdec700a3ae04a667d28dc334 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 22:16:22 +0100 Subject: [PATCH 22/25] fix some wordings --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4e43b2b..5e13f8d 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ Container image: [DockerHub](https://hub.docker.com/r/oitc/modbus-server) # What is Modbus TCP Server? -The Modbus TCP Server is a simple, in python written, Modbus TCP server. -The Modbus registers can be also predefined with values. +The Modbus TCP Server is a simple, written in python, Modbus TCP server. +The Modbus registers can also be predefined with values. The Modbus server was initially created to act as a Modbus slave mock system for enhanced tests with modbus masters and to test collecting values from different registers. @@ -42,10 +42,10 @@ The Modbus specification can be found here: [PDF](https://modbus.org/docs/Modbus # Own Docker builds and version pinning If you want to build your own container image with the [Dockerfile](./Dockerfile) you should know that the file uses version pinning to have a deterministic environment for the application. -This is a best bractice and described in [Hadolint DL3018](https://github.com/hadolint/hadolint/wiki/DL3018). +This is a best practice and described in [Hadolint DL3018](https://github.com/hadolint/hadolint/wiki/DL3018). The problem is, that Alpine Linux doesn't keep old versions inside the software repository. When software will be updated, the old (pinned) version will be removed and is so no longer available. -Docker builds will be successfull today and fail tomorrow. +Docker builds will be successful today and fail tomorrow. See also here: https://github.com/hadolint/hadolint/issues/464 @@ -141,7 +141,7 @@ The `/app/modbus_server.json` file comes with following content: | Field | Type | Description | |------------------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------| | `server` | Object | Modbus slave specific runtime parameters. | -| `server.listenerAddress` | String | The IPv4 Address to bound to when starting the server. `"0.0.0.0"` let the server listens on all interface addresses. | +| `server.listenerAddress` | String | The IPv4 Address to bind to when starting the server. `"0.0.0.0"` lets the server listen on all interface addresses. | | `server.listenerPort` | Integer | The port number of the modbus slave to listen to. | | `server.protocol` | String | Defines if the server should use `TCP` or `UDP` (default: `TCP`) | | `server.tlsParams` | Object | Configuration parameters to use TLS encrypted modbus tcp slave. (untested) | @@ -213,7 +213,7 @@ The persistence layer enables all register changes (made by Modbus write accesse ### When starting up - The server checks whether a persistence file exists. -- **If YES**: Loads all registry values from the file (initial configuration is skipped) +- **If YES**: Loads all register values from the file (initial configuration is skipped) - **If NO**: Use the initial configuration from `modbus_server.json` ### During operation @@ -223,7 +223,7 @@ The persistence layer enables all register changes (made by Modbus write accesse ### When shutting down - A final save is performed. -- All current registry values are backed up. +- All current register values are backed up. ## Configuration From 7b7ed565e1c143a7074816bc12185178c3401858 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 22:21:40 +0100 Subject: [PATCH 23/25] add security information --- SECURITY.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..933f524 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +Due to the small size of this project, only the latest version receives security updates and patches. + +| Version | Supported | +| ------- | ------------------ | +| Latest | :white_check_mark: | +| Older | :x: | + +## Reporting a Vulnerability + +**Please do not open a GitHub issue for security vulnerabilities.** + +Instead, please report security vulnerabilities by sending an email to [info@oberdorf-itc.de](mailto:info@oberdorf-itc.de) with: + +- A description of the vulnerability +- Steps to reproduce (if applicable) +- Potential impact +- Any suggested fixes (optional) + +For more details on how to contribute to this project, please refer to [CONTRIBUTING.md](CONTRIBUTING.md). From a7568cbe808880b8dad0c1c5dff76b4f6dd3f0c7 Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 22:25:36 +0100 Subject: [PATCH 24/25] ignore venv and python version --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 3b28a75..00b756b 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,5 @@ $RECYCLE.BIN/ # End of https://www.gitignore.io/api/osx,java,linux,eclipse,windows,netbeans,java-web,intellij __pycache__/ +/.venv/ +/.python-version From 80e1b7a46e85698585dbd38837c560b01cf561ad Mon Sep 17 00:00:00 2001 From: Michael Oberdorf Date: Sun, 8 Feb 2026 22:42:49 +0100 Subject: [PATCH 25/25] fixes --- src/app/lib/register_persistence/__init__.py | 11 +++++++---- tests/test_register_persistence.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/lib/register_persistence/__init__.py b/src/app/lib/register_persistence/__init__.py index a5938da..278bdaa 100644 --- a/src/app/lib/register_persistence/__init__.py +++ b/src/app/lib/register_persistence/__init__.py @@ -14,7 +14,7 @@ __author__ = "Michael Oberdorf " __status__ = "production" __date__ = "2026-02-08" -__version_info__ = ("1", "0", "1") +__version_info__ = ("1", "0", "2") __version__ = ".".join(__version_info__) __all__ = ["RegisterPersistence"] @@ -153,9 +153,12 @@ def _extract_register_values(self, slave_context: ModbusServerContext, register_ for key, value in store.values.items(): if value != 0: if register_type in ["d", "c"]: - result[key] = True + # For bit registers, include all values (including False) + result[key] = bool(value) else: - result[key] = value + # For word registers, only include non-zero values + if value != 0: + result[key] = value elif isinstance(store, ModbusSequentialDataBlock): # Sequential blocks: iterate and save non-zero values # This saves space in the JSON file @@ -163,7 +166,7 @@ def _extract_register_values(self, slave_context: ModbusServerContext, register_ values = store.getValues(addr, 1) if values and values[0] != 0: if register_type in ["d", "c"]: - result[addr] = True + result[addr] = bool(values[0]) else: result[addr] = values[0] diff --git a/tests/test_register_persistence.py b/tests/test_register_persistence.py index d2e1320..348755a 100644 --- a/tests/test_register_persistence.py +++ b/tests/test_register_persistence.py @@ -218,7 +218,7 @@ def test_extract_sparse_discrete_inputs(self, temp_persistence_file, mock_modbus slave_context = mock_modbus_context[0] result = persistence._extract_register_values(slave_context, "d") - assert result == {0: True, 5: False} + assert result == {0: True} def test_extract_sparse_coils(self, temp_persistence_file, mock_modbus_context): """Test extracting coil values from sparse block"""