leaf-framework 0.10.25__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.25 → leaf_framework-0.11.0}/PKG-INFO +3 -5
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/README.md +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/control_spec.py +52 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/equipment_adapter.py +14 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/config/configuration.yaml +3 -11
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/data/marketplace.yaml +5 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/mqtt.py +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/topic_store.py +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/measure.py +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/equipment_actions.yaml +1 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/markdown/configuration_help.md +5 -3
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/markdown/documentation_main.md +3 -6
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/adapters.py +58 -13
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/utils.py +38 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/pyproject.toml +2 -4
- leaf_framework-0.10.25/leaf/modules/output_modules/keydb.py +0 -243
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/LICENSE +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/error_handler/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/error_handler/error_holder.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/error_handler/exceptions.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/handler.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/measurements.yaml +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/terms.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/.gitkeep +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/ble_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/db_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/event_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_api_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_event_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/file_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/http_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/lads_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/modbus_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/opc_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/polling_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/simple_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/fluorescence.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/measurement_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/optical_density.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/ph.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/temperature.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/file.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/output_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/sql.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/control.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/error.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/external_event_phase.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/initialisation.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/phase.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/start.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/stop.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/continous_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/discrete_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/external_event_process.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/process_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/upload_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/abstract_leaf.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/equipment_data.yaml +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/metadata.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/py.typed +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/topic_utilities.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/discovery.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/loader.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/registry.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/utils.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/start.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/adapter_ui_extension.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/auth.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/app_control.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/header.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/update_notice.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/constants.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/desktop_launcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/images/icon.icns +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/images/icon.ico +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/images/icon.svg +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/interface.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/markdown/documentation_protips.md +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/scripts.js +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/styles.css +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/configuration.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/control.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/documentation.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/logs.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/monitoring.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/update_checker.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/config_utils.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/logger/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/logger/logger_utils.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/running_adapters.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/running_utilities.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,16 +12,14 @@ 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: aioredis (>=2.0.1,<2.1.0)
|
|
16
15
|
Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
|
|
17
16
|
Requires-Dist: dateparser (>=1.4.3,<1.5.0)
|
|
18
17
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
19
|
-
Requires-Dist:
|
|
18
|
+
Requires-Dist: leafobject (>=0.0.14,<0.0.15)
|
|
20
19
|
Requires-Dist: nicegui (>=3.18.0,<3.19.0)
|
|
21
20
|
Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
|
|
22
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 (>=8.1.0,<8.2.0)
|
|
25
23
|
Requires-Dist: requests (>=2.34.2,<2.35.0)
|
|
26
24
|
Requires-Dist: watchdog (>=7.0.0,<7.1.0)
|
|
27
25
|
Project-URL: Documentation, https://leaf-framework.org
|
|
@@ -54,7 +52,7 @@ LEAF checks PyPI for a newer release one hour after it starts, and weekly from t
|
|
|
54
52
|
|
|
55
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.
|
|
56
54
|
|
|
57
|
-
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:
|
|
58
56
|
|
|
59
57
|
```bash
|
|
60
58
|
pip install --upgrade leaf-framework
|
|
@@ -24,7 +24,7 @@ LEAF checks PyPI for a newer release one hour after it starts, and weekly from t
|
|
|
24
24
|
|
|
25
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.
|
|
26
26
|
|
|
27
|
-
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:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
pip install --upgrade leaf-framework
|
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
from typing import cast
|
|
11
11
|
|
|
12
12
|
import yaml
|
|
13
|
+
from leafobject import LeafPoint # pyright: ignore[reportMissingTypeStubs]
|
|
13
14
|
|
|
14
15
|
Control = dict[str, object]
|
|
15
16
|
|
|
@@ -122,3 +123,54 @@ def check_control(controls: list[Control], data: str) -> None:
|
|
|
122
123
|
raise ValueError(f"{metric} cannot be set to {value!r}")
|
|
123
124
|
elif not isinstance(value, str) or not value:
|
|
124
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,7 +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
|
+
from leaf.adapters.control_spec import Control, check_control, result_record
|
|
8
8
|
from leaf.error_handler import exceptions
|
|
9
9
|
from leaf.error_handler.error_holder import ErrorHolder
|
|
10
10
|
from leaf.error_handler.exceptions import LEAFError
|
|
@@ -254,7 +254,10 @@ class EquipmentAdapter(ABC):
|
|
|
254
254
|
except Exception as e:
|
|
255
255
|
# The error holder reports it later; log it now so the sender can see why nothing happened
|
|
256
256
|
self._logger.warning(f"Control message on {topic} rejected: {e}")
|
|
257
|
+
self._report_control(topic, data, "mqtt", e)
|
|
257
258
|
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
259
|
+
return
|
|
260
|
+
self._report_control(topic, data, "mqtt")
|
|
258
261
|
|
|
259
262
|
def has_controller(self) -> bool:
|
|
260
263
|
"""True when the instance runs with control: true and a controller is attached."""
|
|
@@ -281,8 +284,18 @@ class EquipmentAdapter(ABC):
|
|
|
281
284
|
self._controller.control(topic, data)
|
|
282
285
|
except Exception as e:
|
|
283
286
|
# Reported like a control message from MQTT, so it also reaches the error topic
|
|
287
|
+
self._report_control(topic, data, "ui", e)
|
|
284
288
|
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
285
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}")
|
|
286
299
|
|
|
287
300
|
def _subscribe_control(self) -> None:
|
|
288
301
|
if self._controller is not None:
|
|
@@ -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
|
|
@@ -527,7 +527,7 @@ class MQTT(OutputModule):
|
|
|
527
527
|
Retrieve and remove a stored message from the fallback.
|
|
528
528
|
|
|
529
529
|
MQTT does not buffer messages locally, so this delegates to the fallback
|
|
530
|
-
module (e.g.,
|
|
530
|
+
module (e.g., SQL or FILE) to retrieve buffered messages.
|
|
531
531
|
|
|
532
532
|
Args:
|
|
533
533
|
key (Optional[str]): A key for which to pop data. Defaults to None.
|
|
@@ -98,7 +98,7 @@ def history_key(topic: str, point: object) -> str:
|
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
def _point_sample(point: object) -> tuple[float, float] | None:
|
|
101
|
-
"""(timestamp, value) of a LeafPoint
|
|
101
|
+
"""(timestamp, value) of a LeafPoint JSON dict with a numeric fields.value, or None."""
|
|
102
102
|
if not isinstance(point, dict):
|
|
103
103
|
return None
|
|
104
104
|
point_dict = cast(dict[str, object], point)
|
|
@@ -291,7 +291,7 @@ class MeasurePhase(PhaseModule):
|
|
|
291
291
|
|
|
292
292
|
|
|
293
293
|
def point_json_of(item: object) -> dict[str, object] | None:
|
|
294
|
-
"""JSON of
|
|
294
|
+
"""JSON of a leafobject LeafPoint (or anything else with to_json()), else None."""
|
|
295
295
|
if isinstance(item, dict):
|
|
296
296
|
return None
|
|
297
297
|
to_json: object = getattr(item, "to_json", None)
|
|
@@ -3,6 +3,7 @@ running: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/running
|
|
|
3
3
|
heartbeat: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/heartbeat
|
|
4
4
|
error: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/error
|
|
5
5
|
control: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control
|
|
6
|
+
control_result: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control/result
|
|
6
7
|
experiment:
|
|
7
8
|
start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/start
|
|
8
9
|
stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/stop
|
|
@@ -15,10 +15,12 @@ EQUIPMENT_INSTANCES:
|
|
|
15
15
|
Configure data destinations:
|
|
16
16
|
```yaml
|
|
17
17
|
OUTPUTS:
|
|
18
|
-
- plugin:
|
|
19
|
-
|
|
20
|
-
port:
|
|
18
|
+
- plugin: MQTT
|
|
19
|
+
broker: localhost
|
|
20
|
+
port: 1883
|
|
21
21
|
fallback: FILE
|
|
22
|
+
- plugin: FILE
|
|
23
|
+
filename: fallback.json
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
#### Available Adapters
|
|
@@ -41,13 +41,10 @@ OUTPUTS:
|
|
|
41
41
|
- plugin: MQTT
|
|
42
42
|
broker: localhost
|
|
43
43
|
port: 1883
|
|
44
|
-
fallback: KEYDB
|
|
45
|
-
|
|
46
|
-
- plugin: KEYDB
|
|
47
|
-
host: localhost
|
|
48
|
-
port: 6379
|
|
49
|
-
db: 0
|
|
50
44
|
fallback: FILE
|
|
45
|
+
|
|
46
|
+
- plugin: FILE
|
|
47
|
+
filename: fallback.json
|
|
51
48
|
```
|
|
52
49
|
|
|
53
50
|
###### Available Adapters
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""LEAF Adapters tab."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Mapping
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
@@ -8,7 +9,7 @@ from nicegui import ui
|
|
|
8
9
|
|
|
9
10
|
from leaf.registry.discovery import get_all_adapter_codes
|
|
10
11
|
from leaf.ui.constants import CARD_CLASSES, CARD_WIDTH_CLASS, MARKETPLACE_URL
|
|
11
|
-
from leaf.ui.utils import install_adapter, uninstall_adapter
|
|
12
|
+
from leaf.ui.utils import adapter_install_disabled, install_adapter, uninstall_adapter
|
|
12
13
|
from leaf.utility.logger.logger_utils import get_logger
|
|
13
14
|
|
|
14
15
|
logger = get_logger(__name__, log_file="input_module.log")
|
|
@@ -160,6 +161,59 @@ def load_bundled_marketplace() -> list | None:
|
|
|
160
161
|
return None
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
# install_adapter/uninstall_adapter refuse too; these only leave out buttons that would not work
|
|
165
|
+
def _install_button(adapter: Mapping[str, object]) -> None:
|
|
166
|
+
"""The marketplace card's Install button, unless adapter installs are disabled."""
|
|
167
|
+
if adapter_install_disabled():
|
|
168
|
+
return
|
|
169
|
+
if adapter.get("access") == "restricted":
|
|
170
|
+
_ = ui.button("Install with token", icon="lock", on_click=lambda: _token_dialog(adapter)).classes(
|
|
171
|
+
"btn-secondary w-full rounded-lg transition-colors"
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
_ = ui.button("Install", icon="download", on_click=lambda: install_adapter(dict(adapter))).classes(
|
|
175
|
+
"btn-secondary w-full rounded-lg transition-colors"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _token_dialog(adapter: Mapping[str, object]) -> None:
|
|
180
|
+
"""Asks for the access token of a restricted adapter, which is only installable with one."""
|
|
181
|
+
name = str(adapter.get("name") or adapter.get("adapter_id"))
|
|
182
|
+
with ui.dialog() as dialog, ui.card().classes("w-96"):
|
|
183
|
+
_ = ui.label(f"{name} is available on request").classes("text-h6")
|
|
184
|
+
_ = ui.label("Enter the username and token you received, e.g. a GitLab deploy token.").classes("text-body")
|
|
185
|
+
username = ui.input("Username")
|
|
186
|
+
token = ui.input("Token", password=True, password_toggle_button=True)
|
|
187
|
+
request = adapter.get("request_access")
|
|
188
|
+
if request:
|
|
189
|
+
_ = ui.link("Request access", str(request), new_tab=True)
|
|
190
|
+
|
|
191
|
+
async def install() -> None:
|
|
192
|
+
_ = dialog.close()
|
|
193
|
+
await install_adapter(dict(adapter), (str(username.value), str(token.value)))
|
|
194
|
+
|
|
195
|
+
with ui.row().classes("w-full justify-end"):
|
|
196
|
+
_ = ui.button("Cancel", on_click=dialog.close).classes("btn-tertiary")
|
|
197
|
+
_ = ui.button("Install", icon="download", on_click=install).classes("btn-secondary")
|
|
198
|
+
_ = dialog.open()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _uninstall_button(adapter: Mapping[str, object]) -> None:
|
|
202
|
+
"""The installed adapter card's Uninstall button, unless adapter installs are disabled."""
|
|
203
|
+
if adapter_install_disabled():
|
|
204
|
+
return
|
|
205
|
+
_ = ui.button("Uninstall", icon="delete", on_click=lambda: uninstall_adapter(dict(adapter))).classes(
|
|
206
|
+
"btn-tertiary w-full rounded-lg transition-colors"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _install_disabled_note() -> None:
|
|
211
|
+
"""Says why the marketplace has no Install buttons."""
|
|
212
|
+
if not adapter_install_disabled():
|
|
213
|
+
return
|
|
214
|
+
_ = ui.label("Installing and uninstalling adapters is disabled on this LEAF.").classes("text-body mb-4")
|
|
215
|
+
|
|
216
|
+
|
|
163
217
|
def build_adapters_content(container: ui.column) -> None:
|
|
164
218
|
"""
|
|
165
219
|
Build the adapters content (installed and marketplace).
|
|
@@ -228,12 +282,7 @@ def build_adapters_content(container: ui.column) -> None:
|
|
|
228
282
|
ui.label(installed_adapter["code"]).classes("text-lg font-bold text-heading mb-1")
|
|
229
283
|
ui.label(installed_adapter["name"]).classes("text-sm text-body mb-3")
|
|
230
284
|
|
|
231
|
-
|
|
232
|
-
return lambda: uninstall_adapter(adapter)
|
|
233
|
-
|
|
234
|
-
ui.button(
|
|
235
|
-
"Uninstall", icon="delete", on_click=make_uninstall_handler(installed_adapter)
|
|
236
|
-
).classes("btn-tertiary w-full rounded-lg transition-colors")
|
|
285
|
+
_uninstall_button(installed_adapter)
|
|
237
286
|
else:
|
|
238
287
|
with ui.card().classes("bg-info-card border border-default p-4 mb-8"):
|
|
239
288
|
with ui.row().classes("items-center"):
|
|
@@ -246,6 +295,7 @@ def build_adapters_content(container: ui.column) -> None:
|
|
|
246
295
|
with ui.row().classes("items-center mb-4"):
|
|
247
296
|
ui.icon("store", size="1.5rem").classes("text-icon")
|
|
248
297
|
ui.label("Adapter Marketplace").classes("text-xl font-semibold text-subheading ml-2")
|
|
298
|
+
_install_disabled_note()
|
|
249
299
|
|
|
250
300
|
# Container for marketplace adapters
|
|
251
301
|
marketplace_container = ui.column().classes("w-full")
|
|
@@ -350,12 +400,7 @@ def build_adapters_content(container: ui.column) -> None:
|
|
|
350
400
|
ui.label(adapter["adapter_id"]).classes("text-lg font-bold text-heading mb-1")
|
|
351
401
|
ui.label(adapter.get("name", "No description")).classes("text-sm text-body mb-3")
|
|
352
402
|
|
|
353
|
-
|
|
354
|
-
return lambda: install_adapter(adptr)
|
|
355
|
-
|
|
356
|
-
ui.button(
|
|
357
|
-
"Install", icon="download", on_click=make_install_handler(adapter)
|
|
358
|
-
).classes("btn-secondary w-full rounded-lg transition-colors")
|
|
403
|
+
_install_button(adapter)
|
|
359
404
|
except Exception as e:
|
|
360
405
|
logger.error(f"Failed to load marketplace: {e}", exc_info=True)
|
|
361
406
|
# Remove loading indicator
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from typing import Any
|
|
9
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
8
10
|
|
|
9
11
|
from nicegui import ui
|
|
10
12
|
|
|
@@ -59,16 +61,49 @@ def _run_pip(*args: str) -> subprocess.CompletedProcess[str]:
|
|
|
59
61
|
)
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
# Set (1/true/yes) where the adapters are fixed, e.g. a shared deployment whose adapters come from its image
|
|
65
|
+
ADAPTER_INSTALL_DISABLE_ENV = "LEAF_DISABLE_ADAPTER_INSTALL"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def adapter_install_disabled() -> bool:
|
|
69
|
+
"""True when installing and uninstalling adapters from the web interface is switched off."""
|
|
70
|
+
return os.environ.get(ADAPTER_INSTALL_DISABLE_ENV, "").strip().lower() in ("1", "true", "yes")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _refused(action: str) -> bool:
|
|
74
|
+
"""Tell the user and return True when adapter installs are switched off, so pip never runs."""
|
|
75
|
+
if not adapter_install_disabled():
|
|
76
|
+
return False
|
|
77
|
+
logger.warning(f"{action} adapters is disabled via {ADAPTER_INSTALL_DISABLE_ENV}")
|
|
78
|
+
ui.notify(f"{action} adapters is disabled on this LEAF", type="warning", position="top", timeout=5000)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def with_credentials(repository: str, username: str, token: str) -> str:
|
|
83
|
+
"""repository with username and token in it, for pip to clone a private (restricted) adapter."""
|
|
84
|
+
parts = urlsplit(repository)
|
|
85
|
+
host = parts.hostname or ""
|
|
86
|
+
if parts.port:
|
|
87
|
+
host = f"{host}:{parts.port}"
|
|
88
|
+
return urlunsplit(parts._replace(netloc=f"{quote(username, safe='')}:{quote(token, safe='')}@{host}"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def install_adapter(adapter: dict[Any, Any], credentials: tuple[str, str] | None = None) -> None:
|
|
63
92
|
"""
|
|
64
93
|
Install a LEAF adapter from a Git repository.
|
|
65
94
|
|
|
66
95
|
Args:
|
|
67
96
|
adapter: Dictionary containing adapter metadata including 'repo_url' and 'name'
|
|
97
|
+
credentials: Username and token for a restricted adapter's private repository; never logged
|
|
68
98
|
"""
|
|
99
|
+
if _refused("Installing"):
|
|
100
|
+
return
|
|
69
101
|
adapter_name = adapter.get("name") or adapter.get("adapter_id", "adapter")
|
|
70
102
|
logger.info(f"Installing {adapter_name}...")
|
|
71
103
|
repository = adapter["repo_url"]
|
|
104
|
+
if credentials is not None:
|
|
105
|
+
# pip masks the token in its own output; LEAF never logs this URL
|
|
106
|
+
repository = with_credentials(repository, *credentials) # pyright: ignore[reportAny]
|
|
72
107
|
|
|
73
108
|
# Show "installing" notification
|
|
74
109
|
ui.notify(f"Installing {adapter_name}... This may take a moment.", type="info", position="top", timeout=3000)
|
|
@@ -119,6 +154,8 @@ async def uninstall_adapter(installed_adapter: dict[Any, Any]) -> None:
|
|
|
119
154
|
Args:
|
|
120
155
|
installed_adapter: Dictionary containing adapter metadata with 'name' field
|
|
121
156
|
"""
|
|
157
|
+
if _refused("Uninstalling"):
|
|
158
|
+
return
|
|
122
159
|
package_name = installed_adapter.get("name")
|
|
123
160
|
|
|
124
161
|
if not package_name:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "leaf-framework"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
description = "Lightweight Adapter Framework (LEAF)"
|
|
5
5
|
authors = [
|
|
6
6
|
"Matthew Crowther <nmc215@ncl.ac.uk>",
|
|
@@ -23,19 +23,17 @@ include = [
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
[tool.poetry.dependencies]
|
|
26
|
-
aioredis = "~2.0.1"
|
|
27
26
|
dateparser = "~1.4.3"
|
|
28
27
|
httpx = "~0.28.1"
|
|
29
|
-
influxobject = ">=0.0.13" # 0.0.13 adds InfluxPoint.set_organisation/get_organisation, used by leaf-example
|
|
30
28
|
nicegui = "~3.18.0"
|
|
31
29
|
paho-mqtt = "~2.1.0"
|
|
32
30
|
pyyaml = "~6.0.3"
|
|
33
31
|
python = ">=3.12,<3.15"
|
|
34
|
-
redis = "~8.1.0"
|
|
35
32
|
requests = "~2.34.2"
|
|
36
33
|
watchdog = "~7.0.0"
|
|
37
34
|
cairosvg = "^2.9.1"
|
|
38
35
|
pillow = "^13.0.0"
|
|
36
|
+
leafobject = "^0.0.14"
|
|
39
37
|
|
|
40
38
|
[tool.poetry.scripts]
|
|
41
39
|
leaf = "leaf.start:main"
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from typing import Any
|
|
3
|
-
|
|
4
|
-
import redis
|
|
5
|
-
|
|
6
|
-
from leaf.error_handler.error_holder import ErrorHolder
|
|
7
|
-
from leaf.error_handler.exceptions import ClientUnreachableError, SeverityLevel
|
|
8
|
-
from leaf.modules.output_modules.output_module import OutputModule
|
|
9
|
-
from leaf.utility.logger.logger_utils import get_logger
|
|
10
|
-
|
|
11
|
-
logger = get_logger(__name__, log_file="output_module.log")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class KEYDB(OutputModule):
|
|
15
|
-
"""
|
|
16
|
-
An output module for interacting with a KeyDB (Redis-compatible)
|
|
17
|
-
server. This class provides methods to connect to KeyDB, transmit
|
|
18
|
-
data, retrieve data, and handle errors consistently. If connection
|
|
19
|
-
or transmission fails, a fallback module can be used if provided.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
def __init__(
|
|
23
|
-
self,
|
|
24
|
-
host: str,
|
|
25
|
-
port: int = 6379,
|
|
26
|
-
db: int = 0,
|
|
27
|
-
fallback: OutputModule | None = None,
|
|
28
|
-
error_holder: ErrorHolder | None = None,
|
|
29
|
-
name: str | None = None,
|
|
30
|
-
) -> None:
|
|
31
|
-
"""
|
|
32
|
-
Initialize the KEYDB adapter with KeyDB connection details and
|
|
33
|
-
optional fallback.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
host (str): The KeyDB server hostname or IP address.
|
|
37
|
-
port (int): The port for KeyDB connection (default is 6379).
|
|
38
|
-
db (int): The database number to connect to (default is 0).
|
|
39
|
-
fallback (Optional[OutputModule]): Fallback module to use
|
|
40
|
-
if KeyDB operations fail.
|
|
41
|
-
error_holder (Optional[ErrorHolder]): Optional error holder
|
|
42
|
-
for tracking errors.
|
|
43
|
-
"""
|
|
44
|
-
super().__init__(fallback=fallback, error_holder=error_holder, name=name)
|
|
45
|
-
if not self._name:
|
|
46
|
-
self._name = f"KEYDB({host}:{port})"
|
|
47
|
-
self.host: str = host
|
|
48
|
-
self.port: int = port
|
|
49
|
-
self.db: int = db
|
|
50
|
-
self._client: redis.StrictRedis | None = None
|
|
51
|
-
self.connect()
|
|
52
|
-
|
|
53
|
-
def _handle_redis_error(self, exception: redis.RedisError) -> None:
|
|
54
|
-
"""
|
|
55
|
-
Handle Redis-related errors by logging
|
|
56
|
-
them and invoking the error handler.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
exception (redis.RedisError): The Redis exception that occurred.
|
|
60
|
-
"""
|
|
61
|
-
if isinstance(exception, redis.AuthenticationError):
|
|
62
|
-
message = f"Authentication failed for KeyDB at {self.host}:{self.port}"
|
|
63
|
-
severity = SeverityLevel.CRITICAL
|
|
64
|
-
elif isinstance(exception, redis.ConnectionError):
|
|
65
|
-
if "Network is unreachable" in str(exception):
|
|
66
|
-
message = f"Network unreachable for KeyDB at {self.host}:{self.port}"
|
|
67
|
-
severity = SeverityLevel.ERROR
|
|
68
|
-
elif "Connection refused" in str(exception):
|
|
69
|
-
message = f"Connection refused for KeyDB at {self.host}:{self.port}"
|
|
70
|
-
severity = SeverityLevel.WARNING
|
|
71
|
-
else:
|
|
72
|
-
message = f"Failed to connect to KeyDB at {self.host}:{self.port}: {str(exception)}"
|
|
73
|
-
severity = SeverityLevel.CRITICAL
|
|
74
|
-
elif isinstance(exception, redis.TimeoutError):
|
|
75
|
-
message = f"Connection to KeyDB at {self.host}:{self.port} timed out."
|
|
76
|
-
severity = SeverityLevel.ERROR
|
|
77
|
-
else:
|
|
78
|
-
message = f"Redis error for KeyDB: {str(exception)}"
|
|
79
|
-
severity = SeverityLevel.WARNING
|
|
80
|
-
|
|
81
|
-
self._handle_exception(ClientUnreachableError(message, output_module=self, severity=severity))
|
|
82
|
-
|
|
83
|
-
def connect(self) -> None:
|
|
84
|
-
"""
|
|
85
|
-
Establish a connection to the KeyDB server.
|
|
86
|
-
|
|
87
|
-
Logs success or handles connection errors by
|
|
88
|
-
invoking the error handler.
|
|
89
|
-
"""
|
|
90
|
-
try:
|
|
91
|
-
self._client = redis.StrictRedis(host=self.host, port=self.port, db=self.db)
|
|
92
|
-
logger.info(f"Connected to KeyDB server at {self.host}:{self.port}, DB: {self.db}")
|
|
93
|
-
except redis.RedisError as e:
|
|
94
|
-
self._handle_redis_error(e)
|
|
95
|
-
|
|
96
|
-
def transmit(self, topic: str, data: Any | None = None) -> bool:
|
|
97
|
-
"""
|
|
98
|
-
Transmit data to the KeyDB server by appending it to the existing
|
|
99
|
-
value for the given key. The value for each key is always stored
|
|
100
|
-
as a list, even if it contains only one element.
|
|
101
|
-
|
|
102
|
-
Args:
|
|
103
|
-
topic (str): The key name under which the data will be stored.
|
|
104
|
-
data (Optional[Any]): The data to append to the list in KeyDB.
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
bool: True if the data was successfully transmitted,
|
|
108
|
-
False if a fallback was used.
|
|
109
|
-
"""
|
|
110
|
-
if data is None:
|
|
111
|
-
logger.warning("No data provided to transmit.")
|
|
112
|
-
return False
|
|
113
|
-
elif isinstance(data, (dict, list, tuple, int, float, bool)):
|
|
114
|
-
data = json.dumps(data)
|
|
115
|
-
elif isinstance(data, str):
|
|
116
|
-
try:
|
|
117
|
-
# Validate JSON, then normalize as JSON string
|
|
118
|
-
parsed = json.loads(data)
|
|
119
|
-
data = json.dumps(parsed)
|
|
120
|
-
except json.JSONDecodeError:
|
|
121
|
-
logger.error(f"Invalid JSON string: {data}")
|
|
122
|
-
return False
|
|
123
|
-
else:
|
|
124
|
-
logger.error(f"Unsupported data type: {type(data).__name__}")
|
|
125
|
-
return False
|
|
126
|
-
|
|
127
|
-
if self._client is None:
|
|
128
|
-
return self.fallback(topic, data)
|
|
129
|
-
|
|
130
|
-
try:
|
|
131
|
-
self._client.lpush(topic, data)
|
|
132
|
-
logger.info(f"Pushed data to key '{topic}' in KeyDB {self._client} with {self._client.llen(topic)} rows.")
|
|
133
|
-
# Reset global failure counter on successful transmission
|
|
134
|
-
OutputModule.reset_failure_count()
|
|
135
|
-
self.store_latest(topic, data)
|
|
136
|
-
# Transmit to parallel outputs (best-effort)
|
|
137
|
-
self.transmit_to_parallels(topic, data)
|
|
138
|
-
return True
|
|
139
|
-
except redis.RedisError as e:
|
|
140
|
-
self._handle_redis_error(e)
|
|
141
|
-
return self.fallback(topic, data)
|
|
142
|
-
|
|
143
|
-
def is_connected(self) -> bool:
|
|
144
|
-
"""
|
|
145
|
-
Check if the connection to KeyDB is established.
|
|
146
|
-
|
|
147
|
-
Returns:
|
|
148
|
-
bool: True if connected, False otherwise.
|
|
149
|
-
"""
|
|
150
|
-
# TODO Have a thread running to check for content and if present process it to the registered MQTT output module if alive
|
|
151
|
-
topics = self._client.keys()
|
|
152
|
-
if topics:
|
|
153
|
-
logger.info(f"Found existing keys in KeyDB: {topics}")
|
|
154
|
-
for topic in topics:
|
|
155
|
-
# Keys are bytes unless the client decodes responses
|
|
156
|
-
if isinstance(topic, bytes):
|
|
157
|
-
topic = topic.decode("utf-8")
|
|
158
|
-
self.pop(topic)
|
|
159
|
-
else:
|
|
160
|
-
logger.debug("No existing keys found in KeyDB.")
|
|
161
|
-
return self._client is not None
|
|
162
|
-
|
|
163
|
-
def disconnect(self) -> None:
|
|
164
|
-
"""
|
|
165
|
-
Disconnect from the KeyDB server by
|
|
166
|
-
setting the client to None.
|
|
167
|
-
|
|
168
|
-
Logs the disconnection status.
|
|
169
|
-
"""
|
|
170
|
-
if self._client is not None:
|
|
171
|
-
self._client = None
|
|
172
|
-
logger.info("Disconnected from KeyDB.")
|
|
173
|
-
else:
|
|
174
|
-
logger.info("Already disconnected from KeyDB.")
|
|
175
|
-
|
|
176
|
-
def pop(self, key: str | None = None) -> tuple[str, Any] | None:
|
|
177
|
-
"""
|
|
178
|
-
Retrieve and remove a record from KeyDB.
|
|
179
|
-
If a specific key is provided, retrieve and remove all values under
|
|
180
|
-
that key. If no key is provided, retrieve and remove one element from
|
|
181
|
-
a random key's list. The key is removed when the last element is taken.
|
|
182
|
-
|
|
183
|
-
Args:
|
|
184
|
-
key (Optional[str]): The key of the record to retrieve and remove.
|
|
185
|
-
If None, a random record is retrieved and removed.
|
|
186
|
-
|
|
187
|
-
Returns:
|
|
188
|
-
Optional[tuple[str, Any]]: A tuple of the key and the retrieved value,
|
|
189
|
-
or None if the key does not exist or the
|
|
190
|
-
database is empty.
|
|
191
|
-
"""
|
|
192
|
-
if self._client is None:
|
|
193
|
-
logger.warning("Attempted to pop from a disconnected KeyDB client.")
|
|
194
|
-
return None
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
if key is not None:
|
|
198
|
-
result = self._client.lpop(key)
|
|
199
|
-
if result:
|
|
200
|
-
# Content is already popped
|
|
201
|
-
# If the list is empty after popping, delete the key
|
|
202
|
-
if self._client.llen(key) == 0:
|
|
203
|
-
self._client.delete(key)
|
|
204
|
-
return key, json.loads(result.decode("utf-8"))
|
|
205
|
-
return None
|
|
206
|
-
|
|
207
|
-
raw_key = self._client.randomkey()
|
|
208
|
-
if not raw_key:
|
|
209
|
-
logger.info("No keys available in KeyDB.")
|
|
210
|
-
return None
|
|
211
|
-
random_key = str(raw_key)
|
|
212
|
-
if isinstance(raw_key, bytes):
|
|
213
|
-
# Decode bytes to string if necessary
|
|
214
|
-
random_key = raw_key.decode("utf-8")
|
|
215
|
-
|
|
216
|
-
logger.info(f"Popping from key '{random_key}' in KeyDB.")
|
|
217
|
-
result = self._client.lpop(random_key)
|
|
218
|
-
|
|
219
|
-
if not result:
|
|
220
|
-
return None
|
|
221
|
-
|
|
222
|
-
result = json.loads(result.decode("utf-8"))
|
|
223
|
-
if result:
|
|
224
|
-
# Check if the list is empty after popping
|
|
225
|
-
if self._client.llen(random_key) == 0:
|
|
226
|
-
logger.info(f"Key '{random_key}' is empty after popping.")
|
|
227
|
-
self._client.delete(random_key)
|
|
228
|
-
logger.info(f"Popped key '{random_key}' from KeyDB.")
|
|
229
|
-
return random_key, result
|
|
230
|
-
# if isinstance(result, list):
|
|
231
|
-
# popped_value = result.pop(0)
|
|
232
|
-
# if result:
|
|
233
|
-
# self._client.set(random_key, json.dumps(result))
|
|
234
|
-
# else:
|
|
235
|
-
# self._client.delete(random_key)
|
|
236
|
-
# return random_key, popped_value
|
|
237
|
-
|
|
238
|
-
logger.error(f"Unexpected value type for key '{random_key}'. Deleting key.")
|
|
239
|
-
self._client.delete(random_key)
|
|
240
|
-
return None
|
|
241
|
-
except redis.RedisError as e:
|
|
242
|
-
self._handle_redis_error(e)
|
|
243
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/streaming_adapter.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/upload_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/event_watcher.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_api_watcher.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/modbus_watcher.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/polling_watcher.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/simple_watcher.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/__init__.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/carbon_dioxide.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/fluorescence.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/optical_density.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/temperature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/output_module.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/external_event_phase.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/initialisation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/continous_module.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/discrete_module.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/process_module.py
RENAMED
|
File without changes
|
{leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/upload_module.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|