diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4699cb1..a03c6b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: fix-byte-order-marker - id: check-ast @@ -10,7 +10,6 @@ repos: - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - - id: fix-encoding-pragma - id: requirements-txt-fixer - id: mixed-line-ending args: ['--fix=lf'] @@ -18,8 +17,12 @@ repos: - id: detect-aws-credentials args: ['--allow-missing-credentials'] - id: detect-private-key + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade - repo: https://github.com/myint/autoflake - rev: v2.3.1 + rev: v2.3.3 hooks: - id: autoflake args: @@ -27,11 +30,11 @@ repos: - --remove-unused-variables - --remove-all-unused-imports - repo: https://github.com/hadolint/hadolint - rev: v2.12.0 + rev: v2.14.0 hooks: - id: hadolint-docker - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.7.0 + rev: v0.15.2 hooks: - id: ruff args: @@ -39,7 +42,7 @@ repos: - '--fix' - '--exit-non-zero-on-fix' - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 8.0.0 hooks: - id: isort name: isort (python) @@ -48,7 +51,7 @@ repos: - black - '--filter-files' - repo: https://github.com/psf/black - rev: 24.10.0 + rev: 26.1.0 hooks: - id: black args: diff --git a/Dockerfile b/Dockerfile index ccb64ba..7ec6027 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM alpine:3.23.3 LABEL maintainer="Michael Oberdorf IT-Consulting " -LABEL site.local.program.version="2.1.0" +LABEL site.local.program.version="2.2.0" RUN apk upgrade --available --no-cache --update \ && apk add --no-cache --update \ @@ -19,6 +19,7 @@ RUN pip3 install --no-cache-dir -r /requirements.txt --break-system-packages EXPOSE 5020/tcp EXPOSE 5020/udp +EXPOSE 9090/tcp USER 1434:1434 diff --git a/Dockerfile.test b/Dockerfile.test index 3897a26..fb3b811 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,7 +1,7 @@ FROM alpine:3.23.2 LABEL maintainer="Michael Oberdorf IT-Consulting " -LABEL site.local.program.version="2.1.0" +LABEL site.local.program.version="2.2.0" RUN apk upgrade --available --no-cache --update \ && apk add --no-cache --update \ @@ -20,6 +20,7 @@ RUN pip3 install --no-cache-dir -r /requirements.txt --break-system-packages EXPOSE 5020/tcp EXPOSE 5020/udp +EXPOSE 9090/tcp USER 1434:1434 diff --git a/README.md b/README.md index 5e13f8d..82753c1 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,28 @@ Source code: [GitHub](https://github.com/cybcon/modbus-server) Container image: [DockerHub](https://hub.docker.com/r/oitc/modbus-server) -[![][github-action-test-shield]][github-action-test-link] -[![][github-action-release-shield]][github-action-release-link] -[![][github-release-shield]][github-release-link] -[![][github-releasedate-shield]][github-releasedate-link] -[![][github-stars-shield]][github-stars-link] -[![][github-forks-shield]][github-forks-link] -[![][github-issues-shield]][github-issues-link] -[![][github-license-shield]][github-license-link] - -[![][docker-release-shield]][docker-release-link] -[![][docker-pulls-shield]][docker-pulls-link] -[![][docker-stars-shield]][docker-stars-link] -[![][docker-size-shield]][docker-size-link] - -# Supported tags and respective `Dockerfile` links - -* [`latest`, `2.1.0`](https://github.com/cybcon/modbus-server/blob/v2.1.0/Dockerfile) +[![GitHub Tests][github-action-test-shield]][github-action-test-link] +[![GitHub Release][github-action-release-shield]][github-action-release-link] +[![Latest Release][github-release-shield]][github-release-link] +[![Release Date][github-releasedate-shield]][github-releasedate-link] +[![GitHub Stars][github-stars-shield]][github-stars-link] +[![GitHub Forks][github-forks-shield]][github-forks-link] +[![GitHub Issues][github-issues-shield]][github-issues-link] +[![License][github-license-shield]][github-license-link] + +[![Docker Release][docker-release-shield]][docker-release-link] +[![Docker Pulls][docker-pulls-shield]][docker-pulls-link] +[![Docker Stars][docker-stars-shield]][docker-stars-link] +[![Docker Size][docker-size-shield]][docker-size-link] + +## Supported tags and respective `Dockerfile` links + +* [`latest`, `2.2.0`](https://github.com/cybcon/modbus-server/blob/v2.2.0/Dockerfile) +* [`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? +## What is Modbus TCP Server? The Modbus TCP Server is a simple, written in python, Modbus TCP server. The Modbus registers can also be predefined with values. @@ -39,7 +38,7 @@ for enhanced tests with modbus masters and to test collecting values from differ The Modbus specification can be found here: [PDF](https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf) -# Own Docker builds and version pinning +## 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 practice and described in [Hadolint DL3018](https://github.com/hadolint/hadolint/wiki/DL3018). @@ -47,16 +46,14 @@ This is a best practice and described in [Hadolint DL3018](https://github.com/ha 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 successful today and fail tomorrow. -See also here: https://github.com/hadolint/hadolint/issues/464 - +See also here: [https://github.com/hadolint/hadolint/issues/464](https://github.com/hadolint/hadolint/issues/464) The [Dockerfile](./Dockerfile) in this repo may have an not working stand of pinned versions. When you run in errors during your own build, please: 1. Update the versions inside the Dockerfile for your own 2. Don't create an issue in the Github repo, because this is a known issue - -# QuickStart with Modbus TCP Server and Docker +## QuickStart with Modbus TCP Server and Docker Step - 1 : Pull the Modbus TCP Server @@ -85,8 +82,9 @@ or you mount the config file over the default file, then you can skip the file p docker run --rm -p 5020:5020 -v ./server_config.json:/app/modbus_server.json oitc/modbus-server:latest ``` -# Configuration -## Container configuration +## Configuration + +### Container configuration The container reads some configuration via environment variables. @@ -94,13 +92,13 @@ The container reads some configuration via environment variables. |------------------------------|------------------------------------------------------------------------------------|--------------|---------------------------| | `CONFIG_FILE` | The configuration file that that should be used to build the initial Modbus slave. | **OPTIONAL** | `/app/modbus_server.json` | +### Parameters -## Parameter Alternatively, the container can also be configured with a command line option `-f ` instead of an environment variable. By default, the script will use `/app/modbus_server.json`. +### Configuration file -## Configuration file -### Default configuration file of the container +#### Default configuration file of the container The `/app/modbus_server.json` file comes with following content: @@ -125,6 +123,12 @@ The `/app/modbus_server.json` file comes with following content: "file": "/data/modbus_registers.json", "saveInterval": 30 }, +"metrics": { + "enabled": false, + "address": "0.0.0.0", + "port": 9090, + "path": "/metrics" +}, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, @@ -136,12 +140,12 @@ The `/app/modbus_server.json` file comes with following content: } ``` -### Field description +#### Field description | Field | Type | Description | |------------------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------| | `server` | Object | Modbus slave specific runtime parameters. | -| `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.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) | @@ -149,12 +153,17 @@ The `/app/modbus_server.json` file comes with following content: | `server.tlsParams.privateKey` | String | Filesystem path of the private key to use for a TLS encrypted communication. | | `server.tlsParams.certificate` | String | Filesystem path of the TLS certificate to use for a TLS encrypted communication. | | `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.format` | String | The format of the log messages. | | `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). | +| `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). | +| `metrics` | Object | Configuration of the Prometheus/Open Telemetry exporter. | +| `metrics.enabled` | Boolean | If `true` the metrics endpoint will be enabled. | +| `metrics.address` | String | The IPv4 address to bind to for the metrics endpoint. `0.0.0.0` lets the server listen on all interface addresses. | +| `metrics.port` | Integer | TCP port of the HTTP metrics endpoint (default: `9090`). | +| `metrics.path` | String | The URL path, where the endpoint serves the metrics (default: `/metrics`). | | `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`. | @@ -163,14 +172,19 @@ The `/app/modbus_server.json` file comes with following content: | `registers.holdingRegister` | Object | The pre-defined registers of the register type "Holding Registers". | | `registers.inputRegister` | Object | The pre-defined registers of the register type "Input Registers". | -### Pre-define Registers within the configuration file +#### Log message formatting + +The format of the log message, defined in `server.logging.format` is described here: [https://docs.python.org/3/library/logging.html#logrecord-attributes](https://docs.python.org/3/library/logging.html#logrecord-attributes). + +#### Pre-define Registers within the configuration file Pre-define registers always starts with the register number. We use a json format as configuration file, so the "key" needs to be a string. So, the register number needs also to be a string. During server initialization, the json key that represents the register number will be converted to an integer. As by the modbus spec, the "Discrete Input" and "Coils" registers contains a single bit. In the json configuration file, we use `true` or `false` as register values. Example configuration of pre-defined registers from type "Discrete Input" or "Coils": -``` + +```json [..] "": { "0": true, @@ -185,7 +199,8 @@ As by the modbus spec, the "Holding Registers" and "Input Registers" tables cont With v1.2.0 of the modbus-server, you can also use integer values (0-65535) instead. Example configuration of pre-defined registers from type "Holding Registers" or "Input Registers": -``` + +```json [..] "": { "9": "0xAA00", @@ -196,38 +211,39 @@ Example configuration of pre-defined registers from type "Holding Registers" or [..] ``` +### Configuration file examples -## Configuration file examples +* [src/app/modbus_server.json](https://github.com/cybcon/modbus-server/blob/main/src/app/modbus_server.json) +* [examples/abb_coretec_example.json](https://github.com/cybcon/modbus-server/blob/main/examples/abb_coretec_example.json) +* [examples/test.json](https://github.com/cybcon/modbus-server/blob/main/examples/test.json) +* [examples/udp.json](https://github.com/cybcon/modbus-server/blob/main/examples/udp.json) -- [src/app/modbus_server.json](https://github.com/cybcon/modbus-server/blob/main/src/app/modbus_server.json) -- [examples/abb_coretec_example.json](https://github.com/cybcon/modbus-server/blob/main/examples/abb_coretec_example.json) -- [examples/test.json](https://github.com/cybcon/modbus-server/blob/main/examples/test.json) -- [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. -# Data persistence +### Functionality -The persistence layer enables all register changes (made by Modbus write accesses) to be automatically saved and restored after the server is restarted. +#### When starting up -## Functionality +* 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` -### 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 -### 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) +* 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. +#### When shutting down -## Configuration +* A final save is performed. +* All current register values are backed up. -### Enable persistence +### Configuration of the persistence layer + +#### Enable persistence Add the following section to your `modbus_server.json`: @@ -239,11 +255,12 @@ Add the following section to your `modbus_server.json`: "file": "/app/modbus_registers.json", "saveInterval": 30 }, + "metrics": { ... }, "registers": { ... } } ``` -## Persistence file format +### Persistence file format The persistence file is saved as JSON: @@ -273,7 +290,7 @@ The persistence file is saved as JSON: **Hint:** Only registers with values ≠ 0 are stored (space-saving). -## Backup +### 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. @@ -282,8 +299,58 @@ For critical applications, you should create regular backups. When using Docker, 0 2 * * * cp /local/path/to/modbus_registers.json /local/backuppath/to/modbus_registers_$(date +\%Y\%m\%d).json ``` +## Metrics endpoint + +When activating the metrics endpoint, the modbus server provides to html endpoints: + +* `/health`: the health endpoint only shows the text string `OK` and can be used for monitoring. There is no other functionality behind. +* `/metrics`: the metrics endpoint that provides system and operational metrics about python and the modbus server usage. The endpoint path can be changed in the configuration file. + +### Metrics endpoint metrics -# Docker compose configuration +Following metrics will be collected: + +* memory_total_bytes: Gauge of total system memory +* memory_available_bytes: Gauge of available system memory +* memory_consumption_bytes: Gauge of current memory usage +* memory_consumption_percentage: Gauge of memory usage percentage +* cpu_usage_percentage: Gauge of current CPU usage percentage +* cpu_count: Gauge of number of CPU cores +* cpu_load1: Gauge of 1-minute load average per CPU +* cpu_load5: Gauge of 5-minute load average per CPU +* cpu_load15: Gauge of 15-minute load average per CPU +* cpu_load1_percentage: Gauge of 1-minute load average as percentage of CPU capacity +* cpu_load5_percentage: Gauge of 5-minute load average as percentage of CPU capacity +* cpu_load15_percentage: Gauge of 15-minute load average as percentage of CPU capacity +* modbus_requests_total: Counter of requests by function code +* modbus_register_reads_total: Counter of read operations per register +* modbus_register_writes_total: Counter of write operations per register +* modbus_errors_total: Counter of errors by exception code +* modbus_server_uptime_seconds: Counter of server uptime + +### Configuration of the metrics endpoint + +Add the following section to your `modbus_server.json`: + +```json +{ + "server": { ... }, + "persistence": { ... }, + "metrics": { + "enabled": true, + "address": "0.0.0.0", + "port": 9090, + "path": "/metrics" + }, + "registers": { ... } +} +``` + +### Metrics output example + +An output example of the metrics can be found here: [examples/metrics_example.txt](https://github.com/cybcon/modbus-server/blob/main/examples/metrics_example.txt) + +## Docker compose configuration ```yaml services: @@ -294,18 +361,19 @@ services: command: -f /server_config.json ports: - 5020:5020 + - 9090:9090 volumes: - ./server.json:/server_config.json:ro - ./data:/data:rw ``` -# Donate -I would appreciate a small donation to support the further development of my open source projects. +## Donate -Donate with PayPal +I would appreciate a small donation to support the further development of my open source projects. +[![Donate with PayPal][donate-paypal-button]][donate-paypal-link] -# License +## License Copyright (c) 2020-2026 Michael Oberdorf IT-Consulting @@ -336,10 +404,11 @@ SOFTWARE. [docker-size-shield]: https://img.shields.io/docker/image-size/oitc/modbus-server?color=369eff&labelColor=black&style=flat-square [docker-stars-link]: https://hub.docker.com/r/oitc/modbus-server [docker-stars-shield]: https://img.shields.io/docker/stars/oitc/modbus-server?color=45cc11&labelColor=black&style=flat-square +[donate-paypal-button]: https://raw.githubusercontent.com/cybcon/paypal-donate-button/refs/heads/master/paypal-donate-button_200x77.png +[donate-paypal-link]: https://www.paypal.com/donate/?hosted_button_id=BHGJGGUS6RH44 [github-action-release-link]: https://github.com/cybcon/modbus-server/actions/workflows/release-from-label.yaml [github-action-release-shield]: https://img.shields.io/github/actions/workflow/status/cybcon/modbus-server/release-from-label.yaml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square [github-action-test-link]: https://github.com/cybcon/modbus-server/actions/workflows/test.yaml -[github-action-test-shield-original]: https://github.com/cybcon/modbus-server/actions/workflows/test.yaml/badge.svg [github-action-test-shield]: https://img.shields.io/github/actions/workflow/status/cybcon/modbus-server/test.yaml?label=tests&labelColor=black&logo=githubactions&logoColor=white&style=flat-square [github-forks-link]: https://github.com/cybcon/modbus-server/network/members [github-forks-shield]: https://img.shields.io/github/forks/cybcon/modbus-server?color=8ae8ff&labelColor=black&style=flat-square diff --git a/examples/abb_coretec_example.json b/examples/abb_coretec_example.json index 6752712..aa6c106 100644 --- a/examples/abb_coretec_example.json +++ b/examples/abb_coretec_example.json @@ -13,6 +13,17 @@ "logLevel": "DEBUG" } }, +"persistence": { + "enabled": false, + "file": "/data/modbus_registers.json", + "saveInterval": 30 +}, +"metrics": { + "enabled": false, + "address": "0.0.0.0", + "port": 9090, + "path": "/metrics" +}, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, diff --git a/examples/metrics_example.txt b/examples/metrics_example.txt new file mode 100644 index 0000000..e404675 --- /dev/null +++ b/examples/metrics_example.txt @@ -0,0 +1,103 @@ +# HELP memory_total_bytes Total available memory in bytes +# TYPE memory_total_bytes gauge +memory_total_bytes 4093587456 + +# HELP memory_available_bytes Current available memory in bytes +# TYPE memory_available_bytes gauge +memory_available_bytes 325832704 + +# HELP memory_consumption_bytes Current memory consumption in bytes +# TYPE memory_consumption_bytes gauge +memory_consumption_bytes 3524468736 + +# HELP memory_consumption_percentage Current memory consumption percentage +# TYPE memory_consumption_percentage gauge +memory_consumption_percentage 92.0 + +# HELP cpu_usage_percentage Current CPU usage percentage +# TYPE cpu_usage_percentage gauge +cpu_usage_percentage 0.0 + +# HELP cpu_count Number of CPU cores +# TYPE cpu_count gauge +cpu_count 2 + +# HELP cpu_load1 Load average over 1 minute +# TYPE cpu_load1 gauge +cpu_load1 0.085 + +# HELP cpu_load5 Load average over 5 minutes +# TYPE cpu_load5 gauge +cpu_load5 0.215 + +# HELP cpu_load15 Load average over 15 minutes +# TYPE cpu_load15 gauge +cpu_load15 0.22 + +# HELP cpu_load1_percentage Load average percentage over 1 minute +# TYPE cpu_load1_percentage gauge +cpu_load1_percentage 8.5 + +# HELP cpu_load5_percentage Load average percentage over 5 minutes +# TYPE cpu_load5_percentage gauge +cpu_load5_percentage 21.5 + +# HELP cpu_load15_percentage Load average percentage over 15 minutes +# TYPE cpu_load15_percentage gauge +cpu_load15_percentage 22.0 + +# HELP modbus_requests_total Total number of Modbus requests received, by function code +# TYPE modbus_requests_total counter +modbus_requests_total{function_code="01",function_name="read_coils"} 43 +modbus_requests_total{function_code="02",function_name="read_discrete_inputs"} 1 +modbus_requests_total{function_code="03",function_name="read_holding_registers"} 34 +modbus_requests_total{function_code="05",function_name="write_single_coil"} 3 +modbus_requests_total{function_code="06",function_name="write_single_register"} 2 + +# HELP modbus_register_reads_total Total number of read operations per register +# TYPE modbus_register_reads_total counter +modbus_register_reads_total{address="2",type="coil"} 37 +modbus_register_reads_total{address="3",type="coil"} 34 +modbus_register_reads_total{address="4",type="coil"} 34 +modbus_register_reads_total{address="5",type="coil"} 34 +modbus_register_reads_total{address="6",type="coil"} 37 +modbus_register_reads_total{address="7",type="coil"} 34 +modbus_register_reads_total{address="8",type="coil"} 34 +modbus_register_reads_total{address="9",type="coil"} 34 +modbus_register_reads_total{address="10",type="coil"} 37 +modbus_register_reads_total{address="11",type="coil"} 34 +modbus_register_reads_total{address="2",type="discrete_input"} 1 +modbus_register_reads_total{address="3",type="discrete_input"} 1 +modbus_register_reads_total{address="4",type="discrete_input"} 1 +modbus_register_reads_total{address="5",type="discrete_input"} 1 +modbus_register_reads_total{address="6",type="discrete_input"} 1 +modbus_register_reads_total{address="7",type="discrete_input"} 1 +modbus_register_reads_total{address="8",type="discrete_input"} 1 +modbus_register_reads_total{address="9",type="discrete_input"} 1 +modbus_register_reads_total{address="10",type="discrete_input"} 1 +modbus_register_reads_total{address="11",type="discrete_input"} 1 +modbus_register_reads_total{address="2",type="holding"} 29 +modbus_register_reads_total{address="3",type="holding"} 29 +modbus_register_reads_total{address="4",type="holding"} 29 +modbus_register_reads_total{address="5",type="holding"} 29 +modbus_register_reads_total{address="6",type="holding"} 29 +modbus_register_reads_total{address="7",type="holding"} 31 +modbus_register_reads_total{address="8",type="holding"} 29 +modbus_register_reads_total{address="9",type="holding"} 29 +modbus_register_reads_total{address="10",type="holding"} 29 +modbus_register_reads_total{address="11",type="holding"} 32 + +# HELP modbus_register_writes_total Total number of write operations per register +# TYPE modbus_register_writes_total counter +modbus_register_writes_total{address="2",type="coil"} 1 +modbus_register_writes_total{address="6",type="coil"} 1 +modbus_register_writes_total{address="10",type="coil"} 1 +modbus_register_writes_total{address="7",type="holding"} 1 +modbus_register_writes_total{address="11",type="holding"} 1 + +# HELP modbus_errors_total Total number of Modbus errors returned, by exception code +# TYPE modbus_errors_total counter + +# HELP modbus_server_uptime_seconds Total uptime of the mock server in seconds +# TYPE modbus_server_uptime_seconds counter +modbus_server_uptime_seconds 90.30 diff --git a/examples/test.json b/examples/test.json index 90a8aa2..5eb5df5 100644 --- a/examples/test.json +++ b/examples/test.json @@ -13,11 +13,17 @@ "logLevel": "DEBUG" } }, - "persistence": { - "enabled": true, - "file": "/data/modbus_registers.json", - "saveInterval": 30 - }, +"persistence": { + "enabled": true, + "file": "/data/modbus_registers.json", + "saveInterval": 30 +}, +"metrics": { + "enabled": true, + "address": "0.0.0.0", + "port": 9090, + "path": "/metrics" +}, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, diff --git a/examples/udp.json b/examples/udp.json index d35c30d..59b7395 100644 --- a/examples/udp.json +++ b/examples/udp.json @@ -13,6 +13,17 @@ "logLevel": "DEBUG" } }, +"persistence": { + "enabled": false, + "file": "/data/modbus_registers.json", + "saveInterval": 30 +}, +"metrics": { + "enabled": false, + "address": "0.0.0.0", + "port": 9090, + "path": "/metrics" +}, "registers": { "description": "initial values for the register types", "initializeUndefinedRegisters": true, diff --git a/src/app/lib/register_persistence/__init__.py b/src/app/lib/register_persistence/__init__.py index 278bdaa..36479d5 100644 --- a/src/app/lib/register_persistence/__init__.py +++ b/src/app/lib/register_persistence/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ############################################################################### # Library to make register writes persistent across restarts of the modbus server. @@ -7,14 +6,14 @@ # Author: Michael Oberdorf # Date: 2026-02-07 # Last modified by: Michael Oberdorf -# Last modified at: 2026-02-08 +# Last modified at: 2026-02-21 ###############################################################################\n """ __author__ = "Michael Oberdorf " __status__ = "production" -__date__ = "2026-02-08" -__version_info__ = ("1", "0", "2") +__date__ = "2026-02-21" +__version_info__ = ("1", "1", "0") __version__ = ".".join(__version_info__) __all__ = ["RegisterPersistence"] @@ -73,7 +72,7 @@ def load_registers(self) -> Optional[dict]: return None try: - with open(self.persistence_file, "r", encoding="utf-8") as f: + with open(self.persistence_file, encoding="utf-8") as f: data = json.load(f) self.logger.info(f"Successfully loaded register data from {self.persistence_file}") return data @@ -147,6 +146,15 @@ def _extract_register_values(self, slave_context: ModbusServerContext, register_ else: return result + # Unwrap any wrapper blocks (e.g. metrics wrappers) to access the underlying store + try: + # unwrap multiple layers if necessary + while hasattr(store, "wrapped_block"): + store = getattr(store, "wrapped_block") + except Exception: + # if unwrapping fails, log and continue with original store + self.logger.debug("Failed to unwrap store wrapper, continuing with original store") + # Check if it's a sparse or sequential block if isinstance(store, ModbusSparseDataBlock): # Sparse blocks have a values dict diff --git a/src/app/lib/telemetry/__init__.py b/src/app/lib/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/lib/telemetry/metrics_datastore.py b/src/app/lib/telemetry/metrics_datastore.py new file mode 100644 index 0000000..e541617 --- /dev/null +++ b/src/app/lib/telemetry/metrics_datastore.py @@ -0,0 +1,127 @@ +""" +############################################################################### +# Modbus datastore wrapper with metrics tracking. +# This module wraps the pymodbus datastore to track read and write operations +# for Prometheus metrics. +#------------------------------------------------------------------------------ +# Author: Michael Oberdorf +# Date: 2026-02-18 +# Last modified by: Michael Oberdorf +# Last modified at: 2026-02-21 +###############################################################################\n +""" + +__author__ = "Michael Oberdorf " +__status__ = "production" +__date__ = "2026-02-21" +__version_info__ = ("1", "0", "2") +__version__ = ".".join(__version_info__) + +__all__ = ["MetricsTrackingDataBlock"] + +import logging + +from pymodbus.datastore.store import BaseModbusDataBlock + +from .prometheus_metrics import PrometheusMetrics + +log = logging.getLogger(__name__) + + +class MetricsTrackingDataBlock(BaseModbusDataBlock): + """ + Wrapper around a Modbus data block that tracks read/write operations. + + This class wraps an existing Modbus data block and intercepts calls to getValues and setValues. + It uses a PrometheusMetrics instance to record the number of reads and writes for each register + address. The register type (discrete input, coil, holding register, input register) is also + tracked to allow for more detailed metrics. + """ + + def __init__(self, wrapped_block: BaseModbusDataBlock, metrics_collector: PrometheusMetrics, register_type: str): + """ + Initialize the metrics tracking data block. + + :param wrapped_block: The original data block to wrap + :type wrapped_block: BaseModbusDataBlock + :param metrics_collector: PrometheusMetrics instance for recording metrics + :type metrics_collector: PrometheusMetrics + :param register_type: Type of register ('d' for discrete input, 'c' for coil, 'h' for holding register, 'i' for input register) + :type register_type: str + :return: None + """ + self.wrapped_block = wrapped_block + self.metrics_collector = metrics_collector + self.register_type = register_type + + # Initialize parent with same values as wrapped block + super().__init__() + + def validate(self, address: int, count: int = 1) -> bool: + """ + Validate the request. + + This method can be used to track validation attempts if needed. + + :param address: Starting address + :type address: int + :param count: Number of values to validate (default: 1) + :type count: int + :return: Result of validation + :rtype: bool + """ + return self.wrapped_block.validate(address, count) + + def getValues(self, address: int, count: int = 1) -> list: + """ + Get values and track the read operation. + + :param address: Starting address + :type address: int + :param count: Number of values to read (default: 1) + :type count: int + :return: List of values read from the data block + :rtype: list + :raises Exception: If the underlying data block raises an exception during the getValues call + """ + # Track the read operation for each address + if self.metrics_collector: + # infer request function code from register type and record a single request + if self.register_type == "c": + self.metrics_collector.record_request(1) # read coils + elif self.register_type == "d": + self.metrics_collector.record_request(2) # read discrete inputs + elif self.register_type == "h": + self.metrics_collector.record_request(3) # read holding registers + elif self.register_type == "i": + self.metrics_collector.record_request(4) # read input registers + for addr in range(address, address + count): + self.metrics_collector.record_register_read(self.register_type, addr, count=1) + + return self.wrapped_block.getValues(address, count) + + def setValues(self, address: int, values: list) -> None: + """ + Set values and track the write operation. + + :param address: Starting address + :type address: int + :param values: List of values to write + :type values: list + :return: None + :raises Exception: If the underlying data block raises an exception during the setValues call + """ + # Track the write operation for each address and infer request type + if self.metrics_collector: + count = len(values) if isinstance(values, list) else 1 + # infer and record a single request for the write + if self.register_type == "c": + # coils: single (5) vs multiple (15) + self.metrics_collector.record_request(5 if count == 1 else 15) + elif self.register_type == "h": + # holding registers: single (6) vs multiple (16) + self.metrics_collector.record_request(6 if count == 1 else 16) + for i, addr in enumerate(range(address, address + count)): + self.metrics_collector.record_register_write(self.register_type, addr, count=1) + + return self.wrapped_block.setValues(address, values) diff --git a/src/app/lib/telemetry/metrics_server.py b/src/app/lib/telemetry/metrics_server.py new file mode 100644 index 0000000..a248135 --- /dev/null +++ b/src/app/lib/telemetry/metrics_server.py @@ -0,0 +1,214 @@ +""" +############################################################################### +# HTTP server for Prometheus metrics endpoint. +# This module provides a simple HTTP server that exposes the /metrics endpoint +# for Prometheus/OpenTelemetry scraping. +#------------------------------------------------------------------------------ +# Author: Michael Oberdorf +# Date: 2026-02-18 +# Last modified by: Michael Oberdorf +# Last modified at: 2026-02-21 +###############################################################################\n +""" + +__author__ = "Michael Oberdorf " +__status__ = "production" +__date__ = "2026-02-21" +__version_info__ = ("1", "0", "0") +__version__ = ".".join(__version_info__) + +__all__ = ["MetricsRequestHandler", "MetricsServer"] + +import logging +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Optional + +from .prometheus_metrics import PrometheusMetrics + +log = logging.getLogger(__name__) + + +class MetricsRequestHandler(BaseHTTPRequestHandler): + """ + HTTP request handler for the /metrics endpoint. + """ + + # Class variable to hold the metrics collector instance + metrics_collector = None + # Class variable for the metrics path (can be customized if needed) + metrics_path = "/metrics" + + def log_message(self, format: str, *args: tuple): + """ + Override to use the standard logging framework. + + :param format: The format string for the log message + :type format: str + :param args: Arguments to be formatted into the log message + :type args: tuple + :return: None + """ + log.debug(f"{self.address_string()} - {format % args}") + + def do_GET(self): + """ + Handle GET requests. + + Routes requests to the appropriate handler based on the path. + """ + if self.path == "/metrics": + self._handle_metrics() + elif self.path == "/health" or self.path == "/": + self._handle_health() + else: + self.send_error(404, "Not Found") + + def _handle_metrics(self): + """ + Handle requests to the /metrics endpoint. + + This method generates the metrics output and sends it back to the client. + """ + if self.metrics_collector is None: + self.send_error(500, "Metrics collector not initialized") + return + + try: + metrics_output = self.metrics_collector.generate_metrics() + + self.send_response(200) + self.send_header("Content-Type", f"text/plain; version={__version__}; charset=utf-8") + self.send_header("Content-Length", str(len(metrics_output))) + self.end_headers() + self.wfile.write(metrics_output.encode("utf-8")) + + except Exception as e: + log.error(f"Error generating metrics: {e}", exc_info=True) + self.send_error(500, f"Internal Server Error: {str(e)}") + + def _handle_health(self): + """ + Handle requests to the /health endpoint. + + This can be used for health checks by load balancers or monitoring systems. + """ + response = "OK\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response.encode("utf-8")) + + +class MetricsServer: + """ + HTTP server for exposing Prometheus metrics. + + Runs in a separate thread to avoid blocking the Modbus server. + """ + + def __init__( + self, metrics_collector: PrometheusMetrics, address: str = "0.0.0.0", port: int = 9090, path: str = "/metrics" + ): + """ + Initialize the metrics server. + + :param metrics_collector: Instance of PrometheusMetrics to generate metrics from + :type metrics_collector: PrometheusMetrics + :param address: Address to bind to (default: 0.0.0.0) + :type address: str + :param port: Port to listen on (default: 9090) + :type port: int + :param path: Path for the metrics endpoint (default: /metrics) + :type path: str + :return: None + """ + self.metrics_collector = metrics_collector + self.address = address + self.port = port + self.path = path + self.server: Optional[HTTPServer] = None + self.thread: Optional[threading.Thread] = None + self._running = False + + def start(self): + """ + Start the metrics server in a separate thread. + + :return: None + :raises Exception: If the server fails to start + """ + if self._running: + log.warning("Metrics server is already running") + return + + # Set the class variables so the handler can access the metrics collector and path + MetricsRequestHandler.metrics_collector = self.metrics_collector + MetricsRequestHandler.metrics_path = self.path + + try: + # HTTPServer expects a handler *class*, not an instance. + # Pass the class itself so the server can instantiate handlers + # for each incoming request. + self.server = HTTPServer((self.address, self.port), MetricsRequestHandler) + self._running = True + + # Start server in a daemon thread + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + log.info(f"Prometheus metrics server started on {self.address}:{self.port}") + log.info(f"Metrics endpoint: http://{self.address}:{self.port}{self.path}") + + except Exception as e: + log.error(f"Failed to start metrics server: {e}", exc_info=True) + self._running = False + raise + + def _serve(self): + """ + Serve HTTP requests (runs in separate thread). + + :return: None + :raises Exception: If the server encounters an error while serving + """ + try: + log.debug(f"Metrics server thread started, serving on {self.address}:{self.port}") + self.server.serve_forever() + except Exception as e: + log.error(f"Metrics server error: {e}", exc_info=True) + finally: + self._running = False + log.info("Metrics server thread stopped") + + def stop(self): + """ + Stop the metrics server. + + :return: None + """ + if not self._running: + log.debug("Metrics server is not running") + return + + log.info("Stopping metrics server...") + self._running = False + + if self.server: + self.server.shutdown() + self.server.server_close() + + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=5) + + log.info("Metrics server stopped") + + def is_running(self) -> bool: + """ + Check if the metrics server is running. + + :return: True if running, False otherwise + :rtype: bool + """ + return self._running diff --git a/src/app/lib/telemetry/prometheus_metrics.py b/src/app/lib/telemetry/prometheus_metrics.py new file mode 100644 index 0000000..6043f4e --- /dev/null +++ b/src/app/lib/telemetry/prometheus_metrics.py @@ -0,0 +1,354 @@ +""" +############################################################################### +# Prometheus metrics collector for Modbus Server. +# This module provides a Prometheus-compatible metrics endpoint that exposes +# real-time counters and gauges for Modbus activity. +#------------------------------------------------------------------------------ +# Author: Michael Oberdorf +# Date: 2026-02-18 +# Last modified by: Michael Oberdorf +# Last modified at: 2026-02-21 +###############################################################################\n +""" + +__author__ = "Michael Oberdorf " +__status__ = "production" +__date__ = "2026-02-21" +__version_info__ = ("1", "1", "1") +__version__ = ".".join(__version_info__) + +__all__ = ["PrometheusMetrics"] + +import threading +import time +from collections import defaultdict +from typing import Dict, Optional + +import psutil + + +class PrometheusMetrics: + """ + Collects and exposes Prometheus metrics for Modbus server activity. + + Metrics collected: + - memory_total_bytes: Gauge of total system memory + - memory_available_bytes: Gauge of available system memory + - memory_consumption_bytes: Gauge of current memory usage + - memory_consumption_percentage: Gauge of memory usage percentage + - cpu_usage_percentage: Gauge of current CPU usage percentage + - cpu_count: Gauge of number of CPU cores + - cpu_load1: Gauge of 1-minute load average per CPU + - cpu_load5: Gauge of 5-minute load average per CPU + - cpu_load15: Gauge of 15-minute load average per CPU + - cpu_load1_percentage: Gauge of 1-minute load average as percentage of CPU capacity + - cpu_load5_percentage: Gauge of 5-minute load average as percentage of CPU capacity + - cpu_load15_percentage: Gauge of 15-minute load average as percentage of CPU capacity + - modbus_requests_total: Counter of requests by function code + - modbus_register_reads_total: Counter of read operations per register + - modbus_register_writes_total: Counter of write operations per register + - modbus_errors_total: Counter of errors by exception code + - modbus_server_uptime_seconds: Counter of server uptime + + Metrics disabled: + - modbus_connected_clients: Gauge of current active client connections (disabled due to complexity of tracking in async environment) + """ + + # Modbus function code names for better readability + FUNCTION_CODE_NAMES = { + 1: "read_coils", + 2: "read_discrete_inputs", + 3: "read_holding_registers", + 4: "read_input_registers", + 5: "write_single_coil", + 6: "write_single_register", + 15: "write_multiple_coils", + 16: "write_multiple_registers", + } + + # Modbus exception code names + EXCEPTION_CODE_NAMES = { + 1: "illegal_function", + 2: "illegal_data_address", + 3: "illegal_data_value", + 4: "slave_device_failure", + 5: "acknowledge", + 6: "slave_device_busy", + 8: "memory_parity_error", + 10: "gateway_path_unavailable", + 11: "gateway_target_device_failed_to_respond", + } + + # Register type names + REGISTER_TYPE_NAMES = { + "d": "discrete_input", + "c": "coil", + "h": "holding", + "i": "input", + } + + def __init__(self): + """ + Initialize the metrics collector. + + :return: None + """ + self._lock = threading.Lock() + self._start_time = time.time() + + # Counters + self._requests_by_function = defaultdict(int) + self._register_reads = defaultdict(lambda: defaultdict(int)) # {type: {address: count}} + self._register_writes = defaultdict(lambda: defaultdict(int)) # {type: {address: count}} + self._errors_by_exception = defaultdict(int) + + # Gauges + self._connected_clients = 0 + + def record_request(self, function_code: int): + """ + Record a Modbus request. + + :param function_code: The Modbus function code of the request + :type function_code: int + :return: None + """ + with self._lock: + self._requests_by_function[function_code] += 1 + + def record_register_read(self, register_type: str, address: int, count: int = 1): + """ + Record register read operations. + + :param register_type: Type of register ('d', 'c', 'h', 'i') + :type register_type: str + :param address: Register address + :type address: int + :param count: Number of registers read (default: 1) + :type count: int + :return: None + """ + with self._lock: + self._register_reads[register_type][address] += count + + def record_register_write(self, register_type: str, address: int, count: int = 1): + """ + Record register write operations. + + :param register_type: Type of register ('c' for coils, 'h' for holding) + :type register_type: str + :param address: Register address + :type address: int + :param count: Number of registers written (default: 1) + :type count: int + :return: None + """ + with self._lock: + self._register_writes[register_type][address] += count + + def record_error(self, exception_code: int): + """ + Record a Modbus exception. + + :param exception_code: The Modbus exception code + :type exception_code: int + :return: None + """ + with self._lock: + self._errors_by_exception[exception_code] += 1 + + def set_connected_clients(self, count: int): + """ + Set the current number of connected clients. + + :param count: Number of currently connected clients + :type count: int + :return: None + """ + with self._lock: + self._connected_clients = count + + def increment_connected_clients(self): + """ + Increment the connected clients counter. + """ + with self._lock: + self._connected_clients += 1 + + def decrement_connected_clients(self): + """ + Decrement the connected clients counter. + """ + with self._lock: + self._connected_clients = max(0, self._connected_clients - 1) + + def get_uptime(self) -> float: + """ + Get the server uptime in seconds. + + :return: Server uptime in seconds + :rtype: float + """ + return time.time() - self._start_time + + def _format_metric_line( + self, name: str, value, labels: Optional[Dict[str, str]] = None, metric_type: Optional[str] = None + ) -> str: + """ + Format a single metric line in Prometheus format. + + :param name: Metric name + :type name: str + :param value: Metric value + :type value: int, float, or str + :param labels: Optional dict of label key-value pairs + :type labels: dict, optional + :param metric_type: Optional metric type for HELP/TYPE comments + :type metric_type: str, optional + :return: Formatted metric line + :rtype: str + """ + if labels: + label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items())) + return f"{name}{{{label_str}}} {value}" + return f"{name} {value}" + + def generate_metrics(self) -> str: + """ + Generate Prometheus metrics in text exposition format. + + :return: Metrics in Prometheus text format + :rtype: str + """ + lines = [] + + with self._lock: + # memory statistics + lines.append("# HELP memory_total_bytes Total available memory in bytes") + lines.append("# TYPE memory_total_bytes gauge") + lines.append(self._format_metric_line("memory_total_bytes", psutil.virtual_memory().total)) + lines.append("") + lines.append("# HELP memory_available_bytes Current available memory in bytes") + lines.append("# TYPE memory_available_bytes gauge") + lines.append(self._format_metric_line("memory_available_bytes", psutil.virtual_memory().available)) + lines.append("") + lines.append("# HELP memory_consumption_bytes Current memory consumption in bytes") + lines.append("# TYPE memory_consumption_bytes gauge") + lines.append(self._format_metric_line("memory_consumption_bytes", psutil.virtual_memory().used)) + lines.append("") + lines.append("# HELP memory_consumption_percentage Current memory consumption percentage") + lines.append("# TYPE memory_consumption_percentage gauge") + lines.append(self._format_metric_line("memory_consumption_percentage", psutil.virtual_memory().percent)) + + # cpu statistics + lines.append("") + lines.append("# HELP cpu_usage_percentage Current CPU usage percentage") + lines.append("# TYPE cpu_usage_percentage gauge") + lines.append(self._format_metric_line("cpu_usage_percentage", psutil.cpu_percent(interval=0.1))) + lines.append("") + lines.append("# HELP cpu_count Number of CPU cores") + lines.append("# TYPE cpu_count gauge") + lines.append(self._format_metric_line("cpu_count", psutil.cpu_count(logical=True))) + lines.append("") + lines.append("# HELP cpu_load1 Load average over 1 minute") + lines.append("# TYPE cpu_load1 gauge") + lines.append(self._format_metric_line("cpu_load1", psutil.getloadavg()[0] / psutil.cpu_count(logical=True))) + lines.append("") + lines.append("# HELP cpu_load5 Load average over 5 minutes") + lines.append("# TYPE cpu_load5 gauge") + lines.append(self._format_metric_line("cpu_load5", psutil.getloadavg()[1] / psutil.cpu_count(logical=True))) + lines.append("") + lines.append("# HELP cpu_load15 Load average over 15 minutes") + lines.append("# TYPE cpu_load15 gauge") + lines.append( + self._format_metric_line("cpu_load15", psutil.getloadavg()[2] / psutil.cpu_count(logical=True)) + ) + lines.append("") + lines.append("# HELP cpu_load1_percentage Load average percentage over 1 minute") + lines.append("# TYPE cpu_load1_percentage gauge") + lines.append( + self._format_metric_line( + "cpu_load1_percentage", round(psutil.getloadavg()[0] / psutil.cpu_count(logical=True) * 100, 1) + ) + ) + lines.append("") + lines.append("# HELP cpu_load5_percentage Load average percentage over 5 minutes") + lines.append("# TYPE cpu_load5_percentage gauge") + lines.append( + self._format_metric_line( + "cpu_load5_percentage", round(psutil.getloadavg()[1] / psutil.cpu_count(logical=True) * 100, 1) + ) + ) + lines.append("") + lines.append("# HELP cpu_load15_percentage Load average percentage over 15 minutes") + lines.append("# TYPE cpu_load15_percentage gauge") + lines.append( + self._format_metric_line( + "cpu_load15_percentage", round(psutil.getloadavg()[2] / psutil.cpu_count(logical=True) * 100, 1) + ) + ) + + # modbus_requests_total + lines.append("") + lines.append("# HELP modbus_requests_total Total number of Modbus requests received, by function code") + lines.append("# TYPE modbus_requests_total counter") + for func_code, count in sorted(self._requests_by_function.items()): + func_name = self.FUNCTION_CODE_NAMES.get(func_code, f"function_{func_code}") + labels = {"function_code": str(func_code).zfill(2), "function_name": func_name} + lines.append(self._format_metric_line("modbus_requests_total", count, labels)) + + # modbus_register_reads_total + lines.append("") + lines.append("# HELP modbus_register_reads_total Total number of read operations per register") + lines.append("# TYPE modbus_register_reads_total counter") + for reg_type, addresses in sorted(self._register_reads.items()): + type_name = self.REGISTER_TYPE_NAMES.get(reg_type, reg_type) + for address, count in sorted(addresses.items()): + labels = {"type": type_name, "address": str(address)} + lines.append(self._format_metric_line("modbus_register_reads_total", count, labels)) + + # modbus_register_writes_total + lines.append("") + lines.append("# HELP modbus_register_writes_total Total number of write operations per register") + lines.append("# TYPE modbus_register_writes_total counter") + for reg_type, addresses in sorted(self._register_writes.items()): + type_name = self.REGISTER_TYPE_NAMES.get(reg_type, reg_type) + for address, count in sorted(addresses.items()): + labels = {"type": type_name, "address": str(address)} + lines.append(self._format_metric_line("modbus_register_writes_total", count, labels)) + + # modbus_errors_total + lines.append("") + lines.append("# HELP modbus_errors_total Total number of Modbus errors returned, by exception code") + lines.append("# TYPE modbus_errors_total counter") + for exc_code, count in sorted(self._errors_by_exception.items()): + exc_name = self.EXCEPTION_CODE_NAMES.get(exc_code, f"exception_{exc_code}") + labels = {"exception_code": str(exc_code).zfill(2), "exception_name": exc_name} + lines.append(self._format_metric_line("modbus_errors_total", count, labels)) + + # modbus_connected_clients + # lines.append("") + # lines.append("# HELP modbus_connected_clients Current number of active Modbus TCP client connections") + # lines.append("# TYPE modbus_connected_clients gauge") + # lines.append(self._format_metric_line("modbus_connected_clients", self._connected_clients)) + + # modbus_server_uptime_seconds + lines.append("") + lines.append("# HELP modbus_server_uptime_seconds Total uptime of the mock server in seconds") + lines.append("# TYPE modbus_server_uptime_seconds counter") + uptime = self.get_uptime() + lines.append(self._format_metric_line("modbus_server_uptime_seconds", f"{uptime:.2f}")) + + return "\n".join(lines) + "\n" + + def reset_metrics(self): + """ + Reset all metrics (for testing purposes). + """ + with self._lock: + self._requests_by_function.clear() + self._register_reads.clear() + self._register_writes.clear() + self._errors_by_exception.clear() + self._connected_clients = 0 + self._start_time = time.time() diff --git a/src/app/modbus_server.json b/src/app/modbus_server.json index c1d4df8..6e8cef2 100644 --- a/src/app/modbus_server.json +++ b/src/app/modbus_server.json @@ -13,11 +13,17 @@ "logLevel": "INFO" } }, - "persistence": { - "enabled": false, - "file": "/data/modbus_registers.json", - "saveInterval": 30 +"persistence": { + "enabled": false, + "file": "/data/modbus_registers.json", + "saveInterval": 30 }, +"metrics": { + "enabled": false, + "address": "0.0.0.0", + "port": 9090, + "path": "/metrics" +}, "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 9f93ae7..615c939 100644 --- a/src/app/modbus_server.py +++ b/src/app/modbus_server.py @@ -1,21 +1,24 @@ -# -*- coding: utf-8 -*- -""" *************************************************************************** +"""*************************************************************************** Modbus TCP server script for debugging Author: Michael Oberdorf IT-Consulting Datum: 2020-03-30 Last modified by: Michael Oberdorf -Last modified at: 2026-02-07 -*************************************************************************** """ +Last modified at: 2026-02-21 +***************************************************************************""" + import argparse import json import logging import os import socket import sys -from typing import Literal, Optional +from typing import Literal, Optional, Tuple import pymodbus from lib.register_persistence import RegisterPersistence +from lib.telemetry.metrics_datastore import MetricsTrackingDataBlock +from lib.telemetry.metrics_server import MetricsServer +from lib.telemetry.prometheus_metrics import PrometheusMetrics from pymodbus.datastore import ( ModbusDeviceContext, ModbusSequentialDataBlock, @@ -30,7 +33,7 @@ __persistence_path__ = os.path.join(os.path.dirname(__script_path__), "data") default_config_file = os.path.join(__script_path__, "modbus_server.json") default_persistence_file = os.path.join(__persistence_path__, "modbus_registers.json") -VERSION = "2.1.0" +VERSION = "2.2.0" log = logging.getLogger() @@ -60,7 +63,12 @@ def get_ip_address() -> str: return ipaddr -def run_server(persistence_file: Optional[str] = None, persistence_interval: int = 30): +def run_server( + persistence_file: Optional[str] = None, + persistence_interval: int = 30, + metrics_server: Optional[MetricsServer] = None, + metrics_collector: Optional[PrometheusMetrics] = None, +) -> None: """ Run the modbus server(s) @@ -68,6 +76,10 @@ def run_server(persistence_file: Optional[str] = None, persistence_interval: int :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 + :param metrics_server: the metrics server instance for telemetry output (default: None) + :type metrics_server: Optional[MetricsServer] + :param metrics_collector: the metrics collector instance for telemetry output (default: None) + :type metrics_collector: Optional[PrometheusMetrics] """ # Check if we should load from persistence persistence = None @@ -81,7 +93,7 @@ def run_server(persistence_file: Optional[str] = None, persistence_interval: int ) loaded_data = persistence.load_registers() - deviceContext = _get_modbus_device_context(persistence_data=loaded_data) + deviceContext = _get_modbus_device_context(persistence_data=loaded_data, metrics_collector=metrics_collector) log.debug("Define Modbus server context") context = ModbusServerContext(devices=deviceContext, single=True) @@ -132,14 +144,20 @@ def run_server(persistence_file: Optional[str] = None, persistence_interval: int # Ensure we stop auto-save and perform final save on shutdown if persistence: persistence.stop_auto_save() + if metrics_server: + metrics_server.stop() -def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> ModbusDeviceContext: +def _get_modbus_device_context( + persistence_data: Optional[dict] = None, metrics_collector: Optional[PrometheusMetrics] = 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] + :param metrics_collector: the metrics collector instance for telemetry output (default: None) + :type metrics_collector: Optional[PrometheusMetrics] :return: the generated ModbusDeviceContext with the defined registers :rtype: ModbusDeviceContext """ @@ -200,6 +218,8 @@ def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> Modbu # di = ModbusSequentialDataBlock(0x00, [0xaa]*65536) log.debug("set all registers to 0x00") di = ModbusSequentialDataBlock.create() + if metrics_collector: + di = MetricsTrackingDataBlock(wrapped_block=di, metrics_collector=metrics_collector, register_type="d") log.debug("Initialize coils") if isinstance(coils, dict) and coils: @@ -211,6 +231,8 @@ def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> Modbu # co = ModbusSequentialDataBlock(0x00, [0xbb]*65536) log.debug("set all registers to 0x00") co = ModbusSequentialDataBlock.create() + if metrics_collector: + co = MetricsTrackingDataBlock(wrapped_block=co, metrics_collector=metrics_collector, register_type="c") log.debug("Initialize holding registers") if isinstance(holding_registers, dict) and holding_registers: @@ -222,6 +244,8 @@ def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> Modbu # hr = ModbusSequentialDataBlock(0x00, [0xcc]*65536) log.debug("set all registers to 0x00") hr = ModbusSequentialDataBlock.create() + if metrics_collector: + hr = MetricsTrackingDataBlock(wrapped_block=hr, metrics_collector=metrics_collector, register_type="h") log.debug("Initialize input registers") if isinstance(input_registers, dict) and input_registers: @@ -233,6 +257,8 @@ def _get_modbus_device_context(persistence_data: Optional[dict] = None) -> Modbu # ir = ModbusSequentialDataBlock(0x00, [0xdd]*65536) log.debug("set all registers to 0x00") ir = ModbusSequentialDataBlock.create() + if metrics_collector: + ir = MetricsTrackingDataBlock(wrapped_block=ir, metrics_collector=metrics_collector, register_type="i") return ModbusDeviceContext(di=di, co=co, hr=hr, ir=ir) @@ -341,6 +367,40 @@ def _prepare_register( return out_register +def initialize_metrics_server( + enabled: bool = False, address: str = "0.0.0.0", port: int = 9090, path: str = "/metrics" +) -> Tuple[Optional[PrometheusMetrics], Optional[MetricsServer]]: + """ + Initializes the metrics server for telemetry output + + :param enabled: whether to enable the metrics server (default: False) + :type enabled: bool + :param address: the address to listen on for the metrics server (default: "0.0.0.0") + :type address: str + :param port: the port to listen on for the metrics server (default: 9090) + :type port: int + :param path: the path to listen on for the metrics server (default: "/metrics") + :type path: str + :return: the metrics collector and the metrics server instance (if enabled, otherwise None) + :rtype: Tuple[Optional[PrometheusMetrics], Optional[MetricsServer]] + """ + if enabled: + metrics_collector = PrometheusMetrics() + metrics_server = MetricsServer(metrics_collector=metrics_collector, address=address, port=port, path=path) + try: + metrics_server.start() + log.info(f"Metrics endpoint available at " f"http://{address}:{port}{path} for telemetry output") + except Exception as e: + log.error(f"Failed to start metrics server: {e}") + return metrics_collector, None + # On success return the created collector and server + return metrics_collector, metrics_server + + else: + log.info("Metrics server for telemetry output is disabled in configuration") + return None, None + + """ ############################################################################### # M A I N @@ -409,7 +469,20 @@ def _prepare_register( local_ip_addr = get_ip_address() if local_ip_addr != "": log.info(f"Outbound device IP address is: {local_ip_addr}") + + # start metrics server for telemetry output if enabled in configuration + metrics_config = CONFIG.get("metrics", {}) + metrics_collector, metrics_server = initialize_metrics_server( + enabled=metrics_config.get("enabled", False), + address=metrics_config.get("address", "0.0.0.0"), + port=metrics_config.get("port", 9090), + path=metrics_config.get("path", "/metrics"), + ) + + # start the modbus server run_server( persistence_file=persistence_file, persistence_interval=persistence_interval, + metrics_server=metrics_server, + metrics_collector=metrics_collector, ) diff --git a/src/requirements.txt b/src/requirements.txt index 7a3c9f3..1bc570e 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1 +1,2 @@ +psutil >= 7, < 8 pymodbus >= 3, < 4 diff --git a/tests/__init__.py b/tests/__init__.py index f90426b..bf0c87b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import sys diff --git a/tests/test_metrics_datastore.py b/tests/test_metrics_datastore.py new file mode 100644 index 0000000..479d02b --- /dev/null +++ b/tests/test_metrics_datastore.py @@ -0,0 +1,50 @@ +from src.app.lib.telemetry.metrics_datastore import MetricsTrackingDataBlock +from src.app.lib.telemetry.prometheus_metrics import PrometheusMetrics + + +class DummyBlock: + def __init__(self): + self.store = {} + + def validate(self, address, count=1): + return True + + def getValues(self, address, count=1): + return [address + i for i in range(count)] + + def setValues(self, address, values): + if isinstance(values, list): + for i, v in enumerate(values): + self.store[address + i] = v + else: + self.store[address] = values + + +def test_metrics_tracking_datablock_records_reads_and_writes(): + prom = PrometheusMetrics() + dummy = DummyBlock() + # test holding registers ('h') mapping to read function code 3 + wrapped = MetricsTrackingDataBlock(dummy, prom, register_type="h") + + vals = wrapped.getValues(5, 3) + assert vals == [5, 6, 7] + + # check that a read request for function code 3 was recorded + assert prom._requests_by_function[3] == 1 + + # each address should have been recorded as read once + assert prom._register_reads["h"][5] == 1 + assert prom._register_reads["h"][6] == 1 + assert prom._register_reads["h"][7] == 1 + + # single write -> function code 6 for holding registers + wrapped.setValues(8, [42]) + assert prom._requests_by_function[6] == 1 + assert prom._register_writes["h"][8] == 1 + + # multiple write -> function code 16 + wrapped.setValues(10, [1, 2, 3]) + assert prom._requests_by_function[16] == 1 + assert prom._register_writes["h"][10] == 1 + assert prom._register_writes["h"][11] == 1 + assert prom._register_writes["h"][12] == 1 diff --git a/tests/test_metrics_server.py b/tests/test_metrics_server.py new file mode 100644 index 0000000..127682c --- /dev/null +++ b/tests/test_metrics_server.py @@ -0,0 +1,39 @@ +import time +import urllib.request + +from src.app.lib.telemetry.metrics_server import MetricsServer +from src.app.lib.telemetry.prometheus_metrics import PrometheusMetrics + + +def test_metrics_server_start_stop_and_endpoints(): + prom = PrometheusMetrics() + prom.record_request(3) + + server = MetricsServer(prom, address="127.0.0.1", port=0) + server.start() + + # wait briefly for server to start + time.sleep(0.05) + + assert server.is_running() is True + assert server.server is not None + + host, port = server.server.server_address + + # request metrics endpoint + with urllib.request.urlopen(f"http://{host}:{port}/metrics", timeout=2) as resp: + body = resp.read().decode("utf-8") + assert resp.getcode() == 200 + # metrics output should include the modbus_requests_total header + assert "modbus_requests_total" in body + + # request health endpoint + with urllib.request.urlopen(f"http://{host}:{port}/health", timeout=2) as resp: + health = resp.read().decode("utf-8") + assert resp.getcode() == 200 + assert health == "OK\n" + + server.stop() + # allow a moment for shutdown to complete + time.sleep(0.05) + assert server.is_running() is False diff --git a/tests/test_prometheus_metrics.py b/tests/test_prometheus_metrics.py new file mode 100644 index 0000000..34ee3c3 --- /dev/null +++ b/tests/test_prometheus_metrics.py @@ -0,0 +1,61 @@ +import re + +from src.app.lib.telemetry.prometheus_metrics import PrometheusMetrics + + +def test_record_and_generate_metrics_contains_expected_lines(): + m = PrometheusMetrics() + + # record some requests, reads, writes and errors + m.record_request(3) + m.record_request(3) + m.record_request(5) + + m.record_register_read("h", 10, count=2) + m.record_register_write("h", 10, count=1) + + m.record_error(2) + + m.set_connected_clients(4) + + out = m.generate_metrics() + + # basic expected metric labels/sections + assert "# HELP modbus_requests_total" in out + assert "# TYPE modbus_requests_total counter" in out + + # function code 3 should appear with count 2 + assert re.search(r'modbus_requests_total\{.*function_code="03".*\} 2', out) + + # function code 5 should appear with count 1 + assert re.search(r'modbus_requests_total\{.*function_code="05".*\} 1', out) + + # register reads/writes entries should be present + assert "modbus_register_reads_total" in out + assert "modbus_register_writes_total" in out + + # error should be present + assert re.search(r'modbus_errors_total\{.*exception_code="02".*\} 1', out) + + # uptime metric present + assert "modbus_server_uptime_seconds" in out + + +def test_reset_metrics_resets_counters(): + m = PrometheusMetrics() + m.record_request(1) + m.record_register_read("c", 1) + m.record_error(3) + + # ensure metrics show up + assert "modbus_requests_total" in m.generate_metrics() + + # reset + m.reset_metrics() + + # after reset there should be no request entries + out = m.generate_metrics() + assert "modbus_requests_total" in out + # but there should be no recorded function labels (only header lines) + # check that function entries are absent + assert not re.search(r"modbus_requests_total\{", out.split("# HELP modbus_requests_total")[1]) diff --git a/tests/test_register_persistence.py b/tests/test_register_persistence.py index 348755a..a8e4abb 100644 --- a/tests/test_register_persistence.py +++ b/tests/test_register_persistence.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Unit tests for the RegisterPersistence library """ @@ -145,7 +144,7 @@ def test_save_registers_success(self, temp_persistence_file, mock_modbus_context assert os.path.isfile(temp_persistence_file) # Verify saved data - with open(temp_persistence_file, "r") as f: + with open(temp_persistence_file) as f: saved_data = json.load(f) assert "discrete_inputs" in saved_data @@ -195,7 +194,7 @@ def test_save_registers_preserves_data_on_error(self, temp_persistence_file, moc # Save initial data persistence.save_registers() - with open(temp_persistence_file, "r") as f: + with open(temp_persistence_file) as f: initial_data = json.load(f) # Try to save with error @@ -203,7 +202,7 @@ def test_save_registers_preserves_data_on_error(self, temp_persistence_file, moc persistence.save_registers() # Original file should still exist with original data - with open(temp_persistence_file, "r") as f: + with open(temp_persistence_file) as f: current_data = json.load(f) assert current_data == initial_data diff --git a/tests/test_server.py b/tests/test_server.py index 175dfcd..1a67af7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from src.app.modbus_server import _prepare_register diff --git a/tests/test_utils.py b/tests/test_utils.py index cacc5f2..da79b1d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from src.app.modbus_server import get_ip_address