moat-link-gpio 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.
@@ -0,0 +1,14 @@
1
+ The code in this repository, and all MoaT submodules it refers to,
2
+ is part of the MoaT project.
3
+
4
+ Unless a submodule's LICENSE.txt states otherwise, all included files are
5
+ licensed under the LGPL V3, as published by the FSF at
6
+ https://www.gnu.org/licenses/lgpl-3.0.html .
7
+
8
+ In addition to the LGPL's terms, the author(s) respectfully ask all users of
9
+ this code to contribute any bug fixes or enhancements. Also, please link back to
10
+ https://M-o-a-T.org.
11
+
12
+ Thank you.
13
+
14
+ Copyright © 2021 ff.: the MoaT contributor(s), as per the git changelog(s).
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-gpio
3
+ Version: 0.1.0
4
+ Summary: GPIO controller connector for MoaT-Link
5
+ Author-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Framework :: Trio
13
+ Classifier: Framework :: AnyIO
14
+ Classifier: Development Status :: 4 - Beta
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.2
19
+ Requires-Dist: asyncclick~=8.3
20
+ Requires-Dist: attrs
21
+ Requires-Dist: moat-util~=0.63.0
22
+ Requires-Dist: moat-link~=0.10.0
23
+ Requires-Dist: moat-lib-config~=0.2.0
24
+ Requires-Dist: moat-lib-gpio~=0.9.4
25
+ Dynamic: license-file
26
+
27
+ # GPIO controller connector
28
+
29
+ % start synopsis
30
+ Connects MoaT-Link to GPIO lines via the moat-lib-gpio library.
31
+ % end synopsis
32
+
33
+ % start main
34
+
35
+ This module bridges MoaT-Link values and GPIO controller lines, in both
36
+ directions. It replaces the legacy ``moat-kv-gpio`` package.
37
+
38
+ ## Quick start
39
+
40
+ 1. Configure an input line (reads GPIO and publishes to a link path):
41
+
42
+ ```shell
43
+ moat link gpio port myhost 0 5 -t input -m read -a dest .sensors.button
44
+ ```
45
+
46
+ 2. Configure an output line (watches a link path and drives GPIO):
47
+
48
+ ```shell
49
+ moat link gpio port myhost 0 3 -t output -m write -a src .controls.lamp
50
+ ```
51
+
52
+ 3. Run the connector for a chip:
53
+
54
+ ```shell
55
+ moat link gpio monitor myhost 0
56
+ ```
57
+
58
+ ## Deprecation
59
+
60
+ This package supersedes ``moat-kv-gpio``. The address tree now lives in
61
+ MoaT-Link under the ``gpio`` prefix; lines are stored as integer line
62
+ numbers below host and chip entries.
63
+
64
+ % end main
@@ -0,0 +1,38 @@
1
+ # GPIO controller connector
2
+
3
+ % start synopsis
4
+ Connects MoaT-Link to GPIO lines via the moat-lib-gpio library.
5
+ % end synopsis
6
+
7
+ % start main
8
+
9
+ This module bridges MoaT-Link values and GPIO controller lines, in both
10
+ directions. It replaces the legacy ``moat-kv-gpio`` package.
11
+
12
+ ## Quick start
13
+
14
+ 1. Configure an input line (reads GPIO and publishes to a link path):
15
+
16
+ ```shell
17
+ moat link gpio port myhost 0 5 -t input -m read -a dest .sensors.button
18
+ ```
19
+
20
+ 2. Configure an output line (watches a link path and drives GPIO):
21
+
22
+ ```shell
23
+ moat link gpio port myhost 0 3 -t output -m write -a src .controls.lamp
24
+ ```
25
+
26
+ 3. Run the connector for a chip:
27
+
28
+ ```shell
29
+ moat link gpio monitor myhost 0
30
+ ```
31
+
32
+ ## Deprecation
33
+
34
+ This package supersedes ``moat-kv-gpio``. The address tree now lives in
35
+ MoaT-Link under the ``gpio`` prefix; lines are stored as integer line
36
+ numbers below host and chip entries.
37
+
38
+ % end main
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = [ "setuptools", "wheel",]
4
+
5
+ [project]
6
+ classifiers = [
7
+ "Intended Audience :: Developers",
8
+ "Programming Language :: Python :: 3",
9
+ "Framework :: AsyncIO",
10
+ "Framework :: Trio",
11
+ "Framework :: AnyIO",
12
+ "Development Status :: 4 - Beta",
13
+ ]
14
+ dependencies = [
15
+ "anyio ~= 4.2",
16
+ "asyncclick ~= 8.3",
17
+ "attrs",
18
+ "moat-util ~= 0.63.0",
19
+ "moat-link ~= 0.10.0",
20
+ "moat-lib-config ~= 0.2.0",
21
+ "moat-lib-gpio ~= 0.9.4",
22
+ ]
23
+ version = "0.1.0"
24
+ keywords = [ "MoaT",]
25
+ requires-python = ">=3.13"
26
+ name = "moat-link-gpio"
27
+ description = "GPIO controller connector for MoaT-Link"
28
+ readme = "README.md"
29
+
30
+ [[project.authors]]
31
+ name = "Matthias Urlichs"
32
+ email = "matthias@urlichs.de"
33
+
34
+ [project.urls]
35
+ homepage = "https://m-o-a-t.org"
36
+ repository = "https://github.com/M-o-a-T/moat"
37
+
38
+ [tool.setuptools]
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.package-data]
43
+ "*" = ["*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """GPIO controller connector for MoaT-Link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from moat.lib.config import register as _register
6
+
7
+ __all__: list[str] = []
8
+
9
+ _register(__name__)
@@ -0,0 +1,10 @@
1
+ prefix: !P gpio
2
+ interval: 1 # input/count: Pulse reporting frequency
3
+ count: True # input/count, input/button: Pulse direction
4
+ # up down both: True False None
5
+ low: False # if True, the default is active-low
6
+ flow: False # if True, send intermediate results
7
+ skip: True # ignore signals < t_bounce
8
+ t_idle: 1.5 # input/button: max pulse width
9
+ t_clear: 30 # button: set to None after this
10
+ t_bounce: 0.05 # input/button: min pulse width
@@ -0,0 +1,188 @@
1
+ """
2
+ Command-line interface for moat.link.gpio.
3
+
4
+ Manages GPIO line configurations stored in the MoaT-Link data tree
5
+ under the configured ``link.gpio.prefix``. Each host contains chips;
6
+ each chip contains integer-numbered lines.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import logging
13
+
14
+ import asyncclick as click
15
+
16
+ from moat.util import attrdict, yprint
17
+ from moat.lib.path import P
18
+ from moat.lib.run import AliasedGroup, attr_args
19
+ from moat.link._data import data_get, node_attr
20
+ from moat.link.announce import as_service
21
+ from moat.link.client import Link
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @click.group(cls=AliasedGroup, short_help="Manage GPIO controllers.")
27
+ @click.pass_context
28
+ async def cli(ctx):
29
+ """
30
+ Manage GPIO controllers and line configurations.
31
+ """
32
+ obj = ctx.obj
33
+ cfg = obj.cfg["link"]
34
+ obj.conn = await ctx.with_async_resource(
35
+ Link(cfg, common=True, only=getattr(obj, "link_name", None))
36
+ )
37
+ obj.gpio_cfg = obj.cfg.link.gpio
38
+ obj.gpio_prefix = obj.gpio_cfg.prefix
39
+
40
+
41
+ @cli.command("dump")
42
+ @click.argument("path", nargs=1, type=P)
43
+ @click.pass_obj
44
+ async def dump(obj, path):
45
+ """Emit the current state as a YAML file."""
46
+ if len(path) > 3:
47
+ raise click.UsageError("Only up to three path elements (host.chip:pin) allowed")
48
+
49
+ await data_get(obj.conn, obj.gpio_prefix + path, recursive=True, out=obj.stdout)
50
+
51
+
52
+ @cli.command("list")
53
+ @click.argument("path", nargs=1, type=P)
54
+ @click.pass_obj
55
+ async def list_(obj, path):
56
+ """List the next stage."""
57
+ if len(path) > 3:
58
+ raise click.UsageError("Only up to three path elements (host.chip:pin) allowed")
59
+
60
+ async with obj.conn.d_walk(obj.gpio_prefix + path, min_depth=1, max_depth=1) as mon:
61
+ async for p, _d in mon:
62
+ print(p[-1], file=obj.stdout)
63
+
64
+
65
+ @cli.command("attr")
66
+ @attr_args
67
+ @click.argument("path", nargs=1, type=P)
68
+ @click.pass_obj
69
+ async def attr_(obj, path, **kw):
70
+ """Set/get/delete attributes of a given GPIO element.
71
+
72
+ `--eval` without a value deletes the attribute.
73
+ """
74
+ if len(path) != 3:
75
+ raise click.UsageError("Three path elements (host.chip:pin) required")
76
+ res, _meta = await node_attr(obj, obj.gpio_prefix + path, **kw)
77
+ if getattr(obj, "meta", False):
78
+ yprint(res, stream=obj.stdout)
79
+
80
+
81
+ @cli.command("delete")
82
+ @click.argument("path", nargs=1, type=P)
83
+ @click.pass_obj
84
+ async def delete(obj, path):
85
+ """Delete a port."""
86
+ if len(path) != 3:
87
+ raise click.UsageError("Three path elements (host.chip:pin) required")
88
+ res = await obj.conn.d.delete(obj.gpio_prefix + path)
89
+ if getattr(obj, "meta", False):
90
+ yprint(res[0], stream=obj.stdout)
91
+
92
+
93
+ @cli.command("port")
94
+ @click.option("-t", "--type", "typ", help="Port type. 'input' or 'output'.")
95
+ @click.option("-m", "--mode", help="Port mode. Use '-' to disable.")
96
+ @click.option(
97
+ "-a",
98
+ "--attr",
99
+ nargs=2,
100
+ multiple=True,
101
+ help="One attribute to set (NAME VALUE). May be used multiple times.",
102
+ )
103
+ @click.argument("path", nargs=1, type=P)
104
+ @click.pass_obj
105
+ async def port(obj, path, typ, mode, attr):
106
+ """Add/modify a port. This is a shortcut for the "attr" command.
107
+
108
+ \b
109
+ Known attributes for types+modes:
110
+ input:
111
+ read: dest (path)
112
+ count: read + interval (float), count (+-x for up/down/both)
113
+ button: read + t_bounce (float), t_idle (float), skip (+- ignore noise?),
114
+ t_clear (float), flow (bool)
115
+ output:
116
+ write: src (path), state (path)
117
+ oneshot: write + t_on (float), state (path)
118
+ pulse: oneshot + t_off (float)
119
+ *:
120
+ low: bool (signals are active-low if true)
121
+
122
+ \b
123
+ Paths elements are separated by spaces.
124
+ "low" is the state of the wire when the input is False.
125
+ Floats may be paths, in which case they're read from there when starting.
126
+ """
127
+ if len(path) != 3:
128
+ raise click.UsageError("Three path elements (host.chip:pin) required")
129
+ try:
130
+ res = await obj.conn.d_get(obj.gpio_prefix + path)
131
+ except KeyError:
132
+ res = {}
133
+ val = res if isinstance(res, dict) else attrdict()
134
+
135
+ if typ is None:
136
+ raise click.UsageError("Port type is mandatory.")
137
+ if mode is None:
138
+ raise click.UsageError("Port mode is mandatory.")
139
+ attr = (("type", typ), ("mode", mode)) + attr
140
+ for k, v in attr:
141
+ if k == "count":
142
+ if v == "+":
143
+ v = True # noqa: PLW2901
144
+ elif v == "-":
145
+ v = False # noqa: PLW2901
146
+ elif v in "xX*":
147
+ v = None # noqa: PLW2901
148
+ else:
149
+ raise click.UsageError(f"'{k}' wants one of + - X")
150
+ elif k in ("low", "skip", "flow"):
151
+ if v == "+":
152
+ v = True # noqa: PLW2901
153
+ elif v == "-":
154
+ v = False # noqa: PLW2901
155
+ else:
156
+ raise click.UsageError(f"'{k}' wants one of + -")
157
+ elif k in {"src", "dest"}:
158
+ v = P(v) # noqa: PLW2901
159
+ else:
160
+ try:
161
+ v = int(v) # noqa: PLW2901
162
+ except ValueError:
163
+ with contextlib.suppress(ValueError):
164
+ v = float(v) # noqa: PLW2901
165
+ val[k] = v
166
+
167
+ await obj.conn.d_set(obj.gpio_prefix + path, val)
168
+
169
+
170
+ @cli.command("monitor")
171
+ @click.argument("name", nargs=1)
172
+ @click.argument("chip", nargs=1)
173
+ @click.pass_obj
174
+ async def monitor(obj, name, chip):
175
+ """Stand-alone task to monitor a single GPIO chip.
176
+
177
+ The first argument is the host name, the second the chip name.
178
+ """
179
+ from .task import task # noqa: PLC0415
180
+
181
+ async with as_service(obj, host=False) as srv:
182
+ await task(
183
+ obj.conn,
184
+ obj.gpio_cfg,
185
+ name,
186
+ chip,
187
+ task_status=srv,
188
+ )
@@ -0,0 +1,199 @@
1
+ """
2
+ Node model for the GPIO controller connector.
3
+
4
+ The tree mirrors the MoaT-Link subtree under the configured prefix.
5
+ Each host entry is keyed by its name; below it the tree branches
6
+ into integer line numbers, each holding a :class:`GpioLine` leaf.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from attrs import define
14
+
15
+ from moat.util import NotGiven
16
+ from moat.lib.path import Path
17
+ from moat.link.node import Node
18
+
19
+ from collections.abc import Mapping
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from moat.lib.rpc import Key
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @define
30
+ class GpioLine(Node):
31
+ """A single GPIO line.
32
+
33
+ The stored configuration is a dict with these keys:
34
+
35
+ * ``type``: ``"input"`` or ``"output"``.
36
+ * ``mode``: input mode (``"read"``, ``"count"``, ``"button"``) or
37
+ output mode (``"write"``, ``"oneshot"``, ``"pulse"``).
38
+ * ``dest``: destination path (for input modes).
39
+ * ``src``: source path (for output modes).
40
+ * ``state``: optional state path (for output modes).
41
+ * ``low``: active-low flag (default ``False``).
42
+ * ``interval``: polling interval (for ``mode=count``).
43
+ * ``count``: pulse direction (``True``/``False``/``None``).
44
+ * ``t_on``: on-time (for ``mode=oneshot|pulse``).
45
+ * ``t_off``: off-time (for ``mode=pulse``).
46
+ * ``t_bounce``: debounce time.
47
+ * ``t_idle``: max pulse width (for ``mode=button``).
48
+ * ``t_clear``: clear-after time (for ``mode=button``).
49
+ * ``skip``: ignore short bounces.
50
+ * ``flow``: send intermediate results.
51
+ """
52
+
53
+ @property
54
+ def type_(self) -> str | None:
55
+ """Line type, or ``None`` if unset."""
56
+ d = self.data_
57
+ return d.get("type") if isinstance(d, Mapping) else None
58
+
59
+ @property
60
+ def mode(self) -> str | None:
61
+ """Line mode, or ``None`` if unset."""
62
+ d = self.data_
63
+ return d.get("mode") if isinstance(d, Mapping) else None
64
+
65
+ @property
66
+ def dest(self) -> Path | None:
67
+ """Destination path (input modes), or ``None``."""
68
+ d = self.data_
69
+ s = d.get("dest") if isinstance(d, Mapping) else None
70
+ return s if s is None else Path.build(s)
71
+
72
+ @property
73
+ def src(self) -> Path | None:
74
+ """Source path (output modes), or ``None``."""
75
+ d = self.data_
76
+ s = d.get("src") if isinstance(d, Mapping) else None
77
+ return s if s is None else Path.build(s)
78
+
79
+ @property
80
+ def state(self) -> Path | None:
81
+ """Optional state path (output modes), or ``None``."""
82
+ d = self.data_
83
+ s = d.get("state") if isinstance(d, Mapping) else None
84
+ return s if s is None else Path.build(s)
85
+
86
+ @property
87
+ def low(self) -> bool:
88
+ """Active-low flag (default ``False``)."""
89
+ d = self.data_ if self.data_ is not NotGiven else {}
90
+ return bool(d.get("low", False)) if isinstance(d, Mapping) else False
91
+
92
+ @property
93
+ def interval(self) -> float | None:
94
+ """Polling interval (for ``mode=count``), or ``None``."""
95
+ d = self.data_
96
+ return d.get("interval") if isinstance(d, Mapping) else None
97
+
98
+ @property
99
+ def count(self) -> bool | None:
100
+ """Pulse direction (``True``/``False``/``None``)."""
101
+ d = self.data_
102
+ v = d.get("count") if isinstance(d, Mapping) else None
103
+ return v if v is None else bool(v)
104
+
105
+ @property
106
+ def t_on(self) -> float | None:
107
+ """On-time in seconds (for ``mode=oneshot|pulse``), or ``None``."""
108
+ d = self.data_
109
+ return d.get("t_on") if isinstance(d, Mapping) else None
110
+
111
+ @property
112
+ def t_off(self) -> float | None:
113
+ """Off-time in seconds (for ``mode=pulse``), or ``None``."""
114
+ d = self.data_
115
+ return d.get("t_off") if isinstance(d, Mapping) else None
116
+
117
+ @property
118
+ def t_bounce(self) -> float | None:
119
+ """Debounce time, or ``None``."""
120
+ d = self.data_
121
+ return d.get("t_bounce") if isinstance(d, Mapping) else None
122
+
123
+ @property
124
+ def t_idle(self) -> float | None:
125
+ """Max pulse width (for ``mode=button``), or ``None``."""
126
+ d = self.data_
127
+ return d.get("t_idle") if isinstance(d, Mapping) else None
128
+
129
+ @property
130
+ def t_clear(self) -> float | None:
131
+ """Clear-after time (for ``mode=button``), or ``None``."""
132
+ d = self.data_
133
+ return d.get("t_clear") if isinstance(d, Mapping) else None
134
+
135
+ @property
136
+ def skip(self) -> bool:
137
+ """Ignore short bounces (default ``True``)."""
138
+ d = self.data_ if self.data_ is not NotGiven else {}
139
+ return bool(d.get("skip", True)) if isinstance(d, Mapping) else True
140
+
141
+ @property
142
+ def flow(self) -> bool:
143
+ """Send intermediate results (default ``False``)."""
144
+ d = self.data_ if self.data_ is not NotGiven else {}
145
+ return bool(d.get("flow", False)) if isinstance(d, Mapping) else False
146
+
147
+ def is_complete(self) -> bool:
148
+ """Whether this entry has a complete configuration."""
149
+ t = self.type_
150
+ m = self.mode
151
+ if t == "input":
152
+ return m is not None and self.dest is not None
153
+ if t == "output":
154
+ return m is not None and self.src is not None
155
+ return False
156
+
157
+
158
+ @define
159
+ class GpioChip(Node):
160
+ """One GPIO chip (controller); children are :class:`GpioLine`."""
161
+
162
+ def add_child(self, item: Key) -> GpioLine:
163
+ """Create child entries as :class:`GpioLine`."""
164
+ if item in self._sub:
165
+ raise ValueError("exists")
166
+ self._sub[item] = s = GpioLine()
167
+ return s
168
+
169
+ @property
170
+ def name(self) -> str | None:
171
+ """Chip name (this node's key, if known from path context)."""
172
+ return None
173
+
174
+
175
+ @define
176
+ class GpioHost(Node):
177
+ """One host in the configuration tree; children are :class:`GpioChip`."""
178
+
179
+ def add_child(self, item: Key) -> GpioChip:
180
+ """Create child chips as :class:`GpioChip`."""
181
+ if item in self._sub:
182
+ raise ValueError("exists")
183
+ self._sub[item] = s = GpioChip()
184
+ return s
185
+
186
+
187
+ @define
188
+ class GpioRoot(Node):
189
+ """Root of the GPIO configuration tree.
190
+
191
+ Children are :class:`GpioHost` nodes.
192
+ """
193
+
194
+ def add_child(self, item: Key) -> GpioHost:
195
+ """Create child hosts as :class:`GpioHost`."""
196
+ s = self._sub.get(item)
197
+ if not isinstance(s, GpioHost):
198
+ self._sub[item] = s = GpioHost()
199
+ return s
@@ -0,0 +1,123 @@
1
+ """
2
+ Main supervisor task for the GPIO connector.
3
+
4
+ Opens a GPIO chip and monitors the MoaT-Link configuration subtree for
5
+ one host/chip. Spawns / cancels per-line workers as entries appear or
6
+ change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import anyio
12
+ import logging
13
+
14
+ import moat.lib.gpio as gpio
15
+ from moat.util import combine_dict
16
+ from moat.lib.path import Path
17
+
18
+ from .model import GpioChip, GpioLine
19
+ from .worker import run_in, run_out
20
+
21
+ from typing import TYPE_CHECKING
22
+
23
+ if TYPE_CHECKING:
24
+ from anyio.abc import TaskStatus
25
+
26
+ from moat.link.client import LinkSender
27
+
28
+ from collections.abc import Mapping
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ async def task(
34
+ link: LinkSender,
35
+ cfg: Mapping,
36
+ host_name: str,
37
+ chip_name: str,
38
+ *,
39
+ task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
40
+ ) -> None:
41
+ """Run the GPIO connector for one chip.
42
+
43
+ Args:
44
+ link: an active MoaT-Link sender.
45
+ cfg: the ``link.gpio`` configuration section.
46
+ host_name: the host entry name.
47
+ chip_name: the chip entry name inside the host subtree.
48
+ task_status: task-status for ``tg.start``.
49
+ """
50
+ prefix = Path.build(cfg["prefix"])
51
+ chip_path = prefix / host_name / chip_name
52
+
53
+ chip_data = await link.d_get(chip_path)
54
+ if not isinstance(chip_data, dict):
55
+ chip_data = {}
56
+
57
+ with gpio.open_chip(label=chip_name) as chip:
58
+ async with anyio.create_task_group() as tg:
59
+ workers: dict[Path, anyio.CancelScope] = {}
60
+
61
+ def _cancel(p: Path) -> None:
62
+ sc = workers.pop(p, None)
63
+ if sc is not None:
64
+ sc.cancel()
65
+
66
+ async def _start(p: Path, entry: GpioLine) -> None:
67
+ _cancel(p)
68
+ if not entry.is_complete():
69
+ logger.warning("Incomplete entry at %s, skipping", p)
70
+ return
71
+ if len(p) != 1 or not isinstance(p[0], int):
72
+ logger.warning("Entry %s is not a single integer line number", p)
73
+ return
74
+ line_num = int(p[0])
75
+
76
+ runner = run_in if entry.type_ == "input" else run_out
77
+ line_cfg = combine_dict(
78
+ entry.data_ if isinstance(entry.data_, dict) else {},
79
+ dict(cfg),
80
+ )
81
+
82
+ async def _run(
83
+ *,
84
+ task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
85
+ ) -> None:
86
+ with anyio.CancelScope() as sc:
87
+ workers[p] = sc
88
+ task_status.started()
89
+ try:
90
+ await runner(link, chip, entry, line_num, p, line_cfg)
91
+ except Exception:
92
+ logger.exception("Worker for %s failed", p)
93
+ finally:
94
+ if workers.get(p) is sc:
95
+ del workers[p]
96
+
97
+ await tg.start(_run)
98
+
99
+ async with link.d_watch(
100
+ chip_path,
101
+ subtree=True,
102
+ mark=True,
103
+ cls=GpioChip,
104
+ ) as mon:
105
+ task_status.started()
106
+
107
+ async for msg in mon:
108
+ if msg is None:
109
+ continue
110
+ p, _data = msg
111
+ if not p:
112
+ continue
113
+
114
+ node = mon.nodes.get(p)
115
+ if isinstance(node, GpioLine):
116
+ if node.is_complete():
117
+ await _start(p, node)
118
+ else:
119
+ _cancel(p)
120
+ else:
121
+ _cancel(p)
122
+
123
+ tg.cancel_scope.cancel()
@@ -0,0 +1,472 @@
1
+ """
2
+ Per-line workers for the GPIO connector.
3
+
4
+ Input workers (``type=input``) monitor a GPIO line and publish decoded
5
+ values to a MoaT-Link destination. Three input modes are supported:
6
+
7
+ * ``read``: forward debounced line state changes.
8
+ * ``count``: accumulate pulse counts and report periodically.
9
+ * ``button``: decode button press sequences (short/long/multi-click).
10
+
11
+ Output workers (``type=output``) watch a MoaT-Link source and drive
12
+ the GPIO line accordingly. Three output modes are supported:
13
+
14
+ * ``write``: directly mirror the source value.
15
+ * ``oneshot``: pulse high for ``t_on`` seconds, then revert.
16
+ * ``pulse``: continuously pulse with duty cycle ``t_on``/``t_off``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import anyio
22
+ import logging
23
+
24
+ import moat.lib.gpio as gpio
25
+ from moat.util import NotGiven, combine_dict
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ if TYPE_CHECKING:
30
+ from moat.lib.path import Path
31
+ from moat.link.client import LinkSender
32
+
33
+ from .model import GpioLine
34
+
35
+ from typing import Any
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _DIR(d):
41
+ """Translate a direction flag to a :class:`gpio.Edge`."""
42
+ if d is None:
43
+ return gpio.Edge.BOTH
44
+ if d:
45
+ return gpio.Edge.RISING
46
+ return gpio.Edge.FALLING
47
+
48
+
49
+ # ── Input workers ────────────────────────────────────────────────
50
+
51
+
52
+ async def run_in(
53
+ link: LinkSender,
54
+ chip: Any,
55
+ entry: GpioLine,
56
+ line_num: int,
57
+ subpath: Path,
58
+ cfg: dict[str, Any],
59
+ ) -> None:
60
+ """Forward GPIO line state changes to a MoaT-Link destination.
61
+
62
+ Dispatches to :func:`_poll_task`, :func:`_count_task`, or
63
+ :func:`_button_task` depending on ``entry.mode``.
64
+
65
+ Args:
66
+ link: Active MoaT-Link sender.
67
+ chip: Open GPIO chip handle.
68
+ entry: Configuration entry for this line.
69
+ line_num: Line number on the chip.
70
+ subpath: Path of this entry (for logging).
71
+ cfg: Global ``link.gpio`` defaults, merged with per-line config.
72
+ """
73
+ mode = entry.mode
74
+ dest = entry.dest
75
+ if mode is None or dest is None:
76
+ return
77
+
78
+ lc = combine_dict(entry.data_ if isinstance(entry.data_, dict) else {}, cfg)
79
+
80
+ if mode == "read":
81
+ await _poll_task(link, chip, line_num, dest, lc, subpath)
82
+ elif mode == "count":
83
+ await _count_task(link, chip, line_num, dest, lc, subpath)
84
+ elif mode == "button":
85
+ await _button_task(link, chip, line_num, dest, lc, subpath)
86
+ else:
87
+ logger.warning("Unknown input mode %r at %s", mode, subpath)
88
+
89
+
90
+ async def _poll_task(
91
+ link: LinkSender,
92
+ chip: Any,
93
+ line_num: int,
94
+ dest: Path,
95
+ lc: dict[str, Any],
96
+ subpath: Path, # noqa: ARG001
97
+ ) -> None:
98
+ """Monitor a line and forward debounced state changes."""
99
+ negate = lc.get("low", False)
100
+ change = lc.get("change", None)
101
+ skip = lc.get("skip", True)
102
+ bounce = lc.get("t_bounce", 0.05)
103
+
104
+ wire = chip.line(line_num)
105
+ with wire.monitor(gpio.Edge.BOTH) as mon:
106
+ old_value = mon.value
107
+
108
+ async def set_value(value: bool) -> None:
109
+ if negate:
110
+ value = not value
111
+ if change is None or value == change:
112
+ await link.d_set(dest, value)
113
+
114
+ await set_value(old_value)
115
+ mon_iter = mon.__aiter__()
116
+ while True:
117
+ e = await mon_iter.__anext__()
118
+
119
+ if not skip:
120
+ await set_value(not old_value)
121
+
122
+ with anyio.move_on_after(bounce):
123
+ while True:
124
+ e = await mon_iter.__anext__()
125
+
126
+ if old_value == e.value:
127
+ if not skip:
128
+ await set_value(old_value)
129
+ else:
130
+ if skip:
131
+ await set_value(e.value)
132
+ old_value = e.value
133
+
134
+
135
+ async def _count_task(
136
+ link: LinkSender,
137
+ chip: Any,
138
+ line_num: int,
139
+ dest: Path,
140
+ lc: dict[str, Any],
141
+ subpath: Path, # noqa: ARG001
142
+ ) -> None:
143
+ """Count pulses on a line and report periodically."""
144
+ intv = lc.get("interval", 1)
145
+ direc = lc.get("count", True)
146
+ bounce = lc.get("t_bounce", 0.05)
147
+
148
+ async def get_value() -> float:
149
+ try:
150
+ val = await link.d_get(dest)
151
+ except KeyError:
152
+ return 0.0
153
+ if not isinstance(val, (int, float)):
154
+ return 0.0
155
+ return float(val)
156
+
157
+ val = await get_value()
158
+
159
+ wire = chip.line(line_num)
160
+ with wire.monitor(_DIR(None)) as mon:
161
+ i = mon.__aiter__()
162
+ t = anyio.current_time()
163
+ d: int = 0
164
+ armed = False
165
+ value = mon.value
166
+ debounce = True
167
+
168
+ while True:
169
+ if not armed:
170
+ await i.__anext__()
171
+ debounce = True
172
+ t = anyio.current_time()
173
+ tm = intv
174
+ else:
175
+ tm = max(0, t + intv - anyio.current_time())
176
+ try:
177
+ with anyio.fail_after(bounce if debounce else tm):
178
+ await i.__anext__()
179
+ except TimeoutError:
180
+ if debounce:
181
+ debounce = False
182
+ if mon.value != value:
183
+ value = mon.value
184
+ if direc is not (not value):
185
+ if not armed:
186
+ d = 1
187
+ val += d
188
+ await link.d_set(dest, val)
189
+ d = 0
190
+ armed = True
191
+ else:
192
+ d += 1
193
+ elif d == 0:
194
+ armed = False
195
+ else:
196
+ val += d
197
+ await link.d_set(dest, val)
198
+ d = 0
199
+ armed = True
200
+ else:
201
+ debounce = True
202
+
203
+
204
+ async def _button_task(
205
+ link: LinkSender,
206
+ chip: Any,
207
+ line_num: int,
208
+ dest: Path,
209
+ lc: dict[str, Any],
210
+ subpath: Path, # noqa: ARG001
211
+ ) -> None:
212
+ """Decode button-press sequences and publish them."""
213
+ negate = lc.get("low", False)
214
+ skip = lc.get("skip", True)
215
+ bounce = lc.get("t_bounce", 0.05)
216
+ idle = lc.get("t_idle", 1.5)
217
+ idle_h = lc.get("t_idle_on", idle)
218
+ idle_clear = lc.get("t_clear", 30)
219
+ count = lc.get("count", True)
220
+ flow = lc.get("flow", False)
221
+
222
+ wire = chip.line(line_num)
223
+ with wire.monitor(gpio.Edge.BOTH) as mon:
224
+ ival = mon.value
225
+ mon_iter = mon.__aiter__()
226
+
227
+ def td(a, b):
228
+ a = a.timestamp
229
+ b = b.timestamp
230
+ return a[0] - b[0] + (a[1] - b[1]) / 1000000000
231
+
232
+ def inv(x):
233
+ if x is None:
234
+ return x
235
+ if negate:
236
+ return not x
237
+ return bool(x)
238
+
239
+ count = inv(count)
240
+
241
+ async def record(e1, ival):
242
+ res: list[int] = []
243
+ e1.value = None
244
+ e0 = e1
245
+ e2 = None
246
+ flow_bounce = flow
247
+ while True:
248
+ try:
249
+ with anyio.fail_after(bounce):
250
+ e2 = await mon_iter.__anext__()
251
+ except TimeoutError:
252
+ pass
253
+ else:
254
+ e1 = e2
255
+ if flow_bounce and td(e1, e0) > bounce:
256
+ flow_bounce = False
257
+ await link.d_set(
258
+ dest,
259
+ {
260
+ "start": inv(ival),
261
+ "seq": res + [0],
262
+ "end": inv(e1.value),
263
+ "t": bounce,
264
+ "flow": True,
265
+ },
266
+ )
267
+ continue
268
+
269
+ if e2 is None:
270
+ e2 = e1
271
+
272
+ if e0.value is None:
273
+ e0.value = not ival
274
+
275
+ if e1.value != e0.value:
276
+ if skip or td(e1, e0) < bounce:
277
+ e1 = e0
278
+ else:
279
+ if count is not bool(e1.value):
280
+ res.append(int(td(e1, e0) / bounce))
281
+ if flow:
282
+ await link.d_set(
283
+ dest,
284
+ {
285
+ "start": not ival,
286
+ "seq": res,
287
+ "end": inv(e1.value),
288
+ "t": bounce,
289
+ "flow": True,
290
+ },
291
+ )
292
+ e0 = e1
293
+
294
+ try:
295
+ with anyio.fail_after((idle_h if inv(e1.value) else idle) - bounce):
296
+ e2 = await mon_iter.__anext__()
297
+ except TimeoutError:
298
+ if count is not bool(e1.value):
299
+ res.append(0)
300
+ return ival, inv(ival), res, inv(e1.value)
301
+
302
+ if count is not (not bool(e1.value)):
303
+ res.append(int(td(e2, e0) / bounce))
304
+
305
+ e2.value = not e1.value
306
+ e0 = e1 = e2
307
+ if flow:
308
+ await link.d_set(
309
+ dest,
310
+ {
311
+ "start": not ival,
312
+ "seq": res,
313
+ "end": inv(e2.value),
314
+ "t": bounce,
315
+ "flow": True,
316
+ },
317
+ )
318
+ flow_bounce = flow
319
+
320
+ clear = True
321
+ while True:
322
+ if clear and idle_clear:
323
+ try:
324
+ with anyio.fail_after(idle_clear):
325
+ e = await mon.__anext__()
326
+ except TimeoutError:
327
+ await link.d_set(dest, False)
328
+ clear = False
329
+ continue
330
+ else:
331
+ e = await mon.__anext__()
332
+ ival, start_val, res, end_val = await record(e, ival)
333
+ if not res:
334
+ continue
335
+ await link.d_set(
336
+ dest,
337
+ {
338
+ "start": start_val,
339
+ "seq": res,
340
+ "end": end_val,
341
+ "t": bounce,
342
+ },
343
+ )
344
+ clear = True
345
+
346
+
347
+ # ── Output workers ───────────────────────────────────────────────
348
+
349
+
350
+ async def run_out(
351
+ link: LinkSender,
352
+ chip: Any,
353
+ entry: GpioLine,
354
+ line_num: int,
355
+ subpath: Path,
356
+ cfg: dict[str, Any],
357
+ ) -> None:
358
+ """Forward MoaT-Link source updates onto a GPIO line.
359
+
360
+ Dispatches to :func:`_set_value`, :func:`_oneshot_value`, or
361
+ :func:`_pulse_value` depending on ``entry.mode``.
362
+
363
+ Args:
364
+ link: Active MoaT-Link sender.
365
+ chip: Open GPIO chip handle.
366
+ entry: Configuration entry for this line.
367
+ line_num: Line number on the chip.
368
+ subpath: Path of this entry (for logging).
369
+ cfg: Global ``link.gpio`` defaults, merged with per-line config.
370
+ """
371
+ mode = entry.mode
372
+ src = entry.src
373
+ if mode is None or src is None:
374
+ return
375
+
376
+ lc = combine_dict(entry.data_ if isinstance(entry.data_, dict) else {}, cfg)
377
+ negate = lc.get("low", False)
378
+ t_on = lc.get("t_on", None)
379
+ t_off = lc.get("t_off", None)
380
+ state = entry.state
381
+
382
+ with chip.line(line_num).open(direction=gpio.Direction.OUTPUT) as line:
383
+ if mode == "write":
384
+ await _with_output(link, line, src, _set_value, state, negate, subpath)
385
+ elif mode == "oneshot":
386
+ if t_on is None:
387
+ logger.warning("t_on not set at %s", subpath)
388
+ return
389
+ await _with_output(link, line, src, _oneshot_value, state, negate, subpath, t_on)
390
+ elif mode == "pulse":
391
+ if t_on is None or t_off is None:
392
+ logger.warning("t_on/t_off not set at %s", subpath)
393
+ return
394
+ await _with_output(link, line, src, _pulse_value, state, negate, subpath, t_on, t_off)
395
+ else:
396
+ logger.warning("Unknown output mode %r at %s", mode, subpath)
397
+
398
+
399
+ async def _with_output(
400
+ link: LinkSender,
401
+ line: Any,
402
+ src: Path,
403
+ proc: Any,
404
+ *args: Any,
405
+ ) -> None:
406
+ """Watch a MoaT-Link source and call *proc* for each value change."""
407
+ async with link.d_watch(src, mark=False, state=False) as wp:
408
+ async for val in wp:
409
+ if val is NotGiven:
410
+ continue
411
+ if val in (False, True, 0, 1):
412
+ try:
413
+ await proc(link, line, bool(val), *args)
414
+ except Exception as exc:
415
+ logger.warning("Output error at %s: %r", src, exc)
416
+ else:
417
+ logger.warning("Bad value %r at %s", val, src)
418
+
419
+
420
+ async def _set_value(
421
+ link: LinkSender,
422
+ line: Any,
423
+ value: bool,
424
+ state: Path | None,
425
+ negate: bool,
426
+ ) -> None:
427
+ """Directly mirror a value to the GPIO line."""
428
+ if negate:
429
+ value = not value
430
+ if line is not None:
431
+ line.value = value != negate
432
+ if state is not None:
433
+ await link.d_set(state, value)
434
+
435
+
436
+ async def _oneshot_value(
437
+ link: LinkSender,
438
+ line: Any,
439
+ val: bool,
440
+ state: Path | None,
441
+ negate: bool,
442
+ t_on: float,
443
+ ) -> None:
444
+ """Pulse high for *t_on* seconds, then revert."""
445
+ if val:
446
+ await _set_value(link, line, True, state, negate)
447
+ await anyio.sleep(t_on)
448
+ await _set_value(link, line, False, state, negate)
449
+ else:
450
+ await _set_value(link, line, False, state, negate)
451
+
452
+
453
+ async def _pulse_value(
454
+ link: LinkSender,
455
+ line: Any,
456
+ val: bool,
457
+ state: Path | None,
458
+ negate: bool,
459
+ t_on: float,
460
+ t_off: float,
461
+ ) -> None:
462
+ """Continuously pulse with duty cycle *t_on*/(*t_on*+*t_off*)."""
463
+ if not val:
464
+ await _set_value(link, line, False, state, negate)
465
+ return
466
+ if state is not None:
467
+ await link.d_set(state, t_on / (t_on + t_off))
468
+ while True:
469
+ line.value = not negate
470
+ await anyio.sleep(t_on)
471
+ line.value = negate
472
+ await anyio.sleep(t_off)
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-gpio
3
+ Version: 0.1.0
4
+ Summary: GPIO controller connector for MoaT-Link
5
+ Author-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Framework :: Trio
13
+ Classifier: Framework :: AnyIO
14
+ Classifier: Development Status :: 4 - Beta
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.2
19
+ Requires-Dist: asyncclick~=8.3
20
+ Requires-Dist: attrs
21
+ Requires-Dist: moat-util~=0.63.0
22
+ Requires-Dist: moat-link~=0.10.0
23
+ Requires-Dist: moat-lib-config~=0.2.0
24
+ Requires-Dist: moat-lib-gpio~=0.9.4
25
+ Dynamic: license-file
26
+
27
+ # GPIO controller connector
28
+
29
+ % start synopsis
30
+ Connects MoaT-Link to GPIO lines via the moat-lib-gpio library.
31
+ % end synopsis
32
+
33
+ % start main
34
+
35
+ This module bridges MoaT-Link values and GPIO controller lines, in both
36
+ directions. It replaces the legacy ``moat-kv-gpio`` package.
37
+
38
+ ## Quick start
39
+
40
+ 1. Configure an input line (reads GPIO and publishes to a link path):
41
+
42
+ ```shell
43
+ moat link gpio port myhost 0 5 -t input -m read -a dest .sensors.button
44
+ ```
45
+
46
+ 2. Configure an output line (watches a link path and drives GPIO):
47
+
48
+ ```shell
49
+ moat link gpio port myhost 0 3 -t output -m write -a src .controls.lamp
50
+ ```
51
+
52
+ 3. Run the connector for a chip:
53
+
54
+ ```shell
55
+ moat link gpio monitor myhost 0
56
+ ```
57
+
58
+ ## Deprecation
59
+
60
+ This package supersedes ``moat-kv-gpio``. The address tree now lives in
61
+ MoaT-Link under the ``gpio`` prefix; lines are stored as integer line
62
+ numbers below host and chip entries.
63
+
64
+ % end main
@@ -0,0 +1,14 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/moat/link/gpio/__init__.py
5
+ src/moat/link/gpio/_cfg.yaml
6
+ src/moat/link/gpio/_main.py
7
+ src/moat/link/gpio/model.py
8
+ src/moat/link/gpio/task.py
9
+ src/moat/link/gpio/worker.py
10
+ src/moat_link_gpio.egg-info/PKG-INFO
11
+ src/moat_link_gpio.egg-info/SOURCES.txt
12
+ src/moat_link_gpio.egg-info/dependency_links.txt
13
+ src/moat_link_gpio.egg-info/requires.txt
14
+ src/moat_link_gpio.egg-info/top_level.txt
@@ -0,0 +1,7 @@
1
+ anyio~=4.2
2
+ asyncclick~=8.3
3
+ attrs
4
+ moat-lib-config~=0.2.0
5
+ moat-lib-gpio~=0.9.4
6
+ moat-link~=0.10.0
7
+ moat-util~=0.63.0