scietex.gpiod-service 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Anton Bondarenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: scietex.gpiod_service
3
+ Version: 1.2.0
4
+ Summary: Scietex microservice daemon for GPIO control
5
+ Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bond-anton/scietex.gpiod_service
8
+ Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.gpiod_service/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: gpiod~=2.5.0; sys_platform == "linux"
15
+ Requires-Dist: msgspec>=0.21.1
16
+ Requires-Dist: scietex.service[valkey]~=6.2.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: tox>=4.54.0; extra == "dev"
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Requires-Dist: pytest-asyncio; extra == "test"
22
+ Provides-Extra: lint
23
+ Requires-Dist: ruff; extra == "lint"
24
+ Requires-Dist: ty; extra == "lint"
25
+ Dynamic: license-file
26
+
27
+ # scietex.gpiod_service
28
+
29
+ `scietex.gpiod_service` is a [`scietex.service`](https://github.com/bond-anton/scietex.service)
30
+ worker that owns the host's GPIO lines through
31
+ [libgpiod](https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/) v2.
32
+
33
+ It requests the lines the API tells it to, keeps them requested, and exposes
34
+ their values over Valkey. Configuration is delivered by the API and applied
35
+ **live** — changing a line's direction or bias does not restart the worker.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install scietex.gpiod_service
41
+ ```
42
+
43
+ Requires Python 3.13+, Linux, and libgpiod v2 (`gpiod~=2.5.0`).
44
+
45
+ ## Run
46
+
47
+ ```bash
48
+ start-gpiod-service --conf-dir /etc/scietex
49
+ ```
50
+
51
+ | Flag | Environment | Default |
52
+ | --- | --- | --- |
53
+ | `--conf-dir` | `SCIETEX_CONFIG_DIR` | auto-resolved (see below) |
54
+ | `--service-name` | `SCIETEX_SERVICE_NAME` | `GPIODService` |
55
+ | `--logging-level` | `SCIETEX_LOGGING_LEVEL` | `INFO` |
56
+
57
+ On first run the service creates `<conf-dir>/gpiod/gpiod.yml` with defaults.
58
+ With no chips configured it logs `waiting for remote configuration` and keeps
59
+ running; the API then delivers the real configuration.
60
+
61
+ `--service-name` must match the managed service name registered in the API — it
62
+ is what discriminates the Valkey config key. Run exactly one worker per
63
+ installation.
64
+
65
+ ## Documentation
66
+
67
+ | Page | Contents |
68
+ | --- | --- |
69
+ | [Overview](docs/index.md) | What the service does and how it fits together |
70
+ | [Configuration](docs/configuration.md) | The `gpio` section schema, `gpiod.yml`, and the 0.1.x incompatibility |
71
+ | [Operations](docs/operations.md) | Service name, live re-apply vs restart, telemetry, container access |
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ uv sync --all-extras
77
+ uv run tox # format, lint, type, py314
78
+ uv run tox -e py314 # tests only
79
+ ```
80
+
81
+ See [AGENTS.md](AGENTS.md) for repo conventions.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,59 @@
1
+ # scietex.gpiod_service
2
+
3
+ `scietex.gpiod_service` is a [`scietex.service`](https://github.com/bond-anton/scietex.service)
4
+ worker that owns the host's GPIO lines through
5
+ [libgpiod](https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/) v2.
6
+
7
+ It requests the lines the API tells it to, keeps them requested, and exposes
8
+ their values over Valkey. Configuration is delivered by the API and applied
9
+ **live** — changing a line's direction or bias does not restart the worker.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install scietex.gpiod_service
15
+ ```
16
+
17
+ Requires Python 3.13+, Linux, and libgpiod v2 (`gpiod~=2.5.0`).
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ start-gpiod-service --conf-dir /etc/scietex
23
+ ```
24
+
25
+ | Flag | Environment | Default |
26
+ | --- | --- | --- |
27
+ | `--conf-dir` | `SCIETEX_CONFIG_DIR` | auto-resolved (see below) |
28
+ | `--service-name` | `SCIETEX_SERVICE_NAME` | `GPIODService` |
29
+ | `--logging-level` | `SCIETEX_LOGGING_LEVEL` | `INFO` |
30
+
31
+ On first run the service creates `<conf-dir>/gpiod/gpiod.yml` with defaults.
32
+ With no chips configured it logs `waiting for remote configuration` and keeps
33
+ running; the API then delivers the real configuration.
34
+
35
+ `--service-name` must match the managed service name registered in the API — it
36
+ is what discriminates the Valkey config key. Run exactly one worker per
37
+ installation.
38
+
39
+ ## Documentation
40
+
41
+ | Page | Contents |
42
+ | --- | --- |
43
+ | [Overview](docs/index.md) | What the service does and how it fits together |
44
+ | [Configuration](docs/configuration.md) | The `gpio` section schema, `gpiod.yml`, and the 0.1.x incompatibility |
45
+ | [Operations](docs/operations.md) | Service name, live re-apply vs restart, telemetry, container access |
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ uv sync --all-extras
51
+ uv run tox # format, lint, type, py314
52
+ uv run tox -e py314 # tests only
53
+ ```
54
+
55
+ See [AGENTS.md](AGENTS.md) for repo conventions.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scietex.gpiod_service"
7
+ dynamic = ["version"]
8
+ description = "Scietex microservice daemon for GPIO control"
9
+ authors = [{ name = "Anton Bondarenko", email = "bond.anton@gmail.com" }]
10
+ license = "MIT"
11
+ license-files = ["LICEN[CS]E*"]
12
+ readme = "README.md"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ requires-python = ">=3.13"
18
+ dependencies = [
19
+ "gpiod~=2.5.0; sys_platform == 'linux'",
20
+ "msgspec>=0.21.1",
21
+ "scietex.service[valkey]~=6.2.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ "Homepage" = "https://github.com/bond-anton/scietex.gpiod_service"
26
+ "Bug Tracker" = "https://github.com/bond-anton/scietex.gpiod_service/issues"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["tox>=4.54.0"]
30
+ test = ["pytest", "pytest-asyncio"]
31
+ lint = ["ruff", "ty"]
32
+
33
+ [project.scripts]
34
+ start-gpiod-service = "scietex.gpiod_service.run_worker:main"
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = { attr = "scietex.gpiod_service.version.__version__" }
38
+
39
+ [tool.setuptools.package-data]
40
+ "scietex.gpiod_service" = ["py.typed"]
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """Microservice for GPIO control."""
2
+
3
+ from .config import (
4
+ GPIO_SECTION,
5
+ GPIO_SETTINGS_DEFAULTS,
6
+ GpioChipSettings,
7
+ GpioLineSettings,
8
+ GpioServiceSettings,
9
+ read_gpio_config,
10
+ )
11
+ from .gpiod_worker import GpiodWorker
12
+ from .version import __version__
13
+
14
+ __all__ = [
15
+ "__version__",
16
+ "GpiodWorker",
17
+ "GPIO_SECTION",
18
+ "GPIO_SETTINGS_DEFAULTS",
19
+ "GpioChipSettings",
20
+ "GpioLineSettings",
21
+ "GpioServiceSettings",
22
+ "read_gpio_config",
23
+ ]
@@ -0,0 +1,150 @@
1
+ """Configuration models and YAML loader for the GPIO service.
2
+
3
+ `GpioServiceSettings` is the concrete constructor (L0) base and the validation
4
+ target of the framework's four-layer config merge. ``gpiod/gpiod.yml`` is the L1
5
+ bootstrap patch: a field-level map merged onto L0, then overlaid by the
6
+ framework's ``config.yml`` snapshot (L2) and the remote ``gpio`` section (L3).
7
+
8
+ The section mirrors the API's `GPIOWorkerConfiguration` field-for-field (§3.1 of
9
+ the GPIO plugin spec): ``chips`` keyed by chip path, each chip's ``lines`` keyed
10
+ by line name. ``name`` is deliberately absent — the framework strips it from the
11
+ delivered patch. The wire literals are stored verbatim (uppercase), so the worker
12
+ maps them to gpiod enums without a translation layer on either side.
13
+
14
+ ``gpiod.yml`` files written for the 0.1.x worker are not compatible: the old file
15
+ described ``inputs``/``outputs``/``buttons`` dicts; the new one is a thin L1
16
+ bootstrap of the section struct above.
17
+ """
18
+
19
+ from pathlib import Path
20
+ from typing import Literal
21
+
22
+ import msgspec
23
+
24
+ #: Remote-config section name the settings are registered under. Must be
25
+ #: byte-identical to the API-side ``GPIOWorkerConfiguration.section``, or the
26
+ #: framework rejects a delivery with ``UNKNOWN_CONFIG_SECTION``.
27
+ GPIO_SECTION: str = "gpio"
28
+
29
+ #: Subdirectory under the shared config dir that namespaces this service's files.
30
+ GPIO_CONFIG_SUBDIR: str = "gpiod"
31
+
32
+ #: Filename of the service-owned L1 bootstrap patch in the config subdirectory.
33
+ GPIO_CONFIG_FILE: str = "gpiod.yml"
34
+
35
+
36
+ class GpioLineSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
37
+ """Electrical settings for a single requested line.
38
+
39
+ ``offset`` is the physical line offset on the chip and the key used by
40
+ ``request_lines`` / ``get_value`` / ``set_value``. The remaining fields are
41
+ the wire literals from the assigning line configuration (§3.2); ``device``
42
+ and ``item`` are informational only and never used for addressing.
43
+ """
44
+
45
+ offset: int
46
+ direction: Literal["INPUT", "OUTPUT", "AS_IS"] = "AS_IS"
47
+ drive: Literal["PUSH_PULL", "OPEN_DRAIN", "OPEN_SOURCE"] = "PUSH_PULL"
48
+ bias: Literal["PULL_UP", "PULL_DOWN", "DISABLED", "AS_IS"] = "AS_IS"
49
+ edge_detection: Literal["NONE", "RISING", "FALLING", "BOTH"] = "NONE"
50
+ output_value: Literal["ACTIVE", "INACTIVE"] | None = None
51
+ active_level: Literal["HIGH", "LOW"] = "HIGH"
52
+ debounce_period_ms: int | None = None
53
+ device: str | None = None
54
+ item: str | None = None
55
+
56
+
57
+ class GpioChipSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
58
+ """One chip and the lines to request on it.
59
+
60
+ The chip's own path is the dict key in `GpioServiceSettings.chips`, not a
61
+ field here, because that is what ``gpiod.Chip(path)`` opens. ``lines`` is
62
+ keyed by line name — the label the worker's ``read``/``write`` commands and
63
+ telemetry streams use.
64
+ """
65
+
66
+ name: str
67
+ lines: dict[str, GpioLineSettings] = msgspec.field(default_factory=dict)
68
+
69
+
70
+ class GpioServiceSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
71
+ """Top-level GPIO service settings.
72
+
73
+ ``chips`` is keyed by chip path (the device node). ``consumer`` is the kernel
74
+ consumer label passed to every ``request_lines`` call, ``poll_interval`` is
75
+ the monitor-loop cadence, and ``monitor_autostart`` starts the monitor task
76
+ after the first successful apply.
77
+
78
+ ``edge_stream_maxlen`` and ``line_stream_maxlen`` bound the Valkey streams
79
+ the worker appends to. Both are approximate trims (``MAXLEN ~ N``): an exact
80
+ trim on every ``xadd`` costs a full stream scan, and a stream that is a few
81
+ entries over its bound is harmless. ``pulse_max_ms`` caps a ``pulse`` task's
82
+ duration so a caller cannot hold a relay energized indefinitely.
83
+ """
84
+
85
+ chips: dict[str, GpioChipSettings] = msgspec.field(default_factory=dict)
86
+ poll_interval: float = 1.0
87
+ consumer: str = "gpiod"
88
+ monitor_autostart: bool = False
89
+ edge_stream_maxlen: int = 10000
90
+ line_stream_maxlen: int = 1000
91
+ pulse_max_ms: int = 60000
92
+
93
+
94
+ #: Concrete L0 base for the framework's layered merge. A field absent from every
95
+ #: layer, or explicitly cleared with ``null``, resolves to the value here.
96
+ GPIO_SETTINGS_DEFAULTS: GpioServiceSettings = GpioServiceSettings()
97
+
98
+
99
+ def read_gpio_config(conf_dir: Path | None, *, create_default: bool = True) -> dict[str, object]:
100
+ """Read the L1 bootstrap patch from ``gpiod/gpiod.yml`` under the config directory.
101
+
102
+ The file (and, when missing, its directory) is only created when
103
+ ``create_default=True`` (the bootstrap path). Unlike the Modbus loader, a
104
+ missing file or directory with ``create_default=False`` returns ``{}`` rather
105
+ than raising — the bootstrap provider must never fail startup, and an empty
106
+ patch simply inherits the L0 defaults. An existing-but-invalid file raises
107
+ `RuntimeError` and is left untouched.
108
+
109
+ Args:
110
+ conf_dir: Path to the configuration directory.
111
+ create_default: Whether to create the directory and write a default
112
+ ``gpiod.yml`` when missing. Default ``True``.
113
+
114
+ Returns:
115
+ The parsed ``gpiod.yml`` mapping as the L1 patch, the builtins of the
116
+ default `GpioServiceSettings` when the file was just created, or ``{}``
117
+ when the file/directory is missing with ``create_default=False``.
118
+
119
+ Raises:
120
+ RuntimeError: If ``conf_dir`` is ``None``, the resolved service directory
121
+ exists but is not a directory, or the file cannot be parsed.
122
+ """
123
+ if not isinstance(conf_dir, Path):
124
+ raise RuntimeError("Configuration dir was not set!")
125
+ service_dir = conf_dir / GPIO_CONFIG_SUBDIR
126
+ if not service_dir.exists():
127
+ if create_default:
128
+ try:
129
+ service_dir.mkdir(parents=True, exist_ok=True)
130
+ except Exception as exc:
131
+ raise RuntimeError(f"Failed to create configuration directory {service_dir}!") from exc
132
+ else:
133
+ return {}
134
+ elif not service_dir.is_dir():
135
+ raise RuntimeError(f"Provided configuration directory path {service_dir} is not a directory!")
136
+ gpiod_yml = service_dir.joinpath(GPIO_CONFIG_FILE)
137
+ if not gpiod_yml.exists():
138
+ if create_default:
139
+ settings = GpioServiceSettings()
140
+ with open(gpiod_yml, "wb") as f:
141
+ f.write(msgspec.yaml.encode(settings))
142
+ return msgspec.to_builtins(settings)
143
+ return {}
144
+ try:
145
+ with open(gpiod_yml, "rb") as f:
146
+ return msgspec.yaml.decode(f.read(), type=dict)
147
+ except Exception as exc:
148
+ raise RuntimeError(
149
+ f"Failed to parse GPIO configuration file {gpiod_yml}. Fix the file or remove it to regenerate defaults."
150
+ ) from exc