moat-link-knx 0.20.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_knx-0.20.0/LICENSE.txt +3 -0
- moat_link_knx-0.20.0/Makefile +14 -0
- moat_link_knx-0.20.0/PKG-INFO +65 -0
- moat_link_knx-0.20.0/README.md +39 -0
- moat_link_knx-0.20.0/debian/changelog +11 -0
- moat_link_knx-0.20.0/debian/control +24 -0
- moat_link_knx-0.20.0/debian/rules +10 -0
- moat_link_knx-0.20.0/pyproject.toml +43 -0
- moat_link_knx-0.20.0/setup.cfg +4 -0
- moat_link_knx-0.20.0/src/moat/link/knx/__init__.py +9 -0
- moat_link_knx-0.20.0/src/moat/link/knx/_cfg.yaml +5 -0
- moat_link_knx-0.20.0/src/moat/link/knx/_main.py +373 -0
- moat_link_knx-0.20.0/src/moat/link/knx/model.py +192 -0
- moat_link_knx-0.20.0/src/moat/link/knx/task.py +141 -0
- moat_link_knx-0.20.0/src/moat/link/knx/worker.py +190 -0
- moat_link_knx-0.20.0/src/moat_link_knx.egg-info/PKG-INFO +65 -0
- moat_link_knx-0.20.0/src/moat_link_knx.egg-info/SOURCES.txt +19 -0
- moat_link_knx-0.20.0/src/moat_link_knx.egg-info/dependency_links.txt +1 -0
- moat_link_knx-0.20.0/src/moat_link_knx.egg-info/requires.txt +7 -0
- moat_link_knx-0.20.0/src/moat_link_knx.egg-info/top_level.txt +1 -0
- moat_link_knx-0.20.0/systemd/moat-link-knx@.service +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/make -f
|
|
2
|
+
|
|
3
|
+
PACKAGE = moat-link-knx
|
|
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,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moat-link-knx
|
|
3
|
+
Version: 0.20.0
|
|
4
|
+
Summary: KNX bus connector for MoaT-Link
|
|
5
|
+
Author-email: Matthias Urlichs <matthias@urlichs.de>
|
|
6
|
+
Project-URL: homepage, https://m-o-a-t.org
|
|
7
|
+
Project-URL: repository, https://github.com/M-o-a-T/moat
|
|
8
|
+
Keywords: MoaT
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Framework :: Trio
|
|
13
|
+
Classifier: Framework :: AnyIO
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE.txt
|
|
18
|
+
Requires-Dist: anyio~=4.2
|
|
19
|
+
Requires-Dist: asyncclick~=8.3
|
|
20
|
+
Requires-Dist: attrs
|
|
21
|
+
Requires-Dist: moat-util~=0.63.0
|
|
22
|
+
Requires-Dist: moat-link~=0.8.0
|
|
23
|
+
Requires-Dist: moat-lib-config~=0.2.0
|
|
24
|
+
Requires-Dist: moat-lib-xknx~=3.15.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# KNX bus connector
|
|
28
|
+
|
|
29
|
+
% start synopsis
|
|
30
|
+
Connects MoaT-Link to a KNX bus via XKNX/KNXnet/IP tunneling.
|
|
31
|
+
% end synopsis
|
|
32
|
+
|
|
33
|
+
% start main
|
|
34
|
+
|
|
35
|
+
This module bridges MoaT-Link values and KNX group addresses, in both
|
|
36
|
+
directions, via the XKNX library. It replaces the legacy
|
|
37
|
+
``moat-kv-knx`` package.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
1. Configure a KNX gateway:
|
|
42
|
+
|
|
43
|
+
```shell
|
|
44
|
+
moat link knx myhouse add -h 10.0.0.1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. Map a group address to a MoaT-Link path:
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
moat link knx myhouse at 1/2/3 add -t in -m Bool -s dest .data.lamp.kitchen
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
3. Run the connector:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
moat link knx myhouse monitor
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Deprecation
|
|
60
|
+
|
|
61
|
+
This package supersedes ``moat-kv-knx``. The address tree now lives in
|
|
62
|
+
MoaT-Link under the ``knx.<NAME>`` prefix; group addresses are stored as
|
|
63
|
+
the three integer components of an ``a/b/c`` address.
|
|
64
|
+
|
|
65
|
+
% end main
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# KNX bus connector
|
|
2
|
+
|
|
3
|
+
% start synopsis
|
|
4
|
+
Connects MoaT-Link to a KNX bus via XKNX/KNXnet/IP tunneling.
|
|
5
|
+
% end synopsis
|
|
6
|
+
|
|
7
|
+
% start main
|
|
8
|
+
|
|
9
|
+
This module bridges MoaT-Link values and KNX group addresses, in both
|
|
10
|
+
directions, via the XKNX library. It replaces the legacy
|
|
11
|
+
``moat-kv-knx`` package.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
1. Configure a KNX gateway:
|
|
16
|
+
|
|
17
|
+
```shell
|
|
18
|
+
moat link knx myhouse add -h 10.0.0.1
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. Map a group address to a MoaT-Link path:
|
|
22
|
+
|
|
23
|
+
```shell
|
|
24
|
+
moat link knx myhouse at 1/2/3 add -t in -m Bool -s dest .data.lamp.kitchen
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. Run the connector:
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
moat link knx myhouse monitor
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Deprecation
|
|
34
|
+
|
|
35
|
+
This package supersedes ``moat-kv-knx``. The address tree now lives in
|
|
36
|
+
MoaT-Link under the ``knx.<NAME>`` prefix; group addresses are stored as
|
|
37
|
+
the three integer components of an ``a/b/c`` address.
|
|
38
|
+
|
|
39
|
+
% end main
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
moat-link-knx (0.20.0-1) unstable; urgency=medium
|
|
2
|
+
|
|
3
|
+
* New release for 26.1.18
|
|
4
|
+
|
|
5
|
+
-- Matthias Urlichs <matthias@urlichs.de> Wed, 03 Jun 2026 18:52:40 +0200
|
|
6
|
+
|
|
7
|
+
moat-link-knx (0.0.1-1) unstable; urgency=medium
|
|
8
|
+
|
|
9
|
+
* Initial release.
|
|
10
|
+
|
|
11
|
+
-- Matthias Urlichs <matthias@urlichs.de> Mon, 01 Jun 2026 12:00:00 +0200
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Source: moat-link-knx
|
|
2
|
+
Maintainer: Matthias Urlichs <matthias@urlichs.de>
|
|
3
|
+
Section: python
|
|
4
|
+
Priority: optional
|
|
5
|
+
Build-Depends: dh-python, python3-all, debhelper (>= 13),
|
|
6
|
+
python3-setuptools,
|
|
7
|
+
python3-wheel,
|
|
8
|
+
Standards-Version: 3.9.6
|
|
9
|
+
Homepage: https://github.com/M-o-a-T/moat
|
|
10
|
+
X-DH-Compat: 13
|
|
11
|
+
|
|
12
|
+
Package: moat-link-knx
|
|
13
|
+
Architecture: all
|
|
14
|
+
Depends: ${misc:Depends}, ${python3:Depends},
|
|
15
|
+
moat-util,
|
|
16
|
+
moat-link,
|
|
17
|
+
moat-lib-xknx,
|
|
18
|
+
Recommends:
|
|
19
|
+
python3-trio (>= 0.22),
|
|
20
|
+
Description: KNX bus connector for MoaT-Link
|
|
21
|
+
Bridges MoaT-Link values to and from KNX group addresses via the
|
|
22
|
+
XKNX library and a KNXnet/IP tunneling connection.
|
|
23
|
+
.
|
|
24
|
+
Part of the MoaT ecosystem for distributed home automation.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/make -f
|
|
2
|
+
|
|
3
|
+
export PYBUILD_NAME=moat-link-knx
|
|
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-knx@.service \
|
|
10
|
+
debian/moat-link-knx/usr/lib/systemd/system/moat-link-knx@.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
|
+
"attrs",
|
|
18
|
+
"moat-util ~= 0.63.0",
|
|
19
|
+
"moat-link ~= 0.8.0",
|
|
20
|
+
"moat-lib-config ~= 0.2.0",
|
|
21
|
+
"moat-lib-xknx ~= 3.15.0",
|
|
22
|
+
]
|
|
23
|
+
version = "0.20.0"
|
|
24
|
+
keywords = [ "MoaT",]
|
|
25
|
+
requires-python = ">=3.13"
|
|
26
|
+
name = "moat-link-knx"
|
|
27
|
+
description = "KNX bus 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,373 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for moat.link.knx.
|
|
3
|
+
|
|
4
|
+
Mirrors :mod:`moat.link.metrics` but with KNX-specific argument
|
|
5
|
+
parsing: the ``at`` sub-group takes a KNX ``a/b/c`` group address that
|
|
6
|
+
is decoded to a 3-element integer subpath.
|
|
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 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 group_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 KNX server."""
|
|
35
|
+
return obj.knx_prefix + Path.build((obj.knx_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.knx_name!r} does not exist.") from None
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise click.UsageError(f"Server {obj.knx_name!r} has no configuration.")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.group(
|
|
54
|
+
cls=AliasedGroup,
|
|
55
|
+
name="knx",
|
|
56
|
+
short_help="Manage KNX gateways.",
|
|
57
|
+
invoke_without_command=True,
|
|
58
|
+
help="""\
|
|
59
|
+
Manager for KNX bus gateways.
|
|
60
|
+
|
|
61
|
+
\b
|
|
62
|
+
Use '… knx -' to list all entries.
|
|
63
|
+
Use '… knx NAME' to show details of a single entry.
|
|
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 address-specific subcommand."""
|
|
70
|
+
obj = ctx.obj
|
|
71
|
+
cfg = obj.cfg["link"]
|
|
72
|
+
obj.conn = await ctx.with_async_resource(Link(cfg))
|
|
73
|
+
obj.knx_cfg = obj.cfg.link.knx
|
|
74
|
+
obj.knx_prefix = obj.knx_cfg.prefix
|
|
75
|
+
obj.knx_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.knx_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.knx_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
|
+
@cli.command("--help", hidden=True)
|
|
111
|
+
@click.pass_context
|
|
112
|
+
def _cli_help(ctx) -> None:
|
|
113
|
+
"""Workaround so ``cli NAME --help`` produces help text."""
|
|
114
|
+
print(cli.get_help(ctx))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _server_options(proc):
|
|
118
|
+
"""Decorate ``add``/``set`` with the server-config options."""
|
|
119
|
+
proc = click.option(
|
|
120
|
+
"-p",
|
|
121
|
+
"--port",
|
|
122
|
+
type=int,
|
|
123
|
+
default=None,
|
|
124
|
+
help="Port of the KNX/IP gateway (default 3671).",
|
|
125
|
+
)(proc)
|
|
126
|
+
proc = click.option(
|
|
127
|
+
"-h",
|
|
128
|
+
"--host",
|
|
129
|
+
type=str,
|
|
130
|
+
default=None,
|
|
131
|
+
help="Host name or IP of the KNX/IP gateway.",
|
|
132
|
+
)(proc)
|
|
133
|
+
return proc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.command(short_help="Add a KNX gateway")
|
|
137
|
+
@_server_options
|
|
138
|
+
@click.option("-f", "--force", is_flag=True, help="Allow replacing an existing gateway.")
|
|
139
|
+
@click.pass_obj
|
|
140
|
+
async def add(obj, host, port, force) -> None:
|
|
141
|
+
"""Add a KNX gateway."""
|
|
142
|
+
path = _server_path(obj)
|
|
143
|
+
if not force:
|
|
144
|
+
try:
|
|
145
|
+
await obj.conn.d_get(path)
|
|
146
|
+
except KeyError:
|
|
147
|
+
pass
|
|
148
|
+
else:
|
|
149
|
+
raise click.UsageError(
|
|
150
|
+
f"Gateway {obj.knx_name!r} already exists. Use --force or 'set'.",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
srv: dict[str, Any] = {}
|
|
154
|
+
if host is not None:
|
|
155
|
+
srv["host"] = host
|
|
156
|
+
if port is not None:
|
|
157
|
+
srv["port"] = port
|
|
158
|
+
await obj.conn.d_set(path, {"server": srv})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@cli.command("set", short_help="Modify a KNX gateway")
|
|
162
|
+
@_server_options
|
|
163
|
+
@click.pass_obj
|
|
164
|
+
async def set_(obj, host, port) -> None:
|
|
165
|
+
"""Modify a KNX gateway.
|
|
166
|
+
|
|
167
|
+
Pass ``-`` as a value (where applicable) to clear an existing setting.
|
|
168
|
+
"""
|
|
169
|
+
data = await _require_server(obj)
|
|
170
|
+
srv = data.get("server", {})
|
|
171
|
+
if not isinstance(srv, dict):
|
|
172
|
+
srv = {}
|
|
173
|
+
|
|
174
|
+
if host is not None:
|
|
175
|
+
if host == "-":
|
|
176
|
+
srv.pop("host", None)
|
|
177
|
+
else:
|
|
178
|
+
srv["host"] = host
|
|
179
|
+
if port is not None:
|
|
180
|
+
srv["port"] = port
|
|
181
|
+
data["server"] = srv
|
|
182
|
+
|
|
183
|
+
await obj.conn.d_set(_server_path(obj), data)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@cli.command("delete", short_help="Delete a KNX gateway")
|
|
187
|
+
@click.option("-r", "--recursive", is_flag=True, help="Also remove all entries below.")
|
|
188
|
+
@click.pass_obj
|
|
189
|
+
async def delete_(obj, recursive: bool) -> None:
|
|
190
|
+
"""Delete a KNX gateway.
|
|
191
|
+
|
|
192
|
+
Without ``--recursive`` the gateway entry itself is removed but its
|
|
193
|
+
child group-address entries are kept (they become orphans).
|
|
194
|
+
"""
|
|
195
|
+
path = _server_path(obj)
|
|
196
|
+
args: dict[str, Any] = {}
|
|
197
|
+
if recursive:
|
|
198
|
+
args["rec"] = True
|
|
199
|
+
res = await obj.conn.d.delete(path, **args)
|
|
200
|
+
if getattr(obj, "meta", False):
|
|
201
|
+
yprint(res[0], stream=obj.stdout)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@cli.command("dump")
|
|
205
|
+
@click.option("-l", "--one-line", is_flag=True, help="Single line per entry")
|
|
206
|
+
@click.pass_obj
|
|
207
|
+
async def dump_(obj, one_line: bool) -> None:
|
|
208
|
+
"""Emit a gateway's (sub)state as a list / YAML file."""
|
|
209
|
+
path = _server_path(obj)
|
|
210
|
+
if not one_line:
|
|
211
|
+
await data_get(obj.conn, path, recursive=True, out=obj.stdout)
|
|
212
|
+
return
|
|
213
|
+
async with obj.conn.d_walk(path) as mon:
|
|
214
|
+
async for p, d in mon:
|
|
215
|
+
print(f"{path + p} {d}", file=obj.stdout)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@cli.group(
|
|
219
|
+
"at",
|
|
220
|
+
invoke_without_command=True,
|
|
221
|
+
short_help="create/show/delete an entry",
|
|
222
|
+
)
|
|
223
|
+
@click.argument("group", type=str, nargs=1)
|
|
224
|
+
@click.pass_context
|
|
225
|
+
async def at_cli(ctx, group: str) -> None:
|
|
226
|
+
"""Manage a single KNX group-address entry under a gateway.
|
|
227
|
+
|
|
228
|
+
GROUP is a KNX group address in ``a/b/c`` form. It is translated to
|
|
229
|
+
the integer subpath ``a:b:c`` below the gateway.
|
|
230
|
+
"""
|
|
231
|
+
obj = ctx.obj
|
|
232
|
+
try:
|
|
233
|
+
sub = group_subpath(group)
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise click.UsageError(str(exc)) from None
|
|
236
|
+
try:
|
|
237
|
+
await obj.conn.d_get(_server_path(obj))
|
|
238
|
+
except KeyError:
|
|
239
|
+
raise click.UsageError(
|
|
240
|
+
"Create the gateway before assigning group addresses to it!",
|
|
241
|
+
) from None
|
|
242
|
+
obj.knx_subpath = sub
|
|
243
|
+
if ctx.invoked_subcommand is None:
|
|
244
|
+
await data_get(
|
|
245
|
+
obj.conn,
|
|
246
|
+
_server_path(obj) + sub,
|
|
247
|
+
recursive=False,
|
|
248
|
+
out=obj.stdout,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@at_cli.command("--help", hidden=True)
|
|
253
|
+
@click.pass_context
|
|
254
|
+
def _at_help(ctx) -> None:
|
|
255
|
+
"""Workaround so ``at GROUP --help`` produces help text."""
|
|
256
|
+
print(at_cli.get_help(ctx))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@at_cli.command("dump")
|
|
260
|
+
@click.option("-l", "--one-line", is_flag=True, help="Single line per entry")
|
|
261
|
+
@click.pass_obj
|
|
262
|
+
async def dump_at(obj, one_line: bool) -> None:
|
|
263
|
+
"""Emit a subtree as a list / YAML file."""
|
|
264
|
+
path = _server_path(obj) + obj.knx_subpath
|
|
265
|
+
if not one_line:
|
|
266
|
+
await data_get(obj.conn, path, recursive=True, out=obj.stdout)
|
|
267
|
+
return
|
|
268
|
+
async with obj.conn.d_walk(path) as mon:
|
|
269
|
+
async for p, d in mon:
|
|
270
|
+
print(f"{path + p} {d}", file=obj.stdout)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@at_cli.command("add", short_help="Add an entry")
|
|
274
|
+
@attr_args
|
|
275
|
+
@click.option("-f", "--force", is_flag=True, help="Allow replacing an existing entry.")
|
|
276
|
+
@click.option(
|
|
277
|
+
"-t",
|
|
278
|
+
"--type",
|
|
279
|
+
"typ",
|
|
280
|
+
type=click.Choice(["in", "out"]),
|
|
281
|
+
required=True,
|
|
282
|
+
help="Direction: 'in' (KNX→Link) or 'out' (Link→KNX).",
|
|
283
|
+
)
|
|
284
|
+
@click.option(
|
|
285
|
+
"-m",
|
|
286
|
+
"--mode",
|
|
287
|
+
required=True,
|
|
288
|
+
help="XKNX data-point type, e.g. 'binary' or 'Bool'.",
|
|
289
|
+
)
|
|
290
|
+
@click.pass_obj
|
|
291
|
+
async def add_at(obj, typ, mode, force, **kw) -> None:
|
|
292
|
+
"""Add a group-address mapping.
|
|
293
|
+
|
|
294
|
+
\b
|
|
295
|
+
GROUP: a KNX ``a/b/c`` group address. Required.
|
|
296
|
+
|
|
297
|
+
For ``type=in`` the ``dest`` attribute must specify the
|
|
298
|
+
destination MoaT-Link path; for ``type=out`` the ``src``
|
|
299
|
+
attribute must specify the source path. Use the ``-s``
|
|
300
|
+
option to set them, e.g. ``-s dest .my.path``.
|
|
301
|
+
|
|
302
|
+
For ``type=out`` you may additionally set ``state`` to a
|
|
303
|
+
MoaT-Link path that tracks the last observed bus state.
|
|
304
|
+
Commands whose timestamp is older than the recorded state
|
|
305
|
+
are then suppressed (useful on startup).
|
|
306
|
+
"""
|
|
307
|
+
sub: Path = obj.knx_subpath
|
|
308
|
+
path = _server_path(obj) + sub
|
|
309
|
+
|
|
310
|
+
if not force:
|
|
311
|
+
try:
|
|
312
|
+
await obj.conn.d_get(path)
|
|
313
|
+
except KeyError:
|
|
314
|
+
pass
|
|
315
|
+
else:
|
|
316
|
+
raise click.UsageError("This entry already exists. Use '--force' or 'set'.")
|
|
317
|
+
|
|
318
|
+
val: dict[str | None, Any] = {"type": typ, "mode": mode}
|
|
319
|
+
res, _meta = await node_attr(obj, path, val=val, **kw)
|
|
320
|
+
|
|
321
|
+
required = "dest" if typ == "in" else "src"
|
|
322
|
+
if not isinstance(res, dict) or res.get(required) is None:
|
|
323
|
+
raise click.UsageError(
|
|
324
|
+
f"For type={typ!r} you must set the {required!r} attribute, "
|
|
325
|
+
f"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("delete")
|
|
333
|
+
@click.pass_obj
|
|
334
|
+
async def delete_at(obj) -> None:
|
|
335
|
+
"""Remove a group-address mapping from the gateway."""
|
|
336
|
+
path = _server_path(obj) + obj.knx_subpath
|
|
337
|
+
try:
|
|
338
|
+
await obj.conn.d_get(path)
|
|
339
|
+
except KeyError:
|
|
340
|
+
raise click.UsageError("This entry doesn't exist.") from None
|
|
341
|
+
await obj.conn.d_set(path, NotGiven)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@at_cli.command("set")
|
|
345
|
+
@attr_args
|
|
346
|
+
@click.pass_obj
|
|
347
|
+
async def set_at(obj, **kw) -> None:
|
|
348
|
+
"""Modify a group-address mapping."""
|
|
349
|
+
if not any(kw.get(x) for x in ("vars_", "eval_", "path_")):
|
|
350
|
+
return
|
|
351
|
+
path = _server_path(obj) + obj.knx_subpath
|
|
352
|
+
res, _meta = await node_attr(obj, path, **kw)
|
|
353
|
+
if getattr(obj, "meta", False):
|
|
354
|
+
yprint(res, stream=obj.stdout)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@cli.command()
|
|
358
|
+
@click.option("-l", "--local-ip", default=None, help="Force this local IP address.")
|
|
359
|
+
@click.option("-i", "--initial", is_flag=True, help="Push existing outgoing states.")
|
|
360
|
+
@click.pass_obj
|
|
361
|
+
async def monitor(obj, local_ip, initial) -> None:
|
|
362
|
+
"""Stand-alone task to talk to a single KNX gateway."""
|
|
363
|
+
from .task import task # noqa: PLC0415
|
|
364
|
+
|
|
365
|
+
async with as_service(obj) as srv:
|
|
366
|
+
await task(
|
|
367
|
+
obj.conn,
|
|
368
|
+
obj.knx_cfg,
|
|
369
|
+
obj.knx_name,
|
|
370
|
+
local_ip=local_ip,
|
|
371
|
+
initial=initial,
|
|
372
|
+
task_status=srv,
|
|
373
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Node model for the KNX 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
|
+
along the three integer components of a KNX group address
|
|
7
|
+
(``main``/``middle``/``sub``).
|
|
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.lib.xknx.telegram import GroupAddress
|
|
19
|
+
from moat.link.node import Node
|
|
20
|
+
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from moat.lib.rpc import Key
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_group(addr: str) -> tuple[int, int, int]:
|
|
33
|
+
"""Parse a KNX ``a/b/c`` group address into a 3-tuple of integers.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
addr: A KNX group address string, e.g. ``"1/2/3"``.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
``(main, middle, sub)`` as a 3-tuple of ``int``.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: if *addr* is not a valid 3-part group address.
|
|
43
|
+
"""
|
|
44
|
+
if addr.count("/") != 2:
|
|
45
|
+
raise ValueError(f"Not a 3-part KNX group address: {addr!r}")
|
|
46
|
+
ga = GroupAddress(addr)
|
|
47
|
+
main, middle, sub = ga.main, ga.middle, ga.sub
|
|
48
|
+
if main is None or middle is None or sub is None:
|
|
49
|
+
raise ValueError(f"Not a 3-part KNX group address: {addr!r}")
|
|
50
|
+
return (main, middle, sub)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def group_subpath(addr: str) -> Path:
|
|
54
|
+
"""Return *addr* as a 3-element :class:`~moat.lib.path.Path`."""
|
|
55
|
+
return Path.build(parse_group(addr))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@define
|
|
59
|
+
class KnxEntry(Node):
|
|
60
|
+
"""A single KNX group-address mapping.
|
|
61
|
+
|
|
62
|
+
Maps one KNX group address to a MoaT-Link source/destination path.
|
|
63
|
+
The stored configuration is a dict with these keys:
|
|
64
|
+
|
|
65
|
+
* ``type``: ``"in"`` (KNX → Link) or ``"out"`` (Link → KNX).
|
|
66
|
+
* ``mode``: XKNX data-point type, e.g. ``"binary"`` or ``"Bool"``.
|
|
67
|
+
* ``src``: source path (for ``type=out``).
|
|
68
|
+
* ``dest``: destination path (for ``type=in``).
|
|
69
|
+
* ``state``: optional path (``type=out`` only) that holds the last
|
|
70
|
+
observed bus state. If newer than the command at ``src``, the
|
|
71
|
+
command is treated as stale and not forwarded.
|
|
72
|
+
* ``idem``: optional idempotency flag (default ``True``).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def type_(self) -> str | None:
|
|
77
|
+
"""``in`` or ``out`` direction, or ``None`` if unset."""
|
|
78
|
+
d = self.data_
|
|
79
|
+
return d.get("type") if isinstance(d, dict) else None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def mode(self) -> str | None:
|
|
83
|
+
"""XKNX data-point type name, or ``None`` if unset."""
|
|
84
|
+
d = self.data_
|
|
85
|
+
return d.get("mode") if isinstance(d, dict) else None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def src(self) -> Path | None:
|
|
89
|
+
"""Source path (for ``type=out``), or ``None``."""
|
|
90
|
+
d = self.data_
|
|
91
|
+
s = d.get("src") if isinstance(d, dict) else None
|
|
92
|
+
return s if s is None else Path.build(s)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def dest(self) -> Path | None:
|
|
96
|
+
"""Destination path (for ``type=in``), or ``None``."""
|
|
97
|
+
d = self.data_
|
|
98
|
+
s = d.get("dest") if isinstance(d, dict) else None
|
|
99
|
+
return s if s is None else Path.build(s)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def state(self) -> Path | None:
|
|
103
|
+
"""Last-observed-bus-state path (for ``type=out``), or ``None``.
|
|
104
|
+
|
|
105
|
+
If set, this path's stored timestamp is compared with the
|
|
106
|
+
command's timestamp; commands older than the recorded state are
|
|
107
|
+
suppressed.
|
|
108
|
+
"""
|
|
109
|
+
d = self.data_
|
|
110
|
+
s = d.get("state") if isinstance(d, dict) else None
|
|
111
|
+
return s if s is None else Path.build(s)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def idem(self) -> bool:
|
|
115
|
+
"""Whether to skip writes when the value is unchanged."""
|
|
116
|
+
d = self.data_ if self.data_ is not NotGiven else {}
|
|
117
|
+
if not isinstance(d, dict):
|
|
118
|
+
return True
|
|
119
|
+
return bool(d.get("idem", True))
|
|
120
|
+
|
|
121
|
+
def is_complete(self) -> bool:
|
|
122
|
+
"""Whether this entry has a complete configuration."""
|
|
123
|
+
t = self.type_
|
|
124
|
+
if t == "in":
|
|
125
|
+
return self.mode is not None and self.dest is not None
|
|
126
|
+
if t == "out":
|
|
127
|
+
return self.mode is not None and self.src is not None
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@define
|
|
132
|
+
class KnxMiddle(Node):
|
|
133
|
+
"""Intermediate ``middle`` group-address node; children are :class:`KnxEntry`."""
|
|
134
|
+
|
|
135
|
+
def add_child(self, item: Key) -> KnxEntry:
|
|
136
|
+
"""Create child entries as :class:`KnxEntry`."""
|
|
137
|
+
if item in self._sub:
|
|
138
|
+
raise ValueError("exists")
|
|
139
|
+
self._sub[item] = s = KnxEntry()
|
|
140
|
+
return s
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@define
|
|
144
|
+
class KnxMain(Node):
|
|
145
|
+
"""Intermediate ``main`` group-address node; children are :class:`KnxMiddle`."""
|
|
146
|
+
|
|
147
|
+
def add_child(self, item: Key) -> KnxMiddle:
|
|
148
|
+
"""Create child entries as :class:`KnxMiddle`."""
|
|
149
|
+
if item in self._sub:
|
|
150
|
+
raise ValueError("exists")
|
|
151
|
+
self._sub[item] = s = KnxMiddle()
|
|
152
|
+
return s
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@define
|
|
156
|
+
class KnxServer(Node):
|
|
157
|
+
"""One KNX server (gateway) in the configuration tree.
|
|
158
|
+
|
|
159
|
+
Children at the integer ``main`` level branch out into
|
|
160
|
+
middle/sub levels and finally into :class:`KnxEntry` leaves.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def add_child(self, item: Key) -> KnxMain:
|
|
164
|
+
"""Create child entries as :class:`KnxMain`."""
|
|
165
|
+
if item in self._sub:
|
|
166
|
+
raise ValueError("exists")
|
|
167
|
+
self._sub[item] = s = KnxMain()
|
|
168
|
+
return s
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def cfg(self) -> Mapping[str, Any]:
|
|
172
|
+
"""Server-level config dict (``host``/``port``)."""
|
|
173
|
+
d = self.data_
|
|
174
|
+
if d is NotGiven or not isinstance(d, dict):
|
|
175
|
+
return {}
|
|
176
|
+
s = d.get("server", {})
|
|
177
|
+
return s if isinstance(s, dict) else {}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@define
|
|
181
|
+
class KnxRoot(Node):
|
|
182
|
+
"""Root of the KNX configuration tree.
|
|
183
|
+
|
|
184
|
+
Children are :class:`KnxServer` nodes.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def add_child(self, item: Key) -> KnxServer:
|
|
188
|
+
"""Create child servers as :class:`KnxServer`."""
|
|
189
|
+
if item in self._sub:
|
|
190
|
+
raise ValueError("exists")
|
|
191
|
+
self._sub[item] = s = KnxServer()
|
|
192
|
+
return s
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main supervisor task for the KNX connector.
|
|
3
|
+
|
|
4
|
+
Connects to a single KNX gateway and monitors the MoaT-Link
|
|
5
|
+
configuration subtree for one server. Spawns / cancels per-entry
|
|
6
|
+
workers as entries appear or change.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import anyio
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from moat.util import combine_dict
|
|
15
|
+
from moat.lib.path import Path
|
|
16
|
+
from moat.lib.xknx import XKNX
|
|
17
|
+
from moat.lib.xknx.io import ConnectionConfig, ConnectionType
|
|
18
|
+
from moat.lib.xknx.telegram import GroupAddress
|
|
19
|
+
|
|
20
|
+
from .model import KnxEntry, KnxServer
|
|
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
|
+
local_ip: str | None = None,
|
|
41
|
+
initial: bool | None = None,
|
|
42
|
+
task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Run the KNX connector for one gateway.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
link: an active MoaT-Link sender.
|
|
48
|
+
cfg: the ``link.knx`` configuration section.
|
|
49
|
+
server_name: the server entry name inside the config subtree.
|
|
50
|
+
local_ip: optional local IP override for the gateway connection.
|
|
51
|
+
initial: if true, push existing outgoing states / pull inputs on
|
|
52
|
+
startup. Defaults to ``True`` on input.
|
|
53
|
+
task_status: task-status for ``tg.start``.
|
|
54
|
+
"""
|
|
55
|
+
prefix = Path.build(cfg["prefix"])
|
|
56
|
+
server_path = prefix / server_name
|
|
57
|
+
|
|
58
|
+
server_data = await link.d_get(server_path)
|
|
59
|
+
if not isinstance(server_data, dict):
|
|
60
|
+
server_data = {}
|
|
61
|
+
srv_cfg = combine_dict(
|
|
62
|
+
server_data.get("server", {}),
|
|
63
|
+
cfg.get("server_default", {}),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
add: dict = {}
|
|
67
|
+
if local_ip is not None:
|
|
68
|
+
add["local_ip"] = local_ip
|
|
69
|
+
|
|
70
|
+
ccfg = ConnectionConfig(
|
|
71
|
+
connection_type=ConnectionType.TUNNELING,
|
|
72
|
+
gateway_ip=srv_cfg["host"],
|
|
73
|
+
gateway_port=srv_cfg.get("port", 3671),
|
|
74
|
+
**add,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async with XKNX(connection_config=ccfg) as srv, anyio.create_task_group() as tg:
|
|
78
|
+
workers: dict[Path, anyio.CancelScope] = {}
|
|
79
|
+
|
|
80
|
+
def _cancel(p: Path) -> None:
|
|
81
|
+
sc = workers.pop(p, None)
|
|
82
|
+
if sc is not None:
|
|
83
|
+
sc.cancel()
|
|
84
|
+
|
|
85
|
+
async def _start(p: Path, entry: KnxEntry) -> None:
|
|
86
|
+
nonlocal initial
|
|
87
|
+
|
|
88
|
+
_cancel(p)
|
|
89
|
+
if not entry.is_complete():
|
|
90
|
+
logger.warning("Incomplete entry at %s, skipping", p)
|
|
91
|
+
return
|
|
92
|
+
if len(p) != 3 or not all(isinstance(x, int) for x in p):
|
|
93
|
+
logger.warning("Entry %s is not at a 3-element group address", p)
|
|
94
|
+
return
|
|
95
|
+
main, middle, sub = (int(p[0]), int(p[1]), int(p[2]))
|
|
96
|
+
addr = GroupAddress((main << 11) | (middle << 8) | sub)
|
|
97
|
+
|
|
98
|
+
runner = run_in if entry.type_ == "in" else run_out
|
|
99
|
+
if initial is None:
|
|
100
|
+
initial = entry.type_ == "in"
|
|
101
|
+
|
|
102
|
+
async def _run(
|
|
103
|
+
*,
|
|
104
|
+
task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
|
105
|
+
) -> None:
|
|
106
|
+
with anyio.CancelScope() as sc:
|
|
107
|
+
workers[p] = sc
|
|
108
|
+
task_status.started()
|
|
109
|
+
assert initial is not None ## typing *sigh*
|
|
110
|
+
try:
|
|
111
|
+
await runner(link, srv, entry, addr, p, initial=initial)
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.exception("Worker for %s failed", p)
|
|
114
|
+
|
|
115
|
+
await tg.start(_run)
|
|
116
|
+
|
|
117
|
+
async with link.d_watch(
|
|
118
|
+
server_path,
|
|
119
|
+
subtree=True,
|
|
120
|
+
mark=True,
|
|
121
|
+
cls=KnxServer,
|
|
122
|
+
) as mon:
|
|
123
|
+
task_status.started()
|
|
124
|
+
|
|
125
|
+
async for msg in mon:
|
|
126
|
+
if msg is None:
|
|
127
|
+
continue
|
|
128
|
+
p, _data = msg
|
|
129
|
+
if not p:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
node = mon.nodes.get(p)
|
|
133
|
+
if isinstance(node, KnxEntry):
|
|
134
|
+
if node.is_complete():
|
|
135
|
+
await _start(p, node)
|
|
136
|
+
else:
|
|
137
|
+
_cancel(p)
|
|
138
|
+
else:
|
|
139
|
+
_cancel(p)
|
|
140
|
+
|
|
141
|
+
tg.cancel_scope.cancel()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-entry workers for the KNX bus connector.
|
|
3
|
+
|
|
4
|
+
Each worker drives a single :class:`~moat.link.knx.model.KnxEntry`.
|
|
5
|
+
``type=in`` workers watch the KNX bus for incoming telegrams and
|
|
6
|
+
publish the decoded value to MoaT-Link; ``type=out`` workers watch a
|
|
7
|
+
MoaT-Link value and forward changes onto the bus.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from moat.util import NotGiven
|
|
16
|
+
from moat.lib.xknx.devices import BinarySensor, ExposeSensor, Sensor, Switch
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from moat.lib.path import Path
|
|
22
|
+
from moat.lib.xknx import XKNX
|
|
23
|
+
from moat.lib.xknx.telegram import GroupAddress
|
|
24
|
+
from moat.link.client import LinkSender
|
|
25
|
+
from moat.link.meta import MsgMeta
|
|
26
|
+
|
|
27
|
+
from .model import KnxEntry
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_in_device(srv: XKNX, addr: GroupAddress, mode: str, name: str, cb):
|
|
33
|
+
"""Build the XKNX device used by a ``type=in`` worker."""
|
|
34
|
+
args: dict = dict(
|
|
35
|
+
xknx=srv,
|
|
36
|
+
group_address_state=addr,
|
|
37
|
+
name=name,
|
|
38
|
+
device_updated_cb=cb,
|
|
39
|
+
)
|
|
40
|
+
if mode == "binary":
|
|
41
|
+
return BinarySensor(ignore_internal_state=True, **args), lambda d: d.is_on()
|
|
42
|
+
dev = Sensor(value_type=mode, **args)
|
|
43
|
+
return dev, lambda d: d.sensor_value.value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _make_out_device(srv: XKNX, addr: GroupAddress, mode: str, name: str):
|
|
47
|
+
"""Build the XKNX device used by a ``type=out`` worker."""
|
|
48
|
+
args: dict = dict(xknx=srv, group_address=addr, name=name)
|
|
49
|
+
if mode == "binary":
|
|
50
|
+
sw = Switch(**args)
|
|
51
|
+
|
|
52
|
+
async def set_val(dev, val):
|
|
53
|
+
if val:
|
|
54
|
+
await dev.set_on()
|
|
55
|
+
else:
|
|
56
|
+
await dev.set_off()
|
|
57
|
+
|
|
58
|
+
return sw, set_val, lambda d: d.state
|
|
59
|
+
|
|
60
|
+
dev = ExposeSensor(value_type=mode, **args)
|
|
61
|
+
|
|
62
|
+
async def set_val(dev, val):
|
|
63
|
+
await dev.set(val)
|
|
64
|
+
|
|
65
|
+
return dev, set_val, lambda d: d.sensor_value.value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def run_in(
|
|
69
|
+
link: LinkSender,
|
|
70
|
+
srv: XKNX,
|
|
71
|
+
entry: KnxEntry,
|
|
72
|
+
addr: GroupAddress,
|
|
73
|
+
subpath: Path,
|
|
74
|
+
*,
|
|
75
|
+
initial: bool = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Forward KNX bus telegrams to a MoaT-Link destination.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
link: Active MoaT-Link sender.
|
|
81
|
+
srv: Connected XKNX gateway.
|
|
82
|
+
entry: Configuration entry for this address.
|
|
83
|
+
addr: Group address to listen on.
|
|
84
|
+
subpath: Path of this entry relative to the server (for naming).
|
|
85
|
+
initial: If true, request the current value on startup.
|
|
86
|
+
"""
|
|
87
|
+
mode = entry.mode
|
|
88
|
+
dest = entry.dest
|
|
89
|
+
if mode is None or dest is None:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
name = f"{mode}." + ".".join(str(x) for x in subpath)
|
|
93
|
+
evt = anyio.Event()
|
|
94
|
+
|
|
95
|
+
def _cb(_dev) -> None:
|
|
96
|
+
evt.set()
|
|
97
|
+
|
|
98
|
+
device, get_val = _make_in_device(srv, addr, mode, name, _cb)
|
|
99
|
+
srv.devices.async_add(device)
|
|
100
|
+
try:
|
|
101
|
+
if initial:
|
|
102
|
+
await device.sync()
|
|
103
|
+
while True:
|
|
104
|
+
await evt.wait()
|
|
105
|
+
evt = anyio.Event()
|
|
106
|
+
await link.d_set(dest, get_val(device))
|
|
107
|
+
finally:
|
|
108
|
+
srv.devices.async_remove(device)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def run_out(
|
|
112
|
+
link: LinkSender,
|
|
113
|
+
srv: XKNX,
|
|
114
|
+
entry: KnxEntry,
|
|
115
|
+
addr: GroupAddress,
|
|
116
|
+
subpath: Path,
|
|
117
|
+
*,
|
|
118
|
+
initial: bool = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Forward MoaT-Link source updates onto the KNX bus.
|
|
121
|
+
|
|
122
|
+
If :attr:`~moat.link.knx.model.KnxEntry.state` is set, the data
|
|
123
|
+
stored at that path is consulted before the initial command is
|
|
124
|
+
forwarded: a command whose timestamp is older than the recorded state's
|
|
125
|
+
timestamp is suppressed. This avoids re-sending a stale outgoing
|
|
126
|
+
command on startup when other bus actors changed the device state
|
|
127
|
+
in the meantime.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
link: Active MoaT-Link sender.
|
|
131
|
+
srv: Connected XKNX gateway.
|
|
132
|
+
entry: Configuration entry for this address.
|
|
133
|
+
addr: Group address to write on.
|
|
134
|
+
subpath: Path of this entry relative to the server (for naming).
|
|
135
|
+
initial: If true, fetch the current MoaT-Link value on startup.
|
|
136
|
+
"""
|
|
137
|
+
mode = entry.mode
|
|
138
|
+
src = entry.src
|
|
139
|
+
if mode is None or src is None:
|
|
140
|
+
return
|
|
141
|
+
state_path = entry.state
|
|
142
|
+
|
|
143
|
+
name = f"{mode}." + ".".join(str(x) for x in subpath)
|
|
144
|
+
device, set_val, _get_val = _make_out_device(srv, addr, mode, name)
|
|
145
|
+
srv.devices.async_add(device)
|
|
146
|
+
initial_phase = state_path is not None
|
|
147
|
+
try:
|
|
148
|
+
async with link.d_watch(src, meta=True, mark=initial_phase, state=initial or None) as wp:
|
|
149
|
+
async for msg in wp:
|
|
150
|
+
if msg is None:
|
|
151
|
+
# End of initial-state replay; no more stale-suppression.
|
|
152
|
+
initial_phase = False
|
|
153
|
+
continue
|
|
154
|
+
raw, meta = msg
|
|
155
|
+
if raw is NotGiven:
|
|
156
|
+
continue
|
|
157
|
+
if (
|
|
158
|
+
initial_phase
|
|
159
|
+
and state_path is not None
|
|
160
|
+
and not await _state_allows(link, state_path, meta, subpath)
|
|
161
|
+
):
|
|
162
|
+
continue
|
|
163
|
+
await set_val(device, raw)
|
|
164
|
+
finally:
|
|
165
|
+
srv.devices.async_remove(device)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _state_allows(
|
|
169
|
+
link: LinkSender,
|
|
170
|
+
state_path: Path,
|
|
171
|
+
cmd_meta: MsgMeta,
|
|
172
|
+
subpath: Path,
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Return True iff *cmd_meta* is at least as recent as *state_path*.
|
|
175
|
+
|
|
176
|
+
Missing state data is treated as "always allow".
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
_data, state_meta = await link.d_get(state_path, meta=True)
|
|
180
|
+
except KeyError:
|
|
181
|
+
return True
|
|
182
|
+
if state_meta.timestamp > cmd_meta.timestamp:
|
|
183
|
+
logger.debug(
|
|
184
|
+
"Suppressing stale command at %s: state %r newer than cmd %r",
|
|
185
|
+
subpath,
|
|
186
|
+
state_meta.timestamp,
|
|
187
|
+
cmd_meta.timestamp,
|
|
188
|
+
)
|
|
189
|
+
return False
|
|
190
|
+
return True
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moat-link-knx
|
|
3
|
+
Version: 0.20.0
|
|
4
|
+
Summary: KNX bus connector for MoaT-Link
|
|
5
|
+
Author-email: Matthias Urlichs <matthias@urlichs.de>
|
|
6
|
+
Project-URL: homepage, https://m-o-a-t.org
|
|
7
|
+
Project-URL: repository, https://github.com/M-o-a-T/moat
|
|
8
|
+
Keywords: MoaT
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Framework :: Trio
|
|
13
|
+
Classifier: Framework :: AnyIO
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE.txt
|
|
18
|
+
Requires-Dist: anyio~=4.2
|
|
19
|
+
Requires-Dist: asyncclick~=8.3
|
|
20
|
+
Requires-Dist: attrs
|
|
21
|
+
Requires-Dist: moat-util~=0.63.0
|
|
22
|
+
Requires-Dist: moat-link~=0.8.0
|
|
23
|
+
Requires-Dist: moat-lib-config~=0.2.0
|
|
24
|
+
Requires-Dist: moat-lib-xknx~=3.15.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# KNX bus connector
|
|
28
|
+
|
|
29
|
+
% start synopsis
|
|
30
|
+
Connects MoaT-Link to a KNX bus via XKNX/KNXnet/IP tunneling.
|
|
31
|
+
% end synopsis
|
|
32
|
+
|
|
33
|
+
% start main
|
|
34
|
+
|
|
35
|
+
This module bridges MoaT-Link values and KNX group addresses, in both
|
|
36
|
+
directions, via the XKNX library. It replaces the legacy
|
|
37
|
+
``moat-kv-knx`` package.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
1. Configure a KNX gateway:
|
|
42
|
+
|
|
43
|
+
```shell
|
|
44
|
+
moat link knx myhouse add -h 10.0.0.1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. Map a group address to a MoaT-Link path:
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
moat link knx myhouse at 1/2/3 add -t in -m Bool -s dest .data.lamp.kitchen
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
3. Run the connector:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
moat link knx myhouse monitor
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Deprecation
|
|
60
|
+
|
|
61
|
+
This package supersedes ``moat-kv-knx``. The address tree now lives in
|
|
62
|
+
MoaT-Link under the ``knx.<NAME>`` prefix; group addresses are stored as
|
|
63
|
+
the three integer components of an ``a/b/c`` address.
|
|
64
|
+
|
|
65
|
+
% end main
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
Makefile
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
debian/changelog
|
|
6
|
+
debian/control
|
|
7
|
+
debian/rules
|
|
8
|
+
src/moat/link/knx/__init__.py
|
|
9
|
+
src/moat/link/knx/_cfg.yaml
|
|
10
|
+
src/moat/link/knx/_main.py
|
|
11
|
+
src/moat/link/knx/model.py
|
|
12
|
+
src/moat/link/knx/task.py
|
|
13
|
+
src/moat/link/knx/worker.py
|
|
14
|
+
src/moat_link_knx.egg-info/PKG-INFO
|
|
15
|
+
src/moat_link_knx.egg-info/SOURCES.txt
|
|
16
|
+
src/moat_link_knx.egg-info/dependency_links.txt
|
|
17
|
+
src/moat_link_knx.egg-info/requires.txt
|
|
18
|
+
src/moat_link_knx.egg-info/top_level.txt
|
|
19
|
+
systemd/moat-link-knx@.service
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moat
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=MoaT-Link KNX 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 knx %I monitor
|
|
14
|
+
|
|
15
|
+
EnvironmentFile=-/etc/moat/link.env
|
|
16
|
+
|
|
17
|
+
TimeoutSec=300
|
|
18
|
+
WatchdogSec=10
|
|
19
|
+
|
|
20
|
+
Restart=always
|
|
21
|
+
RestartSec=30
|