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.
Files changed (113) hide show
  1. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/PKG-INFO +3 -5
  2. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/README.md +1 -1
  3. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/control_spec.py +52 -0
  4. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/equipment_adapter.py +14 -1
  5. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/config/configuration.yaml +3 -11
  6. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/data/marketplace.yaml +5 -1
  7. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/mqtt.py +1 -1
  8. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/topic_store.py +1 -1
  9. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/measure.py +1 -1
  10. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/equipment_actions.yaml +1 -0
  11. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/markdown/configuration_help.md +5 -3
  12. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/markdown/documentation_main.md +3 -6
  13. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/adapters.py +58 -13
  14. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/utils.py +38 -1
  15. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/pyproject.toml +2 -4
  16. leaf_framework-0.10.25/leaf/modules/output_modules/keydb.py +0 -243
  17. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/LICENSE +0 -0
  18. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/__init__.py +0 -0
  19. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/__init__.py +0 -0
  20. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/__init__.py +0 -0
  21. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
  22. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
  23. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
  24. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
  25. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/error_handler/__init__.py +0 -0
  26. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/error_handler/error_holder.py +0 -0
  27. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/error_handler/exceptions.py +0 -0
  28. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/__init__.py +0 -0
  29. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/handler.py +0 -0
  30. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/measurements.yaml +0 -0
  31. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/measurement_handler/terms.py +0 -0
  32. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/.gitkeep +0 -0
  33. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/__init__.py +0 -0
  34. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/__init__.py +0 -0
  35. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/ble_watcher.py +0 -0
  36. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/db_watcher.py +0 -0
  37. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/event_watcher.py +0 -0
  38. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_api_watcher.py +0 -0
  39. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/external_event_watcher.py +0 -0
  40. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/file_watcher.py +0 -0
  41. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/http_watcher.py +0 -0
  42. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/lads_watcher.py +0 -0
  43. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/modbus_watcher.py +0 -0
  44. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
  45. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
  46. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/opc_watcher.py +0 -0
  47. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/polling_watcher.py +0 -0
  48. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/input_modules/simple_watcher.py +0 -0
  49. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/__init__.py +0 -0
  50. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
  51. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
  52. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/fluorescence.py +0 -0
  53. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/measurement_module.py +0 -0
  54. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/optical_density.py +0 -0
  55. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/ph.py +0 -0
  56. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/measurement_modules/temperature.py +0 -0
  57. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/__init__.py +0 -0
  58. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/file.py +0 -0
  59. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/output_module.py +0 -0
  60. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/output_modules/sql.py +0 -0
  61. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/__init__.py +0 -0
  62. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/control.py +0 -0
  63. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/error.py +0 -0
  64. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/external_event_phase.py +0 -0
  65. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/initialisation.py +0 -0
  66. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/phase.py +0 -0
  67. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/start.py +0 -0
  68. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/phase_modules/stop.py +0 -0
  69. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/__init__.py +0 -0
  70. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/continous_module.py +0 -0
  71. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/discrete_module.py +0 -0
  72. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/external_event_process.py +0 -0
  73. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/process_module.py +0 -0
  74. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/modules/process_modules/upload_module.py +0 -0
  75. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/__init__.py +0 -0
  76. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/abstract_leaf.py +0 -0
  77. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/equipment_data.yaml +0 -0
  78. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/metadata.py +0 -0
  79. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/py.typed +0 -0
  80. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/register/topic_utilities.py +0 -0
  81. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/discovery.py +0 -0
  82. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/loader.py +0 -0
  83. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/registry.py +0 -0
  84. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/registry/utils.py +0 -0
  85. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/start.py +0 -0
  86. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/__init__.py +0 -0
  87. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/adapter_ui_extension.py +0 -0
  88. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/auth.py +0 -0
  89. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/__init__.py +0 -0
  90. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/app_control.py +0 -0
  91. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/header.py +0 -0
  92. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/components/update_notice.py +0 -0
  93. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/constants.py +0 -0
  94. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/desktop_launcher.py +0 -0
  95. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/images/icon.icns +0 -0
  96. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/images/icon.ico +0 -0
  97. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/images/icon.svg +0 -0
  98. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/interface.py +0 -0
  99. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/markdown/documentation_protips.md +0 -0
  100. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/scripts.js +0 -0
  101. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/styles.css +0 -0
  102. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/__init__.py +0 -0
  103. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/configuration.py +0 -0
  104. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/control.py +0 -0
  105. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/documentation.py +0 -0
  106. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/logs.py +0 -0
  107. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/tabs/monitoring.py +0 -0
  108. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/ui/update_checker.py +0 -0
  109. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/config_utils.py +0 -0
  110. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/logger/__init__.py +0 -0
  111. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/logger/logger_utils.py +0 -0
  112. {leaf_framework-0.10.25 → leaf_framework-0.11.0}/leaf/utility/running_adapters.py +0 -0
  113. {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.10.25
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: influxobject (>=0.0.13)
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: SQL #OPTIONAL expects "SQL", "KEYDB", "FILE"
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: KEYDB #OPTIONAL expects "MQTT", "KEYDB", "FILE"
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: FILE #OPTIONAL expects "MQTT", "SQL", "KEYDB"
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 InfluxObject messages."
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., KeyDB or FILE) to retrieve buffered messages.
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/InfluxPoint JSON dict with a numeric fields.value, or None."""
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 an InfluxPoint or leafobject LeafPoint, recognised by to_json() so LEAF need not depend on leafobject."""
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: KEYDB
19
- host: localhost
20
- port: 6379
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
- def make_uninstall_handler(adapter):
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
- def make_install_handler(adptr):
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
- async def install_adapter(adapter: dict[Any, Any]) -> None:
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.10.25"
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