scietex.modbus-service 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scietex_modbus_service-1.1.0/LICENSE +21 -0
- scietex_modbus_service-1.1.0/PKG-INFO +146 -0
- scietex_modbus_service-1.1.0/README.md +121 -0
- scietex_modbus_service-1.1.0/pyproject.toml +45 -0
- scietex_modbus_service-1.1.0/setup.cfg +4 -0
- scietex_modbus_service-1.1.0/src/scietex/modbus_service/__init__.py +23 -0
- scietex_modbus_service-1.1.0/src/scietex/modbus_service/config.py +188 -0
- scietex_modbus_service-1.1.0/src/scietex/modbus_service/modbus_worker.py +135 -0
- scietex_modbus_service-1.1.0/src/scietex/modbus_service/py.typed +0 -0
- scietex_modbus_service-1.1.0/src/scietex/modbus_service/run_worker.py +73 -0
- scietex_modbus_service-1.1.0/src/scietex/modbus_service/version.py +3 -0
- scietex_modbus_service-1.1.0/src/scietex.modbus_service.egg-info/PKG-INFO +146 -0
- scietex_modbus_service-1.1.0/src/scietex.modbus_service.egg-info/SOURCES.txt +19 -0
- scietex_modbus_service-1.1.0/src/scietex.modbus_service.egg-info/dependency_links.txt +1 -0
- scietex_modbus_service-1.1.0/src/scietex.modbus_service.egg-info/entry_points.txt +2 -0
- scietex_modbus_service-1.1.0/src/scietex.modbus_service.egg-info/requires.txt +13 -0
- scietex_modbus_service-1.1.0/src/scietex.modbus_service.egg-info/top_level.txt +1 -0
- scietex_modbus_service-1.1.0/tests/test_config.py +97 -0
- scietex_modbus_service-1.1.0/tests/test_modbus_worker.py +266 -0
- scietex_modbus_service-1.1.0/tests/test_run_worker.py +70 -0
- scietex_modbus_service-1.1.0/tests/test_version.py +52 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Anton Bondarenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scietex.modbus_service
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Scietex microservice daemon for MODBUS communication
|
|
5
|
+
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.modbus_service/issues
|
|
8
|
+
Project-URL: Homepage, https://github.com/bond-anton/scietex.modbus_service
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: scietex.hal.serial>=2.0.2
|
|
15
|
+
Requires-Dist: scietex.service[valkey]~=5.2.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: tox>=4.45.0; extra == "dev"
|
|
18
|
+
Provides-Extra: lint
|
|
19
|
+
Requires-Dist: ruff; extra == "lint"
|
|
20
|
+
Requires-Dist: ty; extra == "lint"
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest; extra == "test"
|
|
23
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# scietex.modbus_service
|
|
27
|
+
|
|
28
|
+
**scietex.modbus_service** is a `scietex.service` worker that runs the
|
|
29
|
+
serial-to-TCP Modbus gateway from
|
|
30
|
+
[`scietex.hal.serial`](https://github.com/bond-anton/scietex.hal.serial). It
|
|
31
|
+
bridges a serial RS485 bus to TCP/IP: TCP clients speak plain Modbus/TCP, and
|
|
32
|
+
the gateway routes each request to the right device on the bus **by device id**.
|
|
33
|
+
|
|
34
|
+
The worker is a long-running daemon. It owns one serial port and one TCP
|
|
35
|
+
listener, both built once at startup from `modbus.yml` and torn down on
|
|
36
|
+
shutdown. Configuration can also be pushed at runtime through the framework's
|
|
37
|
+
remote-config channel, but the gateway itself is **restart-required** — see
|
|
38
|
+
[Remote configuration](docs/remote-config.md).
|
|
39
|
+
|
|
40
|
+
**Python ≥ 3.10** · **License: MIT**
|
|
41
|
+
|
|
42
|
+
## Documentation
|
|
43
|
+
|
|
44
|
+
- [Overview](docs/index.md) — what the service does and how it fits together
|
|
45
|
+
- [Configuration](docs/configuration.md) — the `modbus.yml` schema and precedence
|
|
46
|
+
- [Deployment](docs/deployment.md) — container, serial device access, port 502
|
|
47
|
+
- [Remote configuration](docs/remote-config.md) — the declarative `modbus` section
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install scietex.modbus_service
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This pulls in `scietex.hal.serial` (the gateway core) and
|
|
56
|
+
`scietex.service[valkey]` (the worker framework and its Valkey transport).
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
### 1. Write a configuration file
|
|
61
|
+
|
|
62
|
+
The service reads `modbus.yml` from a `modbus/` subdirectory of its config
|
|
63
|
+
directory. Create it by hand, or let the service generate defaults on first run:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
serial:
|
|
67
|
+
port: /dev/ttyUSB0
|
|
68
|
+
baudrate: 9600
|
|
69
|
+
bytesize: 8
|
|
70
|
+
parity: N
|
|
71
|
+
stopbits: 1
|
|
72
|
+
timeout: null
|
|
73
|
+
host: 0.0.0.0
|
|
74
|
+
port: 502
|
|
75
|
+
default_framer: RTU
|
|
76
|
+
devices:
|
|
77
|
+
1:
|
|
78
|
+
framer: RTU
|
|
79
|
+
2:
|
|
80
|
+
framer: ASCII
|
|
81
|
+
allow_unknown_devices: false
|
|
82
|
+
bus_retries: 0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`port` at the top level is the **TCP listen port**; `serial.port` is the
|
|
86
|
+
**serial device path**. They are unrelated — do not conflate them.
|
|
87
|
+
|
|
88
|
+
### 2. Run the service
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
start-modbus-service --conf-dir /etc/scietex
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The service resolves its config directory automatically when `--conf-dir` is
|
|
95
|
+
omitted (see [Configuration](docs/configuration.md#config-directory-resolution)).
|
|
96
|
+
It runs in the foreground until it receives `SIGINT` or `SIGTERM`.
|
|
97
|
+
|
|
98
|
+
### 3. Point a Modbus/TCP client at it
|
|
99
|
+
|
|
100
|
+
Connect to `host:port` and issue normal Modbus/TCP requests, using the target
|
|
101
|
+
device id as the unit id. No special protocol is required.
|
|
102
|
+
|
|
103
|
+
## Configuration at a glance
|
|
104
|
+
|
|
105
|
+
| Setting | Default | Meaning |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `serial.port` | `/dev/ttyUSB0` | Serial device path |
|
|
108
|
+
| `serial.baudrate` | `9600` | Bus baud rate |
|
|
109
|
+
| `host` / `port` | `0.0.0.0` / `502` | TCP bind address and port |
|
|
110
|
+
| `default_framer` | `RTU` | Framer for devices without an explicit entry |
|
|
111
|
+
| `devices` | `{}` | Per-device routing table, keyed by device id |
|
|
112
|
+
| `allow_unknown_devices` | `false` | Route unknown ids with the default framer |
|
|
113
|
+
| `bus_retries` | `0` | Retry count for bus transactions |
|
|
114
|
+
|
|
115
|
+
Full field reference: [Configuration](docs/configuration.md).
|
|
116
|
+
|
|
117
|
+
## Container
|
|
118
|
+
|
|
119
|
+
A `Containerfile` is included. It installs the released package from PyPI
|
|
120
|
+
(selected by the `VERSION` build arg), runs as a non-root `appuser` in the
|
|
121
|
+
`dialout` group, mounts `/config`, and sets `SCIETEX_CONFIG_DIR=/config`:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
podman build --build-arg VERSION=1.1.0 -t scietex-modbus-service .
|
|
125
|
+
podman run --rm \
|
|
126
|
+
--device /dev/ttyUSB0 \
|
|
127
|
+
--cap-add NET_BIND_SERVICE \
|
|
128
|
+
-v ./config:/config \
|
|
129
|
+
scietex-modbus-service
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Port 502 is privileged — binding it needs `--cap-add NET_BIND_SERVICE`, or
|
|
133
|
+
override the port in `modbus.yml`. See [Deployment](docs/deployment.md) for
|
|
134
|
+
details.
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
Dependencies are managed with **uv**; checks and tests run through **tox**:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv sync --all-extras
|
|
142
|
+
uv run tox # format, lint, type, py314
|
|
143
|
+
uv run tox -e py314 # tests only
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
See [AGENTS.md](AGENTS.md) for the full toolchain and repo conventions.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# scietex.modbus_service
|
|
2
|
+
|
|
3
|
+
**scietex.modbus_service** is a `scietex.service` worker that runs the
|
|
4
|
+
serial-to-TCP Modbus gateway from
|
|
5
|
+
[`scietex.hal.serial`](https://github.com/bond-anton/scietex.hal.serial). It
|
|
6
|
+
bridges a serial RS485 bus to TCP/IP: TCP clients speak plain Modbus/TCP, and
|
|
7
|
+
the gateway routes each request to the right device on the bus **by device id**.
|
|
8
|
+
|
|
9
|
+
The worker is a long-running daemon. It owns one serial port and one TCP
|
|
10
|
+
listener, both built once at startup from `modbus.yml` and torn down on
|
|
11
|
+
shutdown. Configuration can also be pushed at runtime through the framework's
|
|
12
|
+
remote-config channel, but the gateway itself is **restart-required** — see
|
|
13
|
+
[Remote configuration](docs/remote-config.md).
|
|
14
|
+
|
|
15
|
+
**Python ≥ 3.10** · **License: MIT**
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
- [Overview](docs/index.md) — what the service does and how it fits together
|
|
20
|
+
- [Configuration](docs/configuration.md) — the `modbus.yml` schema and precedence
|
|
21
|
+
- [Deployment](docs/deployment.md) — container, serial device access, port 502
|
|
22
|
+
- [Remote configuration](docs/remote-config.md) — the declarative `modbus` section
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install scietex.modbus_service
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This pulls in `scietex.hal.serial` (the gateway core) and
|
|
31
|
+
`scietex.service[valkey]` (the worker framework and its Valkey transport).
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
### 1. Write a configuration file
|
|
36
|
+
|
|
37
|
+
The service reads `modbus.yml` from a `modbus/` subdirectory of its config
|
|
38
|
+
directory. Create it by hand, or let the service generate defaults on first run:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
serial:
|
|
42
|
+
port: /dev/ttyUSB0
|
|
43
|
+
baudrate: 9600
|
|
44
|
+
bytesize: 8
|
|
45
|
+
parity: N
|
|
46
|
+
stopbits: 1
|
|
47
|
+
timeout: null
|
|
48
|
+
host: 0.0.0.0
|
|
49
|
+
port: 502
|
|
50
|
+
default_framer: RTU
|
|
51
|
+
devices:
|
|
52
|
+
1:
|
|
53
|
+
framer: RTU
|
|
54
|
+
2:
|
|
55
|
+
framer: ASCII
|
|
56
|
+
allow_unknown_devices: false
|
|
57
|
+
bus_retries: 0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`port` at the top level is the **TCP listen port**; `serial.port` is the
|
|
61
|
+
**serial device path**. They are unrelated — do not conflate them.
|
|
62
|
+
|
|
63
|
+
### 2. Run the service
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
start-modbus-service --conf-dir /etc/scietex
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The service resolves its config directory automatically when `--conf-dir` is
|
|
70
|
+
omitted (see [Configuration](docs/configuration.md#config-directory-resolution)).
|
|
71
|
+
It runs in the foreground until it receives `SIGINT` or `SIGTERM`.
|
|
72
|
+
|
|
73
|
+
### 3. Point a Modbus/TCP client at it
|
|
74
|
+
|
|
75
|
+
Connect to `host:port` and issue normal Modbus/TCP requests, using the target
|
|
76
|
+
device id as the unit id. No special protocol is required.
|
|
77
|
+
|
|
78
|
+
## Configuration at a glance
|
|
79
|
+
|
|
80
|
+
| Setting | Default | Meaning |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `serial.port` | `/dev/ttyUSB0` | Serial device path |
|
|
83
|
+
| `serial.baudrate` | `9600` | Bus baud rate |
|
|
84
|
+
| `host` / `port` | `0.0.0.0` / `502` | TCP bind address and port |
|
|
85
|
+
| `default_framer` | `RTU` | Framer for devices without an explicit entry |
|
|
86
|
+
| `devices` | `{}` | Per-device routing table, keyed by device id |
|
|
87
|
+
| `allow_unknown_devices` | `false` | Route unknown ids with the default framer |
|
|
88
|
+
| `bus_retries` | `0` | Retry count for bus transactions |
|
|
89
|
+
|
|
90
|
+
Full field reference: [Configuration](docs/configuration.md).
|
|
91
|
+
|
|
92
|
+
## Container
|
|
93
|
+
|
|
94
|
+
A `Containerfile` is included. It installs the released package from PyPI
|
|
95
|
+
(selected by the `VERSION` build arg), runs as a non-root `appuser` in the
|
|
96
|
+
`dialout` group, mounts `/config`, and sets `SCIETEX_CONFIG_DIR=/config`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
podman build --build-arg VERSION=1.1.0 -t scietex-modbus-service .
|
|
100
|
+
podman run --rm \
|
|
101
|
+
--device /dev/ttyUSB0 \
|
|
102
|
+
--cap-add NET_BIND_SERVICE \
|
|
103
|
+
-v ./config:/config \
|
|
104
|
+
scietex-modbus-service
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Port 502 is privileged — binding it needs `--cap-add NET_BIND_SERVICE`, or
|
|
108
|
+
override the port in `modbus.yml`. See [Deployment](docs/deployment.md) for
|
|
109
|
+
details.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
Dependencies are managed with **uv**; checks and tests run through **tox**:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv sync --all-extras
|
|
117
|
+
uv run tox # format, lint, type, py314
|
|
118
|
+
uv run tox -e py314 # tests only
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
See [AGENTS.md](AGENTS.md) for the full toolchain and repo conventions.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scietex.modbus_service"
|
|
3
|
+
description = "Scietex microservice daemon for MODBUS communication"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICEN[CS]E*"]
|
|
8
|
+
authors = [{ name = "Anton Bondarenko", email = "bond.anton@gmail.com" }]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Operating System :: OS Independent",
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"scietex.hal.serial>=2.0.2",
|
|
15
|
+
"scietex.service[valkey]~=5.2.0",
|
|
16
|
+
]
|
|
17
|
+
dynamic = ["version"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
"Bug Tracker" = "https://github.com/bond-anton/scietex.modbus_service/issues"
|
|
21
|
+
"Homepage" = "https://github.com/bond-anton/scietex.modbus_service"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
start-modbus-service = "scietex.modbus_service.run_worker:main"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["tox>=4.45.0"]
|
|
28
|
+
lint = ["ruff", "ty"]
|
|
29
|
+
test = ["pytest", "pytest-asyncio"]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["setuptools>=61.0"]
|
|
33
|
+
build-backend = "setuptools.build_meta"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
pythonpath = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
version = { attr = "scietex.modbus_service.version.__version__" }
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
"scietex.modbus_service" = ["py.typed"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Modbus Gateway Service."""
|
|
2
|
+
|
|
3
|
+
from .config import (
|
|
4
|
+
MODBUS_SECTION,
|
|
5
|
+
ModbusDeviceSettings,
|
|
6
|
+
ModbusSerialSettings,
|
|
7
|
+
ModbusServiceSettings,
|
|
8
|
+
read_modbus_config,
|
|
9
|
+
to_gateway_config,
|
|
10
|
+
)
|
|
11
|
+
from .modbus_worker import ModbusWorker
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"ModbusWorker",
|
|
17
|
+
"MODBUS_SECTION",
|
|
18
|
+
"ModbusDeviceSettings",
|
|
19
|
+
"ModbusSerialSettings",
|
|
20
|
+
"ModbusServiceSettings",
|
|
21
|
+
"read_modbus_config",
|
|
22
|
+
"to_gateway_config",
|
|
23
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Configuration models and YAML loader for the Modbus gateway service.
|
|
2
|
+
|
|
3
|
+
`ModbusServiceSettings` is the service-owned bootstrap snapshot stored in
|
|
4
|
+
`modbus.yml` under the config directory. It is kept deliberately thin: device-id
|
|
5
|
+
range, framer resolvability, and plugin dotted-path resolution are delegated to
|
|
6
|
+
`GatewayConfig.__post_init__` via `to_gateway_config`, so the YAML schema only
|
|
7
|
+
constrains types and unknown fields.
|
|
8
|
+
|
|
9
|
+
The serial settings intentionally omit a ``framer`` field: the gateway selects
|
|
10
|
+
framing per device via `GatewayConfig.default_framer` (and each device's own
|
|
11
|
+
`framer`), so a framer on the serial connection is dead configuration. The
|
|
12
|
+
top-level ``port`` is the TCP listen port, while ``serial.port`` is the device
|
|
13
|
+
path — the two are unrelated and must not be conflated.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import msgspec
|
|
19
|
+
from scietex.hal.serial import GatewayConfig, GatewayDeviceConfig, ModbusSerialConnectionConfig
|
|
20
|
+
from scietex.hal.serial.config.defaults import (
|
|
21
|
+
DEFAULT_BAUDRATE,
|
|
22
|
+
DEFAULT_BYTESIZE,
|
|
23
|
+
DEFAULT_PARITY,
|
|
24
|
+
DEFAULT_STOPBITS,
|
|
25
|
+
DEFAULT_TIMEOUT,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: Remote-config section name the settings are registered under.
|
|
29
|
+
MODBUS_SECTION: str = "modbus"
|
|
30
|
+
|
|
31
|
+
#: Subdirectory under the shared config dir that namespaces this service's
|
|
32
|
+
#: files. The framework's ``config.yml`` snapshot and the service-owned
|
|
33
|
+
#: ``modbus.yml`` both live here, so services sharing one config dir (the
|
|
34
|
+
#: framework resolves a single dir for all scietex services) cannot collide.
|
|
35
|
+
MODBUS_CONFIG_SUBDIR: str = "modbus"
|
|
36
|
+
|
|
37
|
+
#: Filename of the service-owned bootstrap snapshot in the config subdirectory.
|
|
38
|
+
MODBUS_CONFIG_FILE: str = "modbus.yml"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ModbusDeviceSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
|
|
42
|
+
"""Per-device routing settings for a single Modbus device id.
|
|
43
|
+
|
|
44
|
+
The device id itself is the dict key in `ModbusServiceSettings.devices`, not
|
|
45
|
+
a field here, so the conversion seam can derive `GatewayDeviceConfig.device_id`
|
|
46
|
+
from it and satisfy the gateway's ``key == device.device_id`` invariant.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
framer: str = "RTU"
|
|
50
|
+
decoder: str | None = None
|
|
51
|
+
pdus: list[str] = msgspec.field(default_factory=list)
|
|
52
|
+
translator: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ModbusSerialSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
|
|
56
|
+
"""Serial bus connection settings.
|
|
57
|
+
|
|
58
|
+
``port`` is the device path (e.g. ``/dev/ttyUSB0``). There is no ``framer``
|
|
59
|
+
field: the gateway ignores the serial-level framer and selects framing per
|
|
60
|
+
device from `GatewayConfig.default_framer` / `ModbusDeviceSettings.framer`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
port: str = "/dev/ttyUSB0"
|
|
64
|
+
baudrate: int = DEFAULT_BAUDRATE
|
|
65
|
+
bytesize: int = DEFAULT_BYTESIZE
|
|
66
|
+
parity: str = DEFAULT_PARITY
|
|
67
|
+
stopbits: int | float = DEFAULT_STOPBITS
|
|
68
|
+
timeout: float | None = DEFAULT_TIMEOUT
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ModbusServiceSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
|
|
72
|
+
"""Top-level Modbus gateway settings.
|
|
73
|
+
|
|
74
|
+
``port`` is the TCP listen port; ``serial.port`` is the device path. Device
|
|
75
|
+
entries are keyed by device id so the key becomes the `device_id` when
|
|
76
|
+
converted to `GatewayDeviceConfig` objects.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
serial: ModbusSerialSettings = ModbusSerialSettings()
|
|
80
|
+
host: str = "0.0.0.0"
|
|
81
|
+
port: int = 502
|
|
82
|
+
default_framer: str = "RTU"
|
|
83
|
+
devices: dict[int, ModbusDeviceSettings] = msgspec.field(default_factory=dict)
|
|
84
|
+
allow_unknown_devices: bool = False
|
|
85
|
+
bus_retries: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_modbus_config(conf_dir: Path | None, *, create_default: bool = True) -> ModbusServiceSettings:
|
|
89
|
+
"""Read Modbus settings from ``modbus/modbus.yml`` under the config directory.
|
|
90
|
+
|
|
91
|
+
The service's files are namespaced in a ``modbus/`` subdirectory so they do
|
|
92
|
+
not collide with other services sharing the framework's single config dir.
|
|
93
|
+
Mirrors `read_valkey_config` otherwise: the file (and, when missing, its
|
|
94
|
+
directory) is only created when ``create_default=True`` (the bootstrap path).
|
|
95
|
+
A ``None`` or non-directory ``conf_dir``, a missing file/directory with
|
|
96
|
+
``create_default=False``, or an unparseable file each raise `RuntimeError`.
|
|
97
|
+
An existing-but-invalid file is left untouched regardless of ``create_default``.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
conf_dir: Path to the configuration directory.
|
|
101
|
+
create_default: Whether to create the directory and write a default
|
|
102
|
+
``modbus.yml`` when missing. Default ``True``.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
A `ModbusServiceSettings` loaded from ``modbus.yml`` or default values.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
RuntimeError: If ``conf_dir`` is ``None`` or not a directory, the file
|
|
109
|
+
is missing with ``create_default=False``, or the file cannot be parsed.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(conf_dir, Path):
|
|
112
|
+
raise RuntimeError("Configuration dir was not set!")
|
|
113
|
+
service_dir = conf_dir / MODBUS_CONFIG_SUBDIR
|
|
114
|
+
if not service_dir.exists():
|
|
115
|
+
if create_default:
|
|
116
|
+
try:
|
|
117
|
+
service_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
raise RuntimeError(f"Failed to create configuration directory {service_dir}!") from exc
|
|
120
|
+
else:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
f"Configuration directory {service_dir} does not exist and create_default=False (no default generated)."
|
|
123
|
+
)
|
|
124
|
+
elif not service_dir.is_dir():
|
|
125
|
+
raise RuntimeError(f"Provided configuration directory path {service_dir} is not a directory!")
|
|
126
|
+
modbus_yml = service_dir.joinpath(MODBUS_CONFIG_FILE)
|
|
127
|
+
if not modbus_yml.exists():
|
|
128
|
+
if create_default:
|
|
129
|
+
settings = ModbusServiceSettings()
|
|
130
|
+
with open(modbus_yml, "wb") as f:
|
|
131
|
+
f.write(msgspec.yaml.encode(settings))
|
|
132
|
+
return settings
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f"Modbus configuration file {modbus_yml} does not exist and create_default=False "
|
|
135
|
+
"(pass create_default=True to generate defaults)."
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
with open(modbus_yml, "rb") as f:
|
|
139
|
+
return msgspec.yaml.decode(f.read(), type=ModbusServiceSettings, strict=True)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"Failed to parse Modbus configuration file {modbus_yml}. Fix the file or remove it to regenerate defaults."
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def to_gateway_config(settings: ModbusServiceSettings) -> GatewayConfig:
|
|
147
|
+
"""Convert service settings into a `GatewayConfig` for the gateway core.
|
|
148
|
+
|
|
149
|
+
The device id is taken from each dict key so the gateway's invariant
|
|
150
|
+
(``key == device.device_id``) holds. ``framer`` is intentionally not passed
|
|
151
|
+
to `ModbusSerialConnectionConfig` — the gateway ignores it. Validation
|
|
152
|
+
(device-id range, framer/decoder/pdu/translator resolution) is delegated to
|
|
153
|
+
`GatewayConfig.__post_init__`, so `GatewayConfigError` and
|
|
154
|
+
`SerialConnectionConfigError` propagate unchanged to the caller.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
settings: The service settings to convert.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A `GatewayConfig` ready for `ModbusGateway`.
|
|
161
|
+
"""
|
|
162
|
+
serial = ModbusSerialConnectionConfig(
|
|
163
|
+
port=settings.serial.port,
|
|
164
|
+
baudrate=settings.serial.baudrate,
|
|
165
|
+
bytesize=settings.serial.bytesize,
|
|
166
|
+
parity=settings.serial.parity,
|
|
167
|
+
stopbits=settings.serial.stopbits,
|
|
168
|
+
timeout=settings.serial.timeout,
|
|
169
|
+
)
|
|
170
|
+
devices = {
|
|
171
|
+
device_id: GatewayDeviceConfig(
|
|
172
|
+
device_id=device_id,
|
|
173
|
+
framer=device.framer,
|
|
174
|
+
decoder=device.decoder,
|
|
175
|
+
pdus=device.pdus,
|
|
176
|
+
translator=device.translator,
|
|
177
|
+
)
|
|
178
|
+
for device_id, device in settings.devices.items()
|
|
179
|
+
}
|
|
180
|
+
return GatewayConfig(
|
|
181
|
+
serial=serial,
|
|
182
|
+
host=settings.host,
|
|
183
|
+
port=settings.port,
|
|
184
|
+
default_framer=settings.default_framer,
|
|
185
|
+
devices=devices,
|
|
186
|
+
allow_unknown_devices=settings.allow_unknown_devices,
|
|
187
|
+
bus_retries=settings.bus_retries,
|
|
188
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Modbus gateway worker: a Valkey-backed service that owns a Modbus gateway.
|
|
2
|
+
|
|
3
|
+
`ModbusWorker` extends `ValkeyWorker` and fronts a serial Modbus bus with a TCP
|
|
4
|
+
listener. The gateway (serial bus + TCP listener) is built once at startup from
|
|
5
|
+
``modbus.yml`` and torn down at cleanup. The remote-config ``modbus`` section is
|
|
6
|
+
declarative / restart-required: applying it validates and stores the desired
|
|
7
|
+
settings but never live-rebuilds the gateway, because reopening the serial bus
|
|
8
|
+
and rebinding the TCP listener on a running worker is unsafe.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from scietex.hal.serial import GatewayConfigError, GatewayTcpServer, ModbusGateway
|
|
12
|
+
from scietex.hal.serial.config import SerialConnectionConfigError
|
|
13
|
+
from scietex.service import ValkeyWorker, ValkeyWorkerConfig
|
|
14
|
+
|
|
15
|
+
from .config import MODBUS_SECTION, ModbusServiceSettings, read_modbus_config, to_gateway_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModbusWorker(ValkeyWorker):
|
|
19
|
+
"""A `ValkeyWorker` that runs a serial<->TCP Modbus gateway.
|
|
20
|
+
|
|
21
|
+
Settings precedence: constructor default < ``modbus.yml`` < framework
|
|
22
|
+
``config.yml`` / remote ``modbus`` section. The gateway is built from the
|
|
23
|
+
effective settings after the framework applies its local and remote config,
|
|
24
|
+
so the remote section's apply hook runs before gateway construction.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: ValkeyWorkerConfig | None = None, *, client_factory=None, theme=None) -> None:
|
|
28
|
+
super().__init__(config, client_factory=client_factory, theme=theme)
|
|
29
|
+
self._settings: ModbusServiceSettings | None = None
|
|
30
|
+
self._gateway: ModbusGateway | None = None
|
|
31
|
+
self._tcp_server: GatewayTcpServer | None = None
|
|
32
|
+
self.register_config_settings(MODBUS_SECTION, ModbusServiceSettings, apply=self._apply_modbus_settings)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def gateway(self) -> ModbusGateway | None:
|
|
36
|
+
"""The running `ModbusGateway`, or `None` before startup."""
|
|
37
|
+
return self._gateway
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def tcp_server(self) -> GatewayTcpServer | None:
|
|
41
|
+
"""The running `GatewayTcpServer`, or `None` before startup."""
|
|
42
|
+
return self._tcp_server
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def modbus_settings(self) -> ModbusServiceSettings | None:
|
|
46
|
+
"""The effective Modbus settings, or `None` before startup."""
|
|
47
|
+
return self._settings
|
|
48
|
+
|
|
49
|
+
async def initialize(self) -> bool:
|
|
50
|
+
"""Load settings, initialize the framework, then build and start the gateway.
|
|
51
|
+
|
|
52
|
+
The gateway is built after `super().initialize()` so the remote config
|
|
53
|
+
apply (which runs the ``modbus`` section hook) has already updated
|
|
54
|
+
``self._settings`` before the conversion seam reads it. Both the gateway
|
|
55
|
+
and TCP server references are assigned before either is started, so a
|
|
56
|
+
mid-start failure still leaves them reachable by `_stop_gateway`.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
`True` if the framework initialized and the gateway started;
|
|
60
|
+
`False` on any configuration or startup failure.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
self._settings = read_modbus_config(self.conf_dir)
|
|
64
|
+
except RuntimeError as exc:
|
|
65
|
+
self.logger.error("Failed to load Modbus configuration: %s", exc)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
if not await super().initialize():
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
settings = self._settings
|
|
72
|
+
if settings is None:
|
|
73
|
+
self.logger.error("Modbus settings were not resolved before gateway startup")
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
gw_config = to_gateway_config(settings)
|
|
78
|
+
except (GatewayConfigError, SerialConnectionConfigError) as exc:
|
|
79
|
+
self.logger.error("Invalid Modbus gateway configuration: %s", exc)
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
self._gateway = ModbusGateway(gw_config, logger=self.logger)
|
|
83
|
+
self._tcp_server = GatewayTcpServer(gw_config, self._gateway, logger=self.logger)
|
|
84
|
+
try:
|
|
85
|
+
await self._gateway.start()
|
|
86
|
+
await self._tcp_server.start()
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
self.logger.error("Failed to start the Modbus gateway: %s", exc)
|
|
89
|
+
await self._stop_gateway()
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
async def cleanup(self) -> None:
|
|
94
|
+
"""Tear down the gateway, then the framework resources."""
|
|
95
|
+
await self._stop_gateway()
|
|
96
|
+
await super().cleanup()
|
|
97
|
+
|
|
98
|
+
async def _stop_gateway(self) -> None:
|
|
99
|
+
"""Stop the TCP server and gateway, nulling refs before awaiting.
|
|
100
|
+
|
|
101
|
+
Nulling each reference before awaiting its ``stop()`` makes this
|
|
102
|
+
idempotent even when ``start()`` failed midway, and guards against a
|
|
103
|
+
concurrent second cleanup. ``stop()`` errors are logged and swallowed so
|
|
104
|
+
a teardown failure never aborts shutdown.
|
|
105
|
+
"""
|
|
106
|
+
tcp_server, self._tcp_server = self._tcp_server, None
|
|
107
|
+
gateway, self._gateway = self._gateway, None
|
|
108
|
+
if tcp_server is not None:
|
|
109
|
+
try:
|
|
110
|
+
await tcp_server.stop()
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
self.logger.warning("Failed to stop the Modbus TCP server: %s", exc)
|
|
113
|
+
if gateway is not None:
|
|
114
|
+
try:
|
|
115
|
+
await gateway.stop()
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
self.logger.warning("Failed to stop the Modbus gateway: %s", exc)
|
|
118
|
+
|
|
119
|
+
def _apply_modbus_settings(self, settings: ModbusServiceSettings) -> None:
|
|
120
|
+
"""Validate and store remote Modbus settings without rebuilding the gateway.
|
|
121
|
+
|
|
122
|
+
The conversion seam validates declaratively: a bad device id, framer, or
|
|
123
|
+
plugin path raises, which the framework reports as a failed apply. The
|
|
124
|
+
gateway is not rebuilt here — the serial bus and TCP listener are
|
|
125
|
+
restart-required — so a running worker only logs that a restart is needed.
|
|
126
|
+
"""
|
|
127
|
+
to_gateway_config(settings)
|
|
128
|
+
self._settings = settings
|
|
129
|
+
if self._gateway is not None:
|
|
130
|
+
self.logger.warning(
|
|
131
|
+
"Modbus settings applied; restart required to rebuild the gateway "
|
|
132
|
+
"(serial port and TCP listener are not live-reloaded)"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
self.logger.info("Stored Modbus settings for gateway startup")
|
|
File without changes
|