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.
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) Matthias Urlichs
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """KNX bus connector for MoaT-Link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from moat.lib.config import register as _register
6
+
7
+ __all__: list[str] = []
8
+
9
+ _register(__name__)
@@ -0,0 +1,5 @@
1
+ prefix: !P knx
2
+ server_default:
3
+ port: 3671
4
+ poll: 10
5
+ count: true
@@ -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,7 @@
1
+ anyio~=4.2
2
+ asyncclick~=8.3
3
+ attrs
4
+ moat-lib-config~=0.2.0
5
+ moat-lib-xknx~=3.15.0
6
+ moat-link~=0.8.0
7
+ moat-util~=0.63.0
@@ -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