scietex.adc-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_adc_service-0.1.0/LICENSE +21 -0
- scietex_adc_service-0.1.0/PKG-INFO +162 -0
- scietex_adc_service-0.1.0/README.md +132 -0
- scietex_adc_service-0.1.0/pyproject.toml +54 -0
- scietex_adc_service-0.1.0/setup.cfg +4 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/__init__.py +25 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/adc_worker.py +159 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/config.py +147 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/devices.py +318 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/py.typed +0 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/run_worker.py +73 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/__init__.py +35 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/_request.py +96 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/list.py +146 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/read.py +190 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/telemetry.py +102 -0
- scietex_adc_service-0.1.0/src/scietex/adc_service/version.py +3 -0
- scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/PKG-INFO +162 -0
- scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/SOURCES.txt +27 -0
- scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/dependency_links.txt +1 -0
- scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/entry_points.txt +2 -0
- scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/requires.txt +20 -0
- scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/top_level.txt +1 -0
- scietex_adc_service-0.1.0/tests/test_config.py +162 -0
- scietex_adc_service-0.1.0/tests/test_devices.py +283 -0
- scietex_adc_service-0.1.0/tests/test_run_worker.py +70 -0
- scietex_adc_service-0.1.0/tests/test_task_handlers.py +385 -0
- scietex_adc_service-0.1.0/tests/test_telemetry.py +145 -0
- scietex_adc_service-0.1.0/tests/test_worker.py +220 -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,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scietex.adc_service
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scietex microservice daemon for SPI ADC devices
|
|
5
|
+
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.adc_service/issues
|
|
8
|
+
Project-URL: Homepage, https://github.com/bond-anton/scietex.adc_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.adc<1.0.0,>=0.1.0
|
|
16
|
+
Requires-Dist: msgspec>=0.18.6
|
|
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
|
+
Provides-Extra: spi
|
|
26
|
+
Requires-Dist: scietex.hal.adc[spi]<1.0.0,>=0.1.0; extra == "spi"
|
|
27
|
+
Provides-Extra: gpiozero
|
|
28
|
+
Requires-Dist: scietex.hal.adc[gpiozero]<1.0.0,>=0.1.0; extra == "gpiozero"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# scietex.adc_service
|
|
32
|
+
|
|
33
|
+
**scietex.adc_service** is a [`scietex.service`](https://github.com/bond-anton/scietex.service)
|
|
34
|
+
worker connected to Valkey that owns one or more SPI ADC devices (MCP3204,
|
|
35
|
+
MCP3208) through [`scietex.hal.adc`](https://github.com/bond-anton/scietex.hal.adc).
|
|
36
|
+
It opens the configured converters at startup, exposes single-ended and
|
|
37
|
+
differential reads as `adc:*` tasks, and publishes a telemetry snapshot of the
|
|
38
|
+
live devices. A device that fails to open does not stop the worker: it is
|
|
39
|
+
recorded, reported as unavailable, and the remaining devices keep working.
|
|
40
|
+
Configuration can also be pushed at runtime through the framework's
|
|
41
|
+
remote-config channel.
|
|
42
|
+
|
|
43
|
+
**Python ≥ 3.10** · **License: MIT**
|
|
44
|
+
|
|
45
|
+
## Documentation
|
|
46
|
+
|
|
47
|
+
- [Overview](docs/index.md) — what the service does and how it fits together
|
|
48
|
+
- [Configuration](docs/configuration.md) — the `adc.yml` schema and precedence
|
|
49
|
+
- [Deployment](docs/deployment.md) — container, SPI device access, Quadlet
|
|
50
|
+
- [Remote configuration](docs/remote-config.md) — the declarative `adc` section
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install "scietex.adc_service[spi]" # spidev backend for /dev/spidev*
|
|
56
|
+
pip install "scietex.adc_service[gpiozero]" # plain-GPIO backend
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The core package pulls in `scietex.hal.adc`, `scietex.service[valkey]`, and
|
|
60
|
+
`msgspec`. The backend extras are optional: a core install can still build a
|
|
61
|
+
device from a caller-supplied transport, but an operator normally installs the
|
|
62
|
+
extra that matches the board's wiring.
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
### 1. Write a configuration file
|
|
67
|
+
|
|
68
|
+
The service reads `adc/adc.yml` under its config directory. Create it by hand,
|
|
69
|
+
or let the service generate a default on first run. The file is the bootstrap
|
|
70
|
+
layer of a four-layer merge: constructor defaults < `adc.yml` < the framework's
|
|
71
|
+
`config.yml` snapshot < the remote `adc` section.
|
|
72
|
+
|
|
73
|
+
### 2. Run the service
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
start-adc-service --conf-dir /etc/scietex
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The config directory resolves automatically when `--conf-dir` is omitted. The
|
|
80
|
+
service runs in the foreground until `SIGINT` or `SIGTERM`.
|
|
81
|
+
|
|
82
|
+
### 3. Read a channel
|
|
83
|
+
|
|
84
|
+
Submit an `adc:read` task to the worker with payload
|
|
85
|
+
`{"device_id": "adc0", "channel": 0}`. The result carries the raw 12-bit `code`
|
|
86
|
+
and the scaled `voltage`.
|
|
87
|
+
|
|
88
|
+
## Configuration at a glance
|
|
89
|
+
|
|
90
|
+
Devices are keyed by a **string** id, which names the device in task payloads
|
|
91
|
+
and telemetry:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
devices:
|
|
95
|
+
adc0:
|
|
96
|
+
type: MCP3208
|
|
97
|
+
port: 0
|
|
98
|
+
device: 0
|
|
99
|
+
v_ref: 3.3
|
|
100
|
+
adc1:
|
|
101
|
+
type: MCP3204
|
|
102
|
+
device: 1
|
|
103
|
+
v_ref: 5.0
|
|
104
|
+
poll: false
|
|
105
|
+
poll_enabled: true
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Per-device fields: `type` (required, `MCP3204` or `MCP3208`), `port` and `device`
|
|
109
|
+
for the SPI wiring, `clock_pin`/`mosi_pin`/`miso_pin`/`select_pin` for the
|
|
110
|
+
gpiozero backend, `v_ref`, `label`, `log_level`, and `poll` (default `true`).
|
|
111
|
+
`port` is the SPI bus number and `device` is the chip-select; both are integers.
|
|
112
|
+
Top-level fields: `devices` and `poll_enabled` (default `true`). Unknown fields
|
|
113
|
+
are rejected.
|
|
114
|
+
|
|
115
|
+
## Tasks
|
|
116
|
+
|
|
117
|
+
| Task | Lane | Request | Result |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `adc:list` | control | optional `device_id` | per-device `device_id`, `available`, `error`, `v_ref`, `channels`, `differential`, `poll`; plus `poll_enabled` |
|
|
120
|
+
| `adc:read` | data | `device_id`, `channel`, `differential` | `device_id`, `channel`, `differential`, `code`, `voltage` |
|
|
121
|
+
| `adc:read_all` | data | `device_id` | `device_id`, `codes`, `voltages` |
|
|
122
|
+
|
|
123
|
+
Differential reads use `adc:read` with `differential: true`; `channel` then
|
|
124
|
+
names a differential pair instead of a single-ended input. An id that is
|
|
125
|
+
configured but failed to open is a successful `adc:list` entry with
|
|
126
|
+
`available: false`, not a task error.
|
|
127
|
+
|
|
128
|
+
## Telemetry
|
|
129
|
+
|
|
130
|
+
The worker publishes to `scietex:{service}:{instance_id}:telemetry` every 2 s
|
|
131
|
+
with a 6 s TTL. The snapshot lists every configured device with its open state,
|
|
132
|
+
`v_ref`, channel counts, and `poll` flag, plus the service-wide `poll_enabled`
|
|
133
|
+
switch. Polling is opt-out both per device (`poll: false`) and globally
|
|
134
|
+
(`poll_enabled: false`).
|
|
135
|
+
|
|
136
|
+
## Container and quadlet
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
podman build --build-arg VERSION=0.1.0 -t scietex-adc-service .
|
|
140
|
+
podman run --rm --device /dev/spidev0.0 -v ./config:/config scietex-adc-service
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The container only dials Valkey, so it publishes no port and needs no
|
|
144
|
+
capabilities. `/dev/spidevX.Y` must be passed with `--device`; without it the
|
|
145
|
+
worker still starts and reports the device unavailable.
|
|
146
|
+
|
|
147
|
+
For a persistent deployment, install `deploy/adc-service.container` as
|
|
148
|
+
`~/.config/containers/systemd/adc-service.container` and the SPI drop-in as
|
|
149
|
+
`~/.config/containers/systemd/adc-service.container.d/50-spi-devices.conf`,
|
|
150
|
+
then `systemctl --user daemon-reload && systemctl --user start adc-service`.
|
|
151
|
+
The Quadlet bind-mounts `~/.config/scietex`, so `adc/adc.yml` stays
|
|
152
|
+
hand-editable on the host.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
Dependencies are managed with **uv**; checks and tests run through **tox**:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
uv sync --all-extras
|
|
160
|
+
uv run tox # format, lint, type, py314
|
|
161
|
+
uv run tox -e py314 # tests only
|
|
162
|
+
```
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# scietex.adc_service
|
|
2
|
+
|
|
3
|
+
**scietex.adc_service** is a [`scietex.service`](https://github.com/bond-anton/scietex.service)
|
|
4
|
+
worker connected to Valkey that owns one or more SPI ADC devices (MCP3204,
|
|
5
|
+
MCP3208) through [`scietex.hal.adc`](https://github.com/bond-anton/scietex.hal.adc).
|
|
6
|
+
It opens the configured converters at startup, exposes single-ended and
|
|
7
|
+
differential reads as `adc:*` tasks, and publishes a telemetry snapshot of the
|
|
8
|
+
live devices. A device that fails to open does not stop the worker: it is
|
|
9
|
+
recorded, reported as unavailable, and the remaining devices keep working.
|
|
10
|
+
Configuration can also be pushed at runtime through the framework's
|
|
11
|
+
remote-config channel.
|
|
12
|
+
|
|
13
|
+
**Python ≥ 3.10** · **License: MIT**
|
|
14
|
+
|
|
15
|
+
## Documentation
|
|
16
|
+
|
|
17
|
+
- [Overview](docs/index.md) — what the service does and how it fits together
|
|
18
|
+
- [Configuration](docs/configuration.md) — the `adc.yml` schema and precedence
|
|
19
|
+
- [Deployment](docs/deployment.md) — container, SPI device access, Quadlet
|
|
20
|
+
- [Remote configuration](docs/remote-config.md) — the declarative `adc` section
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install "scietex.adc_service[spi]" # spidev backend for /dev/spidev*
|
|
26
|
+
pip install "scietex.adc_service[gpiozero]" # plain-GPIO backend
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The core package pulls in `scietex.hal.adc`, `scietex.service[valkey]`, and
|
|
30
|
+
`msgspec`. The backend extras are optional: a core install can still build a
|
|
31
|
+
device from a caller-supplied transport, but an operator normally installs the
|
|
32
|
+
extra that matches the board's wiring.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
### 1. Write a configuration file
|
|
37
|
+
|
|
38
|
+
The service reads `adc/adc.yml` under its config directory. Create it by hand,
|
|
39
|
+
or let the service generate a default on first run. The file is the bootstrap
|
|
40
|
+
layer of a four-layer merge: constructor defaults < `adc.yml` < the framework's
|
|
41
|
+
`config.yml` snapshot < the remote `adc` section.
|
|
42
|
+
|
|
43
|
+
### 2. Run the service
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
start-adc-service --conf-dir /etc/scietex
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The config directory resolves automatically when `--conf-dir` is omitted. The
|
|
50
|
+
service runs in the foreground until `SIGINT` or `SIGTERM`.
|
|
51
|
+
|
|
52
|
+
### 3. Read a channel
|
|
53
|
+
|
|
54
|
+
Submit an `adc:read` task to the worker with payload
|
|
55
|
+
`{"device_id": "adc0", "channel": 0}`. The result carries the raw 12-bit `code`
|
|
56
|
+
and the scaled `voltage`.
|
|
57
|
+
|
|
58
|
+
## Configuration at a glance
|
|
59
|
+
|
|
60
|
+
Devices are keyed by a **string** id, which names the device in task payloads
|
|
61
|
+
and telemetry:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
devices:
|
|
65
|
+
adc0:
|
|
66
|
+
type: MCP3208
|
|
67
|
+
port: 0
|
|
68
|
+
device: 0
|
|
69
|
+
v_ref: 3.3
|
|
70
|
+
adc1:
|
|
71
|
+
type: MCP3204
|
|
72
|
+
device: 1
|
|
73
|
+
v_ref: 5.0
|
|
74
|
+
poll: false
|
|
75
|
+
poll_enabled: true
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Per-device fields: `type` (required, `MCP3204` or `MCP3208`), `port` and `device`
|
|
79
|
+
for the SPI wiring, `clock_pin`/`mosi_pin`/`miso_pin`/`select_pin` for the
|
|
80
|
+
gpiozero backend, `v_ref`, `label`, `log_level`, and `poll` (default `true`).
|
|
81
|
+
`port` is the SPI bus number and `device` is the chip-select; both are integers.
|
|
82
|
+
Top-level fields: `devices` and `poll_enabled` (default `true`). Unknown fields
|
|
83
|
+
are rejected.
|
|
84
|
+
|
|
85
|
+
## Tasks
|
|
86
|
+
|
|
87
|
+
| Task | Lane | Request | Result |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| `adc:list` | control | optional `device_id` | per-device `device_id`, `available`, `error`, `v_ref`, `channels`, `differential`, `poll`; plus `poll_enabled` |
|
|
90
|
+
| `adc:read` | data | `device_id`, `channel`, `differential` | `device_id`, `channel`, `differential`, `code`, `voltage` |
|
|
91
|
+
| `adc:read_all` | data | `device_id` | `device_id`, `codes`, `voltages` |
|
|
92
|
+
|
|
93
|
+
Differential reads use `adc:read` with `differential: true`; `channel` then
|
|
94
|
+
names a differential pair instead of a single-ended input. An id that is
|
|
95
|
+
configured but failed to open is a successful `adc:list` entry with
|
|
96
|
+
`available: false`, not a task error.
|
|
97
|
+
|
|
98
|
+
## Telemetry
|
|
99
|
+
|
|
100
|
+
The worker publishes to `scietex:{service}:{instance_id}:telemetry` every 2 s
|
|
101
|
+
with a 6 s TTL. The snapshot lists every configured device with its open state,
|
|
102
|
+
`v_ref`, channel counts, and `poll` flag, plus the service-wide `poll_enabled`
|
|
103
|
+
switch. Polling is opt-out both per device (`poll: false`) and globally
|
|
104
|
+
(`poll_enabled: false`).
|
|
105
|
+
|
|
106
|
+
## Container and quadlet
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
podman build --build-arg VERSION=0.1.0 -t scietex-adc-service .
|
|
110
|
+
podman run --rm --device /dev/spidev0.0 -v ./config:/config scietex-adc-service
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The container only dials Valkey, so it publishes no port and needs no
|
|
114
|
+
capabilities. `/dev/spidevX.Y` must be passed with `--device`; without it the
|
|
115
|
+
worker still starts and reports the device unavailable.
|
|
116
|
+
|
|
117
|
+
For a persistent deployment, install `deploy/adc-service.container` as
|
|
118
|
+
`~/.config/containers/systemd/adc-service.container` and the SPI drop-in as
|
|
119
|
+
`~/.config/containers/systemd/adc-service.container.d/50-spi-devices.conf`,
|
|
120
|
+
then `systemctl --user daemon-reload && systemctl --user start adc-service`.
|
|
121
|
+
The Quadlet bind-mounts `~/.config/scietex`, so `adc/adc.yml` stays
|
|
122
|
+
hand-editable on the host.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
Dependencies are managed with **uv**; checks and tests run through **tox**:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
uv sync --all-extras
|
|
130
|
+
uv run tox # format, lint, type, py314
|
|
131
|
+
uv run tox -e py314 # tests only
|
|
132
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scietex.adc_service"
|
|
3
|
+
description = "Scietex microservice daemon for SPI ADC devices"
|
|
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.adc>=0.1.0,<1.0.0",
|
|
16
|
+
"msgspec>=0.18.6",
|
|
17
|
+
]
|
|
18
|
+
dynamic = ["version"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
"Bug Tracker" = "https://github.com/bond-anton/scietex.adc_service/issues"
|
|
22
|
+
"Homepage" = "https://github.com/bond-anton/scietex.adc_service"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
start-adc-service = "scietex.adc_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
|
+
# SPI backends are optional: a core install can still build ADC objects from a
|
|
32
|
+
# caller-supplied transport. An operator installs the extra for the board's
|
|
33
|
+
# wiring — spidev for /dev/spidev*, gpiozero for plain GPIO pins.
|
|
34
|
+
spi = ["scietex.hal.adc[spi]>=0.1.0,<1.0.0"]
|
|
35
|
+
gpiozero = ["scietex.hal.adc[gpiozero]>=0.1.0,<1.0.0"]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["setuptools>=61.0"]
|
|
39
|
+
build-backend = "setuptools.build_meta"
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.dynamic]
|
|
47
|
+
version = { attr = "scietex.adc_service.version.__version__" }
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
"scietex.adc_service" = ["py.typed"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
namespaces = true
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""ADC Service."""
|
|
2
|
+
|
|
3
|
+
from .adc_worker import AdcWorker
|
|
4
|
+
from .config import (
|
|
5
|
+
ADC_SECTION,
|
|
6
|
+
ADC_SETTINGS_DEFAULTS,
|
|
7
|
+
AdcDeviceSettings,
|
|
8
|
+
AdcServiceSettings,
|
|
9
|
+
read_adc_config,
|
|
10
|
+
)
|
|
11
|
+
from .devices import ChannelNotFound, DeviceNotFound, DeviceUnavailable
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"AdcWorker",
|
|
17
|
+
"ADC_SECTION",
|
|
18
|
+
"ADC_SETTINGS_DEFAULTS",
|
|
19
|
+
"AdcDeviceSettings",
|
|
20
|
+
"AdcServiceSettings",
|
|
21
|
+
"ChannelNotFound",
|
|
22
|
+
"DeviceNotFound",
|
|
23
|
+
"DeviceUnavailable",
|
|
24
|
+
"read_adc_config",
|
|
25
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""ADC worker: a Valkey-backed service that owns a set of SPI converters.
|
|
2
|
+
|
|
3
|
+
`AdcWorker` extends `ValkeyWorker` with a live `AdcDeviceRegistry`. Settings
|
|
4
|
+
resolve through the framework's four-layer merge: constructor default (L0) <
|
|
5
|
+
``adc.yml`` bootstrap patch (L1) < framework ``config.yml`` snapshot (L2) <
|
|
6
|
+
remote ``adc`` section (L3). Every apply rebuilds the registry from the merged
|
|
7
|
+
struct; a device that cannot be opened is recorded and the rest keep working.
|
|
8
|
+
A ``Telemetry`` manager publishes the open-device snapshot every 2 s.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
import msgspec
|
|
16
|
+
from glide import (
|
|
17
|
+
ConnectionError as GlideConnectionError,
|
|
18
|
+
)
|
|
19
|
+
from glide import (
|
|
20
|
+
ExpirySet,
|
|
21
|
+
ExpiryType,
|
|
22
|
+
RequestError,
|
|
23
|
+
)
|
|
24
|
+
from glide import (
|
|
25
|
+
TimeoutError as GlideTimeoutError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from scietex.service import Manager, ValkeyWorker, ValkeyWorkerConfig
|
|
29
|
+
|
|
30
|
+
from .config import (
|
|
31
|
+
ADC_SECTION,
|
|
32
|
+
ADC_SETTINGS_DEFAULTS,
|
|
33
|
+
AdcServiceSettings,
|
|
34
|
+
read_adc_config,
|
|
35
|
+
)
|
|
36
|
+
from .devices import AdcDeviceRegistry
|
|
37
|
+
from .task_handlers import AdcListHandler, AdcReadAllHandler, AdcReadHandler
|
|
38
|
+
from .telemetry import TELEMETRY_INTERVAL, TELEMETRY_TTL, build_telemetry
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AdcWorker(ValkeyWorker):
|
|
42
|
+
"""A `ValkeyWorker` that reads configured SPI ADC devices on demand."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: ValkeyWorkerConfig | None = None, *, client_factory=None, theme=None) -> None:
|
|
45
|
+
super().__init__(config, client_factory=client_factory, theme=theme)
|
|
46
|
+
self._settings: AdcServiceSettings | None = None
|
|
47
|
+
self._devices = AdcDeviceRegistry(self.logger)
|
|
48
|
+
# The apply hook runs during ``initialize`` (L2/L3 delivery) and at
|
|
49
|
+
# runtime. False until ``initialize`` finishes so the hook does not
|
|
50
|
+
# rebuild the registry twice on startup; ``initialize`` rebuilds once
|
|
51
|
+
# from the final merged settings.
|
|
52
|
+
self._startup_complete: bool = False
|
|
53
|
+
# Telemetry encoding is owned here because the framework's encoder is
|
|
54
|
+
# private; the key is resolved once at construction.
|
|
55
|
+
self._telemetry_encoder = msgspec.msgpack.Encoder()
|
|
56
|
+
self._telemetry_key = f"scietex:{self.service_name}:{self.instance_id}:telemetry"
|
|
57
|
+
self.register_config_settings(
|
|
58
|
+
ADC_SECTION,
|
|
59
|
+
AdcServiceSettings,
|
|
60
|
+
apply=self._apply_adc_settings,
|
|
61
|
+
defaults=ADC_SETTINGS_DEFAULTS,
|
|
62
|
+
bootstrap=lambda: read_adc_config(self.conf_dir),
|
|
63
|
+
)
|
|
64
|
+
# All three handlers share the registry, so a config apply is visible
|
|
65
|
+
# to an in-flight handler without re-registration.
|
|
66
|
+
self.add_task_handler(AdcListHandler, devices=self._devices)
|
|
67
|
+
self.add_task_handler(AdcReadHandler, devices=self._devices)
|
|
68
|
+
self.add_task_handler(AdcReadAllHandler, devices=self._devices)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def adc_settings(self) -> AdcServiceSettings | None:
|
|
72
|
+
"""The effective ADC settings, or `None` before startup."""
|
|
73
|
+
return self._settings
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def devices(self) -> AdcDeviceRegistry:
|
|
77
|
+
"""The live device registry shared with the task handlers."""
|
|
78
|
+
return self._devices
|
|
79
|
+
|
|
80
|
+
async def initialize(self) -> bool:
|
|
81
|
+
"""Initialize the framework, then open every configured device.
|
|
82
|
+
|
|
83
|
+
`super().initialize()` seeds the L0+L1 layers (running the ``adc.yml``
|
|
84
|
+
bootstrap provider) and applies the L2/L3 layers, so the merged settings
|
|
85
|
+
are read back via `current_config_settings` afterwards. A bootstrap
|
|
86
|
+
failure is swallowed by the framework's seeder and surfaces as an
|
|
87
|
+
unresolved section, which is the ``None`` guard below. Per-device open
|
|
88
|
+
failures do not fail startup: the registry records them and the worker
|
|
89
|
+
serves the hardware that is present.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
`True` if the settings resolved and the registry rebuilt, `False`
|
|
93
|
+
on a framework, resolution, or registry-wide failure.
|
|
94
|
+
"""
|
|
95
|
+
self._startup_complete = False
|
|
96
|
+
if not await super().initialize():
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
resolved = self.current_config_settings(ADC_SECTION)
|
|
100
|
+
if resolved is None:
|
|
101
|
+
self.logger.error("ADC settings were not resolved before device startup")
|
|
102
|
+
return False
|
|
103
|
+
settings = cast(AdcServiceSettings, resolved)
|
|
104
|
+
try:
|
|
105
|
+
self._devices.rebuild(settings)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
self.logger.error("Failed to build the ADC device registry: %s", exc)
|
|
108
|
+
return False
|
|
109
|
+
self._settings = settings
|
|
110
|
+
self._startup_complete = True
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
async def cleanup(self) -> None:
|
|
114
|
+
"""Close every open device, then the framework resources."""
|
|
115
|
+
self._startup_complete = False
|
|
116
|
+
self._devices.close()
|
|
117
|
+
await super().cleanup()
|
|
118
|
+
|
|
119
|
+
def _apply_adc_settings(self, settings: AdcServiceSettings) -> None:
|
|
120
|
+
"""Store merged settings and rebuild the live devices.
|
|
121
|
+
|
|
122
|
+
The hook is synchronous (the framework invokes it inline, before the
|
|
123
|
+
core swap), and `AdcDeviceRegistry.rebuild` is synchronous by design.
|
|
124
|
+
During startup the hook only stores: `initialize` rebuilds once from
|
|
125
|
+
the final merged settings to avoid opening every device twice.
|
|
126
|
+
|
|
127
|
+
A registry-wide failure propagates, which the framework reports as a
|
|
128
|
+
failed apply; per-device failures are recorded in the registry and
|
|
129
|
+
leave the apply successful.
|
|
130
|
+
"""
|
|
131
|
+
if not self._startup_complete:
|
|
132
|
+
self._settings = settings
|
|
133
|
+
return
|
|
134
|
+
self._devices.rebuild(settings)
|
|
135
|
+
self._settings = settings
|
|
136
|
+
|
|
137
|
+
@Manager(name="Telemetry")
|
|
138
|
+
async def telemetry_loop(self) -> None:
|
|
139
|
+
"""Publish device telemetry to Valkey once per iteration.
|
|
140
|
+
|
|
141
|
+
The manager runtime repeats this body forever with auto-restart on
|
|
142
|
+
error. The write is guarded on `self.client` and runs even before the
|
|
143
|
+
first configuration, so the UI observes ``configured=false``. Glide
|
|
144
|
+
errors are logged at WARNING and reported to the transport health,
|
|
145
|
+
which drives the reconnect — matching the heartbeat's error handling.
|
|
146
|
+
"""
|
|
147
|
+
client = self.client
|
|
148
|
+
if client is not None:
|
|
149
|
+
payload = build_telemetry(self)
|
|
150
|
+
try:
|
|
151
|
+
await client.set(
|
|
152
|
+
self._telemetry_key,
|
|
153
|
+
value=self._telemetry_encoder.encode(payload),
|
|
154
|
+
expiry=ExpirySet(ExpiryType.SEC, TELEMETRY_TTL),
|
|
155
|
+
)
|
|
156
|
+
except (GlideConnectionError, RequestError, GlideTimeoutError) as exc:
|
|
157
|
+
self.logger.log(logging.WARNING, "Failed to publish ADC telemetry: %s", exc)
|
|
158
|
+
self._health.report_failure(exc)
|
|
159
|
+
await asyncio.sleep(TELEMETRY_INTERVAL)
|