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.
@@ -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,7 @@
1
+ /files
2
+ /*.log
3
+ /*.debhelper
4
+ /*.debhelper-build-stamp
5
+ /*.substvars
6
+ /debhelper-build-stamp
7
+ /moat-link-ow
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """1-Wire (OWFS) bus connector for MoaT-Link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from moat.lib.config import register as _register
6
+
7
+ __all__: list[str] = []
8
+
9
+ _register(__name__)
@@ -0,0 +1,5 @@
1
+ prefix: !P "1wire"
2
+
3
+ # There is no "server" entry, that's stored in the MoaT-Link node
4
+ server_default:
5
+ port: 4304
@@ -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,7 @@
1
+ anyio~=4.2
2
+ asyncclick~=8.3
3
+ asyncowfs~=0.15
4
+ attrs
5
+ moat-lib-config~=0.2.0
6
+ moat-link~=0.9.0
7
+ moat-util~=0.63.0
@@ -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