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.
- moat_link_wago-0.9.0/LICENSE.txt +21 -0
- moat_link_wago-0.9.0/Makefile +14 -0
- moat_link_wago-0.9.0/PKG-INFO +64 -0
- moat_link_wago-0.9.0/README.md +39 -0
- moat_link_wago-0.9.0/debian/changelog +11 -0
- moat_link_wago-0.9.0/debian/control +22 -0
- moat_link_wago-0.9.0/debian/py3dist-overrides +1 -0
- moat_link_wago-0.9.0/debian/rules +10 -0
- moat_link_wago-0.9.0/pyproject.toml +42 -0
- moat_link_wago-0.9.0/setup.cfg +4 -0
- moat_link_wago-0.9.0/src/moat/link/wago/__init__.py +9 -0
- moat_link_wago-0.9.0/src/moat/link/wago/_cfg.yaml +17 -0
- moat_link_wago-0.9.0/src/moat/link/wago/_main.py +338 -0
- moat_link_wago-0.9.0/src/moat/link/wago/model.py +189 -0
- moat_link_wago-0.9.0/src/moat/link/wago/task.py +153 -0
- moat_link_wago-0.9.0/src/moat/link/wago/worker.py +220 -0
- moat_link_wago-0.9.0/src/moat_link_wago.egg-info/PKG-INFO +64 -0
- moat_link_wago-0.9.0/src/moat_link_wago.egg-info/SOURCES.txt +20 -0
- moat_link_wago-0.9.0/src/moat_link_wago.egg-info/dependency_links.txt +1 -0
- moat_link_wago-0.9.0/src/moat_link_wago.egg-info/requires.txt +6 -0
- moat_link_wago-0.9.0/src/moat_link_wago.egg-info/top_level.txt +1 -0
- moat_link_wago-0.9.0/systemd/moat-link-wago@.service +21 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moat
|
|
@@ -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
|