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.
- moat_link_gpio-0.1.0/LICENSE.txt +14 -0
- moat_link_gpio-0.1.0/PKG-INFO +64 -0
- moat_link_gpio-0.1.0/README.md +38 -0
- moat_link_gpio-0.1.0/pyproject.toml +43 -0
- moat_link_gpio-0.1.0/setup.cfg +4 -0
- moat_link_gpio-0.1.0/src/moat/link/gpio/__init__.py +9 -0
- moat_link_gpio-0.1.0/src/moat/link/gpio/_cfg.yaml +10 -0
- moat_link_gpio-0.1.0/src/moat/link/gpio/_main.py +188 -0
- moat_link_gpio-0.1.0/src/moat/link/gpio/model.py +199 -0
- moat_link_gpio-0.1.0/src/moat/link/gpio/task.py +123 -0
- moat_link_gpio-0.1.0/src/moat/link/gpio/worker.py +472 -0
- moat_link_gpio-0.1.0/src/moat_link_gpio.egg-info/PKG-INFO +64 -0
- moat_link_gpio-0.1.0/src/moat_link_gpio.egg-info/SOURCES.txt +14 -0
- moat_link_gpio-0.1.0/src/moat_link_gpio.egg-info/dependency_links.txt +1 -0
- moat_link_gpio-0.1.0/src/moat_link_gpio.egg-info/requires.txt +7 -0
- moat_link_gpio-0.1.0/src/moat_link_gpio.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moat
|