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 diff --git a/.gitignore b/.gitignore index 773239e..00b756b 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 ### @@ -225,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 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 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"] diff --git a/README.md b/README.md index 2c79588..5e13f8d 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,16 @@ 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) # 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. @@ -41,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 @@ -119,6 +120,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, @@ -135,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) | @@ -145,6 +151,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`. | @@ -195,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 register 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 register 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 @@ -209,6 +296,7 @@ services: - 5020:5020 volumes: - ./server.json:/server_config.json:ro + - ./data:/data:rw ``` # Donate 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). 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 0000000..66eefe5 Binary files /dev/null and b/arc42/diagrams/modbus_master_slave_sequence.png differ diff --git a/arc42/diagrams/modbus_master_slave_sequence.svg b/arc42/diagrams/modbus_master_slave_sequence.svg new file mode 100644 index 0000000..806b158 --- /dev/null +++ b/arc42/diagrams/modbus_master_slave_sequence.svg @@ -0,0 +1 @@ +Persistence 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) 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..90a8aa2 100644 --- a/examples/test.json +++ b/examples/test.json @@ -9,13 +9,17 @@ "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" } }, + "persistence": { + "enabled": true, + "file": "/data/modbus_registers.json", + "saveInterval": 30 + }, "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, diff --git a/src/app/__init__.py b/src/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/lib/register_persistence/__init__.py b/src/app/lib/register_persistence/__init__.py new file mode 100644 index 0000000..278bdaa --- /dev/null +++ b/src/app/lib/register_persistence/__init__.py @@ -0,0 +1,209 @@ +# -*- 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-08 +###############################################################################\n +""" + +__author__ = "Michael Oberdorf " +__status__ = "production" +__date__ = "2026-02-08" +__version_info__ = ("1", "0", "2") +__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 + for key, value in store.values.items(): + if value != 0: + if register_type in ["d", "c"]: + # For bit registers, include all values (including False) + result[key] = bool(value) + else: + # 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 + for addr in range(0, 65536): + values = store.getValues(addr, 1) + if values and values[0] != 0: + if register_type in ["d", "c"]: + result[addr] = bool(values[0]) + else: + 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 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, diff --git a/src/app/modbus_server.py b/src/app/modbus_server.py index 288ed7f..9f93ae7 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,134 @@ 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 + + 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: + 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: + """ + 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 +234,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 +258,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 +388,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 +410,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, ) 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__) diff --git a/tests/test_register_persistence.py b/tests/test_register_persistence.py new file mode 100644 index 0000000..348755a --- /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} + + 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) 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: