leaf-framework 0.10.22__tar.gz → 0.10.25__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.10.25}/PKG-INFO +11 -7
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/README.md +4 -0
- leaf_framework-0.10.25/leaf/adapters/control_spec.py +124 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/equipment_adapter.py +92 -1
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/http_watcher.py +8 -5
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/keydb.py +8 -6
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/mqtt.py +17 -3
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/output_module.py +15 -2
- leaf_framework-0.10.25/leaf/modules/output_modules/topic_store.py +114 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/measure.py +19 -8
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/equipment_actions.yaml +1 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/discovery.py +6 -3
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/loader.py +7 -1
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/start.py +7 -1
- leaf_framework-0.10.25/leaf/ui/auth.py +213 -0
- leaf_framework-0.10.25/leaf/ui/components/app_control.py +86 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/components/header.py +3 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/components/update_notice.py +2 -9
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/interface.py +7 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/configuration.py +61 -73
- leaf_framework-0.10.25/leaf/ui/tabs/control.py +242 -0
- leaf_framework-0.10.25/leaf/ui/tabs/monitoring.py +249 -0
- leaf_framework-0.10.25/leaf/utility/running_adapters.py +13 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/running_utilities.py +41 -3
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/pyproject.toml +22 -9
- 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.10.25}/LICENSE +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/config/configuration.yaml +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/data/marketplace.yaml +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/error_handler/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/error_handler/error_holder.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/error_handler/exceptions.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/handler.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/measurements.yaml +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/terms.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/.gitkeep +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/ble_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/db_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/event_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/external_api_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/external_event_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/file_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/lads_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/modbus_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/opc_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/polling_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/simple_watcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/fluorescence.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/measurement_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/optical_density.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/ph.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/temperature.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/file.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/sql.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/control.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/error.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/external_event_phase.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/initialisation.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/phase.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/start.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/stop.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/continous_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/discrete_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/external_event_process.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/process_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/upload_module.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/abstract_leaf.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/equipment_data.yaml +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/metadata.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/py.typed +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/topic_utilities.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/registry.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/utils.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/adapter_ui_extension.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/components/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/constants.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/desktop_launcher.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/images/icon.icns +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/images/icon.ico +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/images/icon.svg +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/markdown/configuration_help.md +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/markdown/documentation_main.md +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/markdown/documentation_protips.md +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/scripts.js +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/styles.css +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/adapters.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/documentation.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/logs.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/update_checker.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/utils.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/config_utils.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/logger/__init__.py +0 -0
- {leaf_framework-0.10.22 → leaf_framework-0.10.25}/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.10.
|
|
3
|
+
Version: 0.10.25
|
|
4
4
|
Summary: Lightweight Adapter Framework (LEAF)
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -13,17 +13,17 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Requires-Dist: aioredis (>=2.0.1,<2.1.0)
|
|
16
|
-
Requires-Dist: cairosvg (>=2.
|
|
17
|
-
Requires-Dist: dateparser (>=1.3
|
|
16
|
+
Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
|
|
17
|
+
Requires-Dist: dateparser (>=1.4.3,<1.5.0)
|
|
18
18
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
19
19
|
Requires-Dist: influxobject (>=0.0.13)
|
|
20
|
-
Requires-Dist: nicegui (>=3.
|
|
20
|
+
Requires-Dist: nicegui (>=3.18.0,<3.19.0)
|
|
21
21
|
Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
|
|
22
|
-
Requires-Dist: pillow (>=
|
|
22
|
+
Requires-Dist: pillow (>=13.0.0,<14.0.0)
|
|
23
23
|
Requires-Dist: pyyaml (>=6.0.3,<6.1.0)
|
|
24
|
-
Requires-Dist: redis (>=
|
|
24
|
+
Requires-Dist: redis (>=8.1.0,<8.2.0)
|
|
25
25
|
Requires-Dist: requests (>=2.34.2,<2.35.0)
|
|
26
|
-
Requires-Dist: watchdog (>=
|
|
26
|
+
Requires-Dist: watchdog (>=7.0.0,<7.1.0)
|
|
27
27
|
Project-URL: Documentation, https://leaf-framework.org
|
|
28
28
|
Project-URL: Repository, https://gitlab.com/leaf-framework/leaf
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
@@ -44,6 +44,10 @@ leaf
|
|
|
44
44
|
|
|
45
45
|
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
46
|
|
|
47
|
+
## Password for the web interface
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
47
51
|
## Updating
|
|
48
52
|
|
|
49
53
|
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.
|
|
@@ -14,6 +14,10 @@ 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.
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
|
|
14
|
+
Control = dict[str, object]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_controls(adapter_dir: str) -> list[Control]:
|
|
18
|
+
"""The "controls" list from device.json (or device.yaml) in an adapter's package, or []."""
|
|
19
|
+
for name in ("device.json", "device.yaml"):
|
|
20
|
+
path = os.path.join(adapter_dir, name)
|
|
21
|
+
if not os.path.exists(path):
|
|
22
|
+
continue
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
if name.endswith(".json"):
|
|
25
|
+
device = cast(object, json.load(f))
|
|
26
|
+
else:
|
|
27
|
+
device = cast(object, yaml.safe_load(f))
|
|
28
|
+
if not isinstance(device, dict):
|
|
29
|
+
return []
|
|
30
|
+
controls = cast(dict[str, object], device).get("controls")
|
|
31
|
+
if not isinstance(controls, list):
|
|
32
|
+
return []
|
|
33
|
+
return [cast(Control, c) for c in cast(list[object], controls) if isinstance(c, dict)]
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def as_number(value: object) -> float | None:
|
|
38
|
+
"""value as a float, or None when it is not a number (bools are not numbers here)."""
|
|
39
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
40
|
+
return None
|
|
41
|
+
return float(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _narrow(control: Control, limit: object) -> Control:
|
|
45
|
+
"""The control with the instance's min/max, which may only tighten the adapter's own range."""
|
|
46
|
+
metric = control["metric"]
|
|
47
|
+
if control.get("type") != "number" or not isinstance(limit, dict):
|
|
48
|
+
raise ValueError(f"control_limits for {metric} need a number control and min/max keys")
|
|
49
|
+
narrowed = dict(control)
|
|
50
|
+
for key, value in cast(dict[str, object], limit).items():
|
|
51
|
+
bound = as_number(value)
|
|
52
|
+
if key not in ("min", "max") or bound is None:
|
|
53
|
+
raise ValueError(f"control_limits for {metric}: {key} must be min or max with a number")
|
|
54
|
+
adapter_bound = as_number(control.get(key))
|
|
55
|
+
if adapter_bound is not None and (
|
|
56
|
+
(key == "min" and bound < adapter_bound) or (key == "max" and bound > adapter_bound)
|
|
57
|
+
):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"control_limits for {metric}: {key} {bound} is outside the adapter's {key} {adapter_bound}"
|
|
60
|
+
)
|
|
61
|
+
narrowed[key] = bound
|
|
62
|
+
return narrowed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_limits(controls: list[Control], limits: dict[str, object]) -> list[Control]:
|
|
66
|
+
"""Controls with an instance's control_limits applied; ValueError for limits that do not fit."""
|
|
67
|
+
known = {str(c.get("metric")) for c in controls}
|
|
68
|
+
unknown = sorted(set(limits) - known)
|
|
69
|
+
if unknown:
|
|
70
|
+
raise ValueError(f"control_limits for undeclared controls: {', '.join(unknown)}")
|
|
71
|
+
result: list[Control] = []
|
|
72
|
+
for control in controls:
|
|
73
|
+
metric = str(control.get("metric"))
|
|
74
|
+
if metric in limits:
|
|
75
|
+
result.append(_narrow(control, limits[metric]))
|
|
76
|
+
else:
|
|
77
|
+
result.append(control)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_number(control: Control, value: object) -> None:
|
|
82
|
+
number = as_number(value)
|
|
83
|
+
if number is None:
|
|
84
|
+
raise ValueError(f"{control['metric']} needs a number, got {value!r}")
|
|
85
|
+
low = as_number(control.get("min"))
|
|
86
|
+
high = as_number(control.get("max"))
|
|
87
|
+
if low is not None and number < low:
|
|
88
|
+
raise ValueError(f"{control['metric']} = {number} is below its minimum {low}")
|
|
89
|
+
if high is not None and number > high:
|
|
90
|
+
raise ValueError(f"{control['metric']} = {number} is above its maximum {high}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _metric_and_value(data: str) -> tuple[object, object]:
|
|
94
|
+
"""tags.metric and fields.value of a LeafPoint control message."""
|
|
95
|
+
point = cast(object, json.loads(data))
|
|
96
|
+
if not isinstance(point, dict):
|
|
97
|
+
raise ValueError("a control message must be a LeafPoint JSON object")
|
|
98
|
+
point_dict = cast(dict[str, object], point)
|
|
99
|
+
tags = point_dict.get("tags")
|
|
100
|
+
fields = point_dict.get("fields")
|
|
101
|
+
if not isinstance(tags, dict) or not isinstance(fields, dict):
|
|
102
|
+
raise ValueError("a control message needs tags and fields")
|
|
103
|
+
return cast(dict[str, object], tags).get("metric"), cast(dict[str, object], fields).get("value")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def check_control(controls: list[Control], data: str) -> None:
|
|
107
|
+
"""Raise ValueError unless the LeafPoint in data sets a declared metric to an allowed value."""
|
|
108
|
+
if not controls:
|
|
109
|
+
return
|
|
110
|
+
metric, value = _metric_and_value(data)
|
|
111
|
+
control = next((c for c in controls if c.get("metric") == metric), None)
|
|
112
|
+
if control is None:
|
|
113
|
+
raise ValueError(f"{metric!r} is not a declared control")
|
|
114
|
+
if value is not None and value == control.get("reset"):
|
|
115
|
+
return
|
|
116
|
+
kind = control.get("type")
|
|
117
|
+
if kind == "number":
|
|
118
|
+
_check_number(control, value)
|
|
119
|
+
elif kind == "choice":
|
|
120
|
+
options = control.get("options")
|
|
121
|
+
if not isinstance(options, list) or value not in cast(list[object], options):
|
|
122
|
+
raise ValueError(f"{metric} cannot be set to {value!r}")
|
|
123
|
+
elif not isinstance(value, str) or not value:
|
|
124
|
+
raise ValueError(f"{metric} needs a text value")
|
|
@@ -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
|
|
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,69 @@ 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._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
258
|
+
|
|
259
|
+
def has_controller(self) -> bool:
|
|
260
|
+
"""True when the instance runs with control: true and a controller is attached."""
|
|
261
|
+
return self._controller is not None
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def metadata_manager(self) -> MetadataManager:
|
|
265
|
+
"""Instance and equipment metadata, e.g. to tag control points sent from the UI."""
|
|
266
|
+
return self._metadata_manager
|
|
267
|
+
|
|
268
|
+
def send_control(self, data: str) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Hand a control message from inside LEAF (the UI) to the controller, as if it came in on the control topic.
|
|
271
|
+
|
|
272
|
+
Errors are reported like those of messages from MQTT, and also propagate so the caller can show them.
|
|
273
|
+
"""
|
|
274
|
+
if self._controller is None:
|
|
275
|
+
raise exceptions.AdapterLogicError("this instance has no controller; set control: true")
|
|
276
|
+
# Topic builders are generated at runtime, so the checker sees them as Any
|
|
277
|
+
topic = str(self._metadata_manager.control()) # pyright: ignore[reportAny]
|
|
278
|
+
self._logger.info(f"Control message from the UI on {topic}: {data}")
|
|
279
|
+
try:
|
|
280
|
+
check_control(self._controls, data)
|
|
281
|
+
self._controller.control(topic, data)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
# Reported like a control message from MQTT, so it also reaches the error topic
|
|
284
|
+
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
285
|
+
raise
|
|
286
|
+
|
|
287
|
+
def _subscribe_control(self) -> None:
|
|
288
|
+
if self._controller is not None:
|
|
289
|
+
self._output.subscribe(self._metadata_manager.control(), self._on_control)
|
|
290
|
+
|
|
204
291
|
def is_running(self) -> bool:
|
|
205
292
|
"""
|
|
206
293
|
Check if the adapter is actively running.
|
|
@@ -233,6 +320,7 @@ class EquipmentAdapter(ABC):
|
|
|
233
320
|
if self._external_watcher:
|
|
234
321
|
self._external_watcher.start()
|
|
235
322
|
self._output.on_adapter_ready(self._metadata_manager)
|
|
323
|
+
self._subscribe_control()
|
|
236
324
|
|
|
237
325
|
self._stop_event.clear()
|
|
238
326
|
# Expose adapter on all channels.
|
|
@@ -316,6 +404,8 @@ class EquipmentAdapter(ABC):
|
|
|
316
404
|
"""
|
|
317
405
|
self._stop_event.set()
|
|
318
406
|
self._output.on_adapter_stop(self._metadata_manager)
|
|
407
|
+
if self._controller is not None:
|
|
408
|
+
self._output.unsubscribe(self._metadata_manager.control())
|
|
319
409
|
if self._watcher.is_running():
|
|
320
410
|
self._watcher.stop()
|
|
321
411
|
|
|
@@ -348,7 +438,8 @@ class EquipmentAdapter(ABC):
|
|
|
348
438
|
return self.stop()
|
|
349
439
|
|
|
350
440
|
error_json = error.to_json()
|
|
351
|
-
|
|
441
|
+
# Every process publishes to the instance's one error topic, so one of them is enough
|
|
442
|
+
for process in self._processes[:1]:
|
|
352
443
|
process.transmit_error(error_json)
|
|
353
444
|
time.sleep(0.1)
|
|
354
445
|
|
{leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/http_watcher.py
RENAMED
|
@@ -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,7 +2,6 @@ import json
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
import redis
|
|
5
|
-
from redis.typing import ResponseT
|
|
6
5
|
|
|
7
6
|
from leaf.error_handler.error_holder import ErrorHolder
|
|
8
7
|
from leaf.error_handler.exceptions import ClientUnreachableError, SeverityLevel
|
|
@@ -153,7 +152,9 @@ class KEYDB(OutputModule):
|
|
|
153
152
|
if topics:
|
|
154
153
|
logger.info(f"Found existing keys in KeyDB: {topics}")
|
|
155
154
|
for topic in topics:
|
|
156
|
-
|
|
155
|
+
# Keys are bytes unless the client decodes responses
|
|
156
|
+
if isinstance(topic, bytes):
|
|
157
|
+
topic = topic.decode("utf-8")
|
|
157
158
|
self.pop(topic)
|
|
158
159
|
else:
|
|
159
160
|
logger.debug("No existing keys found in KeyDB.")
|
|
@@ -203,13 +204,14 @@ class KEYDB(OutputModule):
|
|
|
203
204
|
return key, json.loads(result.decode("utf-8"))
|
|
204
205
|
return None
|
|
205
206
|
|
|
206
|
-
|
|
207
|
-
if not
|
|
207
|
+
raw_key = self._client.randomkey()
|
|
208
|
+
if not raw_key:
|
|
208
209
|
logger.info("No keys available in KeyDB.")
|
|
209
210
|
return None
|
|
210
|
-
|
|
211
|
+
random_key = str(raw_key)
|
|
212
|
+
if isinstance(raw_key, bytes):
|
|
211
213
|
# Decode bytes to string if necessary
|
|
212
|
-
random_key =
|
|
214
|
+
random_key = raw_key.decode("utf-8")
|
|
213
215
|
|
|
214
216
|
logger.info(f"Popping from key '{random_key}' in KeyDB.")
|
|
215
217
|
result = self._client.lpop(random_key)
|
|
@@ -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
|
|
{leaf_framework-0.10.22 → leaf_framework-0.10.25}/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
|
"""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
# Points kept per topic (one metric each) for the Monitoring plot; in memory, so it starts empty after a restart
|
|
7
|
+
HISTORY_LENGTH = 1000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TopicStore:
|
|
11
|
+
"""
|
|
12
|
+
A lightweight nested-dict wrapper that stores the latest message
|
|
13
|
+
per hierarchical topic. Topics are split on '/' and stored at
|
|
14
|
+
the leaf node using a '_value' sentinel key.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
store.set("unlock/proj1/dev1/temp", {"value": 23.5})
|
|
18
|
+
# Internal: {"unlock": {"proj1": {"dev1": {"temp": {"_value": {"value": 23.5}}}}}}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._store: dict = {}
|
|
23
|
+
self._history: dict[str, deque[tuple[float, float]]] = {}
|
|
24
|
+
# Adapters write from their own threads while the UI reads
|
|
25
|
+
self._history_lock: threading.Lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def set(self, topic: str, message: Any) -> None:
|
|
28
|
+
"""Split topic by '/' and store message at the deepest level."""
|
|
29
|
+
parts = [p for p in topic.split("/") if p]
|
|
30
|
+
node = self._store
|
|
31
|
+
for part in parts:
|
|
32
|
+
if part not in node or not isinstance(node[part], dict):
|
|
33
|
+
node[part] = {}
|
|
34
|
+
node = node[part]
|
|
35
|
+
node["_value"] = message
|
|
36
|
+
payload = cast(object, message)
|
|
37
|
+
self._record_history(topic, payload)
|
|
38
|
+
|
|
39
|
+
def _record_history(self, topic: str, message: object) -> None:
|
|
40
|
+
"""Keep the numeric "value" field of point messages (a point dict or a list of them)."""
|
|
41
|
+
if isinstance(message, list):
|
|
42
|
+
points = cast(list[object], message)
|
|
43
|
+
else:
|
|
44
|
+
points = [message]
|
|
45
|
+
for point in points:
|
|
46
|
+
sample = _point_sample(point)
|
|
47
|
+
if sample is None:
|
|
48
|
+
continue
|
|
49
|
+
key = history_key(topic, point)
|
|
50
|
+
with self._history_lock:
|
|
51
|
+
history = self._history.setdefault(key, deque(maxlen=HISTORY_LENGTH))
|
|
52
|
+
history.append(sample)
|
|
53
|
+
|
|
54
|
+
def get_history(self) -> dict[str, list[tuple[float, float]]]:
|
|
55
|
+
"""(timestamp, value) pairs per history key (see history_key), oldest first."""
|
|
56
|
+
with self._history_lock:
|
|
57
|
+
return {topic: list(points) for topic, points in self._history.items()}
|
|
58
|
+
|
|
59
|
+
def get(self, topic: str) -> Any:
|
|
60
|
+
"""Retrieve the latest message for a topic, or None."""
|
|
61
|
+
parts = [p for p in topic.split("/") if p]
|
|
62
|
+
node = self._store
|
|
63
|
+
for part in parts:
|
|
64
|
+
if not isinstance(node, dict) or part not in node:
|
|
65
|
+
return None
|
|
66
|
+
node = node[part]
|
|
67
|
+
if isinstance(node, dict):
|
|
68
|
+
return node.get("_value")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def get_subtree(self, topic: str) -> dict | None:
|
|
72
|
+
"""Get the full nested dict under a topic prefix."""
|
|
73
|
+
parts = [p for p in topic.split("/") if p]
|
|
74
|
+
node = self._store
|
|
75
|
+
for part in parts:
|
|
76
|
+
if not isinstance(node, dict) or part not in node:
|
|
77
|
+
return None
|
|
78
|
+
node = node[part]
|
|
79
|
+
return node if isinstance(node, dict) else None
|
|
80
|
+
|
|
81
|
+
def get_all(self) -> dict:
|
|
82
|
+
"""Return the full nested store."""
|
|
83
|
+
return self._store
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Separates the topic from the point's entity tag in a history key
|
|
87
|
+
ENTITY_SEPARATOR = "#"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def history_key(topic: str, point: object) -> str:
|
|
91
|
+
"""Topic plus the point's "entity" tag, since one topic can carry several entities (e.g. the wells of a plate)."""
|
|
92
|
+
tags = cast(dict[str, object], point).get("tags")
|
|
93
|
+
if isinstance(tags, dict):
|
|
94
|
+
entity = cast(dict[str, object], tags).get("entity")
|
|
95
|
+
if isinstance(entity, str) and entity:
|
|
96
|
+
return f"{topic}{ENTITY_SEPARATOR}{entity}"
|
|
97
|
+
return topic
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _point_sample(point: object) -> tuple[float, float] | None:
|
|
101
|
+
"""(timestamp, value) of a LeafPoint/InfluxPoint JSON dict with a numeric fields.value, or None."""
|
|
102
|
+
if not isinstance(point, dict):
|
|
103
|
+
return None
|
|
104
|
+
point_dict = cast(dict[str, object], point)
|
|
105
|
+
fields = point_dict.get("fields")
|
|
106
|
+
if not isinstance(fields, dict):
|
|
107
|
+
return None
|
|
108
|
+
value = cast(dict[str, object], fields).get("value")
|
|
109
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
110
|
+
return None
|
|
111
|
+
timestamp = point_dict.get("timestamp")
|
|
112
|
+
if isinstance(timestamp, bool) or not isinstance(timestamp, (int, float)):
|
|
113
|
+
timestamp = time.time()
|
|
114
|
+
return (float(timestamp), float(value))
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import threading
|
|
3
3
|
import time
|
|
4
|
-
from typing import TYPE_CHECKING, Any
|
|
5
|
-
|
|
6
|
-
from influxobject import InfluxPoint
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
5
|
|
|
8
6
|
from leaf.error_handler.error_holder import ErrorHolder
|
|
9
7
|
from leaf.error_handler.exceptions import AdapterLogicError, InterpreterError
|
|
@@ -237,17 +235,20 @@ class MeasurePhase(PhaseModule):
|
|
|
237
235
|
action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
|
|
238
236
|
return [(action, result)]
|
|
239
237
|
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
payload = cast(object, result)
|
|
239
|
+
point_json = point_json_of(payload)
|
|
240
|
+
if point_json is not None:
|
|
241
|
+
result = point_json
|
|
242
242
|
measurement = self._resolve_measurement(result)
|
|
243
243
|
action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
|
|
244
244
|
return [(action, result)]
|
|
245
245
|
|
|
246
246
|
if isinstance(result, list):
|
|
247
247
|
converted = []
|
|
248
|
-
for item in result:
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
for item in cast(list[object], result):
|
|
249
|
+
point_json = point_json_of(item)
|
|
250
|
+
if point_json is not None:
|
|
251
|
+
converted.append(point_json)
|
|
251
252
|
else:
|
|
252
253
|
converted.append(item)
|
|
253
254
|
|
|
@@ -287,3 +288,13 @@ class MeasurePhase(PhaseModule):
|
|
|
287
288
|
return item["measurement"]
|
|
288
289
|
|
|
289
290
|
return "unknown"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def point_json_of(item: object) -> dict[str, object] | None:
|
|
294
|
+
"""JSON of an InfluxPoint or leafobject LeafPoint, recognised by to_json() so LEAF need not depend on leafobject."""
|
|
295
|
+
if isinstance(item, dict):
|
|
296
|
+
return None
|
|
297
|
+
to_json: object = getattr(item, "to_json", None)
|
|
298
|
+
if not callable(to_json):
|
|
299
|
+
return None
|
|
300
|
+
return cast(dict[str, object], to_json())
|