scietex.vfd-service 0.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.
Files changed (28) hide show
  1. scietex_vfd_service-0.1.0/LICENSE +21 -0
  2. scietex_vfd_service-0.1.0/PKG-INFO +166 -0
  3. scietex_vfd_service-0.1.0/README.md +140 -0
  4. scietex_vfd_service-0.1.0/pyproject.toml +46 -0
  5. scietex_vfd_service-0.1.0/setup.cfg +4 -0
  6. scietex_vfd_service-0.1.0/src/scietex/vfd_service/__init__.py +27 -0
  7. scietex_vfd_service-0.1.0/src/scietex/vfd_service/config.py +152 -0
  8. scietex_vfd_service-0.1.0/src/scietex/vfd_service/py.typed +0 -0
  9. scietex_vfd_service-0.1.0/src/scietex/vfd_service/run_worker.py +73 -0
  10. scietex_vfd_service-0.1.0/src/scietex/vfd_service/task_handlers/__init__.py +35 -0
  11. scietex_vfd_service-0.1.0/src/scietex/vfd_service/task_handlers/monitoring.py +108 -0
  12. scietex_vfd_service-0.1.0/src/scietex/vfd_service/task_handlers/vfd_control.py +258 -0
  13. scietex_vfd_service-0.1.0/src/scietex/vfd_service/telemetry.py +118 -0
  14. scietex_vfd_service-0.1.0/src/scietex/vfd_service/version.py +3 -0
  15. scietex_vfd_service-0.1.0/src/scietex/vfd_service/vfd_worker.py +589 -0
  16. scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/PKG-INFO +166 -0
  17. scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/SOURCES.txt +26 -0
  18. scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/dependency_links.txt +1 -0
  19. scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/entry_points.txt +2 -0
  20. scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/requires.txt +14 -0
  21. scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/top_level.txt +1 -0
  22. scietex_vfd_service-0.1.0/tests/test_config.py +133 -0
  23. scietex_vfd_service-0.1.0/tests/test_monitoring_handler.py +142 -0
  24. scietex_vfd_service-0.1.0/tests/test_run_worker.py +59 -0
  25. scietex_vfd_service-0.1.0/tests/test_telemetry.py +114 -0
  26. scietex_vfd_service-0.1.0/tests/test_version.py +52 -0
  27. scietex_vfd_service-0.1.0/tests/test_vfd_control_handler.py +352 -0
  28. scietex_vfd_service-0.1.0/tests/test_vfd_worker.py +728 -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,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: scietex.vfd_service
3
+ Version: 0.1.0
4
+ Summary: Scietex microservice daemon for variable frequency drive monitoring and control
5
+ Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.vfd_service/issues
8
+ Project-URL: Homepage, https://github.com/bond-anton/scietex.vfd_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.service[valkey]~=6.2.0
15
+ Requires-Dist: scietex.hal.vfd<3.0.0,>=2.0.0
16
+ Requires-Dist: pymodbus~=3.15
17
+ Provides-Extra: dev
18
+ Requires-Dist: tox>=4.45.0; extra == "dev"
19
+ Provides-Extra: lint
20
+ Requires-Dist: ruff; extra == "lint"
21
+ Requires-Dist: ty; extra == "lint"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pytest-asyncio; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # scietex.vfd_service
28
+
29
+ Scietex microservice daemon for variable frequency drive (VFD) monitoring and
30
+ control.
31
+
32
+ The service drives one or more VFDs through the Scietex Modbus gateway. Each
33
+ drive is handled by a driver from
34
+ [`scietex.hal.vfd`](https://github.com/bond-anton/scietex.hal.vfd), selected by
35
+ entry point name in the service configuration. The service polls each drive on
36
+ its own long-lived task, publishes readings to a per-drive Valkey stream, and
37
+ exposes control tasks (`vfd:start`, `vfd:stop`, `vfd:set-frequency`,
38
+ `vfd:clear-error`) on the data lane.
39
+
40
+ ## Supported drives
41
+
42
+ | Driver entry point | Drive | Temperature reading |
43
+ | --- | --- | --- |
44
+ | `intek_spe_b_modbus` | Intek SPE-B | none (overheat is a fault code) |
45
+ | `vesper_e5_8200_modbus` | Vesper E5-8200 | none (overheat is a fault code) |
46
+ | `vesper_e4_8400_modbus` | Vesper E4-8400 / E4-P8402 | power-module temperature (U1-46) |
47
+
48
+ The gateway descriptors (`intek_spe_b`, `vesper_e5_8200`, `vesper_e4_8400`)
49
+ live under the `scietex.modbus.drivers` group and are registered with the
50
+ gateway, not with this service. The `_modbus` suffix distinguishes the HAL
51
+ driver entry points from the gateway descriptors.
52
+
53
+ ## Configuration
54
+
55
+ The service reads `vfd/vfd.yml` under the framework config directory
56
+ (`SCIETEX_CONFIG_DIR`, default `/config` in the container). The file is created
57
+ with defaults on first start.
58
+
59
+ ```yaml
60
+ gateway:
61
+ host: 127.0.0.1
62
+ port: 15020
63
+ timeout: 1.0
64
+ drives:
65
+ - label: pump
66
+ driver: intek_spe_b_modbus
67
+ unit_id: 1
68
+ poll_interval: 1.0
69
+ # max_frequency_hz: 50.0
70
+ failure_threshold: 3
71
+ stream_maxlen: 10000
72
+ ```
73
+
74
+ - `gateway` — Modbus/TCP endpoint of the Scietex Modbus gateway.
75
+ - `drives[].label` — unique label; the registry key and the stream key segment.
76
+ - `drives[].driver` — HAL driver entry point name (see the table above).
77
+ - `drives[].unit_id` — Modbus unit id the gateway routes to the drive. This is
78
+ the drive's own configured address (`Pd.02` on SPE-B, `09-00` on E5-8200,
79
+ `H5-01` on E4-8400).
80
+ - `drives[].poll_interval` — seconds between reads.
81
+ - `drives[].max_frequency_hz` — optional. Only meaningful for a drive whose
82
+ protocol does not expose its configured maximum output frequency; the driver
83
+ uses it to derive `frequency_percent` and to bound `set_frequency`. Omit it to
84
+ keep the driver's own default (SPE-B reads `P0.10` from the drive, E4-8400
85
+ reads `E1-04`; E5-8200 falls back to 50 Hz).
86
+ - `failure_threshold` — consecutive failed reads before a drive is reported as
87
+ disconnected. One dropped frame on an RS485 segment must not flip the status.
88
+ - `stream_maxlen` — entries retained per drive's readings stream.
89
+
90
+ A configuration change at runtime is **restart-required**: the drive registry is
91
+ not live-rebuilt.
92
+
93
+ ## Tasks
94
+
95
+ Control lane:
96
+
97
+ - `monitoring:start` / `monitoring:stop` — payload `{"label": "pump"}` or empty
98
+ for every drive.
99
+
100
+ Data lane:
101
+
102
+ - `vfd:read` — payload `{"label": "pump"}`. Returns every parameter field plus
103
+ the decoded fault (`error_code`, `error_message`).
104
+ - `vfd:start` — `{"label": "pump", "forward": true, "slow": false}`.
105
+ - `vfd:stop` — `{"label": "pump", "freewheel": false}`.
106
+ - `vfd:set-frequency` — `{"label": "pump", "frequency_hz": 42.5}`.
107
+ - `vfd:clear-error` — `{"label": "pump", "comm": false}`.
108
+
109
+ Every control task is gated on the drive's declared capabilities, so an
110
+ unsupported command is rejected as `UNSUPPORTED` before the driver is called.
111
+ `vfd:read` is ungated.
112
+
113
+ ## Valkey keys
114
+
115
+ - Readings stream: `scietex:{service}:{instance_id}:vfd:{label}:readings`
116
+ with fields `ts`, `frequency_hz`, `frequency_percent`, `output_current`,
117
+ `output_voltage`, `output_power`, `dc_bus_voltage`, `heatsink_temperature_c`,
118
+ `motor_temperature_c`, `state`. A field the drive does not report is written
119
+ as an empty value, which is distinguishable from a reported `0.0`.
120
+ - Telemetry: `scietex:{service}:{instance_id}:telemetry`, pushed every 2 s with
121
+ a 6 s TTL.
122
+
123
+ ## Running
124
+
125
+ ```bash
126
+ start-vfd-service --conf-dir /etc/scietex
127
+ ```
128
+
129
+ Flags: `--conf-dir` (auto-resolved when omitted), `--service-name` (default
130
+ `$SCIETEX_SERVICE_NAME` or `VFD`), `--logging-level` (default
131
+ `$SCIETEX_LOGGING_LEVEL` or `INFO`). It runs in the foreground until `SIGINT` or
132
+ `SIGTERM`.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ uv venv --python 3.14 .venv
138
+ uv pip install -e ".[dev,test,lint]" --python .venv/bin/python
139
+ tox -e format,lint,type,test
140
+ ```
141
+
142
+ ## Container
143
+
144
+ ```bash
145
+ ./build_image.sh [--latest]
146
+ ```
147
+
148
+ Builds and pushes `registry.buro-nts.ru/scietex-vfd-service:<version>` for
149
+ `linux/amd64` and `linux/arm64`. The image installs the released wheel from
150
+ PyPI, so publish the version first.
151
+
152
+ ## Deployment prerequisites
153
+
154
+ The gateway speaks 8-N-1 on its serial port. Each drive must be configured to
155
+ match before the gateway can talk to it:
156
+
157
+ - **Intek SPE-B** — `Pd.01` → `3` (8-N-1), `Pd.05` → `1` (standard Modbus),
158
+ `Pd.02` → the unit id used in `vfd.yml`.
159
+ - **Vesper E5-8200** — `09-01` → `0` (RTU), `09-03` → `0` (1 stop bit),
160
+ `09-04` → `0` (no parity), `09-05` → `0` (8 data bits), `09-00` → the unit id.
161
+ - **Vesper E4-8400** — `H5-08` → `0` (RTU), `H5-09` → `0` (1 stop bit),
162
+ `H5-10` → `0` (8 data bits), `H5-03` → `0` (no parity), `H5-01` → the unit id.
163
+
164
+ The drive must also accept its setpoint from the communication channel
165
+ (`P0.03` = `9` on SPE-B, `b1-01`/`b1-02` = `2` on E4-8400). See the HAL
166
+ protocol docs for the full register maps.
@@ -0,0 +1,140 @@
1
+ # scietex.vfd_service
2
+
3
+ Scietex microservice daemon for variable frequency drive (VFD) monitoring and
4
+ control.
5
+
6
+ The service drives one or more VFDs through the Scietex Modbus gateway. Each
7
+ drive is handled by a driver from
8
+ [`scietex.hal.vfd`](https://github.com/bond-anton/scietex.hal.vfd), selected by
9
+ entry point name in the service configuration. The service polls each drive on
10
+ its own long-lived task, publishes readings to a per-drive Valkey stream, and
11
+ exposes control tasks (`vfd:start`, `vfd:stop`, `vfd:set-frequency`,
12
+ `vfd:clear-error`) on the data lane.
13
+
14
+ ## Supported drives
15
+
16
+ | Driver entry point | Drive | Temperature reading |
17
+ | --- | --- | --- |
18
+ | `intek_spe_b_modbus` | Intek SPE-B | none (overheat is a fault code) |
19
+ | `vesper_e5_8200_modbus` | Vesper E5-8200 | none (overheat is a fault code) |
20
+ | `vesper_e4_8400_modbus` | Vesper E4-8400 / E4-P8402 | power-module temperature (U1-46) |
21
+
22
+ The gateway descriptors (`intek_spe_b`, `vesper_e5_8200`, `vesper_e4_8400`)
23
+ live under the `scietex.modbus.drivers` group and are registered with the
24
+ gateway, not with this service. The `_modbus` suffix distinguishes the HAL
25
+ driver entry points from the gateway descriptors.
26
+
27
+ ## Configuration
28
+
29
+ The service reads `vfd/vfd.yml` under the framework config directory
30
+ (`SCIETEX_CONFIG_DIR`, default `/config` in the container). The file is created
31
+ with defaults on first start.
32
+
33
+ ```yaml
34
+ gateway:
35
+ host: 127.0.0.1
36
+ port: 15020
37
+ timeout: 1.0
38
+ drives:
39
+ - label: pump
40
+ driver: intek_spe_b_modbus
41
+ unit_id: 1
42
+ poll_interval: 1.0
43
+ # max_frequency_hz: 50.0
44
+ failure_threshold: 3
45
+ stream_maxlen: 10000
46
+ ```
47
+
48
+ - `gateway` — Modbus/TCP endpoint of the Scietex Modbus gateway.
49
+ - `drives[].label` — unique label; the registry key and the stream key segment.
50
+ - `drives[].driver` — HAL driver entry point name (see the table above).
51
+ - `drives[].unit_id` — Modbus unit id the gateway routes to the drive. This is
52
+ the drive's own configured address (`Pd.02` on SPE-B, `09-00` on E5-8200,
53
+ `H5-01` on E4-8400).
54
+ - `drives[].poll_interval` — seconds between reads.
55
+ - `drives[].max_frequency_hz` — optional. Only meaningful for a drive whose
56
+ protocol does not expose its configured maximum output frequency; the driver
57
+ uses it to derive `frequency_percent` and to bound `set_frequency`. Omit it to
58
+ keep the driver's own default (SPE-B reads `P0.10` from the drive, E4-8400
59
+ reads `E1-04`; E5-8200 falls back to 50 Hz).
60
+ - `failure_threshold` — consecutive failed reads before a drive is reported as
61
+ disconnected. One dropped frame on an RS485 segment must not flip the status.
62
+ - `stream_maxlen` — entries retained per drive's readings stream.
63
+
64
+ A configuration change at runtime is **restart-required**: the drive registry is
65
+ not live-rebuilt.
66
+
67
+ ## Tasks
68
+
69
+ Control lane:
70
+
71
+ - `monitoring:start` / `monitoring:stop` — payload `{"label": "pump"}` or empty
72
+ for every drive.
73
+
74
+ Data lane:
75
+
76
+ - `vfd:read` — payload `{"label": "pump"}`. Returns every parameter field plus
77
+ the decoded fault (`error_code`, `error_message`).
78
+ - `vfd:start` — `{"label": "pump", "forward": true, "slow": false}`.
79
+ - `vfd:stop` — `{"label": "pump", "freewheel": false}`.
80
+ - `vfd:set-frequency` — `{"label": "pump", "frequency_hz": 42.5}`.
81
+ - `vfd:clear-error` — `{"label": "pump", "comm": false}`.
82
+
83
+ Every control task is gated on the drive's declared capabilities, so an
84
+ unsupported command is rejected as `UNSUPPORTED` before the driver is called.
85
+ `vfd:read` is ungated.
86
+
87
+ ## Valkey keys
88
+
89
+ - Readings stream: `scietex:{service}:{instance_id}:vfd:{label}:readings`
90
+ with fields `ts`, `frequency_hz`, `frequency_percent`, `output_current`,
91
+ `output_voltage`, `output_power`, `dc_bus_voltage`, `heatsink_temperature_c`,
92
+ `motor_temperature_c`, `state`. A field the drive does not report is written
93
+ as an empty value, which is distinguishable from a reported `0.0`.
94
+ - Telemetry: `scietex:{service}:{instance_id}:telemetry`, pushed every 2 s with
95
+ a 6 s TTL.
96
+
97
+ ## Running
98
+
99
+ ```bash
100
+ start-vfd-service --conf-dir /etc/scietex
101
+ ```
102
+
103
+ Flags: `--conf-dir` (auto-resolved when omitted), `--service-name` (default
104
+ `$SCIETEX_SERVICE_NAME` or `VFD`), `--logging-level` (default
105
+ `$SCIETEX_LOGGING_LEVEL` or `INFO`). It runs in the foreground until `SIGINT` or
106
+ `SIGTERM`.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ uv venv --python 3.14 .venv
112
+ uv pip install -e ".[dev,test,lint]" --python .venv/bin/python
113
+ tox -e format,lint,type,test
114
+ ```
115
+
116
+ ## Container
117
+
118
+ ```bash
119
+ ./build_image.sh [--latest]
120
+ ```
121
+
122
+ Builds and pushes `registry.buro-nts.ru/scietex-vfd-service:<version>` for
123
+ `linux/amd64` and `linux/arm64`. The image installs the released wheel from
124
+ PyPI, so publish the version first.
125
+
126
+ ## Deployment prerequisites
127
+
128
+ The gateway speaks 8-N-1 on its serial port. Each drive must be configured to
129
+ match before the gateway can talk to it:
130
+
131
+ - **Intek SPE-B** — `Pd.01` → `3` (8-N-1), `Pd.05` → `1` (standard Modbus),
132
+ `Pd.02` → the unit id used in `vfd.yml`.
133
+ - **Vesper E5-8200** — `09-01` → `0` (RTU), `09-03` → `0` (1 stop bit),
134
+ `09-04` → `0` (no parity), `09-05` → `0` (8 data bits), `09-00` → the unit id.
135
+ - **Vesper E4-8400** — `H5-08` → `0` (RTU), `H5-09` → `0` (1 stop bit),
136
+ `H5-10` → `0` (8 data bits), `H5-03` → `0` (no parity), `H5-01` → the unit id.
137
+
138
+ The drive must also accept its setpoint from the communication channel
139
+ (`P0.03` = `9` on SPE-B, `b1-01`/`b1-02` = `2` on E4-8400). See the HAL
140
+ protocol docs for the full register maps.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "scietex.vfd_service"
3
+ description = "Scietex microservice daemon for variable frequency drive monitoring and control"
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.service[valkey]~=6.2.0",
15
+ "scietex.hal.vfd>=2.0.0,<3.0.0",
16
+ "pymodbus~=3.15",
17
+ ]
18
+ dynamic = ["version"]
19
+
20
+ [project.urls]
21
+ "Bug Tracker" = "https://github.com/bond-anton/scietex.vfd_service/issues"
22
+ "Homepage" = "https://github.com/bond-anton/scietex.vfd_service"
23
+
24
+ [project.scripts]
25
+ start-vfd-service = "scietex.vfd_service.run_worker:main"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["tox>=4.45.0"]
29
+ lint = ["ruff", "ty"]
30
+ test = ["pytest", "pytest-asyncio"]
31
+
32
+ [build-system]
33
+ requires = ["setuptools>=61.0"]
34
+ build-backend = "setuptools.build_meta"
35
+
36
+ [tool.pytest.ini_options]
37
+ pythonpath = ["src"]
38
+
39
+ [tool.setuptools.dynamic]
40
+ version = { attr = "scietex.vfd_service.version.__version__" }
41
+
42
+ [tool.setuptools.package-data]
43
+ "scietex.vfd_service" = ["py.typed"]
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ """VFD Service."""
2
+
3
+ from .config import (
4
+ VFD_CONFIG_FILE,
5
+ VFD_CONFIG_SUBDIR,
6
+ VFD_SECTION,
7
+ VFD_SETTINGS_DEFAULTS,
8
+ ModbusGatewaySettings,
9
+ VFDServiceSettings,
10
+ VFDSettings,
11
+ read_vfd_config,
12
+ )
13
+ from .version import __version__
14
+ from .vfd_worker import VFDWorker
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "VFDWorker",
19
+ "VFD_SECTION",
20
+ "VFD_CONFIG_SUBDIR",
21
+ "VFD_CONFIG_FILE",
22
+ "VFD_SETTINGS_DEFAULTS",
23
+ "ModbusGatewaySettings",
24
+ "VFDServiceSettings",
25
+ "VFDSettings",
26
+ "read_vfd_config",
27
+ ]
@@ -0,0 +1,152 @@
1
+ """Configuration models and YAML loader for the VFD service.
2
+
3
+ `VFDServiceSettings` is the concrete constructor (L0) base and the validation
4
+ target for the service's remote configuration. The drive list is a field-level
5
+ map merged onto L0 and overlaid by the remote `vfd` section (L3).
6
+
7
+ This module is deliberately **not** registered as an L1 bootstrap provider. The
8
+ worker reads `vfd.yml` directly at startup (before `super().initialize()`) and
9
+ uses it only to seed the initial in-memory drive list; the config section is
10
+ registered with `bootstrap=None`, so L1 never enters the layer stack and the
11
+ remote L3 config cleanly replaces it. Trade-off: `config:show` will not display
12
+ it as a layer.
13
+
14
+ The structs are kept deliberately thin: the drive ``driver`` is an entry point
15
+ name resolved at runtime (e.g. ``intek_spe_b``), so the YAML schema only
16
+ constrains types and unknown fields.
17
+ """
18
+
19
+ from pathlib import Path
20
+
21
+ import msgspec
22
+
23
+ #: Remote-config section name the settings are registered under.
24
+ VFD_SECTION: str = "vfd"
25
+
26
+ #: Subdirectory under the shared config dir that namespaces this service's
27
+ #: files. The service-owned ``vfd.yml`` lives here, so services sharing one
28
+ #: config dir cannot collide.
29
+ VFD_CONFIG_SUBDIR: str = "vfd"
30
+
31
+ #: Filename of the service-owned YAML config file in the config subdirectory.
32
+ VFD_CONFIG_FILE: str = "vfd.yml"
33
+
34
+
35
+ class ModbusGatewaySettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
36
+ """Modbus/TCP gateway connection settings.
37
+
38
+ The service drives VFDs through the Scietex Modbus gateway, so only the TCP
39
+ endpoint and the transaction timeout are configurable here.
40
+ """
41
+
42
+ host: str = "127.0.0.1"
43
+ port: int = 502
44
+ timeout: float = 1.0
45
+
46
+
47
+ class VFDSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
48
+ """Settings for a single variable frequency drive driven by the service.
49
+
50
+ ``label`` and ``driver`` are required. ``driver`` is the entry point name of
51
+ a `scietex.hal.vfd` driver, resolved at runtime, e.g. ``intek_spe_b``.
52
+
53
+ ``max_frequency_hz`` is optional and only meaningful for drives whose
54
+ protocol does not expose their configured maximum output frequency: the
55
+ driver uses it to derive ``frequency_percent`` and to bound
56
+ ``set_frequency``. ``None`` leaves the driver's own default in place.
57
+ """
58
+
59
+ label: str
60
+ driver: str
61
+ unit_id: int = 1
62
+ poll_interval: float = 1.0
63
+ max_frequency_hz: float | None = None
64
+
65
+
66
+ class VFDServiceSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
67
+ """Top-level VFD service settings.
68
+
69
+ ``failure_threshold`` and ``stream_maxlen`` are service-wide rather than
70
+ per-drive: every drive on the bus shares the same RS485 segment, so the
71
+ noise tolerance is a property of the segment, and the readings stream is a
72
+ retention concern that does not vary by drive.
73
+ """
74
+
75
+ gateway: ModbusGatewaySettings = ModbusGatewaySettings()
76
+ drives: list[VFDSettings] = msgspec.field(default_factory=list)
77
+ #: Consecutive failed reads before a drive is reported as disconnected.
78
+ #: One failed read is common on an RS485 segment, so a single glitch must
79
+ #: not flip the status icon.
80
+ failure_threshold: int = 3
81
+ #: Entries retained per drive's readings stream.
82
+ stream_maxlen: int = 10000
83
+
84
+
85
+ #: Concrete L0 base for the service's configuration merge. A field absent from
86
+ #: every layer, or explicitly cleared with ``null``, resolves to the value here.
87
+ VFD_SETTINGS_DEFAULTS: VFDServiceSettings = VFDServiceSettings()
88
+
89
+
90
+ def read_vfd_config(conf_dir: Path | None, *, create_default: bool = True) -> dict[str, object]:
91
+ """Read the service config from ``vfd/vfd.yml`` under the config directory.
92
+
93
+ The service's files are namespaced in a ``vfd/`` subdirectory so they do not
94
+ collide with other services sharing the framework's single config dir. The
95
+ file (and, when missing, its directory) is only created when
96
+ ``create_default=True`` (the bootstrap path). A ``None`` or non-directory
97
+ ``conf_dir``, a missing file/directory with ``create_default=False``, or an
98
+ unparseable file each raise `RuntimeError`. An existing-but-invalid file is
99
+ left untouched regardless of ``create_default``.
100
+
101
+ The return value is the raw ``vfd.yml`` mapping (an L1-style patch). When
102
+ the file is just created, the builtins of the default struct are returned so
103
+ the patch is equivalent to an empty override.
104
+
105
+ Args:
106
+ conf_dir: Path to the configuration directory.
107
+ create_default: Whether to create the directory and write a default
108
+ ``vfd.yml`` when missing. Default ``True``.
109
+
110
+ Returns:
111
+ The parsed ``vfd.yml`` mapping as a dict, or the builtins of the default
112
+ `VFDServiceSettings` when the file was just created.
113
+
114
+ Raises:
115
+ RuntimeError: If ``conf_dir`` is ``None`` or not a directory, the file
116
+ is missing with ``create_default=False``, or the file cannot be
117
+ parsed.
118
+ """
119
+ if not isinstance(conf_dir, Path):
120
+ raise RuntimeError("Configuration dir was not set!")
121
+ service_dir = conf_dir / VFD_CONFIG_SUBDIR
122
+ if not service_dir.exists():
123
+ if create_default:
124
+ try:
125
+ service_dir.mkdir(parents=True, exist_ok=True)
126
+ except Exception as exc:
127
+ raise RuntimeError(f"Failed to create configuration directory {service_dir}!") from exc
128
+ else:
129
+ raise RuntimeError(
130
+ f"Configuration directory {service_dir} does not exist and create_default=False (no default generated)."
131
+ )
132
+ elif not service_dir.is_dir():
133
+ raise RuntimeError(f"Provided configuration directory path {service_dir} is not a directory!")
134
+ vfd_yml = service_dir.joinpath(VFD_CONFIG_FILE)
135
+ if not vfd_yml.exists():
136
+ if create_default:
137
+ settings = VFDServiceSettings()
138
+ with open(vfd_yml, "wb") as f:
139
+ f.write(msgspec.yaml.encode(settings))
140
+ return msgspec.to_builtins(settings)
141
+ raise RuntimeError(
142
+ f"VFD configuration file {vfd_yml} does not exist and create_default=False "
143
+ "(pass create_default=True to generate defaults)."
144
+ )
145
+ try:
146
+ with open(vfd_yml, "rb") as f:
147
+ patch = msgspec.yaml.decode(f.read(), type=dict)
148
+ except Exception as exc:
149
+ raise RuntimeError(
150
+ f"Failed to parse VFD configuration file {vfd_yml}. Fix the file or remove it to regenerate defaults."
151
+ ) from exc
152
+ return patch
@@ -0,0 +1,73 @@
1
+ """Entry point to run the VFD worker as a foreground daemon."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+
7
+ from scietex.service import ValkeyWorkerConfig
8
+
9
+ from .config import VFD_CONFIG_SUBDIR
10
+ from .version import __version__
11
+ from .vfd_worker import VFDWorker
12
+
13
+ #: Environment fallbacks for the CLI options, so a container can be configured
14
+ #: with ``-e`` without overriding its command. An explicit CLI flag still wins.
15
+ ENV_SERVICE_NAME: str = "SCIETEX_SERVICE_NAME"
16
+ ENV_LOGGING_LEVEL: str = "SCIETEX_LOGGING_LEVEL"
17
+
18
+ DEFAULT_SERVICE_NAME: str = "VFD"
19
+ DEFAULT_LOGGING_LEVEL: str = "INFO"
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ """Build the CLI parser with environment-variable fallbacks.
24
+
25
+ ``--conf-dir`` defaults to ``None`` so the framework's ``prepare_conf_dir``
26
+ resolves it (it already honors ``SCIETEX_CONFIG_DIR``); the other options
27
+ fall back to their environment variables when no flag is given.
28
+ """
29
+ parser = argparse.ArgumentParser(description="Run the VFD worker.")
30
+ parser.add_argument("--conf-dir", default=None, help="Configuration directory (default: auto-resolved)")
31
+ parser.add_argument(
32
+ "--service-name",
33
+ default=os.environ.get(ENV_SERVICE_NAME, DEFAULT_SERVICE_NAME),
34
+ help=f"Service name (default: ${ENV_SERVICE_NAME} or {DEFAULT_SERVICE_NAME})",
35
+ )
36
+ parser.add_argument(
37
+ "--logging-level",
38
+ default=os.environ.get(ENV_LOGGING_LEVEL, DEFAULT_LOGGING_LEVEL),
39
+ help=f"Logging level (default: ${ENV_LOGGING_LEVEL} or {DEFAULT_LOGGING_LEVEL})",
40
+ )
41
+ return parser
42
+
43
+
44
+ def _build_config(args: argparse.Namespace) -> ValkeyWorkerConfig:
45
+ """Build the worker config from parsed CLI arguments."""
46
+ return ValkeyWorkerConfig(
47
+ service_name=args.service_name,
48
+ version=__version__,
49
+ conf_dir=args.conf_dir,
50
+ logging_level=args.logging_level,
51
+ remote_config_enabled=True,
52
+ valkey_config=None,
53
+ # Namespace the framework snapshot under the same subdir as vfd.yml, so
54
+ # services sharing one config dir cannot clobber each other's config.
55
+ config_file=f"{VFD_CONFIG_SUBDIR}/config.yml",
56
+ )
57
+
58
+
59
+ def main() -> None:
60
+ """Parse CLI arguments and run the VFD worker until exit is requested."""
61
+ args = _build_parser().parse_args()
62
+ config = _build_config(args)
63
+
64
+ async def run() -> None:
65
+ worker = VFDWorker(config)
66
+ await worker.start()
67
+ await worker.events["exit"].wait()
68
+
69
+ asyncio.run(run())
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,35 @@
1
+ """Task handlers served by the VFD worker."""
2
+
3
+ from .monitoring import (
4
+ MONITORING_START_TASK_NAME,
5
+ MONITORING_STOP_TASK_NAME,
6
+ MonitoringHandler,
7
+ MonitoringRequest,
8
+ MonitoringResponse,
9
+ )
10
+ from .vfd_control import (
11
+ VFD_CLEAR_ERROR_TASK_NAME,
12
+ VFD_READ_TASK_NAME,
13
+ VFD_SET_FREQUENCY_TASK_NAME,
14
+ VFD_START_TASK_NAME,
15
+ VFD_STOP_TASK_NAME,
16
+ VFDCommandRequest,
17
+ VFDCommandResponse,
18
+ VFDControlHandler,
19
+ )
20
+
21
+ __all__ = [
22
+ "MONITORING_START_TASK_NAME",
23
+ "MONITORING_STOP_TASK_NAME",
24
+ "VFD_READ_TASK_NAME",
25
+ "VFD_START_TASK_NAME",
26
+ "VFD_STOP_TASK_NAME",
27
+ "VFD_SET_FREQUENCY_TASK_NAME",
28
+ "VFD_CLEAR_ERROR_TASK_NAME",
29
+ "MonitoringHandler",
30
+ "MonitoringRequest",
31
+ "MonitoringResponse",
32
+ "VFDControlHandler",
33
+ "VFDCommandRequest",
34
+ "VFDCommandResponse",
35
+ ]