moat-link-wago 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Urlichs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/make -f
2
+
3
+ PACKAGE = moat-link-wago
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,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-wago
3
+ Version: 0.9.0
4
+ Summary: Wago 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: asyncwago~=0.31.6
21
+ Requires-Dist: moat-util~=0.63.0
22
+ Requires-Dist: moat-link~=0.9.0
23
+ Requires-Dist: moat-lib-config~=0.2.0
24
+ Dynamic: license-file
25
+
26
+ # Wago controller connector
27
+
28
+ % start synopsis
29
+ Connects MoaT-Link to Wago bus controllers via the asyncwago library.
30
+ % end synopsis
31
+
32
+ % start main
33
+
34
+ This module bridges MoaT-Link values and Wago controller ports, in both
35
+ directions, via the ``asyncwago`` library. It replaces the legacy
36
+ ``moat-kv-wago`` package.
37
+
38
+ ## Quick start
39
+
40
+ 1. Configure a Wago controller:
41
+
42
+ ```shell
43
+ moat link wago myctrl add -h 10.0.0.1
44
+ ```
45
+
46
+ 2. Map a port to a MoaT-Link path:
47
+
48
+ ```shell
49
+ moat link wago myctrl at input 1 3 add -m read -s dest .data.lamp.kitchen
50
+ ```
51
+
52
+ 3. Run the connector:
53
+
54
+ ```shell
55
+ moat link wago myctrl monitor
56
+ ```
57
+
58
+ ## Deprecation
59
+
60
+ This package supersedes ``moat-kv-wago``. The address tree now lives in
61
+ MoaT-Link under the ``wago.<NAME>`` prefix; ports are stored as
62
+ ``input`` or ``output`` type, then card number, then port number.
63
+
64
+ % end main
@@ -0,0 +1,39 @@
1
+ # Wago controller connector
2
+
3
+ % start synopsis
4
+ Connects MoaT-Link to Wago bus controllers via the asyncwago library.
5
+ % end synopsis
6
+
7
+ % start main
8
+
9
+ This module bridges MoaT-Link values and Wago controller ports, in both
10
+ directions, via the ``asyncwago`` library. It replaces the legacy
11
+ ``moat-kv-wago`` package.
12
+
13
+ ## Quick start
14
+
15
+ 1. Configure a Wago controller:
16
+
17
+ ```shell
18
+ moat link wago myctrl add -h 10.0.0.1
19
+ ```
20
+
21
+ 2. Map a port to a MoaT-Link path:
22
+
23
+ ```shell
24
+ moat link wago myctrl at input 1 3 add -m read -s dest .data.lamp.kitchen
25
+ ```
26
+
27
+ 3. Run the connector:
28
+
29
+ ```shell
30
+ moat link wago myctrl monitor
31
+ ```
32
+
33
+ ## Deprecation
34
+
35
+ This package supersedes ``moat-kv-wago``. The address tree now lives in
36
+ MoaT-Link under the ``wago.<NAME>`` prefix; ports are stored as
37
+ ``input`` or ``output`` type, then card number, then port number.
38
+
39
+ % end main
@@ -0,0 +1,11 @@
1
+ moat-link-wago (0.9.0-1) unstable; urgency=medium
2
+
3
+ * New release for 26.2.5
4
+
5
+ -- Matthias Urlichs <matthias@urlichs.de> Thu, 25 Jun 2026 16:10:54 +0200
6
+
7
+ moat-link-wago (0.1.0-1) unstable; urgency=medium
8
+
9
+ * Initial release.
10
+
11
+ -- Matthias Urlichs <matthias@urlichs.de> Wed, 25 Jun 2026 12:00:00 +0200
@@ -0,0 +1,22 @@
1
+ Source: moat-link-wago
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-wago
12
+ Architecture: all
13
+ Depends: ${misc:Depends}, ${python3:Depends},
14
+ moat-util,
15
+ moat-link,
16
+ Recommends:
17
+ python3-trio (>= 0.22),
18
+ Description: Wago controller connector for MoaT-Link
19
+ Bridges MoaT-Link values to and from Wago bus controllers via the
20
+ asyncwago library.
21
+ .
22
+ Part of the MoaT ecosystem for distributed home automation.
@@ -0,0 +1 @@
1
+ asyncwago python3-asyncwago
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/make -f
2
+
3
+ export PYBUILD_NAME=moat-link-wago
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-wago@.service \
10
+ debian/moat-link-wago/usr/lib/systemd/system/moat-link-wago@.service
@@ -0,0 +1,42 @@
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
+ "asyncwago ~= 0.31.6",
18
+ "moat-util ~= 0.63.0",
19
+ "moat-link ~= 0.9.0",
20
+ "moat-lib-config ~= 0.2.0",
21
+ ]
22
+ version = "0.9.0"
23
+ keywords = [ "MoaT",]
24
+ requires-python = ">=3.13"
25
+ name = "moat-link-wago"
26
+ description = "Wago controller connector for MoaT-Link"
27
+ readme = "README.md"
28
+
29
+ [[project.authors]]
30
+ name = "Matthias Urlichs"
31
+ email = "matthias@urlichs.de"
32
+
33
+ [project.urls]
34
+ homepage = "https://m-o-a-t.org"
35
+ repository = "https://github.com/M-o-a-T/moat"
36
+
37
+ [tool.setuptools]
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-data]
42
+ "*" = ["*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """Wago controller connector for MoaT-Link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from moat.lib.config import register as _register
6
+
7
+ __all__: list[str] = []
8
+
9
+ _register(__name__)
@@ -0,0 +1,17 @@
1
+ prefix: !P wago
2
+
3
+ # There is no "server" entry, that's stored in the MoaT-Link node
4
+ server_default:
5
+ port: 29995
6
+
7
+ # poll frequency: server
8
+ poll: 0.1
9
+
10
+ # ping frequency: server
11
+ ping: 5
12
+
13
+ # for counter reporting: port
14
+ interval: 1
15
+
16
+ # Pulses up=True/down=False/both=None, default Up: port
17
+ count: true
@@ -0,0 +1,338 @@
1
+ """
2
+ Command-line interface for moat.link.wago.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import sys
9
+
10
+ import asyncclick as click
11
+
12
+ from moat.util import NotGiven, yprint
13
+ from moat.lib.path import Path
14
+ from moat.lib.run import AliasedGroup, attr_args
15
+ from moat.link._data import data_get, node_attr
16
+ from moat.link.announce import as_service
17
+ from moat.link.client import Link
18
+
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Any
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _server_path(obj) -> Path:
28
+ """Return the link path of the currently-selected Wago server."""
29
+ return obj.wago_prefix + Path.build((obj.wago_name,))
30
+
31
+
32
+ async def _require_server(obj) -> dict[str, Any]:
33
+ """Fetch the data stored at the server entry, or raise.
34
+
35
+ Raises:
36
+ click.UsageError: if the entry is missing or not a mapping.
37
+ """
38
+ try:
39
+ data = await obj.conn.d_get(_server_path(obj))
40
+ except KeyError:
41
+ raise click.UsageError(f"Server {obj.wago_name!r} does not exist.") from None
42
+ if not isinstance(data, dict):
43
+ raise click.UsageError(f"Server {obj.wago_name!r} has no configuration.")
44
+ return data
45
+
46
+
47
+ @click.group(
48
+ cls=AliasedGroup,
49
+ name="wago",
50
+ short_help="Manage Wago controllers.",
51
+ invoke_without_command=True,
52
+ help="""\
53
+ Manager for Wago bus controllers.
54
+
55
+ \b
56
+ Use '… wago -' to list all entries.
57
+ Use '… wago NAME' to show details of a single entry.
58
+ """,
59
+ )
60
+ @click.argument("name", type=str, nargs=1)
61
+ @click.pass_context
62
+ async def cli(ctx, name: str) -> None:
63
+ """Dispatch to a server- or port-specific subcommand."""
64
+ obj = ctx.obj
65
+ cfg = obj.cfg["link"]
66
+ obj.conn = await ctx.with_async_resource(Link(cfg))
67
+ obj.wago_cfg = obj.cfg.link.wago
68
+ obj.wago_prefix = obj.wago_cfg.prefix
69
+ obj.wago_name = name
70
+
71
+ if name == "-":
72
+ if ctx.invoked_subcommand is not None:
73
+ raise click.BadParameter(
74
+ "The name '-' triggers a list and precludes subcommands.",
75
+ )
76
+ cnt = 0
77
+ async with obj.conn.d_walk(obj.wago_prefix, min_depth=1, max_depth=1) as mon:
78
+ async for p, _d in mon:
79
+ cnt += 1
80
+ print(p[-1], file=obj.stdout)
81
+ if not cnt and obj.debug:
82
+ print("no entries", file=sys.stderr)
83
+ return
84
+
85
+ if ctx.invoked_subcommand is None:
86
+ try:
87
+ data = await obj.conn.d_get(_server_path(obj))
88
+ except KeyError:
89
+ raise click.UsageError(
90
+ f"Server {obj.wago_name!r} does not exist.",
91
+ ) from None
92
+ srv = data.get("server", {}) if isinstance(data, dict) else {}
93
+ cnt = 0
94
+ if isinstance(srv, dict):
95
+ for k in ("host", "port"):
96
+ v = srv.get(k)
97
+ if v is not None:
98
+ cnt += 1
99
+ print(f"server {k} {v}", file=obj.stdout)
100
+ if not cnt and obj.debug:
101
+ print("exists, no data", file=sys.stderr)
102
+
103
+
104
+ @cli.command("--help", hidden=True)
105
+ @click.pass_context
106
+ def _cli_help(ctx) -> None:
107
+ """Workaround so ``cli NAME --help`` produces help text."""
108
+ print(cli.get_help(ctx))
109
+
110
+
111
+ def _server_options(proc):
112
+ """Decorate ``add``/``set`` with the server-config options."""
113
+ proc = click.option(
114
+ "-p",
115
+ "--port",
116
+ type=int,
117
+ default=None,
118
+ help="Port of the Wago controller (default 29995).",
119
+ )(proc)
120
+ proc = click.option(
121
+ "-h",
122
+ "--host",
123
+ type=str,
124
+ default=None,
125
+ help="Host name or IP of the Wago controller.",
126
+ )(proc)
127
+ return proc
128
+
129
+
130
+ @cli.command(short_help="Add a Wago controller")
131
+ @_server_options
132
+ @click.option("-f", "--force", is_flag=True, help="Allow replacing an existing controller.")
133
+ @click.pass_obj
134
+ async def add(obj, host, port, force) -> None:
135
+ """Add a Wago controller."""
136
+ path = _server_path(obj)
137
+ if not force:
138
+ try:
139
+ await obj.conn.d_get(path)
140
+ except KeyError:
141
+ pass
142
+ else:
143
+ raise click.UsageError(
144
+ f"Controller {obj.wago_name!r} already exists. Use --force or 'set'.",
145
+ )
146
+
147
+ srv: dict[str, Any] = {}
148
+ if host is not None:
149
+ srv["host"] = host
150
+ if port is not None:
151
+ srv["port"] = port
152
+ await obj.conn.d_set(path, {"server": srv})
153
+
154
+
155
+ @cli.command("set", short_help="Modify a Wago controller")
156
+ @_server_options
157
+ @click.pass_obj
158
+ async def set_(obj, host, port) -> None:
159
+ """Modify a Wago controller.
160
+
161
+ Pass ``-`` as a value (where applicable) to clear an existing setting.
162
+ """
163
+ data = await _require_server(obj)
164
+ srv = data.get("server", {})
165
+ if not isinstance(srv, dict):
166
+ srv = {}
167
+
168
+ if host is not None:
169
+ if host == "-":
170
+ srv.pop("host", None)
171
+ else:
172
+ srv["host"] = host
173
+ if port is not None:
174
+ srv["port"] = port
175
+ data["server"] = srv
176
+
177
+ await obj.conn.d_set(_server_path(obj), data)
178
+
179
+
180
+ @cli.command("delete", short_help="Delete a Wago controller")
181
+ @click.option("-r", "--recursive", is_flag=True, help="Also remove all entries below.")
182
+ @click.pass_obj
183
+ async def delete_(obj, recursive: bool) -> None:
184
+ """Delete a Wago controller.
185
+
186
+ Without ``--recursive`` the controller entry itself is removed but its
187
+ child port entries are kept (they become orphans).
188
+ """
189
+ path = _server_path(obj)
190
+ args: dict[str, Any] = {}
191
+ if recursive:
192
+ args["rec"] = True
193
+ res = await obj.conn.d.delete(path, **args)
194
+ if getattr(obj, "meta", False):
195
+ yprint(res[0], stream=obj.stdout)
196
+
197
+
198
+ @cli.command("dump")
199
+ @click.option("-l", "--one-line", is_flag=True, help="Single line per entry")
200
+ @click.pass_obj
201
+ async def dump_(obj, one_line: bool) -> None:
202
+ """Emit a controller's (sub)state as a list / YAML file."""
203
+ path = _server_path(obj)
204
+ if not one_line:
205
+ await data_get(obj.conn, path, recursive=True, out=obj.stdout)
206
+ return
207
+ async with obj.conn.d_walk(path) as mon:
208
+ async for p, d in mon:
209
+ print(f"{path + p} {d}", file=obj.stdout)
210
+
211
+
212
+ @cli.group(
213
+ "at",
214
+ invoke_without_command=True,
215
+ short_help="Create/show/delete a port entry.",
216
+ )
217
+ @click.argument("type_", type=str, nargs=1)
218
+ @click.argument("card", type=int, nargs=1)
219
+ @click.argument("port", type=int, nargs=1)
220
+ @click.pass_context
221
+ async def at_cli(ctx, type_: str, card: int, port: int) -> None:
222
+ """Manage a single port entry under a controller.
223
+
224
+ TYPE is ``input`` or ``output``. CARD and PORT are integers.
225
+ """
226
+ obj = ctx.obj
227
+ if type_ not in ("input", "output"):
228
+ raise click.UsageError("TYPE must be 'input' or 'output'.")
229
+ try:
230
+ await obj.conn.d_get(_server_path(obj))
231
+ except KeyError:
232
+ raise click.UsageError(
233
+ "Create the controller before assigning ports to it!",
234
+ ) from None
235
+ obj.wago_subpath = Path.build((type_, card, port))
236
+ if ctx.invoked_subcommand is None:
237
+ await data_get(
238
+ obj.conn,
239
+ _server_path(obj) + obj.wago_subpath,
240
+ recursive=False,
241
+ out=obj.stdout,
242
+ )
243
+
244
+
245
+ @at_cli.command("--help", hidden=True)
246
+ @click.pass_context
247
+ def _at_help(ctx) -> None:
248
+ """Workaround so ``at TYPE CARD PORT --help`` produces help text."""
249
+ print(at_cli.get_help(ctx))
250
+
251
+
252
+ @at_cli.command("dump")
253
+ @click.option("-l", "--one-line", is_flag=True, help="Single line per entry")
254
+ @click.pass_obj
255
+ async def dump_at(obj, one_line: bool) -> None:
256
+ """Emit a subtree as a list / YAML file."""
257
+ path = _server_path(obj) + obj.wago_subpath
258
+ if not one_line:
259
+ await data_get(obj.conn, path, recursive=True, out=obj.stdout)
260
+ return
261
+ async with obj.conn.d_walk(path) as mon:
262
+ async for p, d in mon:
263
+ print(f"{path + p} {d}", file=obj.stdout)
264
+
265
+
266
+ @at_cli.command("add", short_help="Add a port entry")
267
+ @attr_args
268
+ @click.option("-f", "--force", is_flag=True, help="Allow replacing an existing entry.")
269
+ @click.option(
270
+ "-m",
271
+ "--mode",
272
+ required=True,
273
+ help='Port mode: "read", "count", "write", "oneshot", or "pulse".',
274
+ )
275
+ @click.pass_obj
276
+ async def add_at(obj, mode, force, **kw) -> None:
277
+ """Add a port mapping.
278
+
279
+ \b
280
+ TYPE CARD PORT: identify the physical port. Required.
281
+
282
+ Use the ``-s`` option to set attributes like ``dest`` or ``src``,
283
+ e.g. ``-s dest .my.path``.
284
+ """
285
+ sub: Path = obj.wago_subpath
286
+ path = _server_path(obj) + sub
287
+
288
+ if not force:
289
+ try:
290
+ await obj.conn.d_get(path)
291
+ except KeyError:
292
+ pass
293
+ else:
294
+ raise click.UsageError("This entry already exists. Use '--force' or 'set'.")
295
+
296
+ val: dict[str | None, Any] = {"mode": mode}
297
+ res, _meta = await node_attr(obj, path, val=val, **kw)
298
+
299
+ if getattr(obj, "meta", False):
300
+ yprint(res, stream=obj.stdout)
301
+
302
+
303
+ @at_cli.command("delete")
304
+ @click.pass_obj
305
+ async def delete_at(obj) -> None:
306
+ """Remove a port mapping from the controller."""
307
+ path = _server_path(obj) + obj.wago_subpath
308
+ try:
309
+ await obj.conn.d_get(path)
310
+ except KeyError:
311
+ raise click.UsageError("This entry doesn't exist.") from None
312
+ await obj.conn.d_set(path, NotGiven)
313
+
314
+
315
+ @at_cli.command("set")
316
+ @attr_args
317
+ @click.pass_obj
318
+ async def set_at(obj, **kw) -> None:
319
+ """Modify a port entry."""
320
+ path = _server_path(obj) + obj.wago_subpath
321
+ res, _meta = await node_attr(obj, path, **kw)
322
+ if getattr(obj, "meta", False):
323
+ yprint(res, stream=obj.stdout)
324
+
325
+
326
+ @cli.command()
327
+ @click.pass_obj
328
+ async def monitor(obj) -> None:
329
+ """Stand-alone task to talk to a single Wago controller."""
330
+ from .task import task # noqa: PLC0415
331
+
332
+ async with as_service(obj) as srv:
333
+ await task(
334
+ obj.conn,
335
+ obj.wago_cfg,
336
+ obj.wago_name,
337
+ task_status=srv,
338
+ )
@@ -0,0 +1,189 @@
1
+ """
2
+ Node model for the Wago controller 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 ``input`` and ``output`` types, then card numbers, then port
7
+ numbers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ from attrs import define
15
+
16
+ from moat.util import NotGiven
17
+ from moat.lib.path import Path
18
+ from moat.link.node import Node
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from moat.lib.rpc import Key
24
+
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @define
31
+ class WagoPort(Node):
32
+ """A single Wago input or output port.
33
+
34
+ The stored configuration is a dict with these keys:
35
+
36
+ * ``mode``: ``"read"``, ``"count"``, ``"write"``, ``"oneshot"``,
37
+ or ``"pulse"``.
38
+ * ``dest``: destination path (for ``mode=read|count``).
39
+ * ``src``: source path (for ``mode=write|oneshot|pulse``).
40
+ * ``state``: optional state path (for output modes).
41
+ * ``rest``: rest-state flag (default ``False``).
42
+ * ``interval``: polling interval in seconds (for ``mode=count``).
43
+ * ``count``: pulse direction (``True``/``False``/``None``).
44
+ * ``t_on``: on-time in seconds (for ``mode=oneshot|pulse``).
45
+ * ``t_off``: off-time in seconds (for ``mode=pulse``).
46
+ """
47
+
48
+ @property
49
+ def mode(self) -> str | None:
50
+ """Port mode, or ``None`` if unset."""
51
+ d = self.data_
52
+ return d.get("mode") if isinstance(d, dict) else None
53
+
54
+ @property
55
+ def dest(self) -> Path | None:
56
+ """Destination path (input modes), or ``None``."""
57
+ d = self.data_
58
+ s = d.get("dest") if isinstance(d, dict) else None
59
+ return s if s is None else Path.build(s)
60
+
61
+ @property
62
+ def src(self) -> Path | None:
63
+ """Source path (output modes), or ``None``."""
64
+ d = self.data_
65
+ s = d.get("src") if isinstance(d, dict) else None
66
+ return s if s is None else Path.build(s)
67
+
68
+ @property
69
+ def state(self) -> Path | None:
70
+ """Optional state path (output modes), or ``None``."""
71
+ d = self.data_
72
+ s = d.get("state") if isinstance(d, dict) else None
73
+ return s if s is None else Path.build(s)
74
+
75
+ @property
76
+ def rest(self) -> bool:
77
+ """Rest-state flag (default ``False``)."""
78
+ d = self.data_ if self.data_ is not NotGiven else {}
79
+ return bool(d.get("rest", False)) if isinstance(d, dict) else False
80
+
81
+ @property
82
+ def interval(self) -> float | None:
83
+ """Polling interval (for ``mode=count``), or ``None``."""
84
+ d = self.data_
85
+ return d.get("interval") if isinstance(d, dict) else None
86
+
87
+ @property
88
+ def count(self) -> bool | None:
89
+ """Pulse direction (``True``/``False``/``None``)."""
90
+ d = self.data_
91
+ v = d.get("count") if isinstance(d, dict) else None
92
+ return v if v is None else bool(v)
93
+
94
+ @property
95
+ def t_on(self) -> float | None:
96
+ """On-time in seconds (for ``mode=oneshot|pulse``), or ``None``."""
97
+ d = self.data_
98
+ return d.get("t_on") if isinstance(d, dict) else None
99
+
100
+ @property
101
+ def t_off(self) -> float | None:
102
+ """Off-time in seconds (for ``mode=pulse``), or ``None``."""
103
+ d = self.data_
104
+ return d.get("t_off") if isinstance(d, dict) else None
105
+
106
+ @property
107
+ def card(self) -> int:
108
+ """Card number (parent key)."""
109
+ # The parent of a port is a card; we walk up the tree.
110
+ # This is typically known from the path context.
111
+ return 0
112
+
113
+ @property
114
+ def port(self) -> int:
115
+ """Port number (this node's key)."""
116
+ return 0
117
+
118
+ def is_complete(self) -> bool:
119
+ """Whether this entry has a complete configuration."""
120
+ m = self.mode
121
+ if m in ("read", "count"):
122
+ return self.dest is not None
123
+ if m in ("write", "oneshot", "pulse"):
124
+ return self.src is not None
125
+ return False
126
+
127
+
128
+ @define
129
+ class WagoCard(Node):
130
+ """Intermediate card-number node; children are :class:`WagoPort`."""
131
+
132
+ def add_child(self, item: Key) -> WagoPort:
133
+ """Create child entries as :class:`WagoPort`."""
134
+ if item in self._sub:
135
+ raise ValueError("exists")
136
+ self._sub[item] = s = WagoPort()
137
+ return s
138
+
139
+
140
+ @define
141
+ class WagoType(Node):
142
+ """Intermediate ``input`` or ``output`` node; children are :class:`WagoCard`."""
143
+
144
+ def add_child(self, item: Key) -> WagoCard:
145
+ """Create child entries as :class:`WagoCard`."""
146
+ if item in self._sub:
147
+ raise ValueError("exists")
148
+ self._sub[item] = s = WagoCard()
149
+ return s
150
+
151
+
152
+ @define
153
+ class WagoServer(Node):
154
+ """One Wago server (controller) in the configuration tree.
155
+
156
+ Children at the ``input`` / ``output`` levels branch out into
157
+ card/port levels and finally into :class:`WagoPort` leaves.
158
+ """
159
+
160
+ def add_child(self, item: Key) -> WagoType:
161
+ """Create child entries as :class:`WagoType`."""
162
+ if item in self._sub:
163
+ raise ValueError("exists")
164
+ self._sub[item] = s = WagoType()
165
+ return s
166
+
167
+ @property
168
+ def cfg(self) -> dict[str, Any]:
169
+ """Server-level config dict (``host``/``port``)."""
170
+ d = self.data_
171
+ if d is NotGiven or not isinstance(d, dict):
172
+ return {}
173
+ s = d.get("server", {})
174
+ return s if isinstance(s, dict) else {}
175
+
176
+
177
+ @define
178
+ class WagoRoot(Node):
179
+ """Root of the Wago configuration tree.
180
+
181
+ Children are :class:`WagoServer` nodes.
182
+ """
183
+
184
+ def add_child(self, item: Key) -> WagoServer:
185
+ """Create child servers as :class:`WagoServer`."""
186
+ if item in self._sub:
187
+ raise ValueError("exists")
188
+ self._sub[item] = s = WagoServer()
189
+ return s
@@ -0,0 +1,153 @@
1
+ """
2
+ Main supervisor task for the Wago connector.
3
+
4
+ Connects to a single Wago controller and monitors the MoaT-Link
5
+ configuration subtree for one server. Spawns / cancels per-port
6
+ workers as entries appear or change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import anyio
12
+ import logging
13
+
14
+ import asyncwago as wago
15
+
16
+ from moat.util import combine_dict
17
+ from moat.lib.path import Path
18
+ from moat.link.meta import MsgMeta
19
+
20
+ from .model import WagoPort, WagoRoot, WagoServer
21
+ from .worker import run_in, run_out
22
+
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from anyio.abc import TaskStatus
27
+
28
+ from moat.link.client import LinkSender
29
+
30
+ from collections.abc import Mapping
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ async def task(
36
+ link: LinkSender,
37
+ cfg: Mapping,
38
+ server_name: str,
39
+ *,
40
+ task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
41
+ ) -> None:
42
+ """Run the Wago connector for one controller.
43
+
44
+ Args:
45
+ link: an active MoaT-Link sender.
46
+ cfg: the ``link.wago`` configuration section.
47
+ server_name: the server entry name inside the config subtree.
48
+ task_status: task-status for ``tg.start``.
49
+ """
50
+ prefix = Path.build(cfg["prefix"])
51
+ server_path = prefix / server_name
52
+
53
+ server_data = await link.d_get(server_path)
54
+ if not isinstance(server_data, dict):
55
+ server_data = {}
56
+ srv_cfg = combine_dict(
57
+ server_data.get("server", {}),
58
+ cfg.get("server_default", {}),
59
+ )
60
+
61
+ try:
62
+ async with wago.open_server(**srv_cfg) as srv:
63
+ r = await srv.describe()
64
+
65
+ # Build a local node tree and mark discovered ports present.
66
+ root = WagoRoot()
67
+ for type_name, cards in r.items():
68
+ srv_node = root.add_child(server_name)
69
+ type_node = srv_node.add_child(type_name)
70
+ for card_num, port_count in cards.items():
71
+ card_node = type_node.add_child(card_num)
72
+ for port_num in range(1, port_count + 1):
73
+ port_node = card_node.add_child(port_num)
74
+ meta = MsgMeta(origin="discover", t=anyio.current_time())
75
+ port_node.set_(Path(), {"present": True}, meta)
76
+
77
+ # Update server-level settings.
78
+ poll = cfg.get("poll")
79
+ if poll is not None:
80
+ await srv.set_freq(poll)
81
+ ping = cfg.get("ping")
82
+ if ping is not None:
83
+ await srv.set_ping_freq(ping)
84
+
85
+ async with anyio.create_task_group() as tg:
86
+ workers: dict[Path, anyio.CancelScope] = {}
87
+
88
+ def _cancel(p: Path) -> None:
89
+ sc = workers.pop(p, None)
90
+ if sc is not None:
91
+ sc.cancel()
92
+
93
+ async def _start(p: Path, entry: WagoPort) -> None:
94
+ _cancel(p)
95
+ if not entry.is_complete():
96
+ logger.warning("Incomplete entry at %s, skipping", p)
97
+ return
98
+ if len(p) != 3:
99
+ logger.warning("Entry %s is not at a 3-element port path", p)
100
+ return
101
+ card, port_num = int(p[1]), int(p[2])
102
+ type_name = str(p[0])
103
+
104
+ runner = run_in if type_name == "input" else run_out
105
+
106
+ async def _run(
107
+ *,
108
+ task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
109
+ ) -> None:
110
+ with anyio.CancelScope() as sc:
111
+ workers[p] = sc
112
+ task_status.started()
113
+ try:
114
+ await runner(link, srv, entry, card, port_num, p)
115
+ except Exception:
116
+ logger.exception("Worker for %s failed", p)
117
+ finally:
118
+ if workers.get(p) is sc:
119
+ del workers[p]
120
+
121
+ await tg.start(_run)
122
+
123
+ async with link.d_watch(
124
+ server_path,
125
+ subtree=True,
126
+ mark=True,
127
+ cls=WagoServer,
128
+ ) as mon:
129
+ task_status.started()
130
+
131
+ async for msg in mon:
132
+ if msg is None:
133
+ continue
134
+ p, _data = msg
135
+ if not p:
136
+ continue
137
+
138
+ node = mon.nodes.get(p)
139
+ if isinstance(node, WagoPort):
140
+ if node.is_complete():
141
+ await _start(p, node)
142
+ else:
143
+ _cancel(p)
144
+ else:
145
+ _cancel(p)
146
+
147
+ tg.cancel_scope.cancel()
148
+ except TimeoutError:
149
+ raise
150
+ except OSError as exc:
151
+ raise RuntimeError(
152
+ f"Cannot connect to {srv_cfg.get('host')}:{srv_cfg.get('port')}"
153
+ ) from exc
@@ -0,0 +1,220 @@
1
+ """
2
+ Per-port workers for the Wago controller connector.
3
+
4
+ Each worker drives a single :class:`~moat.link.wago.model.WagoPort`.
5
+ Input workers monitor the Wago controller and publish to MoaT-Link;
6
+ output workers watch a MoaT-Link value and write to the controller.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import anyio
12
+ import logging
13
+
14
+ from moat.util import NotGiven
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from moat.lib.path import Path
20
+ from moat.link.client import LinkSender
21
+
22
+ from .model import WagoPort
23
+
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ async def run_in(
30
+ link: LinkSender,
31
+ srv: Any,
32
+ entry: WagoPort,
33
+ card: int,
34
+ port: int,
35
+ subpath: Path,
36
+ ) -> None:
37
+ """Forward Wago input changes to a MoaT-Link destination.
38
+
39
+ Args:
40
+ link: Active MoaT-Link sender.
41
+ srv: Connected Wago server.
42
+ entry: Configuration entry for this port.
43
+ card: Card number.
44
+ port: Port number.
45
+ subpath: Path of this entry relative to the server (for naming).
46
+ """
47
+ mode = entry.mode
48
+ dest = entry.dest
49
+ if mode is None or dest is None:
50
+ return
51
+
52
+ rest = entry.rest
53
+
54
+ if mode == "read":
55
+ async with srv.monitor_input(card, port) as mon:
56
+ async for val in mon:
57
+ await link.d_set(dest, val != rest)
58
+ elif mode == "count":
59
+ intv = entry.interval
60
+ direc = entry.count
61
+ delta = 0
62
+ try:
63
+ old = await link.d_get(dest)
64
+ if isinstance(old, (int, float)):
65
+ delta = old
66
+ except KeyError:
67
+ pass
68
+ async with srv.count_input(card, port, direction=direc, interval=intv) as mon:
69
+ async for val in mon:
70
+ await link.d_set(dest, val + delta)
71
+ else:
72
+ logger.warning("Unknown input mode %r at %s", mode, subpath)
73
+
74
+
75
+ async def run_out(
76
+ link: LinkSender,
77
+ srv: Any,
78
+ entry: WagoPort,
79
+ card: int,
80
+ port: int,
81
+ subpath: Path,
82
+ ) -> None:
83
+ """Forward MoaT-Link source updates to a Wago output.
84
+
85
+ Args:
86
+ link: Active MoaT-Link sender.
87
+ srv: Connected Wago server.
88
+ entry: Configuration entry for this port.
89
+ card: Card number.
90
+ port: Port number.
91
+ subpath: Path of this entry relative to the server (for naming).
92
+ """
93
+ mode = entry.mode
94
+ src = entry.src
95
+ if mode is None or src is None:
96
+ return
97
+
98
+ rest = entry.rest
99
+ state = entry.state
100
+
101
+ if mode == "write":
102
+ async with link.d_watch(src, mark=False, state=False) as wp:
103
+ async for raw in wp:
104
+ if raw is NotGiven:
105
+ continue
106
+ val = bool(raw)
107
+ await srv.write_output(card, port, val != rest)
108
+ if state is not None:
109
+ await link.d_set(state, val)
110
+ elif mode == "oneshot":
111
+ t_on = entry.t_on
112
+ _work: anyio.CancelScope | None = None
113
+ _work_done: anyio.Event | None = None
114
+
115
+ async def _cancel_work() -> None:
116
+ nonlocal _work, _work_done
117
+ if _work is not None:
118
+ _work.cancel()
119
+ if _work_done is not None:
120
+ await _work_done.wait()
121
+ _work = None
122
+ _work_done = None
123
+
124
+ async def _do_oneshot(val: bool) -> None:
125
+ nonlocal _work, _work_done
126
+ if val:
127
+ await _cancel_work()
128
+ done_evt = anyio.Event()
129
+ _work_done = done_evt
130
+ with anyio.CancelScope() as sc:
131
+ _work = sc
132
+ async with srv.write_timed_output(card, port, not rest, t_on) as work:
133
+ if state is not None:
134
+ await link.d_set(state, True)
135
+ await work.wait()
136
+ if _work is sc:
137
+ _work = None
138
+ if _work_done is done_evt:
139
+ _work_done = None
140
+ done_evt.set()
141
+ with anyio.fail_after(2, shield=True):
142
+ if state is not None:
143
+ try:
144
+ v = await srv.read_output(card, port)
145
+ except anyio.ClosedResourceError:
146
+ pass
147
+ else:
148
+ await link.d_set(state, v != rest)
149
+ else:
150
+ await _cancel_work()
151
+ await srv.write_output(card, port, rest)
152
+ if state is not None:
153
+ await link.d_set(state, False)
154
+
155
+ async with link.d_watch(src, mark=False, state=False) as wp:
156
+ async for raw in wp:
157
+ if raw is NotGiven:
158
+ continue
159
+ await _do_oneshot(bool(raw))
160
+
161
+ elif mode == "pulse":
162
+ t_on = entry.t_on
163
+ t_off = entry.t_off
164
+ _work: anyio.CancelScope | None = None
165
+ _work_done: anyio.Event | None = None
166
+
167
+ async def _cancel_pulse() -> None:
168
+ nonlocal _work, _work_done
169
+ if _work is not None:
170
+ _work.cancel()
171
+ if _work_done is not None:
172
+ await _work_done.wait()
173
+ _work = None
174
+ _work_done = None
175
+
176
+ async def _do_pulse(val: bool) -> None:
177
+ nonlocal _work, _work_done
178
+ if val:
179
+ await _cancel_pulse()
180
+ done_evt = anyio.Event()
181
+ _work_done = done_evt
182
+ try:
183
+ with anyio.CancelScope() as sc:
184
+ _work = sc
185
+ if t_on is None or t_off is None:
186
+ raise RuntimeError("t_on or t_off is None")
187
+ async with srv.write_pulsed_output(
188
+ card, port, not rest, t_on, t_off
189
+ ) as work:
190
+ if state is not None:
191
+ await link.d_set(state, t_on / (t_on + t_off))
192
+ await work.wait()
193
+ finally:
194
+ if _work is sc:
195
+ _work = None
196
+ if _work_done is done_evt:
197
+ _work_done = None
198
+ done_evt.set()
199
+ with anyio.fail_after(2, shield=True):
200
+ if state is not None:
201
+ try:
202
+ v = await srv.read_output(card, port)
203
+ except anyio.ClosedResourceError:
204
+ pass
205
+ else:
206
+ await link.d_set(state, v != rest)
207
+ else:
208
+ await _cancel_pulse()
209
+ await srv.write_output(card, port, rest)
210
+ if state is not None:
211
+ await link.d_set(state, False)
212
+
213
+ async with link.d_watch(src, mark=False, state=False) as wp:
214
+ async for raw in wp:
215
+ if raw is NotGiven:
216
+ continue
217
+ await _do_pulse(bool(raw))
218
+
219
+ else:
220
+ logger.warning("Unknown output mode %r at %s", mode, subpath)
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-link-wago
3
+ Version: 0.9.0
4
+ Summary: Wago 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: asyncwago~=0.31.6
21
+ Requires-Dist: moat-util~=0.63.0
22
+ Requires-Dist: moat-link~=0.9.0
23
+ Requires-Dist: moat-lib-config~=0.2.0
24
+ Dynamic: license-file
25
+
26
+ # Wago controller connector
27
+
28
+ % start synopsis
29
+ Connects MoaT-Link to Wago bus controllers via the asyncwago library.
30
+ % end synopsis
31
+
32
+ % start main
33
+
34
+ This module bridges MoaT-Link values and Wago controller ports, in both
35
+ directions, via the ``asyncwago`` library. It replaces the legacy
36
+ ``moat-kv-wago`` package.
37
+
38
+ ## Quick start
39
+
40
+ 1. Configure a Wago controller:
41
+
42
+ ```shell
43
+ moat link wago myctrl add -h 10.0.0.1
44
+ ```
45
+
46
+ 2. Map a port to a MoaT-Link path:
47
+
48
+ ```shell
49
+ moat link wago myctrl at input 1 3 add -m read -s dest .data.lamp.kitchen
50
+ ```
51
+
52
+ 3. Run the connector:
53
+
54
+ ```shell
55
+ moat link wago myctrl monitor
56
+ ```
57
+
58
+ ## Deprecation
59
+
60
+ This package supersedes ``moat-kv-wago``. The address tree now lives in
61
+ MoaT-Link under the ``wago.<NAME>`` prefix; ports are stored as
62
+ ``input`` or ``output`` type, then card number, then port number.
63
+
64
+ % end main
@@ -0,0 +1,20 @@
1
+ LICENSE.txt
2
+ Makefile
3
+ README.md
4
+ pyproject.toml
5
+ debian/changelog
6
+ debian/control
7
+ debian/py3dist-overrides
8
+ debian/rules
9
+ src/moat/link/wago/__init__.py
10
+ src/moat/link/wago/_cfg.yaml
11
+ src/moat/link/wago/_main.py
12
+ src/moat/link/wago/model.py
13
+ src/moat/link/wago/task.py
14
+ src/moat/link/wago/worker.py
15
+ src/moat_link_wago.egg-info/PKG-INFO
16
+ src/moat_link_wago.egg-info/SOURCES.txt
17
+ src/moat_link_wago.egg-info/dependency_links.txt
18
+ src/moat_link_wago.egg-info/requires.txt
19
+ src/moat_link_wago.egg-info/top_level.txt
20
+ systemd/moat-link-wago@.service
@@ -0,0 +1,6 @@
1
+ anyio~=4.2
2
+ asyncclick~=8.3
3
+ asyncwago~=0.31.6
4
+ moat-lib-config~=0.2.0
5
+ moat-link~=0.9.0
6
+ moat-util~=0.63.0
@@ -0,0 +1,21 @@
1
+ [Unit]
2
+ Description=MoaT-Link Wago 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 wago %I monitor
14
+
15
+ EnvironmentFile=-/etc/moat/link.env
16
+
17
+ TimeoutSec=300
18
+ WatchdogSec=10
19
+
20
+ Restart=always
21
+ RestartSec=30