leaf-framework 0.10.22__tar.gz → 0.11.0__tar.gz

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