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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")