moat-link-ow 0.1.1__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_ow-0.1.1/LICENSE.txt +14 -0
- moat_link_ow-0.1.1/Makefile +14 -0
- moat_link_ow-0.1.1/PKG-INFO +66 -0
- moat_link_ow-0.1.1/README.md +40 -0
- moat_link_ow-0.1.1/debian/.gitignore +7 -0
- moat_link_ow-0.1.1/debian/changelog +11 -0
- moat_link_ow-0.1.1/debian/control +22 -0
- moat_link_ow-0.1.1/debian/py3dist-overrides +1 -0
- moat_link_ow-0.1.1/debian/rules +10 -0
- moat_link_ow-0.1.1/pyproject.toml +43 -0
- moat_link_ow-0.1.1/setup.cfg +4 -0
- moat_link_ow-0.1.1/src/moat/link/ow/__init__.py +9 -0
- moat_link_ow-0.1.1/src/moat/link/ow/_cfg.yaml +5 -0
- moat_link_ow-0.1.1/src/moat/link/ow/_main.py +367 -0
- moat_link_ow-0.1.1/src/moat/link/ow/model.py +207 -0
- moat_link_ow-0.1.1/src/moat/link/ow/task.py +246 -0
- moat_link_ow-0.1.1/src/moat/link/ow/worker.py +106 -0
- moat_link_ow-0.1.1/src/moat_link_ow.egg-info/PKG-INFO +66 -0
- moat_link_ow-0.1.1/src/moat_link_ow.egg-info/SOURCES.txt +21 -0
- moat_link_ow-0.1.1/src/moat_link_ow.egg-info/dependency_links.txt +1 -0
- moat_link_ow-0.1.1/src/moat_link_ow.egg-info/requires.txt +7 -0
- moat_link_ow-0.1.1/src/moat_link_ow.egg-info/top_level.txt +1 -0
- moat_link_ow-0.1.1/systemd/moat-link-ow@.service +21 -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,14 @@
|
|
|
1
|
+
#!/usr/bin/make -f
|
|
2
|
+
|
|
3
|
+
PACKAGE = moat-link-ow
|
|
4
|
+
MAKEINCL ?= $(shell python3 -mmoat src path)/make/py
|
|
5
|
+
|
|
6
|
+
ifneq ($(wildcard $(MAKEINCL)),)
|
|
7
|
+
include $(MAKEINCL)
|
|
8
|
+
# availabe via http://github.com/smurfix/sourcemgr
|
|
9
|
+
|
|
10
|
+
else
|
|
11
|
+
%:
|
|
12
|
+
@echo "Please fix 'python3 -mmoat src path'."
|
|
13
|
+
@exit 1
|
|
14
|
+
endif
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moat-link-ow
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 1-Wire (OWFS) 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: asyncowfs~=0.15
|
|
21
|
+
Requires-Dist: attrs
|
|
22
|
+
Requires-Dist: moat-util~=0.63.0
|
|
23
|
+
Requires-Dist: moat-link~=0.9.0
|
|
24
|
+
Requires-Dist: moat-lib-config~=0.2.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# 1-Wire (OWFS) connector
|
|
28
|
+
|
|
29
|
+
% start synopsis
|
|
30
|
+
Connects MoaT-Link to 1-Wire buses via the asyncowfs library.
|
|
31
|
+
% end synopsis
|
|
32
|
+
|
|
33
|
+
% start main
|
|
34
|
+
|
|
35
|
+
This module bridges MoaT-Link values and 1-Wire device attributes, in
|
|
36
|
+
both directions, via the ``asyncowfs`` library. It replaces the legacy
|
|
37
|
+
``moat-kv-ow`` package.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
1. Configure an OWFS server (owserver):
|
|
42
|
+
|
|
43
|
+
```shell
|
|
44
|
+
moat link ow mybus add -h 10.0.0.1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. Map a device attribute to a MoaT-Link path:
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
moat link ow mybus at 10.345678.90 temperature add -s dest .data.temp.room -s interval =5
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
3. Run the connector:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
moat link ow mybus monitor
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Deprecation
|
|
60
|
+
|
|
61
|
+
This package supersedes ``moat-kv-ow``. The address tree now lives in
|
|
62
|
+
MoaT-Link under the ``ow.<NAME>`` prefix; device attributes are stored
|
|
63
|
+
below the server entry, keyed by family code, hardware code, and the
|
|
64
|
+
attribute path.
|
|
65
|
+
|
|
66
|
+
% end main
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 1-Wire (OWFS) connector
|
|
2
|
+
|
|
3
|
+
% start synopsis
|
|
4
|
+
Connects MoaT-Link to 1-Wire buses via the asyncowfs library.
|
|
5
|
+
% end synopsis
|
|
6
|
+
|
|
7
|
+
% start main
|
|
8
|
+
|
|
9
|
+
This module bridges MoaT-Link values and 1-Wire device attributes, in
|
|
10
|
+
both directions, via the ``asyncowfs`` library. It replaces the legacy
|
|
11
|
+
``moat-kv-ow`` package.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
1. Configure an OWFS server (owserver):
|
|
16
|
+
|
|
17
|
+
```shell
|
|
18
|
+
moat link ow mybus add -h 10.0.0.1
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. Map a device attribute to a MoaT-Link path:
|
|
22
|
+
|
|
23
|
+
```shell
|
|
24
|
+
moat link ow mybus at 10.345678.90 temperature add -s dest .data.temp.room -s interval =5
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. Run the connector:
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
moat link ow mybus monitor
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Deprecation
|
|
34
|
+
|
|
35
|
+
This package supersedes ``moat-kv-ow``. The address tree now lives in
|
|
36
|
+
MoaT-Link under the ``ow.<NAME>`` prefix; device attributes are stored
|
|
37
|
+
below the server entry, keyed by family code, hardware code, and the
|
|
38
|
+
attribute path.
|
|
39
|
+
|
|
40
|
+
% end main
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
moat-link-ow (0.1.1-1) unstable; urgency=medium
|
|
2
|
+
|
|
3
|
+
* New release for 26.2.8
|
|
4
|
+
|
|
5
|
+
-- Matthias Urlichs <matthias@urlichs.de> Sat, 11 Jul 2026 03:26:42 +0200
|
|
6
|
+
|
|
7
|
+
moat-link-ow (0.1.0-1) unstable; urgency=medium
|
|
8
|
+
|
|
9
|
+
* Initial release.
|
|
10
|
+
|
|
11
|
+
-- Matthias Urlichs <matthias@urlichs.de> Wed, 09 Jul 2026 12:00:00 +0200
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Source: moat-link-ow
|
|
2
|
+
Maintainer: Matthias Urlichs <matthias@urlichs.de>
|
|
3
|
+
Section: python
|
|
4
|
+
Priority: optional
|
|
5
|
+
Build-Depends: dh-python, python3-all, debhelper (>= 13), debhelper-compat (= 13),
|
|
6
|
+
python3-setuptools,
|
|
7
|
+
python3-wheel,
|
|
8
|
+
Standards-Version: 3.9.6
|
|
9
|
+
Homepage: https://github.com/M-o-a-T/moat
|
|
10
|
+
|
|
11
|
+
Package: moat-link-ow
|
|
12
|
+
Architecture: all
|
|
13
|
+
Depends: ${misc:Depends}, ${python3:Depends},
|
|
14
|
+
moat-util,
|
|
15
|
+
moat-link,
|
|
16
|
+
Recommends:
|
|
17
|
+
python3-trio (>= 0.22),
|
|
18
|
+
Description: 1-Wire (OWFS) connector for MoaT-Link
|
|
19
|
+
Bridges MoaT-Link values to and from 1-Wire device attributes via the
|
|
20
|
+
asyncowfs library and an owserver connection.
|
|
21
|
+
.
|
|
22
|
+
Part of the MoaT ecosystem for distributed home automation.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
asyncowfs python3-asyncowfs
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/make -f
|
|
2
|
+
|
|
3
|
+
export PYBUILD_NAME=moat-link-ow
|
|
4
|
+
%:
|
|
5
|
+
dh $@ --with python3 --buildsystem=pybuild
|
|
6
|
+
|
|
7
|
+
override_dh_auto_install:
|
|
8
|
+
dh_auto_install
|
|
9
|
+
install -D -m 644 systemd/moat-link-ow@.service \
|
|
10
|
+
debian/moat-link-ow/usr/lib/systemd/system/moat-link-ow@.service
|
|
@@ -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
|
+
"asyncowfs ~= 0.15",
|
|
18
|
+
"attrs",
|
|
19
|
+
"moat-util ~= 0.63.0",
|
|
20
|
+
"moat-link ~= 0.9.0",
|
|
21
|
+
"moat-lib-config ~= 0.2.0",
|
|
22
|
+
]
|
|
23
|
+
version = "0.1.1"
|
|
24
|
+
keywords = [ "MoaT",]
|
|
25
|
+
requires-python = ">=3.13"
|
|
26
|
+
name = "moat-link-ow"
|
|
27
|
+
description = "1-Wire (OWFS) 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,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for moat.link.ow.
|
|
3
|
+
|
|
4
|
+
Manages 1-Wire (OWFS) servers and their per-attribute mirror entries.
|
|
5
|
+
The ``at`` sub-group targets a single device attribute identified by a
|
|
6
|
+
1-Wire device id (``FF.CODE.CHK``) and an attribute path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import asyncclick as click
|
|
15
|
+
|
|
16
|
+
from moat.util import NotGiven, yprint
|
|
17
|
+
from moat.lib.path import P, Path
|
|
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
|
+
from .model import device_subpath
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _server_path(obj) -> Path:
|
|
34
|
+
"""Return the link path of the currently-selected OWFS server."""
|
|
35
|
+
return obj.ow_prefix + Path.build((obj.ow_name,))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _require_server(obj) -> dict[str, Any]:
|
|
39
|
+
"""Fetch the data stored at the server entry, or raise.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
click.UsageError: if the entry is missing or not a mapping.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
data = await obj.conn.d_get(_server_path(obj))
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise click.UsageError(f"Server {obj.ow_name!r} does not exist.") from None
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise click.UsageError(f"Server {obj.ow_name!r} has no configuration.")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.group(
|
|
54
|
+
cls=AliasedGroup,
|
|
55
|
+
name="ow",
|
|
56
|
+
short_help="Manage 1-Wire (OWFS) servers.",
|
|
57
|
+
invoke_without_command=True,
|
|
58
|
+
help="""\
|
|
59
|
+
Manager for 1-Wire (OWFS) bus servers.
|
|
60
|
+
|
|
61
|
+
\b
|
|
62
|
+
Use '… ow -' to list all servers.
|
|
63
|
+
Use '… ow NAME' to show details of a single server.
|
|
64
|
+
""",
|
|
65
|
+
)
|
|
66
|
+
@click.argument("name", type=str, nargs=1)
|
|
67
|
+
@click.pass_context
|
|
68
|
+
async def cli(ctx, name: str) -> None:
|
|
69
|
+
"""Dispatch to a server- or attribute-specific subcommand."""
|
|
70
|
+
obj = ctx.obj
|
|
71
|
+
cfg = obj.cfg["link"]
|
|
72
|
+
obj.conn = await ctx.with_async_resource(Link(cfg))
|
|
73
|
+
obj.ow_cfg = obj.cfg.link.ow
|
|
74
|
+
obj.ow_prefix = obj.ow_cfg.prefix
|
|
75
|
+
obj.ow_name = name
|
|
76
|
+
|
|
77
|
+
if name == "-":
|
|
78
|
+
if ctx.invoked_subcommand is not None:
|
|
79
|
+
raise click.BadParameter(
|
|
80
|
+
"The name '-' triggers a list and precludes subcommands.",
|
|
81
|
+
)
|
|
82
|
+
cnt = 0
|
|
83
|
+
async with obj.conn.d_walk(obj.ow_prefix, min_depth=1, max_depth=1) as mon:
|
|
84
|
+
async for p, _d in mon:
|
|
85
|
+
cnt += 1
|
|
86
|
+
print(p[-1], file=obj.stdout)
|
|
87
|
+
if not cnt and obj.debug:
|
|
88
|
+
print("no entries", file=sys.stderr)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if ctx.invoked_subcommand is None:
|
|
92
|
+
try:
|
|
93
|
+
data = await obj.conn.d_get(_server_path(obj))
|
|
94
|
+
except KeyError:
|
|
95
|
+
raise click.UsageError(
|
|
96
|
+
f"Server {obj.ow_name!r} does not exist.",
|
|
97
|
+
) from None
|
|
98
|
+
srv = data.get("server", {}) if isinstance(data, dict) else {}
|
|
99
|
+
cnt = 0
|
|
100
|
+
if isinstance(srv, dict):
|
|
101
|
+
for k in ("host", "port"):
|
|
102
|
+
v = srv.get(k)
|
|
103
|
+
if v is not None:
|
|
104
|
+
cnt += 1
|
|
105
|
+
print(f"server {k} {v}", file=obj.stdout)
|
|
106
|
+
if not cnt and obj.debug:
|
|
107
|
+
print("exists, no data", file=sys.stderr)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _server_options(proc):
|
|
111
|
+
"""Decorate ``add``/``set`` with the server-config options."""
|
|
112
|
+
proc = click.option(
|
|
113
|
+
"-p",
|
|
114
|
+
"--port",
|
|
115
|
+
type=int,
|
|
116
|
+
default=None,
|
|
117
|
+
help="Port of the owserver (default 4304).",
|
|
118
|
+
)(proc)
|
|
119
|
+
proc = click.option(
|
|
120
|
+
"-h",
|
|
121
|
+
"--host",
|
|
122
|
+
type=str,
|
|
123
|
+
default=None,
|
|
124
|
+
help="Host name or IP of the owserver.",
|
|
125
|
+
)(proc)
|
|
126
|
+
return proc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.command(short_help="Add an OWFS server")
|
|
130
|
+
@_server_options
|
|
131
|
+
@click.option("-f", "--force", is_flag=True, help="Allow replacing an existing server.")
|
|
132
|
+
@click.pass_obj
|
|
133
|
+
async def add(obj, host, port, force) -> None:
|
|
134
|
+
"""Add an OWFS server (owserver)."""
|
|
135
|
+
path = _server_path(obj)
|
|
136
|
+
if not force:
|
|
137
|
+
try:
|
|
138
|
+
await obj.conn.d_get(path)
|
|
139
|
+
except KeyError:
|
|
140
|
+
pass
|
|
141
|
+
else:
|
|
142
|
+
raise click.UsageError(
|
|
143
|
+
f"Server {obj.ow_name!r} already exists. Use --force or 'set'.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
srv: dict[str, Any] = {}
|
|
147
|
+
if host is not None:
|
|
148
|
+
srv["host"] = host
|
|
149
|
+
if port is not None:
|
|
150
|
+
srv["port"] = port
|
|
151
|
+
await obj.conn.d_set(path, {"server": srv})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@cli.command("set", short_help="Modify an OWFS server")
|
|
155
|
+
@_server_options
|
|
156
|
+
@click.pass_obj
|
|
157
|
+
async def set_(obj, host, port) -> None:
|
|
158
|
+
"""Modify an OWFS server.
|
|
159
|
+
|
|
160
|
+
Pass ``-`` as a value (where applicable) to clear an existing setting.
|
|
161
|
+
"""
|
|
162
|
+
data = await _require_server(obj)
|
|
163
|
+
srv = data.get("server", {})
|
|
164
|
+
if not isinstance(srv, dict):
|
|
165
|
+
srv = {}
|
|
166
|
+
|
|
167
|
+
if host is not None:
|
|
168
|
+
if host == "-":
|
|
169
|
+
srv.pop("host", None)
|
|
170
|
+
else:
|
|
171
|
+
srv["host"] = host
|
|
172
|
+
if port is not None:
|
|
173
|
+
srv["port"] = port
|
|
174
|
+
data["server"] = srv
|
|
175
|
+
|
|
176
|
+
await obj.conn.d_set(_server_path(obj), data)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@cli.command("delete", short_help="Delete an OWFS server")
|
|
180
|
+
@click.option("-r", "--recursive", is_flag=True, help="Also remove all entries below.")
|
|
181
|
+
@click.pass_obj
|
|
182
|
+
async def delete_(obj, recursive: bool) -> None:
|
|
183
|
+
"""Delete an OWFS server.
|
|
184
|
+
|
|
185
|
+
Without ``--recursive`` the server entry itself is removed but its
|
|
186
|
+
child attribute entries are kept (they become orphans).
|
|
187
|
+
"""
|
|
188
|
+
path = _server_path(obj)
|
|
189
|
+
args: dict[str, Any] = {}
|
|
190
|
+
if recursive:
|
|
191
|
+
args["rec"] = True
|
|
192
|
+
res = await obj.conn.d.delete(path, **args)
|
|
193
|
+
if getattr(obj, "meta", False):
|
|
194
|
+
yprint(res[0], stream=obj.stdout)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@cli.command("dump")
|
|
198
|
+
@click.option("-l", "--one-line", is_flag=True, help="Single line per entry")
|
|
199
|
+
@click.pass_obj
|
|
200
|
+
async def dump_(obj, one_line: bool) -> None:
|
|
201
|
+
"""Emit a server's (sub)state as a list / YAML file."""
|
|
202
|
+
path = _server_path(obj)
|
|
203
|
+
if not one_line:
|
|
204
|
+
await data_get(obj.conn, path, recursive=True, out=obj.stdout)
|
|
205
|
+
return
|
|
206
|
+
async with obj.conn.d_walk(path) as mon:
|
|
207
|
+
async for p, d in mon:
|
|
208
|
+
print(f"{path + p} {d}", file=obj.stdout)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@cli.group(
|
|
212
|
+
"at",
|
|
213
|
+
invoke_without_command=True,
|
|
214
|
+
short_help="create/show/delete an attribute mapping",
|
|
215
|
+
)
|
|
216
|
+
@click.argument("device", type=str, nargs=1)
|
|
217
|
+
@click.argument("attr", type=str, nargs=1)
|
|
218
|
+
@click.pass_context
|
|
219
|
+
async def at_cli(ctx, device: str, attr: str) -> None:
|
|
220
|
+
"""Manage a single 1-Wire attribute mapping under a server.
|
|
221
|
+
|
|
222
|
+
DEVICE is a 1-Wire device id in ``FF.CODE.CHK`` form (the checksum is
|
|
223
|
+
ignored); ATTR is the device attribute path, e.g. ``temperature`` or
|
|
224
|
+
``foo.bar``.
|
|
225
|
+
"""
|
|
226
|
+
obj = ctx.obj
|
|
227
|
+
try:
|
|
228
|
+
dev_sub = device_subpath(device)
|
|
229
|
+
except ValueError as exc:
|
|
230
|
+
raise click.UsageError(str(exc)) from None
|
|
231
|
+
try:
|
|
232
|
+
sub = dev_sub + P(attr)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
raise click.UsageError(f"Bad attribute {attr!r}: {exc}") from None
|
|
235
|
+
try:
|
|
236
|
+
await obj.conn.d_get(_server_path(obj))
|
|
237
|
+
except KeyError:
|
|
238
|
+
raise click.UsageError(
|
|
239
|
+
"Create the server before mapping attributes to it!",
|
|
240
|
+
) from None
|
|
241
|
+
obj.ow_subpath = sub
|
|
242
|
+
if ctx.invoked_subcommand is None:
|
|
243
|
+
await data_get(
|
|
244
|
+
obj.conn,
|
|
245
|
+
_server_path(obj) + sub,
|
|
246
|
+
recursive=False,
|
|
247
|
+
out=obj.stdout,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@at_cli.command("dump")
|
|
252
|
+
@click.option("-l", "--one-line", is_flag=True, help="Single line per entry")
|
|
253
|
+
@click.pass_obj
|
|
254
|
+
async def dump_at(obj, one_line: bool) -> None:
|
|
255
|
+
"""Emit a subtree as a list / YAML file."""
|
|
256
|
+
path = _server_path(obj) + obj.ow_subpath
|
|
257
|
+
if not one_line:
|
|
258
|
+
await data_get(obj.conn, path, recursive=True, out=obj.stdout)
|
|
259
|
+
return
|
|
260
|
+
async with obj.conn.d_walk(path) as mon:
|
|
261
|
+
async for p, d in mon:
|
|
262
|
+
print(f"{path + p} {d}", file=obj.stdout)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@at_cli.command("add", short_help="Add an attribute mapping")
|
|
266
|
+
@attr_args
|
|
267
|
+
@click.option("-f", "--force", is_flag=True, help="Allow replacing an existing entry.")
|
|
268
|
+
@click.option(
|
|
269
|
+
"-w",
|
|
270
|
+
"--write",
|
|
271
|
+
is_flag=True,
|
|
272
|
+
help="Write direction (Link→device); needs 'src'. Default: read (device→Link).",
|
|
273
|
+
)
|
|
274
|
+
@click.option(
|
|
275
|
+
"-i",
|
|
276
|
+
"--interval",
|
|
277
|
+
type=float,
|
|
278
|
+
default=None,
|
|
279
|
+
help="Polling interval in seconds (read direction).",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"-a",
|
|
283
|
+
"--attr",
|
|
284
|
+
"attr_",
|
|
285
|
+
default=None,
|
|
286
|
+
help="Sub-attribute path: 'src_attr' for write, 'dest_attr' for read.",
|
|
287
|
+
)
|
|
288
|
+
@click.pass_obj
|
|
289
|
+
async def add_at(obj, write, interval, attr_, force, **kw) -> None:
|
|
290
|
+
"""Add a 1-Wire attribute mapping.
|
|
291
|
+
|
|
292
|
+
\b
|
|
293
|
+
DEVICE ATTR: identify the device attribute. Required.
|
|
294
|
+
|
|
295
|
+
For read direction (default) set ``dest`` to the MoaT-Link path that
|
|
296
|
+
receives the polled value; for write direction (``-w``) set ``src``
|
|
297
|
+
to the MoaT-Link path whose changes are forwarded to the device.
|
|
298
|
+
|
|
299
|
+
Use the ``-s`` option to set attributes, e.g. ``-s dest .my.path``
|
|
300
|
+
or ``-s src .my.path``.
|
|
301
|
+
"""
|
|
302
|
+
sub: Path = obj.ow_subpath
|
|
303
|
+
path = _server_path(obj) + sub
|
|
304
|
+
|
|
305
|
+
if not force:
|
|
306
|
+
try:
|
|
307
|
+
await obj.conn.d_get(path)
|
|
308
|
+
except KeyError:
|
|
309
|
+
pass
|
|
310
|
+
else:
|
|
311
|
+
raise click.UsageError("This entry already exists. Use '--force' or 'set'.")
|
|
312
|
+
|
|
313
|
+
val: dict[str | None, Any] = {}
|
|
314
|
+
if attr_ is not None:
|
|
315
|
+
val["src_attr" if write else "dest_attr"] = P(attr_)
|
|
316
|
+
if not write and interval is not None:
|
|
317
|
+
val["interval"] = interval
|
|
318
|
+
|
|
319
|
+
res, _meta = await node_attr(obj, path, val=val, **kw)
|
|
320
|
+
|
|
321
|
+
required = "src" if write else "dest"
|
|
322
|
+
if not isinstance(res, dict) or res.get(required) is None:
|
|
323
|
+
raise click.UsageError(
|
|
324
|
+
f"For {'write' if write else 'read'} direction you must set the "
|
|
325
|
+
f"{required!r} attribute, e.g. `-s {required} .some.path`.",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if getattr(obj, "meta", False):
|
|
329
|
+
yprint(res, stream=obj.stdout)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@at_cli.command("set")
|
|
333
|
+
@attr_args
|
|
334
|
+
@click.pass_obj
|
|
335
|
+
async def set_at(obj, **kw) -> None:
|
|
336
|
+
"""Modify a 1-Wire attribute mapping."""
|
|
337
|
+
path = _server_path(obj) + obj.ow_subpath
|
|
338
|
+
res, _meta = await node_attr(obj, path, **kw)
|
|
339
|
+
if getattr(obj, "meta", False):
|
|
340
|
+
yprint(res, stream=obj.stdout)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@at_cli.command("delete")
|
|
344
|
+
@click.pass_obj
|
|
345
|
+
async def delete_at(obj) -> None:
|
|
346
|
+
"""Remove an attribute mapping from the server."""
|
|
347
|
+
path = _server_path(obj) + obj.ow_subpath
|
|
348
|
+
try:
|
|
349
|
+
await obj.conn.d_get(path)
|
|
350
|
+
except KeyError:
|
|
351
|
+
raise click.UsageError("This entry doesn't exist.") from None
|
|
352
|
+
await obj.conn.d_set(path, NotGiven)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@cli.command()
|
|
356
|
+
@click.pass_obj
|
|
357
|
+
async def monitor(obj) -> None:
|
|
358
|
+
"""Stand-alone task to talk to a single OWFS server."""
|
|
359
|
+
from .task import task # noqa: PLC0415
|
|
360
|
+
|
|
361
|
+
async with as_service(obj, host=False) as srv:
|
|
362
|
+
await task(
|
|
363
|
+
obj.conn,
|
|
364
|
+
obj.ow_cfg,
|
|
365
|
+
obj.ow_name,
|
|
366
|
+
task_status=srv,
|
|
367
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Node model for the 1-Wire (OWFS) bus connector.
|
|
3
|
+
|
|
4
|
+
The tree mirrors the MoaT-Link subtree under the configured prefix.
|
|
5
|
+
Each server entry is keyed by its name; below it the tree branches
|
|
6
|
+
into the device's family code (a byte), then the device's 48-bit
|
|
7
|
+
hardware code, and finally the attribute path that selects the
|
|
8
|
+
1-Wire value to mirror.
|
|
9
|
+
|
|
10
|
+
A single attribute entry can mirror in either direction:
|
|
11
|
+
|
|
12
|
+
* **read** (``dest`` set): the device attribute is polled and its value
|
|
13
|
+
is published to the MoaT-Link path ``dest``.
|
|
14
|
+
* **write** (``src`` set): a MoaT-Link path ``src`` is watched and its
|
|
15
|
+
value is written to the device attribute.
|
|
16
|
+
|
|
17
|
+
Both directions accept an optional sub-attribute path (``dest_attr`` /
|
|
18
|
+
``src_attr``) that selects, or merges into, a nested field of the
|
|
19
|
+
transferred value.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
from attrs import define
|
|
27
|
+
|
|
28
|
+
from moat.util import NotGiven
|
|
29
|
+
from moat.lib.path import Path
|
|
30
|
+
from moat.link.node import Node
|
|
31
|
+
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from moat.lib.rpc import Key
|
|
36
|
+
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_device(addr: str) -> tuple[int, int]:
|
|
43
|
+
"""Split a 1-Wire device id ``FF.CODE.CHECK`` into ``(family, code)``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
addr: A device id such as ``"10.345678.90"``. The family and code
|
|
47
|
+
components are interpreted as hexadecimal; the trailing
|
|
48
|
+
checksum is discarded.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
``(family, code)`` as a pair of ``int``.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: if *addr* is not a 3-part dotted device id.
|
|
55
|
+
"""
|
|
56
|
+
parts = addr.split(".")
|
|
57
|
+
if len(parts) != 3:
|
|
58
|
+
raise ValueError(f"Not a 3-part 1-Wire device id: {addr!r}")
|
|
59
|
+
return int(parts[0], 16), int(parts[1], 16)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def device_subpath(addr: str) -> Path:
|
|
63
|
+
"""Return the ``(family, code)`` subpath for a 1-Wire device id."""
|
|
64
|
+
f, c = parse_device(addr)
|
|
65
|
+
return Path.build((f, c))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@define
|
|
69
|
+
class OwAttr(Node):
|
|
70
|
+
"""A single 1-Wire attribute mapping.
|
|
71
|
+
|
|
72
|
+
The stored configuration is a dict with these keys:
|
|
73
|
+
|
|
74
|
+
* ``dest``: destination MoaT-Link path (read direction).
|
|
75
|
+
* ``src``: source MoaT-Link path (write direction).
|
|
76
|
+
* ``interval``: polling interval in seconds (read direction).
|
|
77
|
+
* ``dest_attr``: sub-attribute path merged into ``dest``'s value.
|
|
78
|
+
* ``src_attr``: sub-attribute path extracted from ``src``'s value.
|
|
79
|
+
* ``idem``: idempotency flag (default ``True``).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def add_child(self, item: Key) -> OwAttr:
|
|
83
|
+
"""Nested attribute segments are :class:`OwAttr` nodes too."""
|
|
84
|
+
if item in self._sub:
|
|
85
|
+
raise ValueError("exists")
|
|
86
|
+
self._sub[item] = s = OwAttr()
|
|
87
|
+
return s
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def dest(self) -> Path | None:
|
|
91
|
+
"""Destination path (read direction), or ``None``."""
|
|
92
|
+
d = self.data_
|
|
93
|
+
s = d.get("dest") if isinstance(d, dict) else None
|
|
94
|
+
return s if s is None else Path.build(s)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def src(self) -> Path | None:
|
|
98
|
+
"""Source path (write direction), or ``None``."""
|
|
99
|
+
d = self.data_
|
|
100
|
+
s = d.get("src") if isinstance(d, dict) else None
|
|
101
|
+
return s if s is None else Path.build(s)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def interval(self) -> float | None:
|
|
105
|
+
"""Polling interval in seconds (read direction), or ``None``."""
|
|
106
|
+
d = self.data_
|
|
107
|
+
return d.get("interval") if isinstance(d, dict) else None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def dest_attr(self) -> Path | None:
|
|
111
|
+
"""Sub-attribute merged into ``dest``'s value, or ``None``."""
|
|
112
|
+
d = self.data_
|
|
113
|
+
s = d.get("dest_attr") if isinstance(d, dict) else None
|
|
114
|
+
return s if s is None else Path.build(s)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def src_attr(self) -> Path | None:
|
|
118
|
+
"""Sub-attribute extracted from ``src``'s value, or ``None``."""
|
|
119
|
+
d = self.data_
|
|
120
|
+
s = d.get("src_attr") if isinstance(d, dict) else None
|
|
121
|
+
return s if s is None else Path.build(s)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def idem(self) -> bool:
|
|
125
|
+
"""Idempotency flag (default ``True``)."""
|
|
126
|
+
d = self.data_ if self.data_ is not NotGiven else {}
|
|
127
|
+
if not isinstance(d, dict):
|
|
128
|
+
return True
|
|
129
|
+
return bool(d.get("idem", True))
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_read(self) -> bool:
|
|
133
|
+
"""Whether this entry mirrors device→Link (``dest`` is set)."""
|
|
134
|
+
return self.dest is not None
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def is_write(self) -> bool:
|
|
138
|
+
"""Whether this entry mirrors Link→device (``src`` is set)."""
|
|
139
|
+
return self.src is not None
|
|
140
|
+
|
|
141
|
+
def is_complete(self) -> bool:
|
|
142
|
+
"""Whether this entry has a usable direction."""
|
|
143
|
+
return self.is_read or self.is_write
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@define
|
|
147
|
+
class OwDevice(Node):
|
|
148
|
+
"""One 1-Wire device; children are :class:`OwAttr`."""
|
|
149
|
+
|
|
150
|
+
def add_child(self, item: Key) -> OwAttr:
|
|
151
|
+
"""Create child entries as :class:`OwAttr`."""
|
|
152
|
+
if item in self._sub:
|
|
153
|
+
raise ValueError("exists")
|
|
154
|
+
self._sub[item] = s = OwAttr()
|
|
155
|
+
return s
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@define
|
|
159
|
+
class OwFamily(Node):
|
|
160
|
+
"""Intermediate family-code node; children are :class:`OwDevice`."""
|
|
161
|
+
|
|
162
|
+
def add_child(self, item: Key) -> OwDevice:
|
|
163
|
+
"""Create child entries as :class:`OwDevice`."""
|
|
164
|
+
if item in self._sub:
|
|
165
|
+
raise ValueError("exists")
|
|
166
|
+
self._sub[item] = s = OwDevice()
|
|
167
|
+
return s
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@define
|
|
171
|
+
class OwServer(Node):
|
|
172
|
+
"""One OWFS server (owserver) in the configuration tree.
|
|
173
|
+
|
|
174
|
+
Children at the integer ``family`` level branch out into
|
|
175
|
+
code/attribute levels and finally into :class:`OwAttr` leaves.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def add_child(self, item: Key) -> OwFamily:
|
|
179
|
+
"""Create child entries as :class:`OwFamily`."""
|
|
180
|
+
if item in self._sub:
|
|
181
|
+
raise ValueError("exists")
|
|
182
|
+
self._sub[item] = s = OwFamily()
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def cfg(self) -> dict[str, Any]:
|
|
187
|
+
"""Server-level config dict (``host``/``port``)."""
|
|
188
|
+
d = self.data_
|
|
189
|
+
if d is NotGiven or not isinstance(d, dict):
|
|
190
|
+
return {}
|
|
191
|
+
s = d.get("server", {})
|
|
192
|
+
return s if isinstance(s, dict) else {}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@define
|
|
196
|
+
class OwRoot(Node):
|
|
197
|
+
"""Root of the 1-Wire configuration tree.
|
|
198
|
+
|
|
199
|
+
Children are :class:`OwServer` nodes.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def add_child(self, item: Key) -> OwServer:
|
|
203
|
+
"""Create child servers as :class:`OwServer`."""
|
|
204
|
+
if item in self._sub:
|
|
205
|
+
raise ValueError(f"exists: {item!r}")
|
|
206
|
+
self._sub[item] = s = OwServer()
|
|
207
|
+
return s
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main supervisor task for the 1-Wire (OWFS) connector.
|
|
3
|
+
|
|
4
|
+
Connects to a single owserver and monitors the MoaT-Link configuration
|
|
5
|
+
subtree for one server. Attribute entries are mirrored in both
|
|
6
|
+
directions:
|
|
7
|
+
|
|
8
|
+
* **read** entries are polled by asyncowfs; the resulting values arrive
|
|
9
|
+
on the single :attr:`~asyncowfs.service.Service.events` stream and are
|
|
10
|
+
dispatched to their MoaT-Link destination here.
|
|
11
|
+
* **write** entries get a dedicated worker that watches a MoaT-Link
|
|
12
|
+
source and forwards changes onto the bus.
|
|
13
|
+
|
|
14
|
+
Per-entry write workers are spawned / cancelled as entries appear,
|
|
15
|
+
change, or disappear; read polling is (re)configured whenever a device
|
|
16
|
+
is located or its entry changes.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import anyio
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
from asyncowfs import OWFS
|
|
25
|
+
from asyncowfs.event import (
|
|
26
|
+
DeviceException,
|
|
27
|
+
DeviceLocated,
|
|
28
|
+
DeviceNotFound,
|
|
29
|
+
DeviceValue,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from moat.util import combine_dict
|
|
33
|
+
from moat.lib.path import Path
|
|
34
|
+
|
|
35
|
+
from .model import OwAttr, OwServer
|
|
36
|
+
from .worker import forward_read, run_out
|
|
37
|
+
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from anyio.abc import TaskStatus
|
|
42
|
+
|
|
43
|
+
from moat.link.client import LinkSender
|
|
44
|
+
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _canon_attr(attr: Any) -> tuple[str, ...]:
|
|
52
|
+
"""Canonicalise a polled attribute into a string tuple for matching.
|
|
53
|
+
|
|
54
|
+
asyncowfs reports single attributes as strings (``"temperature"``)
|
|
55
|
+
and nested ones either as slash-joined strings or as the tuple that
|
|
56
|
+
was originally passed to :meth:`~asyncowfs.device.Device.set_polling_interval`.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(attr, str):
|
|
59
|
+
attr = attr.split("/")
|
|
60
|
+
return tuple(str(x) for x in attr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def task(
|
|
64
|
+
link: LinkSender,
|
|
65
|
+
cfg: Mapping,
|
|
66
|
+
server_name: str,
|
|
67
|
+
*,
|
|
68
|
+
task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Run the 1-Wire connector for one owserver.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
link: an active MoaT-Link sender.
|
|
74
|
+
cfg: the ``link.ow`` configuration section.
|
|
75
|
+
server_name: the server entry name inside the config subtree.
|
|
76
|
+
task_status: task-status for ``tg.start``.
|
|
77
|
+
"""
|
|
78
|
+
prefix = Path.build(cfg["prefix"])
|
|
79
|
+
server_path = prefix / server_name
|
|
80
|
+
|
|
81
|
+
server_data = await link.d_get(server_path)
|
|
82
|
+
if not isinstance(server_data, dict):
|
|
83
|
+
server_data = {}
|
|
84
|
+
srv_cfg = combine_dict(
|
|
85
|
+
server_data.get("server", {}),
|
|
86
|
+
cfg.get("server_default", {}),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async with OWFS() as ow, anyio.create_task_group() as tg:
|
|
90
|
+
devs: dict[tuple[int, int], Any] = {}
|
|
91
|
+
entries: dict[Path, OwAttr] = {}
|
|
92
|
+
by_key: dict[tuple[int, int, tuple[str, ...]], Path] = {}
|
|
93
|
+
writers: dict[Path, anyio.CancelScope] = {}
|
|
94
|
+
|
|
95
|
+
def _fc(p: Path) -> tuple[int, int]:
|
|
96
|
+
return int(p[0]), int(p[1])
|
|
97
|
+
|
|
98
|
+
def _attr_path(p: Path) -> Path:
|
|
99
|
+
return Path.build(tuple(p)[2:])
|
|
100
|
+
|
|
101
|
+
def _key(p: Path) -> tuple[int, int, tuple[str, ...]]:
|
|
102
|
+
f, c = _fc(p)
|
|
103
|
+
return f, c, _canon_attr(tuple(p)[2:])
|
|
104
|
+
|
|
105
|
+
def _cancel_writer(p: Path) -> None:
|
|
106
|
+
sc = writers.pop(p, None)
|
|
107
|
+
if sc is not None:
|
|
108
|
+
sc.cancel()
|
|
109
|
+
|
|
110
|
+
async def _activate(p: Path, n: OwAttr) -> None:
|
|
111
|
+
"""(Re)configure one entry: writer and/or polling."""
|
|
112
|
+
_cancel_writer(p)
|
|
113
|
+
entries[p] = n
|
|
114
|
+
by_key[_key(p)] = p
|
|
115
|
+
|
|
116
|
+
dev = devs.get(_fc(p))
|
|
117
|
+
if dev is None:
|
|
118
|
+
return # device not located yet; activated on arrival
|
|
119
|
+
|
|
120
|
+
ap = _attr_path(p)
|
|
121
|
+
# Clear any stale polling from a previous incarnation.
|
|
122
|
+
try:
|
|
123
|
+
await dev.set_polling_interval(ap, 0)
|
|
124
|
+
except Exception:
|
|
125
|
+
logger.debug("Clear-poll for %s failed", p, exc_info=True)
|
|
126
|
+
|
|
127
|
+
if n.is_read:
|
|
128
|
+
intv = n.interval
|
|
129
|
+
if intv and intv > 0:
|
|
130
|
+
try:
|
|
131
|
+
await dev.set_polling_interval(ap, intv)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.warning("Polling setup for %s failed: %r", p, exc)
|
|
134
|
+
elif n.is_write:
|
|
135
|
+
apw = ap
|
|
136
|
+
|
|
137
|
+
async def _run(
|
|
138
|
+
*,
|
|
139
|
+
task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
|
140
|
+
) -> None:
|
|
141
|
+
with anyio.CancelScope() as sc:
|
|
142
|
+
writers[p] = sc
|
|
143
|
+
task_status.started()
|
|
144
|
+
try:
|
|
145
|
+
await run_out(link, dev, n, apw, p)
|
|
146
|
+
except Exception:
|
|
147
|
+
logger.exception("Writer for %s failed", p)
|
|
148
|
+
finally:
|
|
149
|
+
if writers.get(p) is sc:
|
|
150
|
+
del writers[p]
|
|
151
|
+
|
|
152
|
+
await tg.start(_run)
|
|
153
|
+
|
|
154
|
+
def _deactivate(p: Path) -> None:
|
|
155
|
+
"""Tear down one entry."""
|
|
156
|
+
_cancel_writer(p)
|
|
157
|
+
n = entries.pop(p, None)
|
|
158
|
+
if n is None:
|
|
159
|
+
return
|
|
160
|
+
if by_key.get(_key(p)) is p:
|
|
161
|
+
del by_key[_key(p)]
|
|
162
|
+
if n.is_read:
|
|
163
|
+
dev = devs.get(_fc(p))
|
|
164
|
+
if dev is not None:
|
|
165
|
+
tg.start_soon(_safe_clear_poll, dev, _attr_path(p), p)
|
|
166
|
+
|
|
167
|
+
async def _safe_clear_poll(dev: Any, ap: Path, p: Path) -> None:
|
|
168
|
+
try:
|
|
169
|
+
await dev.set_polling_interval(ap, 0)
|
|
170
|
+
except Exception:
|
|
171
|
+
logger.debug("Clear-poll for %s failed", p, exc_info=True)
|
|
172
|
+
|
|
173
|
+
async def _on_located(dev: Any) -> None:
|
|
174
|
+
key = int(dev.family), int(dev.code)
|
|
175
|
+
devs[key] = dev
|
|
176
|
+
for p, n in list(entries.items()):
|
|
177
|
+
if _fc(p) == key:
|
|
178
|
+
await _activate(p, n)
|
|
179
|
+
|
|
180
|
+
def _on_not_found(dev: Any) -> None:
|
|
181
|
+
key = int(dev.family), int(dev.code)
|
|
182
|
+
devs.pop(key, None)
|
|
183
|
+
for p in list(writers):
|
|
184
|
+
if _fc(p) == key:
|
|
185
|
+
_cancel_writer(p)
|
|
186
|
+
|
|
187
|
+
async def _on_value(dev: Any, attr: Any, val: Any) -> None:
|
|
188
|
+
key = int(dev.family), int(dev.code), _canon_attr(attr)
|
|
189
|
+
p = by_key.get(key)
|
|
190
|
+
if p is None:
|
|
191
|
+
return
|
|
192
|
+
n = entries.get(p)
|
|
193
|
+
if n is not None and n.is_read:
|
|
194
|
+
try:
|
|
195
|
+
await forward_read(link, n, val)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
logger.warning("Forward read for %s failed: %r", p, exc)
|
|
198
|
+
|
|
199
|
+
async def mon_events(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None:
|
|
200
|
+
async with ow.events as events:
|
|
201
|
+
task_status.started()
|
|
202
|
+
async for msg in events:
|
|
203
|
+
try:
|
|
204
|
+
if isinstance(msg, DeviceValue):
|
|
205
|
+
await _on_value(msg.device, msg.attribute, msg.value)
|
|
206
|
+
elif isinstance(msg, DeviceLocated):
|
|
207
|
+
await _on_located(msg.device)
|
|
208
|
+
elif isinstance(msg, DeviceNotFound):
|
|
209
|
+
_on_not_found(msg.device)
|
|
210
|
+
elif isinstance(msg, DeviceException):
|
|
211
|
+
logger.warning(
|
|
212
|
+
"OWFS error at %s.%s: %r",
|
|
213
|
+
msg.device,
|
|
214
|
+
msg.attribute,
|
|
215
|
+
msg.exception,
|
|
216
|
+
)
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.exception("Event processing failed for %r", msg)
|
|
219
|
+
|
|
220
|
+
# Start the event reader before adding the server so that the
|
|
221
|
+
# initial scan's device-location events are not lost.
|
|
222
|
+
await tg.start(mon_events)
|
|
223
|
+
await ow.add_server(name=server_name, **srv_cfg)
|
|
224
|
+
|
|
225
|
+
async with link.d_watch(
|
|
226
|
+
server_path,
|
|
227
|
+
subtree=True,
|
|
228
|
+
mark=True,
|
|
229
|
+
cls=OwServer,
|
|
230
|
+
) as mon:
|
|
231
|
+
task_status.started()
|
|
232
|
+
|
|
233
|
+
async for msg in mon:
|
|
234
|
+
if msg is None:
|
|
235
|
+
continue
|
|
236
|
+
p, _d = msg
|
|
237
|
+
if not p:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
node = mon.nodes.get(p)
|
|
241
|
+
if isinstance(node, OwAttr) and node.is_complete():
|
|
242
|
+
await _activate(p, node)
|
|
243
|
+
else:
|
|
244
|
+
_deactivate(p)
|
|
245
|
+
|
|
246
|
+
tg.cancel_scope.cancel()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-attribute workers for the 1-Wire (OWFS) bus connector.
|
|
3
|
+
|
|
4
|
+
The read direction (device→MoaT-Link) is driven by asyncowfs' central
|
|
5
|
+
event stream and dispatched from :mod:`moat.link.ow.task`; this module
|
|
6
|
+
provides :func:`forward_read`, which publishes a polled value to a
|
|
7
|
+
MoaT-Link destination.
|
|
8
|
+
|
|
9
|
+
The write direction (MoaT-Link→device) is implemented by :func:`run_out`,
|
|
10
|
+
which watches a MoaT-Link source path and forwards changes onto the
|
|
11
|
+
1-Wire bus.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from moat.util import NotGiven, attrdict
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from moat.lib.path import Path
|
|
24
|
+
from moat.link.client import LinkSender
|
|
25
|
+
|
|
26
|
+
from .model import OwAttr
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def forward_read(link: LinkSender, entry: OwAttr, val: Any) -> None:
|
|
34
|
+
"""Publish a polled device value to the entry's MoaT-Link destination.
|
|
35
|
+
|
|
36
|
+
If :attr:`~moat.link.ow.model.OwAttr.dest_attr` is set, the value is
|
|
37
|
+
merged into the (possibly pre-existing) dict stored at ``dest``;
|
|
38
|
+
otherwise ``dest`` is replaced wholesale.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
link: Active MoaT-Link sender.
|
|
42
|
+
entry: Configuration entry for this attribute.
|
|
43
|
+
val: The value read from the 1-Wire device.
|
|
44
|
+
"""
|
|
45
|
+
dest = entry.dest
|
|
46
|
+
if dest is None:
|
|
47
|
+
return
|
|
48
|
+
dest_attr = entry.dest_attr
|
|
49
|
+
|
|
50
|
+
if dest_attr is not None:
|
|
51
|
+
try:
|
|
52
|
+
cur = await link.d_get(dest)
|
|
53
|
+
except KeyError:
|
|
54
|
+
cur = attrdict()
|
|
55
|
+
else:
|
|
56
|
+
if not isinstance(cur, dict):
|
|
57
|
+
cur = attrdict()
|
|
58
|
+
else:
|
|
59
|
+
cur = attrdict(cur)
|
|
60
|
+
new = cur._update(dest_attr, val) # noqa: SLF001
|
|
61
|
+
await link.d_set(dest, new)
|
|
62
|
+
else:
|
|
63
|
+
await link.d_set(dest, val)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def run_out(
|
|
67
|
+
link: LinkSender,
|
|
68
|
+
dev: Any,
|
|
69
|
+
entry: OwAttr,
|
|
70
|
+
attr_path: Path,
|
|
71
|
+
subpath: Path,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Forward MoaT-Link source updates onto a 1-Wire device attribute.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
link: Active MoaT-Link sender.
|
|
77
|
+
dev: Located asyncowfs :class:`~asyncowfs.device.Device`.
|
|
78
|
+
entry: Configuration entry for this attribute.
|
|
79
|
+
attr_path: The device-side attribute path to write to.
|
|
80
|
+
subpath: Path of this entry relative to the server (for logging).
|
|
81
|
+
"""
|
|
82
|
+
src = entry.src
|
|
83
|
+
if src is None:
|
|
84
|
+
return
|
|
85
|
+
src_attr = entry.src_attr
|
|
86
|
+
|
|
87
|
+
async with link.d_watch(src, mark=False, state=False) as wp:
|
|
88
|
+
async for val in wp:
|
|
89
|
+
if val is NotGiven:
|
|
90
|
+
continue
|
|
91
|
+
out = val
|
|
92
|
+
if src_attr is not None:
|
|
93
|
+
try:
|
|
94
|
+
for k in src_attr:
|
|
95
|
+
out = out[k]
|
|
96
|
+
except (KeyError, TypeError, IndexError):
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Attribute %r missing in source value at %s",
|
|
99
|
+
tuple(src_attr),
|
|
100
|
+
subpath,
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
await dev.set(*attr_path, value=out)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
logger.warning("Write to %s failed: %r", subpath, exc)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moat-link-ow
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 1-Wire (OWFS) 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: asyncowfs~=0.15
|
|
21
|
+
Requires-Dist: attrs
|
|
22
|
+
Requires-Dist: moat-util~=0.63.0
|
|
23
|
+
Requires-Dist: moat-link~=0.9.0
|
|
24
|
+
Requires-Dist: moat-lib-config~=0.2.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# 1-Wire (OWFS) connector
|
|
28
|
+
|
|
29
|
+
% start synopsis
|
|
30
|
+
Connects MoaT-Link to 1-Wire buses via the asyncowfs library.
|
|
31
|
+
% end synopsis
|
|
32
|
+
|
|
33
|
+
% start main
|
|
34
|
+
|
|
35
|
+
This module bridges MoaT-Link values and 1-Wire device attributes, in
|
|
36
|
+
both directions, via the ``asyncowfs`` library. It replaces the legacy
|
|
37
|
+
``moat-kv-ow`` package.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
1. Configure an OWFS server (owserver):
|
|
42
|
+
|
|
43
|
+
```shell
|
|
44
|
+
moat link ow mybus add -h 10.0.0.1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. Map a device attribute to a MoaT-Link path:
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
moat link ow mybus at 10.345678.90 temperature add -s dest .data.temp.room -s interval =5
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
3. Run the connector:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
moat link ow mybus monitor
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Deprecation
|
|
60
|
+
|
|
61
|
+
This package supersedes ``moat-kv-ow``. The address tree now lives in
|
|
62
|
+
MoaT-Link under the ``ow.<NAME>`` prefix; device attributes are stored
|
|
63
|
+
below the server entry, keyed by family code, hardware code, and the
|
|
64
|
+
attribute path.
|
|
65
|
+
|
|
66
|
+
% end main
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
Makefile
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
debian/.gitignore
|
|
6
|
+
debian/changelog
|
|
7
|
+
debian/control
|
|
8
|
+
debian/py3dist-overrides
|
|
9
|
+
debian/rules
|
|
10
|
+
src/moat/link/ow/__init__.py
|
|
11
|
+
src/moat/link/ow/_cfg.yaml
|
|
12
|
+
src/moat/link/ow/_main.py
|
|
13
|
+
src/moat/link/ow/model.py
|
|
14
|
+
src/moat/link/ow/task.py
|
|
15
|
+
src/moat/link/ow/worker.py
|
|
16
|
+
src/moat_link_ow.egg-info/PKG-INFO
|
|
17
|
+
src/moat_link_ow.egg-info/SOURCES.txt
|
|
18
|
+
src/moat_link_ow.egg-info/dependency_links.txt
|
|
19
|
+
src/moat_link_ow.egg-info/requires.txt
|
|
20
|
+
src/moat_link_ow.egg-info/top_level.txt
|
|
21
|
+
systemd/moat-link-ow@.service
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moat
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=MoaT-Link 1-Wire connector (%I)
|
|
3
|
+
After=moat-link-server.service
|
|
4
|
+
Requires=moat-link-server.service
|
|
5
|
+
|
|
6
|
+
ConditionFileNotEmpty=/etc/moat/moat.yaml
|
|
7
|
+
|
|
8
|
+
[Install]
|
|
9
|
+
WantedBy=multi-user.target
|
|
10
|
+
|
|
11
|
+
[Service]
|
|
12
|
+
Type=notify
|
|
13
|
+
ExecStart=/usr/bin/moat link ow %I monitor
|
|
14
|
+
|
|
15
|
+
EnvironmentFile=-/etc/moat/link.env
|
|
16
|
+
|
|
17
|
+
TimeoutSec=300
|
|
18
|
+
WatchdogSec=10
|
|
19
|
+
|
|
20
|
+
Restart=always
|
|
21
|
+
RestartSec=30
|