leaf-framework 0.10.25__tar.gz → 0.11.1__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.1}/PKG-INFO +3 -5
  2. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/README.md +1 -1
  3. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/control_spec.py +58 -0
  4. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/equipment_adapter.py +15 -1
  5. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/config/configuration.yaml +3 -11
  6. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/data/marketplace.yaml +5 -1
  7. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/ble_watcher.py +0 -1
  8. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -19
  9. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/mqtt.py +1 -1
  10. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/topic_store.py +1 -1
  11. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/measure.py +23 -1
  12. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/equipment_actions.yaml +1 -0
  13. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/metadata.py +10 -1
  14. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/discovery.py +1 -2
  15. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/registry.py +0 -7
  16. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/start.py +1 -1
  17. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/constants.py +1 -6
  18. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/interface.py +0 -6
  19. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/markdown/configuration_help.md +5 -3
  20. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/markdown/documentation_main.md +3 -6
  21. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/adapters.py +96 -13
  22. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/utils.py +50 -1
  23. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/pyproject.toml +2 -4
  24. leaf_framework-0.10.25/leaf/measurement_handler/handler.py +0 -11
  25. leaf_framework-0.10.25/leaf/modules/input_modules/db_watcher.py +0 -15
  26. leaf_framework-0.10.25/leaf/modules/output_modules/keydb.py +0 -243
  27. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/LICENSE +0 -0
  28. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/__init__.py +0 -0
  29. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/__init__.py +0 -0
  30. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/__init__.py +0 -0
  31. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
  32. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
  33. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
  34. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
  35. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/error_handler/__init__.py +0 -0
  36. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/error_handler/error_holder.py +0 -0
  37. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/error_handler/exceptions.py +0 -0
  38. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/measurement_handler/__init__.py +0 -0
  39. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/measurement_handler/measurements.yaml +0 -0
  40. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/measurement_handler/terms.py +0 -0
  41. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/.gitkeep +0 -0
  42. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/__init__.py +0 -0
  43. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/__init__.py +0 -0
  44. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/event_watcher.py +0 -0
  45. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/external_api_watcher.py +0 -0
  46. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/external_event_watcher.py +0 -0
  47. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/file_watcher.py +0 -0
  48. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/http_watcher.py +0 -0
  49. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/lads_watcher.py +0 -0
  50. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/modbus_watcher.py +0 -0
  51. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
  52. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/opc_watcher.py +0 -0
  53. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/polling_watcher.py +0 -0
  54. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/simple_watcher.py +0 -0
  55. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/__init__.py +0 -0
  56. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
  57. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
  58. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/fluorescence.py +0 -0
  59. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/measurement_module.py +0 -0
  60. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/optical_density.py +0 -0
  61. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/ph.py +0 -0
  62. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/temperature.py +0 -0
  63. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/__init__.py +0 -0
  64. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/file.py +0 -0
  65. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/output_module.py +0 -0
  66. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/sql.py +0 -0
  67. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/__init__.py +0 -0
  68. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/control.py +0 -0
  69. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/error.py +0 -0
  70. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/external_event_phase.py +0 -0
  71. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/initialisation.py +0 -0
  72. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/phase.py +0 -0
  73. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/start.py +0 -0
  74. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/stop.py +0 -0
  75. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/__init__.py +0 -0
  76. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/continous_module.py +0 -0
  77. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/discrete_module.py +0 -0
  78. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/external_event_process.py +0 -0
  79. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/process_module.py +0 -0
  80. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/upload_module.py +0 -0
  81. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/__init__.py +0 -0
  82. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/abstract_leaf.py +0 -0
  83. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/equipment_data.yaml +0 -0
  84. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/py.typed +0 -0
  85. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/topic_utilities.py +0 -0
  86. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/loader.py +0 -0
  87. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/utils.py +0 -0
  88. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/__init__.py +0 -0
  89. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/adapter_ui_extension.py +0 -0
  90. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/auth.py +0 -0
  91. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/__init__.py +0 -0
  92. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/app_control.py +0 -0
  93. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/header.py +0 -0
  94. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/update_notice.py +0 -0
  95. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/desktop_launcher.py +0 -0
  96. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/images/icon.icns +0 -0
  97. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/images/icon.ico +0 -0
  98. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/images/icon.svg +0 -0
  99. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/markdown/documentation_protips.md +0 -0
  100. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/scripts.js +0 -0
  101. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/styles.css +0 -0
  102. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/__init__.py +0 -0
  103. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/configuration.py +0 -0
  104. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/control.py +0 -0
  105. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/documentation.py +0 -0
  106. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/logs.py +0 -0
  107. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/monitoring.py +0 -0
  108. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/update_checker.py +0 -0
  109. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/config_utils.py +0 -0
  110. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/logger/__init__.py +0 -0
  111. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/logger/logger_utils.py +0 -0
  112. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/running_adapters.py +0 -0
  113. {leaf_framework-0.10.25 → leaf_framework-0.11.1}/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.1
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,60 @@ 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(
144
+ data: str, source: str, error: Exception | None = None, instance_tags: dict[str, str] | None = None
145
+ ) -> dict[str, object]:
146
+ """
147
+ A control message as the LeafPoint LEAF publishes on <control topic>/result, so that what was
148
+ sent to the equipment is stored with the measurements.
149
+
150
+ source ("ui" or "mqtt"), status ("applied" or "rejected") and a rejection's "error" are tags,
151
+ for consumers to filter on. Built as a leafobject LeafPoint, so tags are text and there is
152
+ always a field: a message without fields keeps its raw text in "message".
153
+ """
154
+ point: dict[str, object] = {}
155
+ try:
156
+ parsed = cast(object, json.loads(data))
157
+ if isinstance(parsed, dict):
158
+ point = cast(dict[str, object], parsed)
159
+ except ValueError:
160
+ pass
161
+ tags: dict[str, str] = {}
162
+ for key, value in _part(point, "tags").items():
163
+ tags[key] = str(value)
164
+ # The instance's organisation and department, where the message didn't set them
165
+ if instance_tags is not None:
166
+ for key, value in instance_tags.items():
167
+ _ = tags.setdefault(key, value)
168
+ fields: dict[str, int | float | str] = {}
169
+ for key, value in _part(point, "fields").items():
170
+ fields[key] = _field_value(value)
171
+ tags["source"] = source
172
+ tags["status"] = "applied"
173
+ if error is not None:
174
+ tags["status"] = "rejected"
175
+ tags["error"] = str(error)
176
+ if not fields:
177
+ fields["message"] = data
178
+ record = LeafPoint()
179
+ record.set_measurement(str(point.get("measurement", "control")))
180
+ record.set_tags(tags)
181
+ record.set_fields(fields)
182
+ 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,19 @@ 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
+ record = result_record(data, source, error, self._metadata_manager.instance_tags())
297
+ _ = self._output.transmit(f"{topic}/result", record)
298
+ except Exception as e:
299
+ self._logger.warning(f"Could not publish the control record for {topic}: {e}")
286
300
 
287
301
  def _subscribe_control(self) -> None:
288
302
  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
@@ -21,7 +21,6 @@ logger = get_logger(__name__, log_file="input_module.log")
21
21
  FIRST_RECONNECT_DELAY = 1
22
22
  RECONNECT_RATE = 2
23
23
  MAX_RECONNECT_DELAY = 60
24
- MAX_RECONNECT_COUNT = 12
25
24
 
26
25
 
27
26
  class BLEEventWatcher(EventWatcher):
@@ -241,22 +241,3 @@ class MQTTExternalEventWatcher(ExternalEventWatcher):
241
241
  bool: True if the client is connected, False otherwise.
242
242
  """
243
243
  return self.client.is_connected()
244
-
245
- def _handle_return_code(self, return_code: int) -> ClientUnreachableError | None:
246
- """
247
- Handle MQTT return codes.
248
-
249
- Args:
250
- return_code (int): The return code to handle.
251
-
252
- Returns:
253
- Optional[ClientUnreachableError]: An error if one occurred, None otherwise.
254
- """
255
- if return_code == mqtt.MQTT_ERR_SUCCESS:
256
- return None
257
- message = {
258
- mqtt.MQTT_ERR_NO_CONN: "Can't connect to broker",
259
- mqtt.MQTT_ERR_QUEUE_SIZE: "Message queue size limit reached",
260
- }.get(return_code, f"Unknown error with return code {return_code}")
261
-
262
- return ClientUnreachableError(message, output_module=self, severity=SeverityLevel.INFO)
@@ -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)
@@ -43,6 +43,7 @@ class MeasurePhase(PhaseModule):
43
43
 
44
44
  super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
45
45
  self._maximum_message_size: int = maximum_message_size
46
+ self._tag_mismatches: set[tuple[str, str]] = set()
46
47
  # Guards against a second measurement running concurrently with one still
47
48
  # draining (e.g. a generator-based interpreter that streams for a long time,
48
49
  # or forever). Without this, an overlapping event (e.g. FileWatcher's
@@ -231,6 +232,7 @@ class MeasurePhase(PhaseModule):
231
232
  list[tuple]: A list of (action, result) tuples.
232
233
  """
233
234
  if isinstance(result, dict):
235
+ self._add_instance_tags(cast(object, result))
234
236
  measurement = self._resolve_measurement(result)
235
237
  action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
236
238
  return [(action, result)]
@@ -239,6 +241,7 @@ class MeasurePhase(PhaseModule):
239
241
  point_json = point_json_of(payload)
240
242
  if point_json is not None:
241
243
  result = point_json
244
+ self._add_instance_tags(cast(object, result))
242
245
  measurement = self._resolve_measurement(result)
243
246
  action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
244
247
  return [(action, result)]
@@ -254,6 +257,7 @@ class MeasurePhase(PhaseModule):
254
257
 
255
258
  groups: dict[str, list] = {}
256
259
  for item in converted:
260
+ self._add_instance_tags(cast(object, item))
257
261
  if isinstance(item, dict):
258
262
  key = self._resolve_measurement(item)
259
263
  else:
@@ -270,6 +274,24 @@ class MeasurePhase(PhaseModule):
270
274
  self._handle_exception(excp)
271
275
  return []
272
276
 
277
+ def _add_instance_tags(self, item: object) -> None:
278
+ """Tag a point with its instance's organisation and department, unless the adapter already did."""
279
+ if self._metadata_manager is None or not isinstance(item, dict) or "fields" not in item:
280
+ return
281
+ tags = cast(dict[str, object], item).setdefault("tags", {})
282
+ if not isinstance(tags, dict):
283
+ return
284
+ for key, value in self._metadata_manager.instance_tags().items():
285
+ current = cast(dict[str, object], tags).get(key)
286
+ if current is None:
287
+ tags[key] = value
288
+ elif str(current) != value and (key, str(current)) not in self._tag_mismatches:
289
+ # Kept as the adapter set it; warned once so every measurement doesn't repeat it
290
+ self._tag_mismatches.add((key, str(current)))
291
+ logger.warning(
292
+ f"Tag {key}={current!r} differs from the configured {key} {value!r}; keeping {current!r}"
293
+ )
294
+
273
295
  def _resolve_measurement(self, item: dict) -> str:
274
296
  """
275
297
  Resolve the measurement label used for the topic's last segment.
@@ -291,7 +313,7 @@ class MeasurePhase(PhaseModule):
291
313
 
292
314
 
293
315
  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."""
316
+ """JSON of a leafobject LeafPoint (or anything else with to_json()), else None."""
295
317
  if isinstance(item, dict):
296
318
  return None
297
319
  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
@@ -1,6 +1,6 @@
1
1
  import json
2
2
  import os
3
- from typing import Any
3
+ from typing import Any, cast
4
4
 
5
5
  import yaml # type: ignore
6
6
 
@@ -60,6 +60,15 @@ class MetadataManager:
60
60
  return self._instance_data
61
61
  return self._instance_data.get(key)
62
62
 
63
+ def instance_tags(self) -> dict[str, str]:
64
+ """The organisation and department tags every point of this instance carries."""
65
+ tags: dict[str, str] = {}
66
+ for key in ("organisation", "department"):
67
+ value = cast(object, self._instance_data.get(key)) # pyright: ignore[reportUnknownMemberType]
68
+ if value:
69
+ tags[key] = str(value)
70
+ return tags
71
+
63
72
  def get_instance_id(self) -> str:
64
73
  """Convenience accessor for 'instance_id' in equipment."""
65
74
  return self._instance_data.get("instance_id", "")
@@ -16,8 +16,7 @@ from leaf.utility.logger.logger_utils import get_logger
16
16
  root_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")
17
17
  adapter_dir = os.path.join(root_dir, "adapters")
18
18
  core_adapter_dir = os.path.join(adapter_dir, "core_adapters")
19
- functional_adapter_dir = os.path.join(adapter_dir, "functional_adapters")
20
- default_equipment_locations = [core_adapter_dir, functional_adapter_dir]
19
+ default_equipment_locations = [core_adapter_dir]
21
20
 
22
21
  output_module_dir = os.path.join(root_dir, "modules", "output_modules")
23
22
  input_module_dir = os.path.join(root_dir, "modules", "input_modules")
@@ -56,13 +56,6 @@ def get_external_input(code: str) -> type[ExternalEventWatcher]:
56
56
  return cls
57
57
 
58
58
 
59
- def all_registered(plugin_type: PluginType) -> dict[str, type[Any]]:
60
- """
61
- Return all registered classes of a given plugin type.
62
- """
63
- return dict(_registry[plugin_type])
64
-
65
-
66
59
  def discover_from_config(config: dict[str, Any], external_path: str = None) -> None:
67
60
  """
68
61
  Discover and register only the plugins referenced in the given configuration.
@@ -111,7 +111,7 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
111
111
  "--port",
112
112
  type=int,
113
113
  default=4242,
114
- help="Port to run the NiceGUI interface on. Default is 8080.",
114
+ help="Port to run the NiceGUI interface on. Default is 4242.",
115
115
  )
116
116
 
117
117
  parser.add_argument(
@@ -1,16 +1,11 @@
1
1
  """Constants for LEAF UI."""
2
2
 
3
3
  # Server constants
4
- DEFAULT_PORT = 8080
4
+ DEFAULT_PORT = 4242
5
5
  MARKETPLACE_URL = "https://gitlab.com/leaf-framework/leaf-marketplace/-/raw/main/public/adapter.yaml"
6
6
  MAX_LOG_LINES = 1000
7
- CONFIGURATION_FILENAME = "configuration.yaml"
8
7
 
9
8
  # UI Constants
10
9
  CARD_WIDTH_CLASS = "w-64"
11
- HEADER_CLASSES = "leaf-header"
12
10
  CARD_CLASSES = "leaf-card"
13
11
  TAB_CLASSES = "leaf-tab"
14
-
15
- # Color constants
16
- STATUS_COLORS = {"online": "status-online", "offline": "status-offline", "warning": "status-warning"}
@@ -38,12 +38,6 @@ def get_startup_error() -> str | None:
38
38
  return _startup_error
39
39
 
40
40
 
41
- def clear_startup_error() -> None:
42
- """Clear the startup error."""
43
- global _startup_error
44
- _startup_error = None
45
-
46
-
47
41
  def start_nicegui(port: int = DEFAULT_PORT, config_path: Path | None = None, auto_open_browser: bool = True) -> None:
48
42
  """
49
43
  Start the LEAF NiceGUI web interface.
@@ -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, check_repository_url, 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,96 @@ 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").classes("w-full")
186
+ token = ui.input("Token", password=True, password_toggle_button=True).classes("w-full")
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 _install_from_url_button() -> None:
202
+ """Installs an adapter that is not in the marketplace, unless adapter installs are disabled."""
203
+ if adapter_install_disabled():
204
+ return
205
+ _ = ui.button("Install from git URL", icon="link", on_click=_url_dialog).classes("btn-secondary mb-4")
206
+
207
+
208
+ def _url_dialog() -> None:
209
+ """Asks for the git URL of an adapter, plus a username and token if its repository is private."""
210
+ with ui.dialog() as dialog, ui.card().classes("w-96"):
211
+ _ = ui.label("Install from a git URL").classes("text-h6")
212
+ _ = ui.label("Only install adapters from sources you trust: they run on this machine.").classes("text-body")
213
+ url = ui.input("Git URL", placeholder="https://gitlab.com/group/leaf-adapter.git").classes("w-full")
214
+ username = ui.input("Username (private repositories only)").classes("w-full")
215
+ token = ui.input("Token (private repositories only)", password=True, password_toggle_button=True).classes(
216
+ "w-full"
217
+ )
218
+
219
+ async def install() -> None:
220
+ repository = str(url.value).strip()
221
+ credentials = None
222
+ if username.value or token.value:
223
+ credentials = (str(username.value), str(token.value))
224
+ problem = check_repository_url(repository, credentials is not None)
225
+ if problem:
226
+ ui.notify(problem, type="negative", position="top")
227
+ return
228
+ _ = dialog.close()
229
+ name = repository.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
230
+ await install_adapter({"name": name, "repo_url": repository}, credentials)
231
+
232
+ with ui.row().classes("w-full justify-end"):
233
+ _ = ui.button("Cancel", on_click=dialog.close).classes("btn-tertiary")
234
+ _ = ui.button("Install", icon="download", on_click=install).classes("btn-secondary")
235
+ _ = dialog.open()
236
+
237
+
238
+ def _uninstall_button(adapter: Mapping[str, object]) -> None:
239
+ """The installed adapter card's Uninstall button, unless adapter installs are disabled."""
240
+ if adapter_install_disabled():
241
+ return
242
+ _ = ui.button("Uninstall", icon="delete", on_click=lambda: uninstall_adapter(dict(adapter))).classes(
243
+ "btn-tertiary w-full rounded-lg transition-colors"
244
+ )
245
+
246
+
247
+ def _install_disabled_note() -> None:
248
+ """Says why the marketplace has no Install buttons."""
249
+ if not adapter_install_disabled():
250
+ return
251
+ _ = ui.label("Installing and uninstalling adapters is disabled on this LEAF.").classes("text-body mb-4")
252
+
253
+
163
254
  def build_adapters_content(container: ui.column) -> None:
164
255
  """
165
256
  Build the adapters content (installed and marketplace).
@@ -228,12 +319,7 @@ def build_adapters_content(container: ui.column) -> None:
228
319
  ui.label(installed_adapter["code"]).classes("text-lg font-bold text-heading mb-1")
229
320
  ui.label(installed_adapter["name"]).classes("text-sm text-body mb-3")
230
321
 
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")
322
+ _uninstall_button(installed_adapter)
237
323
  else:
238
324
  with ui.card().classes("bg-info-card border border-default p-4 mb-8"):
239
325
  with ui.row().classes("items-center"):
@@ -246,6 +332,8 @@ def build_adapters_content(container: ui.column) -> None:
246
332
  with ui.row().classes("items-center mb-4"):
247
333
  ui.icon("store", size="1.5rem").classes("text-icon")
248
334
  ui.label("Adapter Marketplace").classes("text-xl font-semibold text-subheading ml-2")
335
+ _install_disabled_note()
336
+ _install_from_url_button()
249
337
 
250
338
  # Container for marketplace adapters
251
339
  marketplace_container = ui.column().classes("w-full")
@@ -350,12 +438,7 @@ def build_adapters_content(container: ui.column) -> None:
350
438
  ui.label(adapter["adapter_id"]).classes("text-lg font-bold text-heading mb-1")
351
439
  ui.label(adapter.get("name", "No description")).classes("text-sm text-body mb-3")
352
440
 
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")
441
+ _install_button(adapter)
359
442
  except Exception as e:
360
443
  logger.error(f"Failed to load marketplace: {e}", exc_info=True)
361
444
  # Remove loading indicator