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.
Files changed (29) hide show
  1. scietex_adc_service-0.1.0/LICENSE +21 -0
  2. scietex_adc_service-0.1.0/PKG-INFO +162 -0
  3. scietex_adc_service-0.1.0/README.md +132 -0
  4. scietex_adc_service-0.1.0/pyproject.toml +54 -0
  5. scietex_adc_service-0.1.0/setup.cfg +4 -0
  6. scietex_adc_service-0.1.0/src/scietex/adc_service/__init__.py +25 -0
  7. scietex_adc_service-0.1.0/src/scietex/adc_service/adc_worker.py +159 -0
  8. scietex_adc_service-0.1.0/src/scietex/adc_service/config.py +147 -0
  9. scietex_adc_service-0.1.0/src/scietex/adc_service/devices.py +318 -0
  10. scietex_adc_service-0.1.0/src/scietex/adc_service/py.typed +0 -0
  11. scietex_adc_service-0.1.0/src/scietex/adc_service/run_worker.py +73 -0
  12. scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/__init__.py +35 -0
  13. scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/_request.py +96 -0
  14. scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/list.py +146 -0
  15. scietex_adc_service-0.1.0/src/scietex/adc_service/task_handlers/read.py +190 -0
  16. scietex_adc_service-0.1.0/src/scietex/adc_service/telemetry.py +102 -0
  17. scietex_adc_service-0.1.0/src/scietex/adc_service/version.py +3 -0
  18. scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/PKG-INFO +162 -0
  19. scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/SOURCES.txt +27 -0
  20. scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/dependency_links.txt +1 -0
  21. scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/entry_points.txt +2 -0
  22. scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/requires.txt +20 -0
  23. scietex_adc_service-0.1.0/src/scietex.adc_service.egg-info/top_level.txt +1 -0
  24. scietex_adc_service-0.1.0/tests/test_config.py +162 -0
  25. scietex_adc_service-0.1.0/tests/test_devices.py +283 -0
  26. scietex_adc_service-0.1.0/tests/test_run_worker.py +70 -0
  27. scietex_adc_service-0.1.0/tests/test_task_handlers.py +385 -0
  28. scietex_adc_service-0.1.0/tests/test_telemetry.py +145 -0
  29. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)