leaf-framework 0.11.0__tar.gz → 0.12.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 (112) hide show
  1. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/PKG-INFO +2 -2
  2. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/control_spec.py +7 -1
  3. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +1 -1
  4. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/streaming_adapter.py +2 -2
  5. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/equipment_adapter.py +7 -1
  6. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/ble_watcher.py +1 -2
  7. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/file_watcher.py +3 -3
  8. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/lads_watcher.py +2 -2
  9. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/modbus_watcher.py +1 -1
  10. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -19
  11. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/mqtt_watcher.py +3 -3
  12. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/opc_watcher.py +2 -2
  13. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/polling_watcher.py +8 -5
  14. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/output_modules/topic_store.py +2 -17
  15. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/measure.py +87 -31
  16. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/start.py +2 -2
  17. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/stop.py +2 -2
  18. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/process_modules/upload_module.py +3 -3
  19. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/equipment_actions.yaml +5 -4
  20. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/metadata.py +23 -11
  21. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/topic_utilities.py +1 -1
  22. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/registry/discovery.py +1 -2
  23. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/registry/registry.py +0 -7
  24. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/start.py +1 -1
  25. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/constants.py +1 -6
  26. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/interface.py +0 -6
  27. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/adapters.py +41 -3
  28. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/control.py +12 -6
  29. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/monitoring.py +50 -39
  30. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/utils.py +12 -0
  31. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/pyproject.toml +2 -2
  32. leaf_framework-0.11.0/leaf/measurement_handler/handler.py +0 -11
  33. leaf_framework-0.11.0/leaf/modules/input_modules/db_watcher.py +0 -15
  34. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/LICENSE +0 -0
  35. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/README.md +0 -0
  36. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/__init__.py +0 -0
  37. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/__init__.py +0 -0
  38. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/__init__.py +0 -0
  39. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
  40. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
  41. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/config/configuration.yaml +0 -0
  42. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/data/marketplace.yaml +0 -0
  43. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/error_handler/__init__.py +0 -0
  44. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/error_handler/error_holder.py +0 -0
  45. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/error_handler/exceptions.py +0 -0
  46. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/measurement_handler/__init__.py +0 -0
  47. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/measurement_handler/measurements.yaml +0 -0
  48. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/measurement_handler/terms.py +0 -0
  49. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/.gitkeep +0 -0
  50. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/__init__.py +0 -0
  51. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/__init__.py +0 -0
  52. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/event_watcher.py +0 -0
  53. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_api_watcher.py +0 -0
  54. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_event_watcher.py +0 -0
  55. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/http_watcher.py +0 -0
  56. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/input_modules/simple_watcher.py +0 -0
  57. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/__init__.py +0 -0
  58. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
  59. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
  60. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/fluorescence.py +0 -0
  61. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/measurement_module.py +0 -0
  62. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/optical_density.py +0 -0
  63. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/ph.py +0 -0
  64. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/temperature.py +0 -0
  65. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/output_modules/__init__.py +0 -0
  66. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/output_modules/file.py +0 -0
  67. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/output_modules/mqtt.py +0 -0
  68. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/output_modules/output_module.py +0 -0
  69. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/output_modules/sql.py +0 -0
  70. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/__init__.py +0 -0
  71. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/control.py +0 -0
  72. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/error.py +0 -0
  73. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/external_event_phase.py +0 -0
  74. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/initialisation.py +0 -0
  75. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/phase_modules/phase.py +0 -0
  76. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/process_modules/__init__.py +0 -0
  77. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/process_modules/continous_module.py +0 -0
  78. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/process_modules/discrete_module.py +0 -0
  79. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/process_modules/external_event_process.py +0 -0
  80. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/modules/process_modules/process_module.py +0 -0
  81. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/__init__.py +0 -0
  82. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/abstract_leaf.py +0 -0
  83. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/equipment_data.yaml +0 -0
  84. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/register/py.typed +0 -0
  85. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/registry/loader.py +0 -0
  86. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/registry/utils.py +0 -0
  87. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/__init__.py +0 -0
  88. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/adapter_ui_extension.py +0 -0
  89. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/auth.py +0 -0
  90. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/components/__init__.py +0 -0
  91. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/components/app_control.py +0 -0
  92. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/components/header.py +0 -0
  93. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/components/update_notice.py +0 -0
  94. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/desktop_launcher.py +0 -0
  95. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/images/icon.icns +0 -0
  96. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/images/icon.ico +0 -0
  97. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/images/icon.svg +0 -0
  98. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/markdown/configuration_help.md +0 -0
  99. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/markdown/documentation_main.md +0 -0
  100. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/markdown/documentation_protips.md +0 -0
  101. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/scripts.js +0 -0
  102. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/styles.css +0 -0
  103. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/__init__.py +0 -0
  104. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/configuration.py +0 -0
  105. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/documentation.py +0 -0
  106. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/tabs/logs.py +0 -0
  107. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/ui/update_checker.py +0 -0
  108. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/utility/config_utils.py +0 -0
  109. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/utility/logger/__init__.py +0 -0
  110. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/utility/logger/logger_utils.py +0 -0
  111. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/utility/running_adapters.py +0 -0
  112. {leaf_framework-0.11.0 → leaf_framework-0.12.0}/leaf/utility/running_utilities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leaf-framework
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Lightweight Adapter Framework (LEAF)
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
16
16
  Requires-Dist: dateparser (>=1.4.3,<1.5.0)
17
17
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
18
- Requires-Dist: leafobject (>=0.0.14,<0.0.15)
18
+ Requires-Dist: leafobject (>=0.1.0,<0.2.0)
19
19
  Requires-Dist: nicegui (>=3.18.0,<3.19.0)
20
20
  Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
21
21
  Requires-Dist: pillow (>=13.0.0,<14.0.0)
@@ -140,7 +140,9 @@ def _field_value(value: object) -> int | float | str:
140
140
  return json.dumps(value)
141
141
 
142
142
 
143
- def result_record(data: str, source: str, error: Exception | None = None) -> dict[str, object]:
143
+ def result_record(
144
+ data: str, source: str, error: Exception | None = None, instance_tags: dict[str, str] | None = None
145
+ ) -> dict[str, object]:
144
146
  """
145
147
  A control message as the LeafPoint LEAF publishes on <control topic>/result, so that what was
146
148
  sent to the equipment is stored with the measurements.
@@ -159,6 +161,10 @@ def result_record(data: str, source: str, error: Exception | None = None) -> dic
159
161
  tags: dict[str, str] = {}
160
162
  for key, value in _part(point, "tags").items():
161
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)
162
168
  fields: dict[str, int | float | str] = {}
163
169
  for key, value in _part(point, "fields").items():
164
170
  fields[key] = _field_value(value)
@@ -87,6 +87,6 @@ class ContinuousExperimentAdapter(EquipmentAdapter):
87
87
  Injects a synthetic start message via the control process
88
88
  to initialise any start-specific logic or output.
89
89
  """
90
- start_topic = self._metadata_manager.experiment.start
90
+ start_topic = self._metadata_manager.start
91
91
  self._control_process.process_input(start_topic, {})
92
92
  super().start()
@@ -159,9 +159,9 @@ class StreamingAdapter(EquipmentAdapter):
159
159
  is active for a filename, further non-start/stop events for it are
160
160
  no-ops; the running stream() call handles tailing on its own.
161
161
  """
162
- if topic() == self._metadata_manager.experiment.start():
162
+ if topic() == self._metadata_manager.start():
163
163
  self._launch_stream(data)
164
- elif topic() == self._metadata_manager.experiment.stop():
164
+ elif topic() == self._metadata_manager.stop():
165
165
  self._stop_stream(data)
166
166
  else:
167
167
  with self._streams_guard:
@@ -293,7 +293,13 @@ class EquipmentAdapter(ABC):
293
293
  """Publish what happened to a control message on <control topic>/result, so it is stored with the data."""
294
294
  # Never let the record break the command it describes
295
295
  try:
296
- _ = self._output.transmit(f"{topic}/result", result_record(data, source, error))
296
+ # A control acts on the instance itself, so that is its entity unless the message names one
297
+ instance_tags = {
298
+ **self._metadata_manager.instance_tags(),
299
+ "entity": self._metadata_manager.get_instance_id(),
300
+ }
301
+ record = result_record(data, source, error, instance_tags)
302
+ _ = self._output.transmit(f"{topic}/result", record)
297
303
  except Exception as e:
298
304
  self._logger.warning(f"Could not publish the control record for {topic}: {e}")
299
305
 
@@ -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):
@@ -232,7 +231,7 @@ class BLEEventWatcher(EventWatcher):
232
231
 
233
232
  # Dispatch to callbacks using the measurement event
234
233
  if self._metadata_manager:
235
- self._dispatch_callback(self._metadata_manager.experiment.measurement, event_data)
234
+ self._dispatch_callback(self._metadata_manager.measurement, event_data)
236
235
  else:
237
236
  # Fallback: call callbacks directly with "measurement" event
238
237
  for callback in self._callbacks:
@@ -131,9 +131,9 @@ class FileWatcher(FileSystemEventHandler, EventWatcher):
131
131
  self._pending_timers: dict = {} # Track pending trailing timers per file path
132
132
 
133
133
  self._term_map = {
134
- self.on_created: metadata_manager.experiment.start,
135
- self.on_modified: metadata_manager.experiment.measurement,
136
- self.on_deleted: metadata_manager.experiment.stop,
134
+ self.on_created: metadata_manager.start,
135
+ self.on_modified: metadata_manager.measurement,
136
+ self.on_deleted: metadata_manager.stop,
137
137
  }
138
138
 
139
139
  def start(self) -> None:
@@ -100,7 +100,7 @@ class LADSWatcher(EventWatcher):
100
100
  self.logger.debug(f"LADS datachange_notification: node={nodeid}, value={val}")
101
101
  info = self._variables.get(nodeid, {})
102
102
  self._dispatch_callback(
103
- self._metadata_manager.experiment.measurement,
103
+ self._metadata_manager.measurement,
104
104
  {
105
105
  "functional_unit": info.get("functional_unit"),
106
106
  "node": node.nodeid.Identifier,
@@ -321,7 +321,7 @@ class LADSWatcher(EventWatcher):
321
321
  self.logger.info(f"Subscribed to: {info['functional_unit']}/{info['browse_name']} ({nodeid})")
322
322
  # Send a dummy value to trigger the callback
323
323
  self._dispatch_callback(
324
- self._metadata_manager.experiment.measurement,
324
+ self._metadata_manager.measurement,
325
325
  {
326
326
  "functional_unit": info["functional_unit"],
327
327
  "node": node.nodeid.Identifier,
@@ -170,7 +170,7 @@ class ModbusWatcher(ExternalEventWatcher):
170
170
 
171
171
  def _emit(self, data: dict[str, Any]) -> None:
172
172
  if self._metadata_manager:
173
- self._dispatch_callback(self._metadata_manager.experiment.measurement, data)
173
+ self._dispatch_callback(self._metadata_manager.measurement, data)
174
174
  else:
175
175
  for callback in self._callbacks:
176
176
  try:
@@ -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)
@@ -92,9 +92,9 @@ class MQTTEventWatcher(EventWatcher):
92
92
 
93
93
  # topic -> list of events
94
94
  self._topic_event_map: dict[str, list[str]] = {}
95
- self._register_topics(start_topics, metadata_manager.experiment.start)
96
- self._register_topics(measurement_topics, metadata_manager.experiment.measurement)
97
- self._register_topics(stop_topics, metadata_manager.experiment.stop)
95
+ self._register_topics(start_topics, metadata_manager.start)
96
+ self._register_topics(measurement_topics, metadata_manager.measurement)
97
+ self._register_topics(stop_topics, metadata_manager.stop)
98
98
  self._register_topics(error_topics, metadata_manager.error)
99
99
 
100
100
  # Set when on_connect fires so start() can block until subscriptions are active
@@ -77,7 +77,7 @@ class OPCWatcher(EventWatcher):
77
77
  def datachange_notification(self, node: Node, val: int | str | float, data: DataChangeNotification) -> None:
78
78
  self.logger.debug(f"OPC datachange_notification: node={node.nodeid.Identifier}, value={val}")
79
79
  self._dispatch_callback(
80
- self._metadata_manager.experiment.measurement,
80
+ self._metadata_manager.measurement,
81
81
  {
82
82
  "node": node.nodeid.Identifier,
83
83
  "value": val,
@@ -233,7 +233,7 @@ class OPCWatcher(EventWatcher):
233
233
  self.logger.info(f"Subscribed to: {node.nodeid.to_string()}")
234
234
  # Send a dummy value to trigger the callback
235
235
  self._dispatch_callback(
236
- self._metadata_manager.experiment.measurement,
236
+ self._metadata_manager.measurement,
237
237
  {
238
238
  "node": node.nodeid.Identifier,
239
239
  "value": node.get_value(),
@@ -1,5 +1,4 @@
1
1
  import threading
2
- import time
3
2
  from abc import abstractmethod
4
3
  from collections.abc import Callable
5
4
  from typing import Any
@@ -40,10 +39,12 @@ class PollingWatcher(EventWatcher):
40
39
 
41
40
  self._interval: int = interval
42
41
  self._thread: threading.Thread | None = None
42
+ # Set by stop() so the poll loop wakes at once instead of finishing a full interval.
43
+ self._stop_event: threading.Event = threading.Event()
43
44
  self._term_map = {
44
- self.start_message: metadata_manager.experiment.start,
45
- self.stop_message: metadata_manager.experiment.stop,
46
- self.measurement_message: metadata_manager.experiment.measurement,
45
+ self.start_message: metadata_manager.start,
46
+ self.stop_message: metadata_manager.stop,
47
+ self.measurement_message: metadata_manager.measurement,
47
48
  }
48
49
 
49
50
  def start_message(self, data: Any) -> None:
@@ -99,7 +100,7 @@ class PollingWatcher(EventWatcher):
99
100
  # reporting data.
100
101
  pass
101
102
 
102
- time.sleep(self._interval)
103
+ _ = self._stop_event.wait(self._interval)
103
104
 
104
105
  def start(self) -> None:
105
106
  """
@@ -112,6 +113,7 @@ class PollingWatcher(EventWatcher):
112
113
  return
113
114
 
114
115
  self._running = True
116
+ self._stop_event.clear()
115
117
  self._thread = threading.Thread(target=self._poll, daemon=True)
116
118
  self._thread.start()
117
119
 
@@ -127,6 +129,7 @@ class PollingWatcher(EventWatcher):
127
129
  return
128
130
 
129
131
  self._running = False
132
+ self._stop_event.set()
130
133
  if self._thread:
131
134
  self._thread.join()
132
135
  logger.info("PollingWatcher stopped.")
@@ -46,13 +46,12 @@ class TopicStore:
46
46
  sample = _point_sample(point)
47
47
  if sample is None:
48
48
  continue
49
- key = history_key(topic, point)
50
49
  with self._history_lock:
51
- history = self._history.setdefault(key, deque(maxlen=HISTORY_LENGTH))
50
+ history = self._history.setdefault(topic, deque(maxlen=HISTORY_LENGTH))
52
51
  history.append(sample)
53
52
 
54
53
  def get_history(self) -> dict[str, list[tuple[float, float]]]:
55
- """(timestamp, value) pairs per history key (see history_key), oldest first."""
54
+ """(timestamp, value) pairs per topic, oldest first."""
56
55
  with self._history_lock:
57
56
  return {topic: list(points) for topic, points in self._history.items()}
58
57
 
@@ -83,20 +82,6 @@ class TopicStore:
83
82
  return self._store
84
83
 
85
84
 
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
85
  def _point_sample(point: object) -> tuple[float, float] | None:
101
86
  """(timestamp, value) of a LeafPoint JSON dict with a numeric fields.value, or None."""
102
87
  if not isinstance(point, dict):
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import re
2
3
  import threading
3
4
  import time
4
5
  from typing import TYPE_CHECKING, Any, cast
@@ -35,14 +36,14 @@ class MeasurePhase(PhaseModule):
35
36
  maximum_message_size (Optional[int]): The maximum number of measurements in a single message.
36
37
  error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
37
38
  """
38
- term_builder = (
39
- metadata_manager.experiment.measurement
40
- if metadata_manager is not None
41
- else "metadata_manager.experiment.measurement"
42
- )
39
+ if metadata_manager is not None:
40
+ term_builder = metadata_manager.measurement
41
+ else:
42
+ term_builder = "metadata_manager.measurement"
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
@@ -146,10 +147,10 @@ class MeasurePhase(PhaseModule):
146
147
  finally:
147
148
  self._measurement_lock.release()
148
149
  else:
149
- experiment_id = kwargs.get("experiment_id", "unknown")
150
150
  measurement = kwargs.get("measurement", "unknown")
151
+ entity = kwargs.get("entity", self._instance_entity())
151
152
 
152
- action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
153
+ action = self._term_builder(entity=entity, measurement=measurement)
153
154
  return [(action, data)]
154
155
 
155
156
  def stream(self, output: "OutputModule", filename: Any) -> None:
@@ -217,11 +218,9 @@ class MeasurePhase(PhaseModule):
217
218
  """
218
219
  Formulate one or more (topic, data) messages for a measurement result.
219
220
 
220
- A list/set of points can mix several distinct measurement types
221
- (e.g. pH, temperature, oxygen) in the same batch. Since the topic
222
- encodes a single measurement type, each distinct type present in
223
- the batch is split out into its own message rather than all being
224
- published under whichever type happened to come first.
221
+ A list/set of points can mix several measurement types (e.g. pH,
222
+ temperature) and entities (e.g. reactors) in one batch. The topic
223
+ encodes one of each, so every distinct pair gets its own message.
225
224
 
226
225
  Args:
227
226
  experiment_id (str): The ID of the experiment.
@@ -231,45 +230,102 @@ class MeasurePhase(PhaseModule):
231
230
  list[tuple]: A list of (action, result) tuples.
232
231
  """
233
232
  if isinstance(result, dict):
234
- measurement = self._resolve_measurement(result)
235
- action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
236
- return [(action, result)]
233
+ self._add_instance_tags(cast(object, result), experiment_id)
234
+ return [(self._topic_of(result), result)]
237
235
 
238
236
  payload = cast(object, result)
239
- point_json = point_json_of(payload)
237
+ try:
238
+ point_json = point_json_of(self._with_entity(payload))
239
+ except ValueError as ex:
240
+ # A LeafPoint that fails validation, e.g. one without an entity tag
241
+ self._handle_exception(InterpreterError(str(ex)))
242
+ return []
240
243
  if point_json is not None:
241
244
  result = point_json
242
- measurement = self._resolve_measurement(result)
243
- action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
244
- return [(action, result)]
245
+ self._add_instance_tags(cast(object, result), experiment_id)
246
+ return [(self._topic_of(result), result)]
245
247
 
246
248
  if isinstance(result, list):
247
249
  converted = []
248
250
  for item in cast(list[object], result):
249
- point_json = point_json_of(item)
251
+ try:
252
+ point_json = point_json_of(self._with_entity(item))
253
+ except ValueError as ex:
254
+ # Only the invalid point is dropped, the rest of the batch is still sent
255
+ self._handle_exception(InterpreterError(str(ex)))
256
+ continue
250
257
  if point_json is not None:
251
258
  converted.append(point_json)
252
259
  else:
253
260
  converted.append(item)
254
261
 
262
+ # One message per topic, so per metric and entity
255
263
  groups: dict[str, list] = {}
256
264
  for item in converted:
257
- if isinstance(item, dict):
258
- key = self._resolve_measurement(item)
259
- else:
260
- key = "unknown"
261
- groups.setdefault(key, []).append(item)
262
-
263
- messages = []
264
- for key, group in groups.items():
265
- action = self._term_builder(experiment_id=experiment_id, measurement=key)
266
- messages.append((action, group))
267
- return messages
265
+ self._add_instance_tags(cast(object, item), experiment_id)
266
+ groups.setdefault(self._topic_of(item), []).append(item)
267
+ return list(groups.items())
268
268
 
269
269
  excp = AdapterLogicError(f"Unknown measurement data type: {type(result)}")
270
270
  self._handle_exception(excp)
271
271
  return []
272
272
 
273
+ def _add_instance_tags(self, item: object, experiment_id: str | None = None) -> None:
274
+ """Tag a point with its instance's organisation, department, experiment id and entity, unless the adapter already did."""
275
+ if self._metadata_manager is None or not isinstance(item, dict) or "fields" not in item:
276
+ return
277
+ tags = cast(dict[str, object], item).setdefault("tags", {})
278
+ if not isinstance(tags, dict):
279
+ return
280
+ expected = self._metadata_manager.instance_tags()
281
+ # These defaults mean the adapter never set an experiment id
282
+ if experiment_id and experiment_id not in ("undefined", "invalid_id"):
283
+ expected["experiment_id"] = experiment_id
284
+ for key, value in expected.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
+ # Every point has an entity; one inside the instance (e.g. a reactor) differs from it by design, so no warning
295
+ if not tags.get("entity"):
296
+ tags["entity"] = self._instance_entity()
297
+
298
+ def _with_entity(self, item: object) -> object:
299
+ """Name the instance as the entity of a LeafPoint that names none."""
300
+ # Before to_json(), which rejects a point without an entity
301
+ tags: object = getattr(item, "tags", None)
302
+ if not isinstance(tags, dict):
303
+ return item
304
+ point_tags = cast(dict[str, object], tags)
305
+ if not point_tags.get("entity"):
306
+ point_tags["entity"] = self._instance_entity()
307
+ return item
308
+
309
+ def _topic_of(self, item: object) -> str:
310
+ """Measurement topic of a point: .../measurement/<entity>/<metric>."""
311
+ if not isinstance(item, dict):
312
+ return self._term_builder(entity=self._instance_entity(), measurement="unknown")
313
+ return self._term_builder(entity=self._resolve_entity(item), measurement=self._resolve_measurement(item))
314
+
315
+ def _instance_entity(self) -> str:
316
+ """Entity of a point that names none: the instance itself."""
317
+ if self._metadata_manager is None:
318
+ return "unknown"
319
+ return self._metadata_manager.get_instance_id() or "unknown"
320
+
321
+ def _resolve_entity(self, item: dict[str, Any]) -> str:
322
+ """The point's "entity" tag, else the instance."""
323
+ tags = item.get("tags")
324
+ if not isinstance(tags, dict) or not tags.get("entity"):
325
+ return self._instance_entity()
326
+ # Level separators and wildcards would break the topic
327
+ return re.sub(r"[/+#]", "_", str(tags["entity"]))
328
+
273
329
  def _resolve_measurement(self, item: dict) -> str:
274
330
  """
275
331
  Resolve the measurement label used for the topic's last segment.
@@ -25,7 +25,7 @@ class StartPhase(ControlPhase):
25
25
  metadata_manager (MetadataManager): Manages metadata associated with the phase.
26
26
  error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
27
27
  """
28
- term_builder = metadata_manager.experiment.start
28
+ term_builder = metadata_manager.start
29
29
  super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
30
30
 
31
31
  def update(self, data: Any) -> list:
@@ -48,5 +48,5 @@ class StartPhase(ControlPhase):
48
48
 
49
49
  data = super().update(data)
50
50
  data += [(self._metadata_manager.running(), True)]
51
- data += [(self._metadata_manager.experiment.stop(), None)]
51
+ data += [(self._metadata_manager.stop(), None)]
52
52
  return data
@@ -25,7 +25,7 @@ class StopPhase(ControlPhase):
25
25
  metadata_manager (MetadataManager): Manages metadata associated with the phase.
26
26
  error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
27
27
  """
28
- term_builder = metadata_manager.experiment.stop
28
+ term_builder = metadata_manager.stop
29
29
  super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
30
30
 
31
31
  def update(self, data: Any) -> list:
@@ -47,5 +47,5 @@ class StopPhase(ControlPhase):
47
47
  self._handle_exception(leaf_exp)
48
48
  data = super().update(data)
49
49
  data += [(self._metadata_manager.running(), False)]
50
- data += [(self._metadata_manager.experiment.start(), None)]
50
+ data += [(self._metadata_manager.start(), None)]
51
51
  return data
@@ -43,7 +43,7 @@ class UploadProcess(DiscreteProcess):
43
43
  topic (str): The topic to activate a specific phase.
44
44
  data (dict): The data to be processed by the phase.
45
45
  """
46
- if topic() != self._metadata_manager.experiment.start():
46
+ if topic() != self._metadata_manager.start():
47
47
  assert topic() == self._metadata_manager.details()
48
48
  return super().process_input(topic, data)
49
49
 
@@ -52,11 +52,11 @@ class UploadProcess(DiscreteProcess):
52
52
  time.sleep(1)
53
53
 
54
54
  # Measurements
55
- measure_builder = self._metadata_manager.experiment.measurement
55
+ measure_builder = self._metadata_manager.measurement
56
56
  super().process_input(measure_builder, data)
57
57
  time.sleep(1)
58
58
 
59
59
  # Stop
60
- stop_builder = self._metadata_manager.experiment.stop
60
+ stop_builder = self._metadata_manager.stop
61
61
  super().process_input(stop_builder, data)
62
62
  time.sleep(1)
@@ -4,7 +4,8 @@ heartbeat: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/heartbe
4
4
  error: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/error
5
5
  control: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control
6
6
  control_result: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control/result
7
- experiment:
8
- start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/start
9
- stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/stop
10
- measurement: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/<experiment_id>/measurement/<measurement>
7
+ # A run (experiment, batch, upload) starts and stops on the instance itself; its id is a point tag
8
+ start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/start
9
+ stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/stop
10
+ # The entity is what a point is about: the instance itself, or something inside it such as a reactor
11
+ measurement: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/measurement/<entity>/<measurement>
@@ -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", "")
@@ -108,8 +117,8 @@ class MetadataManager:
108
117
 
109
118
  def __getattr__(self, name: str) -> Any:
110
119
  """
111
- If the user does 'metadata_manager.experiment.start()', we look up
112
- 'experiment.start' in the TopicUtility's root group, and wrap it to
120
+ If the user does 'metadata_manager.start()', we look up
121
+ 'start' in the TopicUtility's root group, and wrap it to
113
122
  auto-fill placeholders with equipment metadata.
114
123
  """
115
124
  try:
@@ -120,10 +129,7 @@ class MetadataManager:
120
129
  if callable(sub):
121
130
 
122
131
  def _wrapper(**overrides) -> str:
123
- data = self.get_equipment_data().copy()
124
- data.update(self.get_instance_data())
125
- placeholders = {**data, **overrides}
126
- return sub(**placeholders)
132
+ return cast(str, sub(**self._placeholders(overrides)))
127
133
 
128
134
  return _wrapper
129
135
  if isinstance(sub, _TemplateGroup):
@@ -131,6 +137,15 @@ class MetadataManager:
131
137
 
132
138
  return sub
133
139
 
140
+ def _placeholders(self, overrides: dict[str, Any]) -> dict[str, Any]:
141
+ """Template placeholders from the equipment and instance data, then the caller's overrides."""
142
+ data = self.get_equipment_data().copy()
143
+ data.update(self.get_instance_data())
144
+ # A point that names no entity is about the instance itself
145
+ if data.get("instance_id"):
146
+ data.setdefault("entity", data["instance_id"])
147
+ return {**data, **overrides}
148
+
134
149
 
135
150
  class _ManagerGroup:
136
151
  """
@@ -147,10 +162,7 @@ class _ManagerGroup:
147
162
  if callable(sub):
148
163
 
149
164
  def _wrapper(**overrides) -> str:
150
- data = self._manager.get_equipment_data().copy()
151
- data.update(self._manager.get_instance_data())
152
- placeholders = {**data, **overrides}
153
- return sub(**placeholders)
165
+ return cast(str, sub(**self._manager._placeholders(overrides))) # pyright: ignore[reportPrivateUsage]
154
166
 
155
167
  return _wrapper
156
168
  if isinstance(sub, _TemplateGroup):
@@ -68,7 +68,7 @@ class TopicUtility:
68
68
  self._collect_templates(self._raw_dict)
69
69
  self.root = _TemplateGroup(self._raw_dict, self)
70
70
 
71
- def __getattr__(self, name: str):
71
+ def __getattr__(self, name: str) -> Any:
72
72
  """
73
73
  Handle dynamic attribute access:
74
74
  - If it's a required field from the YAML, return its name.
@@ -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(