leaf-framework 0.10.22__tar.gz → 0.11.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.
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/PKG-INFO +12 -10
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/README.md +5 -1
- leaf_framework-0.11.0/leaf/adapters/control_spec.py +176 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/equipment_adapter.py +105 -1
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/config/configuration.yaml +3 -11
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/data/marketplace.yaml +5 -1
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/http_watcher.py +8 -5
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/output_modules/mqtt.py +18 -4
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/output_modules/output_module.py +15 -2
- leaf_framework-0.11.0/leaf/modules/output_modules/topic_store.py +114 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/measure.py +19 -8
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/equipment_actions.yaml +2 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/registry/discovery.py +6 -3
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/registry/loader.py +7 -1
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/start.py +7 -1
- leaf_framework-0.11.0/leaf/ui/auth.py +213 -0
- leaf_framework-0.11.0/leaf/ui/components/app_control.py +86 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/components/header.py +3 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/components/update_notice.py +2 -9
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/interface.py +7 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/markdown/configuration_help.md +5 -3
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/markdown/documentation_main.md +3 -6
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/tabs/adapters.py +58 -13
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/tabs/configuration.py +61 -73
- leaf_framework-0.11.0/leaf/ui/tabs/control.py +242 -0
- leaf_framework-0.11.0/leaf/ui/tabs/monitoring.py +249 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/utils.py +38 -1
- leaf_framework-0.11.0/leaf/utility/running_adapters.py +13 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/utility/running_utilities.py +41 -3
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/pyproject.toml +22 -11
- leaf_framework-0.10.22/leaf/modules/output_modules/keydb.py +0 -241
- leaf_framework-0.10.22/leaf/modules/output_modules/topic_store.py +0 -52
- leaf_framework-0.10.22/leaf/ui/tabs/monitoring.py +0 -64
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/LICENSE +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/error_handler/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/error_handler/error_holder.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/error_handler/exceptions.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/measurement_handler/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/measurement_handler/handler.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/measurement_handler/measurements.yaml +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/measurement_handler/terms.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/.gitkeep +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/ble_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/db_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/event_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_api_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_event_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/file_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/lads_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/modbus_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/opc_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/polling_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/input_modules/simple_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/fluorescence.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/measurement_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/optical_density.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/ph.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/temperature.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/output_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/output_modules/file.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/output_modules/sql.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/control.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/error.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/external_event_phase.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/initialisation.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/phase.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/start.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/phase_modules/stop.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/process_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/process_modules/continous_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/process_modules/discrete_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/process_modules/external_event_process.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/process_modules/process_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/process_modules/upload_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/abstract_leaf.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/equipment_data.yaml +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/metadata.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/py.typed +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/register/topic_utilities.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/registry/registry.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/registry/utils.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/adapter_ui_extension.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/components/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/constants.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/desktop_launcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/images/icon.icns +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/images/icon.ico +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/images/icon.svg +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/markdown/documentation_protips.md +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/scripts.js +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/styles.css +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/tabs/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/tabs/documentation.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/tabs/logs.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/ui/update_checker.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/utility/config_utils.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/utility/logger/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/utility/logger/logger_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leaf-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Lightweight Adapter Framework (LEAF)
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,18 +12,16 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
-
Requires-Dist:
|
|
16
|
-
Requires-Dist:
|
|
17
|
-
Requires-Dist: dateparser (>=1.3.0,<1.4.0)
|
|
15
|
+
Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
|
|
16
|
+
Requires-Dist: dateparser (>=1.4.3,<1.5.0)
|
|
18
17
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist: nicegui (>=3.
|
|
18
|
+
Requires-Dist: leafobject (>=0.0.14,<0.0.15)
|
|
19
|
+
Requires-Dist: nicegui (>=3.18.0,<3.19.0)
|
|
21
20
|
Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
|
|
22
|
-
Requires-Dist: pillow (>=
|
|
21
|
+
Requires-Dist: pillow (>=13.0.0,<14.0.0)
|
|
23
22
|
Requires-Dist: pyyaml (>=6.0.3,<6.1.0)
|
|
24
|
-
Requires-Dist: redis (>=7.2.0,<7.3.0)
|
|
25
23
|
Requires-Dist: requests (>=2.34.2,<2.35.0)
|
|
26
|
-
Requires-Dist: watchdog (>=
|
|
24
|
+
Requires-Dist: watchdog (>=7.0.0,<7.1.0)
|
|
27
25
|
Project-URL: Documentation, https://leaf-framework.org
|
|
28
26
|
Project-URL: Repository, https://gitlab.com/leaf-framework/leaf
|
|
29
27
|
Description-Content-Type: text/markdown
|
|
@@ -44,13 +42,17 @@ leaf
|
|
|
44
42
|
|
|
45
43
|
LEAF automatically shows a **desktop control panel** on desktop systems, making it easy to see if the application is running and where to access it.
|
|
46
44
|
|
|
45
|
+
## Password for the web interface
|
|
46
|
+
|
|
47
|
+
The web interface is open by default. When it runs where others can reach it (a Kubernetes pod with an ingress, a shared server), set `LEAF_UI_PASSWORD`: every page then asks for that password once, on a login form password managers can fill, and keeps the browser logged in for 30 days. Changing the password logs every browser out. It sends the password over the wire, so serve the interface over HTTPS.
|
|
48
|
+
|
|
47
49
|
## Updating
|
|
48
50
|
|
|
49
51
|
LEAF checks PyPI for a newer release one hour after it starts, and weekly from then on -- never at startup, so a restart simply re-arms the one-hour delay. When a newer version exists, an **update badge** appears next to the version number in the web interface header; clicking it offers to install the update and restart LEAF.
|
|
50
52
|
|
|
51
53
|
To check without waiting for the schedule, **click the version number** in the header. The result appears as a banner: green when this install is current, amber when a newer release exists, red when PyPI could not be reached.
|
|
52
54
|
|
|
53
|
-
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
55
|
+
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Set `LEAF_DISABLE_ADAPTER_INSTALL=1` to stop the web interface from installing or uninstalling adapters, e.g. on a shared deployment whose adapters come from its image. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
54
56
|
|
|
55
57
|
```bash
|
|
56
58
|
pip install --upgrade leaf-framework
|
|
@@ -14,13 +14,17 @@ leaf
|
|
|
14
14
|
|
|
15
15
|
LEAF automatically shows a **desktop control panel** on desktop systems, making it easy to see if the application is running and where to access it.
|
|
16
16
|
|
|
17
|
+
## Password for the web interface
|
|
18
|
+
|
|
19
|
+
The web interface is open by default. When it runs where others can reach it (a Kubernetes pod with an ingress, a shared server), set `LEAF_UI_PASSWORD`: every page then asks for that password once, on a login form password managers can fill, and keeps the browser logged in for 30 days. Changing the password logs every browser out. It sends the password over the wire, so serve the interface over HTTPS.
|
|
20
|
+
|
|
17
21
|
## Updating
|
|
18
22
|
|
|
19
23
|
LEAF checks PyPI for a newer release one hour after it starts, and weekly from then on -- never at startup, so a restart simply re-arms the one-hour delay. When a newer version exists, an **update badge** appears next to the version number in the web interface header; clicking it offers to install the update and restart LEAF.
|
|
20
24
|
|
|
21
25
|
To check without waiting for the schedule, **click the version number** in the header. The result appears as a banner: green when this install is current, amber when a newer release exists, red when PyPI could not be reached.
|
|
22
26
|
|
|
23
|
-
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
27
|
+
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Set `LEAF_DISABLE_ADAPTER_INSTALL=1` to stop the web interface from installing or uninstalling adapters, e.g. on a shared deployment whose adapters come from its image. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
24
28
|
|
|
25
29
|
```bash
|
|
26
30
|
pip install --upgrade leaf-framework
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The controls an adapter declares in its device.json, narrowed by an instance's control_limits.
|
|
3
|
+
|
|
4
|
+
Every control message, from MQTT or the UI, is checked against them before the controller sees it,
|
|
5
|
+
so the limits hold however the message arrives. An adapter that declares no controls is not checked.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from leafobject import LeafPoint # pyright: ignore[reportMissingTypeStubs]
|
|
14
|
+
|
|
15
|
+
Control = dict[str, object]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_controls(adapter_dir: str) -> list[Control]:
|
|
19
|
+
"""The "controls" list from device.json (or device.yaml) in an adapter's package, or []."""
|
|
20
|
+
for name in ("device.json", "device.yaml"):
|
|
21
|
+
path = os.path.join(adapter_dir, name)
|
|
22
|
+
if not os.path.exists(path):
|
|
23
|
+
continue
|
|
24
|
+
with open(path) as f:
|
|
25
|
+
if name.endswith(".json"):
|
|
26
|
+
device = cast(object, json.load(f))
|
|
27
|
+
else:
|
|
28
|
+
device = cast(object, yaml.safe_load(f))
|
|
29
|
+
if not isinstance(device, dict):
|
|
30
|
+
return []
|
|
31
|
+
controls = cast(dict[str, object], device).get("controls")
|
|
32
|
+
if not isinstance(controls, list):
|
|
33
|
+
return []
|
|
34
|
+
return [cast(Control, c) for c in cast(list[object], controls) if isinstance(c, dict)]
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def as_number(value: object) -> float | None:
|
|
39
|
+
"""value as a float, or None when it is not a number (bools are not numbers here)."""
|
|
40
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
41
|
+
return None
|
|
42
|
+
return float(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _narrow(control: Control, limit: object) -> Control:
|
|
46
|
+
"""The control with the instance's min/max, which may only tighten the adapter's own range."""
|
|
47
|
+
metric = control["metric"]
|
|
48
|
+
if control.get("type") != "number" or not isinstance(limit, dict):
|
|
49
|
+
raise ValueError(f"control_limits for {metric} need a number control and min/max keys")
|
|
50
|
+
narrowed = dict(control)
|
|
51
|
+
for key, value in cast(dict[str, object], limit).items():
|
|
52
|
+
bound = as_number(value)
|
|
53
|
+
if key not in ("min", "max") or bound is None:
|
|
54
|
+
raise ValueError(f"control_limits for {metric}: {key} must be min or max with a number")
|
|
55
|
+
adapter_bound = as_number(control.get(key))
|
|
56
|
+
if adapter_bound is not None and (
|
|
57
|
+
(key == "min" and bound < adapter_bound) or (key == "max" and bound > adapter_bound)
|
|
58
|
+
):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"control_limits for {metric}: {key} {bound} is outside the adapter's {key} {adapter_bound}"
|
|
61
|
+
)
|
|
62
|
+
narrowed[key] = bound
|
|
63
|
+
return narrowed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def apply_limits(controls: list[Control], limits: dict[str, object]) -> list[Control]:
|
|
67
|
+
"""Controls with an instance's control_limits applied; ValueError for limits that do not fit."""
|
|
68
|
+
known = {str(c.get("metric")) for c in controls}
|
|
69
|
+
unknown = sorted(set(limits) - known)
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ValueError(f"control_limits for undeclared controls: {', '.join(unknown)}")
|
|
72
|
+
result: list[Control] = []
|
|
73
|
+
for control in controls:
|
|
74
|
+
metric = str(control.get("metric"))
|
|
75
|
+
if metric in limits:
|
|
76
|
+
result.append(_narrow(control, limits[metric]))
|
|
77
|
+
else:
|
|
78
|
+
result.append(control)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_number(control: Control, value: object) -> None:
|
|
83
|
+
number = as_number(value)
|
|
84
|
+
if number is None:
|
|
85
|
+
raise ValueError(f"{control['metric']} needs a number, got {value!r}")
|
|
86
|
+
low = as_number(control.get("min"))
|
|
87
|
+
high = as_number(control.get("max"))
|
|
88
|
+
if low is not None and number < low:
|
|
89
|
+
raise ValueError(f"{control['metric']} = {number} is below its minimum {low}")
|
|
90
|
+
if high is not None and number > high:
|
|
91
|
+
raise ValueError(f"{control['metric']} = {number} is above its maximum {high}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _metric_and_value(data: str) -> tuple[object, object]:
|
|
95
|
+
"""tags.metric and fields.value of a LeafPoint control message."""
|
|
96
|
+
point = cast(object, json.loads(data))
|
|
97
|
+
if not isinstance(point, dict):
|
|
98
|
+
raise ValueError("a control message must be a LeafPoint JSON object")
|
|
99
|
+
point_dict = cast(dict[str, object], point)
|
|
100
|
+
tags = point_dict.get("tags")
|
|
101
|
+
fields = point_dict.get("fields")
|
|
102
|
+
if not isinstance(tags, dict) or not isinstance(fields, dict):
|
|
103
|
+
raise ValueError("a control message needs tags and fields")
|
|
104
|
+
return cast(dict[str, object], tags).get("metric"), cast(dict[str, object], fields).get("value")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_control(controls: list[Control], data: str) -> None:
|
|
108
|
+
"""Raise ValueError unless the LeafPoint in data sets a declared metric to an allowed value."""
|
|
109
|
+
if not controls:
|
|
110
|
+
return
|
|
111
|
+
metric, value = _metric_and_value(data)
|
|
112
|
+
control = next((c for c in controls if c.get("metric") == metric), None)
|
|
113
|
+
if control is None:
|
|
114
|
+
raise ValueError(f"{metric!r} is not a declared control")
|
|
115
|
+
if value is not None and value == control.get("reset"):
|
|
116
|
+
return
|
|
117
|
+
kind = control.get("type")
|
|
118
|
+
if kind == "number":
|
|
119
|
+
_check_number(control, value)
|
|
120
|
+
elif kind == "choice":
|
|
121
|
+
options = control.get("options")
|
|
122
|
+
if not isinstance(options, list) or value not in cast(list[object], options):
|
|
123
|
+
raise ValueError(f"{metric} cannot be set to {value!r}")
|
|
124
|
+
elif not isinstance(value, str) or not value:
|
|
125
|
+
raise ValueError(f"{metric} needs a text value")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _part(point: dict[str, object], key: str) -> dict[str, object]:
|
|
129
|
+
"""A copy of point[key] when it is a dict, else an empty dict."""
|
|
130
|
+
part = point.get(key)
|
|
131
|
+
if isinstance(part, dict):
|
|
132
|
+
return dict(cast(dict[str, object], part))
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _field_value(value: object) -> int | float | str:
|
|
137
|
+
"""value as a LeafPoint field allows it: a number or text, anything else as its JSON."""
|
|
138
|
+
if isinstance(value, (int, float, str)):
|
|
139
|
+
return value
|
|
140
|
+
return json.dumps(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def result_record(data: str, source: str, error: Exception | None = None) -> dict[str, object]:
|
|
144
|
+
"""
|
|
145
|
+
A control message as the LeafPoint LEAF publishes on <control topic>/result, so that what was
|
|
146
|
+
sent to the equipment is stored with the measurements.
|
|
147
|
+
|
|
148
|
+
source ("ui" or "mqtt"), status ("applied" or "rejected") and a rejection's "error" are tags,
|
|
149
|
+
for consumers to filter on. Built as a leafobject LeafPoint, so tags are text and there is
|
|
150
|
+
always a field: a message without fields keeps its raw text in "message".
|
|
151
|
+
"""
|
|
152
|
+
point: dict[str, object] = {}
|
|
153
|
+
try:
|
|
154
|
+
parsed = cast(object, json.loads(data))
|
|
155
|
+
if isinstance(parsed, dict):
|
|
156
|
+
point = cast(dict[str, object], parsed)
|
|
157
|
+
except ValueError:
|
|
158
|
+
pass
|
|
159
|
+
tags: dict[str, str] = {}
|
|
160
|
+
for key, value in _part(point, "tags").items():
|
|
161
|
+
tags[key] = str(value)
|
|
162
|
+
fields: dict[str, int | float | str] = {}
|
|
163
|
+
for key, value in _part(point, "fields").items():
|
|
164
|
+
fields[key] = _field_value(value)
|
|
165
|
+
tags["source"] = source
|
|
166
|
+
tags["status"] = "applied"
|
|
167
|
+
if error is not None:
|
|
168
|
+
tags["status"] = "rejected"
|
|
169
|
+
tags["error"] = str(error)
|
|
170
|
+
if not fields:
|
|
171
|
+
fields["message"] = data
|
|
172
|
+
record = LeafPoint()
|
|
173
|
+
record.set_measurement(str(point.get("measurement", "control")))
|
|
174
|
+
record.set_tags(tags)
|
|
175
|
+
record.set_fields(fields)
|
|
176
|
+
return {**record.to_json()}
|
|
@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
|
|
|
4
4
|
from threading import Event
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from leaf.adapters.control_spec import Control, check_control, result_record
|
|
7
8
|
from leaf.error_handler import exceptions
|
|
8
9
|
from leaf.error_handler.error_holder import ErrorHolder
|
|
9
10
|
from leaf.error_handler.exceptions import LEAFError
|
|
@@ -119,6 +120,29 @@ class AbstractInterpreter(ABC):
|
|
|
119
120
|
raise exception
|
|
120
121
|
|
|
121
122
|
|
|
123
|
+
class AbstractController(ABC):
|
|
124
|
+
"""
|
|
125
|
+
Base class for an adapter's controller.py.
|
|
126
|
+
|
|
127
|
+
When an instance is configured with ``control: true``, LEAF subscribes to
|
|
128
|
+
<root>/<organisation>/<department>/<adapter_id>/<instance_id>/control and
|
|
129
|
+
hands every message to control(). ``self.adapter`` is the running adapter.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
self.adapter: EquipmentAdapter | None = None
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def control(self, topic: str, data: str) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Act on a control message.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
topic (str): The control topic the message arrived on.
|
|
142
|
+
data (str): Raw message payload.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
122
146
|
class EquipmentAdapter(ABC):
|
|
123
147
|
"""
|
|
124
148
|
Base class for all equipment adapters.
|
|
@@ -201,6 +225,82 @@ class EquipmentAdapter(ABC):
|
|
|
201
225
|
self._external_process.set_interpreter(interpreter)
|
|
202
226
|
self._external_watcher.add_callback(self._external_process.process_input)
|
|
203
227
|
|
|
228
|
+
self._controller: AbstractController | None = None
|
|
229
|
+
self._controls: list[Control] = []
|
|
230
|
+
|
|
231
|
+
def set_controller(self, controller: AbstractController, controls: list[Control] | None = None) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Attach a controller; its control() runs for each message on the instance's control topic.
|
|
234
|
+
|
|
235
|
+
Messages are first checked against controls (from device.json, with the instance's limits).
|
|
236
|
+
"""
|
|
237
|
+
controller.adapter = self
|
|
238
|
+
self._controller = controller
|
|
239
|
+
self._controls = controls or []
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def controls(self) -> list[Control]:
|
|
243
|
+
"""The declared controls with this instance's limits, as used to check control messages."""
|
|
244
|
+
return self._controls
|
|
245
|
+
|
|
246
|
+
def _on_control(self, topic: str, data: str) -> None:
|
|
247
|
+
# Runs on the MQTT network thread, so never let an exception escape.
|
|
248
|
+
if self._controller is None:
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
self._logger.info(f"Control message on {topic}: {data}")
|
|
252
|
+
check_control(self._controls, data)
|
|
253
|
+
self._controller.control(topic, data)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
# The error holder reports it later; log it now so the sender can see why nothing happened
|
|
256
|
+
self._logger.warning(f"Control message on {topic} rejected: {e}")
|
|
257
|
+
self._report_control(topic, data, "mqtt", e)
|
|
258
|
+
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
259
|
+
return
|
|
260
|
+
self._report_control(topic, data, "mqtt")
|
|
261
|
+
|
|
262
|
+
def has_controller(self) -> bool:
|
|
263
|
+
"""True when the instance runs with control: true and a controller is attached."""
|
|
264
|
+
return self._controller is not None
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def metadata_manager(self) -> MetadataManager:
|
|
268
|
+
"""Instance and equipment metadata, e.g. to tag control points sent from the UI."""
|
|
269
|
+
return self._metadata_manager
|
|
270
|
+
|
|
271
|
+
def send_control(self, data: str) -> None:
|
|
272
|
+
"""
|
|
273
|
+
Hand a control message from inside LEAF (the UI) to the controller, as if it came in on the control topic.
|
|
274
|
+
|
|
275
|
+
Errors are reported like those of messages from MQTT, and also propagate so the caller can show them.
|
|
276
|
+
"""
|
|
277
|
+
if self._controller is None:
|
|
278
|
+
raise exceptions.AdapterLogicError("this instance has no controller; set control: true")
|
|
279
|
+
# Topic builders are generated at runtime, so the checker sees them as Any
|
|
280
|
+
topic = str(self._metadata_manager.control()) # pyright: ignore[reportAny]
|
|
281
|
+
self._logger.info(f"Control message from the UI on {topic}: {data}")
|
|
282
|
+
try:
|
|
283
|
+
check_control(self._controls, data)
|
|
284
|
+
self._controller.control(topic, data)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
# Reported like a control message from MQTT, so it also reaches the error topic
|
|
287
|
+
self._report_control(topic, data, "ui", e)
|
|
288
|
+
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
289
|
+
raise
|
|
290
|
+
self._report_control(topic, data, "ui")
|
|
291
|
+
|
|
292
|
+
def _report_control(self, topic: str, data: str, source: str, error: Exception | None = None) -> None:
|
|
293
|
+
"""Publish what happened to a control message on <control topic>/result, so it is stored with the data."""
|
|
294
|
+
# Never let the record break the command it describes
|
|
295
|
+
try:
|
|
296
|
+
_ = self._output.transmit(f"{topic}/result", result_record(data, source, error))
|
|
297
|
+
except Exception as e:
|
|
298
|
+
self._logger.warning(f"Could not publish the control record for {topic}: {e}")
|
|
299
|
+
|
|
300
|
+
def _subscribe_control(self) -> None:
|
|
301
|
+
if self._controller is not None:
|
|
302
|
+
self._output.subscribe(self._metadata_manager.control(), self._on_control)
|
|
303
|
+
|
|
204
304
|
def is_running(self) -> bool:
|
|
205
305
|
"""
|
|
206
306
|
Check if the adapter is actively running.
|
|
@@ -233,6 +333,7 @@ class EquipmentAdapter(ABC):
|
|
|
233
333
|
if self._external_watcher:
|
|
234
334
|
self._external_watcher.start()
|
|
235
335
|
self._output.on_adapter_ready(self._metadata_manager)
|
|
336
|
+
self._subscribe_control()
|
|
236
337
|
|
|
237
338
|
self._stop_event.clear()
|
|
238
339
|
# Expose adapter on all channels.
|
|
@@ -316,6 +417,8 @@ class EquipmentAdapter(ABC):
|
|
|
316
417
|
"""
|
|
317
418
|
self._stop_event.set()
|
|
318
419
|
self._output.on_adapter_stop(self._metadata_manager)
|
|
420
|
+
if self._controller is not None:
|
|
421
|
+
self._output.unsubscribe(self._metadata_manager.control())
|
|
319
422
|
if self._watcher.is_running():
|
|
320
423
|
self._watcher.stop()
|
|
321
424
|
|
|
@@ -348,7 +451,8 @@ class EquipmentAdapter(ABC):
|
|
|
348
451
|
return self.stop()
|
|
349
452
|
|
|
350
453
|
error_json = error.to_json()
|
|
351
|
-
|
|
454
|
+
# Every process publishes to the instance's one error topic, so one of them is enough
|
|
455
|
+
for process in self._processes[:1]:
|
|
352
456
|
process.transmit_error(error_json)
|
|
353
457
|
time.sleep(0.1)
|
|
354
458
|
|
|
@@ -26,24 +26,16 @@ OUTPUTS:
|
|
|
26
26
|
# protocol: v3 #OPTIONAL expects "v3" "v5"
|
|
27
27
|
# transport: tcp #OPTIONAL expects "tcp", "websockets", "unix"
|
|
28
28
|
# tls: False #OPTIONAL expects True, False
|
|
29
|
-
# fallback:
|
|
29
|
+
# fallback: FILE #OPTIONAL expects "SQL", "FILE"
|
|
30
30
|
|
|
31
31
|
# SQL output requires 'filename' of database
|
|
32
32
|
# and optionally an alternative 'fallback' output
|
|
33
33
|
# - plugin: SQL
|
|
34
34
|
# filename: messages.db
|
|
35
|
-
# fallback:
|
|
36
|
-
|
|
37
|
-
# KEYDB output requires 'host', 'port', 'db'
|
|
38
|
-
# and optionally an alternative 'fallback' output
|
|
39
|
-
# - plugin: KEYDB
|
|
40
|
-
# host: localhost
|
|
41
|
-
# port: 6379
|
|
42
|
-
# db: 0
|
|
43
|
-
# fallback: FILE #OPTIONAL expects "MQTT", "SQL", "FILE"
|
|
35
|
+
# fallback: FILE #OPTIONAL expects "MQTT", "FILE"
|
|
44
36
|
|
|
45
37
|
# FILE output requires 'filename'
|
|
46
38
|
# and optionally an alternative 'fallback' output
|
|
47
39
|
# - plugin: FILE
|
|
48
40
|
# filename: local.json
|
|
49
|
-
# fallback:
|
|
41
|
+
# fallback: SQL #OPTIONAL expects "MQTT", "SQL"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
- adapter_id: Table
|
|
5
5
|
image: "./images/table.png"
|
|
6
|
-
description: "File conversion module to parse tabular data from various file formats into
|
|
6
|
+
description: "File conversion module to parse tabular data from various file formats into LeafPoint messages."
|
|
7
7
|
equipment_data:
|
|
8
8
|
version: "1.0"
|
|
9
9
|
manufacturer: LEAF
|
|
@@ -233,6 +233,10 @@
|
|
|
233
233
|
application: Monitoring
|
|
234
234
|
# requirements:
|
|
235
235
|
repo_url: https://git.wur.nl/leaf/adapters/leaf-ramsmart.git
|
|
236
|
+
# Private repository: installed with a username and token handed out per user. One deploy
|
|
237
|
+
# token (read_repository, with an expiry) per person, named after them; lost → revoke and reissue.
|
|
238
|
+
access: restricted
|
|
239
|
+
request_access: mailto:access@leaf-framework.org?subject=Access%20to%20LEAF-RAMSMART
|
|
236
240
|
name: LEAF-RAMSMART
|
|
237
241
|
- adapter_id: BluetoothScanner
|
|
238
242
|
image: icon:bi-bluetooth
|
|
@@ -41,19 +41,22 @@ class URLState:
|
|
|
41
41
|
Returns:
|
|
42
42
|
Optional[dict]: Parsed JSON if the response is new; else None.
|
|
43
43
|
"""
|
|
44
|
-
|
|
44
|
+
etag = response.headers.get("ETag")
|
|
45
|
+
last_modified = response.headers.get("Last-Modified")
|
|
46
|
+
# Only a header the server sent can say "unchanged"; two missing ones are not a match
|
|
47
|
+
if etag is not None and etag == self.etag:
|
|
45
48
|
return None
|
|
46
|
-
if
|
|
49
|
+
if last_modified is not None and last_modified == self.last_modified:
|
|
47
50
|
return None
|
|
48
51
|
|
|
49
52
|
current_data = response.json()
|
|
50
53
|
|
|
51
|
-
if
|
|
54
|
+
if etag is None and last_modified is None:
|
|
52
55
|
if current_data == self.previous_data:
|
|
53
56
|
return None
|
|
54
57
|
|
|
55
|
-
self.etag =
|
|
56
|
-
self.last_modified =
|
|
58
|
+
self.etag = etag
|
|
59
|
+
self.last_modified = last_modified
|
|
57
60
|
self.previous_data = current_data
|
|
58
61
|
|
|
59
62
|
return current_data
|
|
@@ -2,8 +2,9 @@ import json
|
|
|
2
2
|
import re
|
|
3
3
|
import threading
|
|
4
4
|
import time
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from socket import gaierror
|
|
6
|
-
from typing import Any, Literal
|
|
7
|
+
from typing import Any, Literal, cast
|
|
7
8
|
|
|
8
9
|
import paho.mqtt.client as mqtt
|
|
9
10
|
from paho.mqtt.enums import CallbackAPIVersion
|
|
@@ -102,6 +103,8 @@ class MQTT(OutputModule):
|
|
|
102
103
|
self.messages: dict[str, list[str]] = {}
|
|
103
104
|
self.sending_success: dict[str, bool] = {}
|
|
104
105
|
self._sanitized_topic_warned: set[str] = set()
|
|
106
|
+
# Topics with a callback; kept so they are resubscribed after a reconnect (clean session).
|
|
107
|
+
self._subscriptions: dict[str, Callable[[str, str], None]] = {}
|
|
105
108
|
# paho's connect() returns once the socket is open and the CONNECT
|
|
106
109
|
# packet is sent, but the broker's CONNACK (which is what actually
|
|
107
110
|
# flips is_connected() to True) is only processed once loop_start()'s
|
|
@@ -257,6 +260,8 @@ class MQTT(OutputModule):
|
|
|
257
260
|
return self.fallback(topic, data)
|
|
258
261
|
if data == "":
|
|
259
262
|
data = {}
|
|
263
|
+
# The topic store keeps the dict, which the Monitoring plot reads points from
|
|
264
|
+
message = cast(object, data)
|
|
260
265
|
if isinstance(data, (dict, list)):
|
|
261
266
|
data = json.dumps(data)
|
|
262
267
|
|
|
@@ -299,7 +304,7 @@ class MQTT(OutputModule):
|
|
|
299
304
|
# Reset global failure counter only after successful transmission AND fallback processing
|
|
300
305
|
OutputModule.reset_failure_count()
|
|
301
306
|
|
|
302
|
-
self.store_latest(topic,
|
|
307
|
+
self.store_latest(topic, message)
|
|
303
308
|
|
|
304
309
|
# Transmit to parallel outputs (best-effort, failures don't affect primary result)
|
|
305
310
|
self.transmit_to_parallels(topic, data)
|
|
@@ -365,6 +370,8 @@ class MQTT(OutputModule):
|
|
|
365
370
|
self._handle_exception(ClientUnreachableError(f"Connection refused: {message}", output_module=self))
|
|
366
371
|
return
|
|
367
372
|
|
|
373
|
+
for topic in list(self._subscriptions):
|
|
374
|
+
self.client.subscribe(topic)
|
|
368
375
|
self._connected_event.set()
|
|
369
376
|
|
|
370
377
|
def on_connect_fail(
|
|
@@ -453,6 +460,9 @@ class MQTT(OutputModule):
|
|
|
453
460
|
if topic not in self.messages:
|
|
454
461
|
self.messages[topic] = []
|
|
455
462
|
self.messages[topic].append(payload)
|
|
463
|
+
callback = self._subscriptions.get(topic)
|
|
464
|
+
if callback is not None:
|
|
465
|
+
callback(topic, payload)
|
|
456
466
|
|
|
457
467
|
def reset_messages(self) -> None:
|
|
458
468
|
"""
|
|
@@ -460,17 +470,20 @@ class MQTT(OutputModule):
|
|
|
460
470
|
"""
|
|
461
471
|
self.messages = {}
|
|
462
472
|
|
|
463
|
-
def subscribe(self, topic: str) -> str:
|
|
473
|
+
def subscribe(self, topic: str, callback: Callable[[str, str], None] | None = None) -> str:
|
|
464
474
|
"""
|
|
465
475
|
Subscribe to a topic on the MQTT broker.
|
|
466
476
|
|
|
467
477
|
Args:
|
|
468
478
|
topic (str): The topic to subscribe to.
|
|
479
|
+
callback (Optional[Callable]): Called with (topic, payload) for each message on this exact topic.
|
|
469
480
|
|
|
470
481
|
Returns:
|
|
471
482
|
str: The subscribed topic.
|
|
472
483
|
"""
|
|
473
484
|
logger.info(f"Subscribing to {topic}")
|
|
485
|
+
if callback is not None:
|
|
486
|
+
self._subscriptions[topic] = callback
|
|
474
487
|
self.client.subscribe(topic)
|
|
475
488
|
return topic
|
|
476
489
|
|
|
@@ -485,6 +498,7 @@ class MQTT(OutputModule):
|
|
|
485
498
|
str: The unsubscribed topic.
|
|
486
499
|
"""
|
|
487
500
|
logger.info(f"Unsubscribing from {topic}")
|
|
501
|
+
self._subscriptions.pop(topic, None)
|
|
488
502
|
self.client.unsubscribe(topic)
|
|
489
503
|
return topic
|
|
490
504
|
|
|
@@ -513,7 +527,7 @@ class MQTT(OutputModule):
|
|
|
513
527
|
Retrieve and remove a stored message from the fallback.
|
|
514
528
|
|
|
515
529
|
MQTT does not buffer messages locally, so this delegates to the fallback
|
|
516
|
-
module (e.g.,
|
|
530
|
+
module (e.g., SQL or FILE) to retrieve buffered messages.
|
|
517
531
|
|
|
518
532
|
Args:
|
|
519
533
|
key (Optional[str]): A key for which to pop data. Defaults to None.
|
{leaf_framework-0.10.22 → leaf_framework-0.11.0}/leaf/modules/output_modules/output_module.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import time
|
|
2
2
|
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from typing import Any, Optional
|
|
4
5
|
|
|
5
6
|
from leaf.error_handler.error_holder import ErrorHolder
|
|
@@ -119,15 +120,27 @@ class OutputModule(ABC):
|
|
|
119
120
|
if self._fallback is not None:
|
|
120
121
|
self._fallback.flush(topic)
|
|
121
122
|
|
|
122
|
-
def subscribe(self, topic: str) -> None:
|
|
123
|
+
def subscribe(self, topic: str, callback: Callable[[str, str], None] | None = None) -> None:
|
|
123
124
|
"""
|
|
124
125
|
Listen on the specified topic if supported by the module.
|
|
125
126
|
|
|
126
127
|
Args:
|
|
127
128
|
topic (str): Topic to subscribe to.
|
|
129
|
+
callback (Optional[Callable]): Called with (topic, payload) for each message, if supported.
|
|
128
130
|
"""
|
|
129
131
|
if self._fallback is not None:
|
|
130
|
-
self._fallback.subscribe(topic)
|
|
132
|
+
self._fallback.subscribe(topic, callback)
|
|
133
|
+
|
|
134
|
+
def unsubscribe(self, topic: str) -> str | None:
|
|
135
|
+
"""
|
|
136
|
+
Stop listening on the specified topic if supported by the module.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
topic (str): Topic to unsubscribe from.
|
|
140
|
+
"""
|
|
141
|
+
if self._fallback is not None:
|
|
142
|
+
return self._fallback.unsubscribe(topic)
|
|
143
|
+
return None
|
|
131
144
|
|
|
132
145
|
def fallback(self, topic: str, data: str | dict[Any, Any] | None = None) -> bool:
|
|
133
146
|
"""
|