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.
- scietex_vfd_service-0.1.0/LICENSE +21 -0
- scietex_vfd_service-0.1.0/PKG-INFO +166 -0
- scietex_vfd_service-0.1.0/README.md +140 -0
- scietex_vfd_service-0.1.0/pyproject.toml +46 -0
- scietex_vfd_service-0.1.0/setup.cfg +4 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/__init__.py +27 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/config.py +152 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/py.typed +0 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/run_worker.py +73 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/task_handlers/__init__.py +35 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/task_handlers/monitoring.py +108 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/task_handlers/vfd_control.py +258 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/telemetry.py +118 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/version.py +3 -0
- scietex_vfd_service-0.1.0/src/scietex/vfd_service/vfd_worker.py +589 -0
- scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/PKG-INFO +166 -0
- scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/SOURCES.txt +26 -0
- scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/dependency_links.txt +1 -0
- scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/entry_points.txt +2 -0
- scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/requires.txt +14 -0
- scietex_vfd_service-0.1.0/src/scietex.vfd_service.egg-info/top_level.txt +1 -0
- scietex_vfd_service-0.1.0/tests/test_config.py +133 -0
- scietex_vfd_service-0.1.0/tests/test_monitoring_handler.py +142 -0
- scietex_vfd_service-0.1.0/tests/test_run_worker.py +59 -0
- scietex_vfd_service-0.1.0/tests/test_telemetry.py +114 -0
- scietex_vfd_service-0.1.0/tests/test_version.py +52 -0
- scietex_vfd_service-0.1.0/tests/test_vfd_control_handler.py +352 -0
- 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,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
|
|
File without changes
|
|
@@ -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
|
+
]
|