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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/PKG-INFO +11 -7
  2. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/README.md +4 -0
  3. leaf_framework-0.10.25/leaf/adapters/control_spec.py +124 -0
  4. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/equipment_adapter.py +92 -1
  5. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/http_watcher.py +8 -5
  6. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/keydb.py +8 -6
  7. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/mqtt.py +17 -3
  8. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/output_module.py +15 -2
  9. leaf_framework-0.10.25/leaf/modules/output_modules/topic_store.py +114 -0
  10. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/measure.py +19 -8
  11. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/equipment_actions.yaml +1 -0
  12. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/discovery.py +6 -3
  13. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/loader.py +7 -1
  14. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/start.py +7 -1
  15. leaf_framework-0.10.25/leaf/ui/auth.py +213 -0
  16. leaf_framework-0.10.25/leaf/ui/components/app_control.py +86 -0
  17. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/components/header.py +3 -0
  18. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/components/update_notice.py +2 -9
  19. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/interface.py +7 -0
  20. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/configuration.py +61 -73
  21. leaf_framework-0.10.25/leaf/ui/tabs/control.py +242 -0
  22. leaf_framework-0.10.25/leaf/ui/tabs/monitoring.py +249 -0
  23. leaf_framework-0.10.25/leaf/utility/running_adapters.py +13 -0
  24. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/running_utilities.py +41 -3
  25. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/pyproject.toml +22 -9
  26. leaf_framework-0.10.22/leaf/modules/output_modules/topic_store.py +0 -52
  27. leaf_framework-0.10.22/leaf/ui/tabs/monitoring.py +0 -64
  28. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/LICENSE +0 -0
  29. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/__init__.py +0 -0
  30. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/__init__.py +0 -0
  31. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/__init__.py +0 -0
  32. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
  33. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
  34. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
  35. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
  36. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/config/configuration.yaml +0 -0
  37. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/data/marketplace.yaml +0 -0
  38. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/error_handler/__init__.py +0 -0
  39. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/error_handler/error_holder.py +0 -0
  40. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/error_handler/exceptions.py +0 -0
  41. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/__init__.py +0 -0
  42. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/handler.py +0 -0
  43. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/measurements.yaml +0 -0
  44. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/measurement_handler/terms.py +0 -0
  45. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/.gitkeep +0 -0
  46. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/__init__.py +0 -0
  47. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/__init__.py +0 -0
  48. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/ble_watcher.py +0 -0
  49. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/db_watcher.py +0 -0
  50. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/event_watcher.py +0 -0
  51. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/external_api_watcher.py +0 -0
  52. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/external_event_watcher.py +0 -0
  53. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/file_watcher.py +0 -0
  54. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/lads_watcher.py +0 -0
  55. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/modbus_watcher.py +0 -0
  56. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
  57. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
  58. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/opc_watcher.py +0 -0
  59. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/polling_watcher.py +0 -0
  60. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/input_modules/simple_watcher.py +0 -0
  61. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/__init__.py +0 -0
  62. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
  63. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
  64. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/fluorescence.py +0 -0
  65. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/measurement_module.py +0 -0
  66. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/optical_density.py +0 -0
  67. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/ph.py +0 -0
  68. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/measurement_modules/temperature.py +0 -0
  69. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/__init__.py +0 -0
  70. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/file.py +0 -0
  71. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/output_modules/sql.py +0 -0
  72. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/__init__.py +0 -0
  73. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/control.py +0 -0
  74. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/error.py +0 -0
  75. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/external_event_phase.py +0 -0
  76. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/initialisation.py +0 -0
  77. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/phase.py +0 -0
  78. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/start.py +0 -0
  79. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/phase_modules/stop.py +0 -0
  80. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/__init__.py +0 -0
  81. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/continous_module.py +0 -0
  82. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/discrete_module.py +0 -0
  83. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/external_event_process.py +0 -0
  84. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/process_module.py +0 -0
  85. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/modules/process_modules/upload_module.py +0 -0
  86. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/__init__.py +0 -0
  87. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/abstract_leaf.py +0 -0
  88. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/equipment_data.yaml +0 -0
  89. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/metadata.py +0 -0
  90. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/py.typed +0 -0
  91. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/register/topic_utilities.py +0 -0
  92. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/registry.py +0 -0
  93. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/registry/utils.py +0 -0
  94. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/__init__.py +0 -0
  95. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/adapter_ui_extension.py +0 -0
  96. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/components/__init__.py +0 -0
  97. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/constants.py +0 -0
  98. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/desktop_launcher.py +0 -0
  99. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/images/icon.icns +0 -0
  100. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/images/icon.ico +0 -0
  101. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/images/icon.svg +0 -0
  102. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/markdown/configuration_help.md +0 -0
  103. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/markdown/documentation_main.md +0 -0
  104. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/markdown/documentation_protips.md +0 -0
  105. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/scripts.js +0 -0
  106. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/styles.css +0 -0
  107. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/__init__.py +0 -0
  108. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/adapters.py +0 -0
  109. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/documentation.py +0 -0
  110. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/tabs/logs.py +0 -0
  111. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/update_checker.py +0 -0
  112. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/ui/utils.py +0 -0
  113. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/config_utils.py +0 -0
  114. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/logger/__init__.py +0 -0
  115. {leaf_framework-0.10.22 → leaf_framework-0.10.25}/leaf/utility/logger/logger_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leaf-framework
3
- Version: 0.10.22
3
+ Version: 0.10.25
4
4
  Summary: Lightweight Adapter Framework (LEAF)
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -13,17 +13,17 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Dist: aioredis (>=2.0.1,<2.1.0)
16
- Requires-Dist: cairosvg (>=2.8.2,<3.0.0)
17
- Requires-Dist: dateparser (>=1.3.0,<1.4.0)
16
+ Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
17
+ Requires-Dist: dateparser (>=1.4.3,<1.5.0)
18
18
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
19
19
  Requires-Dist: influxobject (>=0.0.13)
20
- Requires-Dist: nicegui (>=3.16.0,<3.17.0)
20
+ Requires-Dist: nicegui (>=3.18.0,<3.19.0)
21
21
  Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
22
- Requires-Dist: pillow (>=12.1.1,<13.0.0)
22
+ Requires-Dist: pillow (>=13.0.0,<14.0.0)
23
23
  Requires-Dist: pyyaml (>=6.0.3,<6.1.0)
24
- Requires-Dist: redis (>=7.2.0,<7.3.0)
24
+ Requires-Dist: redis (>=8.1.0,<8.2.0)
25
25
  Requires-Dist: requests (>=2.34.2,<2.35.0)
26
- Requires-Dist: watchdog (>=6.0.0,<6.1.0)
26
+ Requires-Dist: watchdog (>=7.0.0,<7.1.0)
27
27
  Project-URL: Documentation, https://leaf-framework.org
28
28
  Project-URL: Repository, https://gitlab.com/leaf-framework/leaf
29
29
  Description-Content-Type: text/markdown
@@ -44,6 +44,10 @@ leaf
44
44
 
45
45
  LEAF automatically shows a **desktop control panel** on desktop systems, making it easy to see if the application is running and where to access it.
46
46
 
47
+ ## Password for the web interface
48
+
49
+ The web interface is open by default. When it runs where others can reach it (a Kubernetes pod with an ingress, a shared server), set `LEAF_UI_PASSWORD`: every page then asks for that password once, on a login form password managers can fill, and keeps the browser logged in for 30 days. Changing the password logs every browser out. It sends the password over the wire, so serve the interface over HTTPS.
50
+
47
51
  ## Updating
48
52
 
49
53
  LEAF checks PyPI for a newer release one hour after it starts, and weekly from then on -- never at startup, so a restart simply re-arms the one-hour delay. When a newer version exists, an **update badge** appears next to the version number in the web interface header; clicking it offers to install the update and restart LEAF.
@@ -14,6 +14,10 @@ leaf
14
14
 
15
15
  LEAF automatically shows a **desktop control panel** on desktop systems, making it easy to see if the application is running and where to access it.
16
16
 
17
+ ## Password for the web interface
18
+
19
+ The web interface is open by default. When it runs where others can reach it (a Kubernetes pod with an ingress, a shared server), set `LEAF_UI_PASSWORD`: every page then asks for that password once, on a login form password managers can fill, and keeps the browser logged in for 30 days. Changing the password logs every browser out. It sends the password over the wire, so serve the interface over HTTPS.
20
+
17
21
  ## Updating
18
22
 
19
23
  LEAF checks PyPI for a newer release one hour after it starts, and weekly from then on -- never at startup, so a restart simply re-arms the one-hour delay. When a newer version exists, an **update badge** appears next to the version number in the web interface header; clicking it offers to install the update and restart LEAF.
@@ -0,0 +1,124 @@
1
+ """
2
+ The controls an adapter declares in its device.json, narrowed by an instance's control_limits.
3
+
4
+ Every control message, from MQTT or the UI, is checked against them before the controller sees it,
5
+ so the limits hold however the message arrives. An adapter that declares no controls is not checked.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from typing import cast
11
+
12
+ import yaml
13
+
14
+ Control = dict[str, object]
15
+
16
+
17
+ def read_controls(adapter_dir: str) -> list[Control]:
18
+ """The "controls" list from device.json (or device.yaml) in an adapter's package, or []."""
19
+ for name in ("device.json", "device.yaml"):
20
+ path = os.path.join(adapter_dir, name)
21
+ if not os.path.exists(path):
22
+ continue
23
+ with open(path) as f:
24
+ if name.endswith(".json"):
25
+ device = cast(object, json.load(f))
26
+ else:
27
+ device = cast(object, yaml.safe_load(f))
28
+ if not isinstance(device, dict):
29
+ return []
30
+ controls = cast(dict[str, object], device).get("controls")
31
+ if not isinstance(controls, list):
32
+ return []
33
+ return [cast(Control, c) for c in cast(list[object], controls) if isinstance(c, dict)]
34
+ return []
35
+
36
+
37
+ def as_number(value: object) -> float | None:
38
+ """value as a float, or None when it is not a number (bools are not numbers here)."""
39
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
40
+ return None
41
+ return float(value)
42
+
43
+
44
+ def _narrow(control: Control, limit: object) -> Control:
45
+ """The control with the instance's min/max, which may only tighten the adapter's own range."""
46
+ metric = control["metric"]
47
+ if control.get("type") != "number" or not isinstance(limit, dict):
48
+ raise ValueError(f"control_limits for {metric} need a number control and min/max keys")
49
+ narrowed = dict(control)
50
+ for key, value in cast(dict[str, object], limit).items():
51
+ bound = as_number(value)
52
+ if key not in ("min", "max") or bound is None:
53
+ raise ValueError(f"control_limits for {metric}: {key} must be min or max with a number")
54
+ adapter_bound = as_number(control.get(key))
55
+ if adapter_bound is not None and (
56
+ (key == "min" and bound < adapter_bound) or (key == "max" and bound > adapter_bound)
57
+ ):
58
+ raise ValueError(
59
+ f"control_limits for {metric}: {key} {bound} is outside the adapter's {key} {adapter_bound}"
60
+ )
61
+ narrowed[key] = bound
62
+ return narrowed
63
+
64
+
65
+ def apply_limits(controls: list[Control], limits: dict[str, object]) -> list[Control]:
66
+ """Controls with an instance's control_limits applied; ValueError for limits that do not fit."""
67
+ known = {str(c.get("metric")) for c in controls}
68
+ unknown = sorted(set(limits) - known)
69
+ if unknown:
70
+ raise ValueError(f"control_limits for undeclared controls: {', '.join(unknown)}")
71
+ result: list[Control] = []
72
+ for control in controls:
73
+ metric = str(control.get("metric"))
74
+ if metric in limits:
75
+ result.append(_narrow(control, limits[metric]))
76
+ else:
77
+ result.append(control)
78
+ return result
79
+
80
+
81
+ def _check_number(control: Control, value: object) -> None:
82
+ number = as_number(value)
83
+ if number is None:
84
+ raise ValueError(f"{control['metric']} needs a number, got {value!r}")
85
+ low = as_number(control.get("min"))
86
+ high = as_number(control.get("max"))
87
+ if low is not None and number < low:
88
+ raise ValueError(f"{control['metric']} = {number} is below its minimum {low}")
89
+ if high is not None and number > high:
90
+ raise ValueError(f"{control['metric']} = {number} is above its maximum {high}")
91
+
92
+
93
+ def _metric_and_value(data: str) -> tuple[object, object]:
94
+ """tags.metric and fields.value of a LeafPoint control message."""
95
+ point = cast(object, json.loads(data))
96
+ if not isinstance(point, dict):
97
+ raise ValueError("a control message must be a LeafPoint JSON object")
98
+ point_dict = cast(dict[str, object], point)
99
+ tags = point_dict.get("tags")
100
+ fields = point_dict.get("fields")
101
+ if not isinstance(tags, dict) or not isinstance(fields, dict):
102
+ raise ValueError("a control message needs tags and fields")
103
+ return cast(dict[str, object], tags).get("metric"), cast(dict[str, object], fields).get("value")
104
+
105
+
106
+ def check_control(controls: list[Control], data: str) -> None:
107
+ """Raise ValueError unless the LeafPoint in data sets a declared metric to an allowed value."""
108
+ if not controls:
109
+ return
110
+ metric, value = _metric_and_value(data)
111
+ control = next((c for c in controls if c.get("metric") == metric), None)
112
+ if control is None:
113
+ raise ValueError(f"{metric!r} is not a declared control")
114
+ if value is not None and value == control.get("reset"):
115
+ return
116
+ kind = control.get("type")
117
+ if kind == "number":
118
+ _check_number(control, value)
119
+ elif kind == "choice":
120
+ options = control.get("options")
121
+ if not isinstance(options, list) or value not in cast(list[object], options):
122
+ raise ValueError(f"{metric} cannot be set to {value!r}")
123
+ elif not isinstance(value, str) or not value:
124
+ raise ValueError(f"{metric} needs a text value")
@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
4
4
  from threading import Event
5
5
  from typing import Any
6
6
 
7
+ from leaf.adapters.control_spec import Control, check_control
7
8
  from leaf.error_handler import exceptions
8
9
  from leaf.error_handler.error_holder import ErrorHolder
9
10
  from leaf.error_handler.exceptions import LEAFError
@@ -119,6 +120,29 @@ class AbstractInterpreter(ABC):
119
120
  raise exception
120
121
 
121
122
 
123
+ class AbstractController(ABC):
124
+ """
125
+ Base class for an adapter's controller.py.
126
+
127
+ When an instance is configured with ``control: true``, LEAF subscribes to
128
+ <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control and
129
+ hands every message to control(). ``self.adapter`` is the running adapter.
130
+ """
131
+
132
+ def __init__(self) -> None:
133
+ self.adapter: EquipmentAdapter | None = None
134
+
135
+ @abstractmethod
136
+ def control(self, topic: str, data: str) -> None:
137
+ """
138
+ Act on a control message.
139
+
140
+ Args:
141
+ topic (str): The control topic the message arrived on.
142
+ data (str): Raw message payload.
143
+ """
144
+
145
+
122
146
  class EquipmentAdapter(ABC):
123
147
  """
124
148
  Base class for all equipment adapters.
@@ -201,6 +225,69 @@ class EquipmentAdapter(ABC):
201
225
  self._external_process.set_interpreter(interpreter)
202
226
  self._external_watcher.add_callback(self._external_process.process_input)
203
227
 
228
+ self._controller: AbstractController | None = None
229
+ self._controls: list[Control] = []
230
+
231
+ def set_controller(self, controller: AbstractController, controls: list[Control] | None = None) -> None:
232
+ """
233
+ Attach a controller; its control() runs for each message on the instance's control topic.
234
+
235
+ Messages are first checked against controls (from device.json, with the instance's limits).
236
+ """
237
+ controller.adapter = self
238
+ self._controller = controller
239
+ self._controls = controls or []
240
+
241
+ @property
242
+ def controls(self) -> list[Control]:
243
+ """The declared controls with this instance's limits, as used to check control messages."""
244
+ return self._controls
245
+
246
+ def _on_control(self, topic: str, data: str) -> None:
247
+ # Runs on the MQTT network thread, so never let an exception escape.
248
+ if self._controller is None:
249
+ return
250
+ try:
251
+ self._logger.info(f"Control message on {topic}: {data}")
252
+ check_control(self._controls, data)
253
+ self._controller.control(topic, data)
254
+ except Exception as e:
255
+ # The error holder reports it later; log it now so the sender can see why nothing happened
256
+ self._logger.warning(f"Control message on {topic} rejected: {e}")
257
+ self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
258
+
259
+ def has_controller(self) -> bool:
260
+ """True when the instance runs with control: true and a controller is attached."""
261
+ return self._controller is not None
262
+
263
+ @property
264
+ def metadata_manager(self) -> MetadataManager:
265
+ """Instance and equipment metadata, e.g. to tag control points sent from the UI."""
266
+ return self._metadata_manager
267
+
268
+ def send_control(self, data: str) -> None:
269
+ """
270
+ Hand a control message from inside LEAF (the UI) to the controller, as if it came in on the control topic.
271
+
272
+ Errors are reported like those of messages from MQTT, and also propagate so the caller can show them.
273
+ """
274
+ if self._controller is None:
275
+ raise exceptions.AdapterLogicError("this instance has no controller; set control: true")
276
+ # Topic builders are generated at runtime, so the checker sees them as Any
277
+ topic = str(self._metadata_manager.control()) # pyright: ignore[reportAny]
278
+ self._logger.info(f"Control message from the UI on {topic}: {data}")
279
+ try:
280
+ check_control(self._controls, data)
281
+ self._controller.control(topic, data)
282
+ except Exception as e:
283
+ # Reported like a control message from MQTT, so it also reaches the error topic
284
+ self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
285
+ raise
286
+
287
+ def _subscribe_control(self) -> None:
288
+ if self._controller is not None:
289
+ self._output.subscribe(self._metadata_manager.control(), self._on_control)
290
+
204
291
  def is_running(self) -> bool:
205
292
  """
206
293
  Check if the adapter is actively running.
@@ -233,6 +320,7 @@ class EquipmentAdapter(ABC):
233
320
  if self._external_watcher:
234
321
  self._external_watcher.start()
235
322
  self._output.on_adapter_ready(self._metadata_manager)
323
+ self._subscribe_control()
236
324
 
237
325
  self._stop_event.clear()
238
326
  # Expose adapter on all channels.
@@ -316,6 +404,8 @@ class EquipmentAdapter(ABC):
316
404
  """
317
405
  self._stop_event.set()
318
406
  self._output.on_adapter_stop(self._metadata_manager)
407
+ if self._controller is not None:
408
+ self._output.unsubscribe(self._metadata_manager.control())
319
409
  if self._watcher.is_running():
320
410
  self._watcher.stop()
321
411
 
@@ -348,7 +438,8 @@ class EquipmentAdapter(ABC):
348
438
  return self.stop()
349
439
 
350
440
  error_json = error.to_json()
351
- for process in self._processes:
441
+ # Every process publishes to the instance's one error topic, so one of them is enough
442
+ for process in self._processes[:1]:
352
443
  process.transmit_error(error_json)
353
444
  time.sleep(0.1)
354
445
 
@@ -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,7 +2,6 @@ import json
2
2
  from typing import Any
3
3
 
4
4
  import redis
5
- from redis.typing import ResponseT
6
5
 
7
6
  from leaf.error_handler.error_holder import ErrorHolder
8
7
  from leaf.error_handler.exceptions import ClientUnreachableError, SeverityLevel
@@ -153,7 +152,9 @@ class KEYDB(OutputModule):
153
152
  if topics:
154
153
  logger.info(f"Found existing keys in KeyDB: {topics}")
155
154
  for topic in topics:
156
- topic = topic.decode("utf-8")
155
+ # Keys are bytes unless the client decodes responses
156
+ if isinstance(topic, bytes):
157
+ topic = topic.decode("utf-8")
157
158
  self.pop(topic)
158
159
  else:
159
160
  logger.debug("No existing keys found in KeyDB.")
@@ -203,13 +204,14 @@ class KEYDB(OutputModule):
203
204
  return key, json.loads(result.decode("utf-8"))
204
205
  return None
205
206
 
206
- random_key: ResponseT = self._client.randomkey()
207
- if not random_key:
207
+ raw_key = self._client.randomkey()
208
+ if not raw_key:
208
209
  logger.info("No keys available in KeyDB.")
209
210
  return None
210
- if isinstance(random_key, bytes):
211
+ random_key = str(raw_key)
212
+ if isinstance(raw_key, bytes):
211
213
  # Decode bytes to string if necessary
212
- random_key = random_key.decode("utf-8")
214
+ random_key = raw_key.decode("utf-8")
213
215
 
214
216
  logger.info(f"Popping from key '{random_key}' in KeyDB.")
215
217
  result = self._client.lpop(random_key)
@@ -2,8 +2,9 @@ import json
2
2
  import re
3
3
  import threading
4
4
  import time
5
+ from collections.abc import Callable
5
6
  from socket import gaierror
6
- from typing import Any, Literal
7
+ from typing import Any, Literal, cast
7
8
 
8
9
  import paho.mqtt.client as mqtt
9
10
  from paho.mqtt.enums import CallbackAPIVersion
@@ -102,6 +103,8 @@ class MQTT(OutputModule):
102
103
  self.messages: dict[str, list[str]] = {}
103
104
  self.sending_success: dict[str, bool] = {}
104
105
  self._sanitized_topic_warned: set[str] = set()
106
+ # Topics with a callback; kept so they are resubscribed after a reconnect (clean session).
107
+ self._subscriptions: dict[str, Callable[[str, str], None]] = {}
105
108
  # paho's connect() returns once the socket is open and the CONNECT
106
109
  # packet is sent, but the broker's CONNACK (which is what actually
107
110
  # flips is_connected() to True) is only processed once loop_start()'s
@@ -257,6 +260,8 @@ class MQTT(OutputModule):
257
260
  return self.fallback(topic, data)
258
261
  if data == "":
259
262
  data = {}
263
+ # The topic store keeps the dict, which the Monitoring plot reads points from
264
+ message = cast(object, data)
260
265
  if isinstance(data, (dict, list)):
261
266
  data = json.dumps(data)
262
267
 
@@ -299,7 +304,7 @@ class MQTT(OutputModule):
299
304
  # Reset global failure counter only after successful transmission AND fallback processing
300
305
  OutputModule.reset_failure_count()
301
306
 
302
- self.store_latest(topic, 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
 
@@ -1,5 +1,6 @@
1
1
  import time
2
2
  from abc import ABC, abstractmethod
3
+ from collections.abc import Callable
3
4
  from typing import Any, Optional
4
5
 
5
6
  from leaf.error_handler.error_holder import ErrorHolder
@@ -119,15 +120,27 @@ class OutputModule(ABC):
119
120
  if self._fallback is not None:
120
121
  self._fallback.flush(topic)
121
122
 
122
- def subscribe(self, topic: str) -> None:
123
+ def subscribe(self, topic: str, callback: Callable[[str, str], None] | None = None) -> None:
123
124
  """
124
125
  Listen on the specified topic if supported by the module.
125
126
 
126
127
  Args:
127
128
  topic (str): Topic to subscribe to.
129
+ callback (Optional[Callable]): Called with (topic, payload) for each message, if supported.
128
130
  """
129
131
  if self._fallback is not None:
130
- self._fallback.subscribe(topic)
132
+ self._fallback.subscribe(topic, callback)
133
+
134
+ def unsubscribe(self, topic: str) -> str | None:
135
+ """
136
+ Stop listening on the specified topic if supported by the module.
137
+
138
+ Args:
139
+ topic (str): Topic to unsubscribe from.
140
+ """
141
+ if self._fallback is not None:
142
+ return self._fallback.unsubscribe(topic)
143
+ return None
131
144
 
132
145
  def fallback(self, topic: str, data: str | dict[Any, Any] | None = None) -> bool:
133
146
  """
@@ -0,0 +1,114 @@
1
+ import threading
2
+ import time
3
+ from collections import deque
4
+ from typing import Any, cast
5
+
6
+ # Points kept per topic (one metric each) for the Monitoring plot; in memory, so it starts empty after a restart
7
+ HISTORY_LENGTH = 1000
8
+
9
+
10
+ class TopicStore:
11
+ """
12
+ A lightweight nested-dict wrapper that stores the latest message
13
+ per hierarchical topic. Topics are split on '/' and stored at
14
+ the leaf node using a '_value' sentinel key.
15
+
16
+ Example:
17
+ store.set("unlock/proj1/dev1/temp", {"value": 23.5})
18
+ # Internal: {"unlock": {"proj1": {"dev1": {"temp": {"_value": {"value": 23.5}}}}}}
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ self._store: dict = {}
23
+ self._history: dict[str, deque[tuple[float, float]]] = {}
24
+ # Adapters write from their own threads while the UI reads
25
+ self._history_lock: threading.Lock = threading.Lock()
26
+
27
+ def set(self, topic: str, message: Any) -> None:
28
+ """Split topic by '/' and store message at the deepest level."""
29
+ parts = [p for p in topic.split("/") if p]
30
+ node = self._store
31
+ for part in parts:
32
+ if part not in node or not isinstance(node[part], dict):
33
+ node[part] = {}
34
+ node = node[part]
35
+ node["_value"] = message
36
+ payload = cast(object, message)
37
+ self._record_history(topic, payload)
38
+
39
+ def _record_history(self, topic: str, message: object) -> None:
40
+ """Keep the numeric "value" field of point messages (a point dict or a list of them)."""
41
+ if isinstance(message, list):
42
+ points = cast(list[object], message)
43
+ else:
44
+ points = [message]
45
+ for point in points:
46
+ sample = _point_sample(point)
47
+ if sample is None:
48
+ continue
49
+ key = history_key(topic, point)
50
+ with self._history_lock:
51
+ history = self._history.setdefault(key, deque(maxlen=HISTORY_LENGTH))
52
+ history.append(sample)
53
+
54
+ def get_history(self) -> dict[str, list[tuple[float, float]]]:
55
+ """(timestamp, value) pairs per history key (see history_key), oldest first."""
56
+ with self._history_lock:
57
+ return {topic: list(points) for topic, points in self._history.items()}
58
+
59
+ def get(self, topic: str) -> Any:
60
+ """Retrieve the latest message for a topic, or None."""
61
+ parts = [p for p in topic.split("/") if p]
62
+ node = self._store
63
+ for part in parts:
64
+ if not isinstance(node, dict) or part not in node:
65
+ return None
66
+ node = node[part]
67
+ if isinstance(node, dict):
68
+ return node.get("_value")
69
+ return None
70
+
71
+ def get_subtree(self, topic: str) -> dict | None:
72
+ """Get the full nested dict under a topic prefix."""
73
+ parts = [p for p in topic.split("/") if p]
74
+ node = self._store
75
+ for part in parts:
76
+ if not isinstance(node, dict) or part not in node:
77
+ return None
78
+ node = node[part]
79
+ return node if isinstance(node, dict) else None
80
+
81
+ def get_all(self) -> dict:
82
+ """Return the full nested store."""
83
+ return self._store
84
+
85
+
86
+ # Separates the topic from the point's entity tag in a history key
87
+ ENTITY_SEPARATOR = "#"
88
+
89
+
90
+ def history_key(topic: str, point: object) -> str:
91
+ """Topic plus the point's "entity" tag, since one topic can carry several entities (e.g. the wells of a plate)."""
92
+ tags = cast(dict[str, object], point).get("tags")
93
+ if isinstance(tags, dict):
94
+ entity = cast(dict[str, object], tags).get("entity")
95
+ if isinstance(entity, str) and entity:
96
+ return f"{topic}{ENTITY_SEPARATOR}{entity}"
97
+ return topic
98
+
99
+
100
+ def _point_sample(point: object) -> tuple[float, float] | None:
101
+ """(timestamp, value) of a LeafPoint/InfluxPoint JSON dict with a numeric fields.value, or None."""
102
+ if not isinstance(point, dict):
103
+ return None
104
+ point_dict = cast(dict[str, object], point)
105
+ fields = point_dict.get("fields")
106
+ if not isinstance(fields, dict):
107
+ return None
108
+ value = cast(dict[str, object], fields).get("value")
109
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
110
+ return None
111
+ timestamp = point_dict.get("timestamp")
112
+ if isinstance(timestamp, bool) or not isinstance(timestamp, (int, float)):
113
+ timestamp = time.time()
114
+ return (float(timestamp), float(value))
@@ -1,9 +1,7 @@
1
1
  import inspect
2
2
  import threading
3
3
  import time
4
- from typing import TYPE_CHECKING, Any
5
-
6
- from influxobject import InfluxPoint
4
+ from typing import TYPE_CHECKING, Any, cast
7
5
 
8
6
  from leaf.error_handler.error_holder import ErrorHolder
9
7
  from leaf.error_handler.exceptions import AdapterLogicError, InterpreterError
@@ -237,17 +235,20 @@ class MeasurePhase(PhaseModule):
237
235
  action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
238
236
  return [(action, result)]
239
237
 
240
- if isinstance(result, InfluxPoint):
241
- result = result.to_json()
238
+ payload = cast(object, result)
239
+ point_json = point_json_of(payload)
240
+ if point_json is not None:
241
+ result = point_json
242
242
  measurement = self._resolve_measurement(result)
243
243
  action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
244
244
  return [(action, result)]
245
245
 
246
246
  if isinstance(result, list):
247
247
  converted = []
248
- for item in result:
249
- if isinstance(item, InfluxPoint):
250
- converted.append(item.to_json())
248
+ for item in cast(list[object], result):
249
+ point_json = point_json_of(item)
250
+ if point_json is not None:
251
+ converted.append(point_json)
251
252
  else:
252
253
  converted.append(item)
253
254
 
@@ -287,3 +288,13 @@ class MeasurePhase(PhaseModule):
287
288
  return item["measurement"]
288
289
 
289
290
  return "unknown"
291
+
292
+
293
+ def point_json_of(item: object) -> dict[str, object] | None:
294
+ """JSON of an InfluxPoint or leafobject LeafPoint, recognised by to_json() so LEAF need not depend on leafobject."""
295
+ if isinstance(item, dict):
296
+ return None
297
+ to_json: object = getattr(item, "to_json", None)
298
+ if not callable(to_json):
299
+ return None
300
+ return cast(dict[str, object], to_json())