leaf-framework 0.10.14__tar.gz → 0.10.16__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 (108) hide show
  1. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/PKG-INFO +13 -1
  2. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/README.md +12 -0
  3. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/config/configuration.yaml +8 -5
  4. leaf_framework-0.10.16/leaf/modules/input_modules/lads_watcher.py +351 -0
  5. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/modbus_watcher.py +10 -1
  6. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/polling_watcher.py +26 -10
  7. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/file.py +20 -10
  8. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/mqtt.py +17 -4
  9. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/output_module.py +9 -6
  10. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/start.py +3 -0
  11. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/components/header.py +4 -2
  12. leaf_framework-0.10.16/leaf/ui/components/update_notice.py +209 -0
  13. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/interface.py +8 -1
  14. leaf_framework-0.10.16/leaf/ui/update_checker.py +275 -0
  15. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/pyproject.toml +1 -1
  16. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/LICENSE +0 -0
  17. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/__init__.py +0 -0
  18. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/__init__.py +0 -0
  19. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/__init__.py +0 -0
  20. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
  21. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
  22. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
  23. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
  24. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/equipment_adapter.py +0 -0
  25. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/data/marketplace.yaml +0 -0
  26. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/error_handler/__init__.py +0 -0
  27. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/error_handler/error_holder.py +0 -0
  28. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/error_handler/exceptions.py +0 -0
  29. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/__init__.py +0 -0
  30. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/handler.py +0 -0
  31. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/measurements.yaml +0 -0
  32. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/terms.py +0 -0
  33. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/.gitkeep +0 -0
  34. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/__init__.py +0 -0
  35. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/__init__.py +0 -0
  36. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/ble_watcher.py +0 -0
  37. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/db_watcher.py +0 -0
  38. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/event_watcher.py +0 -0
  39. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/external_api_watcher.py +0 -0
  40. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/external_event_watcher.py +0 -0
  41. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/file_watcher.py +0 -0
  42. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/http_watcher.py +0 -0
  43. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
  44. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
  45. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/opc_watcher.py +0 -0
  46. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/simple_watcher.py +0 -0
  47. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/__init__.py +0 -0
  48. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
  49. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
  50. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/fluorescence.py +0 -0
  51. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/measurement_module.py +0 -0
  52. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/optical_density.py +0 -0
  53. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/ph.py +0 -0
  54. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/temperature.py +0 -0
  55. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/__init__.py +0 -0
  56. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/keydb.py +0 -0
  57. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/sql.py +0 -0
  58. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/topic_store.py +0 -0
  59. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/__init__.py +0 -0
  60. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/control.py +0 -0
  61. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/error.py +0 -0
  62. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/external_event_phase.py +0 -0
  63. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/initialisation.py +0 -0
  64. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/measure.py +0 -0
  65. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/phase.py +0 -0
  66. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/start.py +0 -0
  67. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/stop.py +0 -0
  68. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/__init__.py +0 -0
  69. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/continous_module.py +0 -0
  70. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/discrete_module.py +0 -0
  71. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/external_event_process.py +0 -0
  72. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/process_module.py +0 -0
  73. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/upload_module.py +0 -0
  74. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/__init__.py +0 -0
  75. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/abstract_leaf.py +0 -0
  76. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/equipment_actions.yaml +0 -0
  77. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/equipment_data.yaml +0 -0
  78. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/metadata.py +0 -0
  79. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/py.typed +0 -0
  80. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/topic_utilities.py +0 -0
  81. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/discovery.py +0 -0
  82. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/loader.py +0 -0
  83. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/registry.py +0 -0
  84. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/utils.py +0 -0
  85. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/__init__.py +0 -0
  86. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/adapter_ui_extension.py +0 -0
  87. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/components/__init__.py +0 -0
  88. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/constants.py +0 -0
  89. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/desktop_launcher.py +0 -0
  90. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/images/icon.icns +0 -0
  91. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/images/icon.ico +0 -0
  92. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/images/icon.svg +0 -0
  93. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/markdown/configuration_help.md +0 -0
  94. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/markdown/documentation_main.md +0 -0
  95. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/markdown/documentation_protips.md +0 -0
  96. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/scripts.js +0 -0
  97. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/styles.css +0 -0
  98. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/__init__.py +0 -0
  99. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/adapters.py +0 -0
  100. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/configuration.py +0 -0
  101. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/documentation.py +0 -0
  102. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/logs.py +0 -0
  103. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/monitoring.py +0 -0
  104. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/utils.py +0 -0
  105. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/config_utils.py +0 -0
  106. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/logger/__init__.py +0 -0
  107. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/logger/logger_utils.py +0 -0
  108. {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/running_utilities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leaf-framework
3
- Version: 0.10.14
3
+ Version: 0.10.16
4
4
  Summary: Lightweight Adapter Framework (LEAF)
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -44,6 +44,18 @@ 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
+ ## Updating
48
+
49
+ 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
+
51
+ 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
+
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:
54
+
55
+ ```bash
56
+ pip install --upgrade leaf-framework
57
+ ```
58
+
47
59
  ## Documentation
48
60
 
49
61
  The complete documentation can be found [here](https://leaf-framework.org).
@@ -14,6 +14,18 @@ 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
+ ## Updating
18
+
19
+ 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
+
21
+ 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
+
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:
24
+
25
+ ```bash
26
+ pip install --upgrade leaf-framework
27
+ ```
28
+
17
29
  ## Documentation
18
30
 
19
31
  The complete documentation can be found [here](https://leaf-framework.org).
@@ -9,14 +9,17 @@ EQUIPMENT_INSTANCES:
9
9
  interval: 1
10
10
 
11
11
  OUTPUTS:
12
+ - plugin: mqtt
13
+ broker: localhost
14
+ port: 1883
12
15
  # MQTT output requires 'broker' and 'port'
13
16
  # optionally 'username', 'password', 'clientid', 'protocol', 'transport', and 'tls'
14
17
  # and optionally an alternative 'fallback' output
15
- - plugin: FILE
16
- filename: testing.json
17
- parallel: SQL
18
- - plugin: SQL
19
- filename: testing.db
18
+ # - plugin: FILE
19
+ # filename: testing.json
20
+ # parallel: SQL
21
+ # - plugin: SQL
22
+ # filename: testing.db
20
23
  # username: None #OPTIONAL expects a str
21
24
  # password: None #OPTIONAL expects a str
22
25
  # clientid: None #OPTIONAL expects a str
@@ -0,0 +1,351 @@
1
+ import threading
2
+ import time
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from leaf.register.metadata import MetadataManager
7
+ from leaf.utility.logger.logger_utils import get_logger
8
+
9
+ logger = get_logger(__name__, log_file="input_module.log")
10
+
11
+ try:
12
+ from opcua import Client, Node, Subscription, ua # type: ignore
13
+ from opcua.ua import DataChangeNotification # type: ignore
14
+
15
+ OPCUA_AVAILABLE = True
16
+ # Suppress OPC UA library logging to avoid cluttering the output
17
+ import logging
18
+
19
+ logging.getLogger("opcua").setLevel(logging.WARNING)
20
+ except ImportError:
21
+ logger.warning("OPC UA library not available. LADSWatcher will not function.")
22
+ OPCUA_AVAILABLE = False
23
+ Client = Node = Subscription = DataChangeNotification = ua = None # Placeholders
24
+
25
+ from leaf.error_handler.error_holder import ErrorHolder
26
+ from leaf.modules.input_modules.event_watcher import EventWatcher
27
+
28
+ FIRST_RECONNECT_DELAY = 1
29
+ RECONNECT_RATE = 2
30
+ MAX_RECONNECT_DELAY = 60
31
+ KEEPALIVE_INTERVAL = 10
32
+
33
+ # Standard LADS subtrees that hold configuration/metadata rather than live
34
+ # measurement data (locking, historizing config, program templates, static
35
+ # state-machine descriptors) - excluded from discovery by default.
36
+ SKIP_BROWSE_NAMES = {
37
+ "Lock",
38
+ "HA Configuration",
39
+ "ProgramTemplateSet",
40
+ "AvailableStates",
41
+ "AvailableTransitions",
42
+ }
43
+
44
+
45
+ class LADSWatcher(EventWatcher):
46
+ """
47
+ A concrete implementation of EventWatcher for OPC UA LADS (IEC 63243)
48
+ lab-device servers. Unlike OPCWatcher, which requires callers to supply
49
+ exact NodeIds, LADSWatcher discovers monitorable variables automatically
50
+ by walking the standard LADS DeviceSet -> FunctionalUnitSet -> FunctionSet
51
+ structure.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ metadata_manager: MetadataManager,
57
+ host: str,
58
+ port: int,
59
+ functional_units: set[str] | None = None,
60
+ exclude_variables: list[str] | None = None,
61
+ interval: int = 1,
62
+ callbacks: list[Callable[..., Any]] | None = None,
63
+ error_holder: ErrorHolder | None = None,
64
+ ) -> None:
65
+ """
66
+ Initialize LADSWatcher.
67
+
68
+ Args:
69
+ metadata_manager (MetadataManager): Manages equipment metadata.
70
+ host (str): LADS OPC UA server host.
71
+ port (int): LADS OPC UA server port.
72
+ functional_units (Optional[set[str]]): Names of functional units to
73
+ monitor. If None, all discovered functional units are monitored.
74
+ exclude_variables (Optional[list[str]]): Variable browse names to
75
+ exclude from discovery (e.g. "RawValue").
76
+ callbacks (Optional[List[Callable]]): Callbacks for event updates.
77
+ error_holder (Optional[ErrorHolder]): Optional object to manage errors.
78
+ """
79
+ super().__init__(metadata_manager, callbacks=callbacks, error_holder=error_holder)
80
+
81
+ self._host = host
82
+ self._port = port
83
+ self._functional_units: set[str] | None = set(functional_units) if functional_units else None
84
+ self._exclude_variables: list[str] = exclude_variables or []
85
+ self._metadata_manager = metadata_manager
86
+ self._sub: Subscription | None = None
87
+ self._handler = self._dispatch_callback
88
+ self._handles: list[Any] = []
89
+ self._interval = interval
90
+ self.logger = get_logger(__name__, log_file="input_module.log")
91
+ if host is None or port is None:
92
+ raise ValueError("Host and port must be specified.")
93
+ self._client: Client | None = None
94
+ self._thread: threading.Thread | None = None
95
+ # NodeId string -> {"functional_unit": str, "browse_name": str, "node": Node}
96
+ self._variables: dict[str, dict[str, Any]] = {}
97
+
98
+ def datachange_notification(self, node: Node, val: int | str | float, data: DataChangeNotification) -> None:
99
+ nodeid = node.nodeid.to_string()
100
+ self.logger.debug(f"LADS datachange_notification: node={nodeid}, value={val}")
101
+ info = self._variables.get(nodeid, {})
102
+ self._dispatch_callback(
103
+ self._metadata_manager.experiment.measurement,
104
+ {
105
+ "functional_unit": info.get("functional_unit"),
106
+ "node": node.nodeid.Identifier,
107
+ "value": val,
108
+ "timestamp": data.monitored_item.Value.SourceTimestamp,
109
+ "data": data,
110
+ },
111
+ )
112
+
113
+ def start(self) -> None:
114
+ """
115
+ Start the LADSWatcher in a background thread with automatic reconnection.
116
+ """
117
+ if not OPCUA_AVAILABLE:
118
+ raise Exception("OPC UA library is not available. Cannot start LADSWatcher.")
119
+
120
+ if self._thread and self._thread.is_alive():
121
+ self.logger.warning("LADSWatcher already running.")
122
+ return
123
+
124
+ super().start()
125
+ self._thread = threading.Thread(target=self._connection_loop, daemon=True)
126
+ self._thread.start()
127
+ self.logger.info(f"LADSWatcher started for {self._host}:{self._port}")
128
+
129
+ def _connection_loop(self) -> None:
130
+ """
131
+ Main connection loop with automatic reconnection on failure.
132
+
133
+ Mirrors OPCWatcher's pattern: connect, discover, subscribe, keepalive,
134
+ and retry with exponential backoff if anything goes wrong.
135
+ """
136
+ reconnect_delay = FIRST_RECONNECT_DELAY
137
+
138
+ while self._running:
139
+ self.logger.info(f"Connecting to LADS OPC UA server {self._host}:{self._port}...")
140
+ try:
141
+ self._client = Client(f"opc.tcp://{self._host}:{self._port}")
142
+ self._client.connect()
143
+
144
+ objects = self._client.get_objects_node()
145
+ self._variables = self._discover_variables(objects)
146
+ if not self._variables:
147
+ raise ValueError("No LADS functional unit variables discovered on server.")
148
+
149
+ self._subscribe_to_variables()
150
+
151
+ # Reset backoff on successful connection
152
+ reconnect_delay = FIRST_RECONNECT_DELAY
153
+ self.logger.info(
154
+ f"LADS OPC UA connected and subscribed to {len(self._variables)} variables. Monitoring..."
155
+ )
156
+
157
+ # Keepalive loop — detect silent disconnections
158
+ while self._running:
159
+ time.sleep(KEEPALIVE_INTERVAL)
160
+ try:
161
+ self._client.get_root_node().get_browse_name()
162
+ except Exception as e:
163
+ self.logger.warning(f"LADS OPC UA keepalive failed, reconnecting: {e}")
164
+ break # exit keepalive loop → triggers reconnect
165
+
166
+ except Exception as e:
167
+ self.logger.error(f"LADS OPC UA connection error: {e}")
168
+
169
+ finally:
170
+ self._cleanup_connection()
171
+
172
+ if not self._running:
173
+ break
174
+
175
+ self.logger.info(f"Reconnecting in {reconnect_delay}s...")
176
+ time.sleep(reconnect_delay)
177
+ reconnect_delay = min(reconnect_delay * RECONNECT_RATE, MAX_RECONNECT_DELAY)
178
+
179
+ def _cleanup_connection(self) -> None:
180
+ """Unsubscribe and disconnect cleanly."""
181
+ if self._sub and self._handles:
182
+ try:
183
+ for handle in self._handles:
184
+ self._sub.unsubscribe(handle)
185
+ self._handles.clear()
186
+ except Exception as e:
187
+ self.logger.debug(f"Error unsubscribing: {e}")
188
+
189
+ if self._sub:
190
+ try:
191
+ self._sub.delete()
192
+ except Exception as e:
193
+ self.logger.debug(f"Error deleting subscription: {e}")
194
+ self._sub = None
195
+
196
+ if self._client:
197
+ try:
198
+ self._client.disconnect()
199
+ except Exception as e:
200
+ self.logger.debug(f"Error disconnecting: {e}")
201
+ self._client = None
202
+
203
+ self._variables = {}
204
+
205
+ def _browse_device_set(self, objects: Node) -> list[Node]:
206
+ """
207
+ Return device nodes under Objects/DeviceSet. A device is identified
208
+ structurally (an Object with a FunctionalUnitSet child) rather than by
209
+ name, since DeviceSet can also contain non-device components such as
210
+ DeviceFeatures.
211
+ """
212
+ device_set = None
213
+ for child in objects.get_children():
214
+ if child.get_browse_name().Name == "DeviceSet":
215
+ device_set = child
216
+ break
217
+ if device_set is None:
218
+ return []
219
+
220
+ devices = []
221
+ for child in device_set.get_children():
222
+ if child.get_node_class() != ua.NodeClass.Object:
223
+ continue
224
+ if any(gc.get_browse_name().Name == "FunctionalUnitSet" for gc in child.get_children()):
225
+ devices.append(child)
226
+ return devices
227
+
228
+ def _browse_functional_units(self, device_node: Node) -> dict[str, Node]:
229
+ """Return {functional_unit_name: node} for a device's FunctionalUnitSet."""
230
+ units: dict[str, Node] = {}
231
+ for child in device_node.get_children():
232
+ if child.get_browse_name().Name != "FunctionalUnitSet":
233
+ continue
234
+ for fu in child.get_children():
235
+ if fu.get_node_class() != ua.NodeClass.Object:
236
+ continue
237
+ units[fu.get_browse_name().Name] = fu
238
+ return units
239
+
240
+ def _walk_variables(self, node: Node, visited: set[str], found: dict[str, Node]) -> None:
241
+ """
242
+ Recursively collect readable Variable nodes under a functional unit.
243
+
244
+ LADS nodesets reference the same object through multiple reference
245
+ types (e.g. Organizes + HasComponent), so naive recursion without a
246
+ visited-set revisits and duplicates nodes.
247
+ """
248
+ nodeid = node.nodeid.to_string()
249
+ if nodeid in visited:
250
+ return
251
+ visited.add(nodeid)
252
+
253
+ try:
254
+ browse_name = node.get_browse_name().Name
255
+ except Exception as e:
256
+ self.logger.debug(f"Skipping node {node.nodeid}, could not read browse name: {e}")
257
+ return
258
+
259
+ if browse_name in SKIP_BROWSE_NAMES:
260
+ return
261
+
262
+ try:
263
+ node_class = node.get_node_class()
264
+ except Exception as e:
265
+ self.logger.debug(f"Skipping node {node.nodeid}, could not read node class: {e}")
266
+ return
267
+
268
+ if node_class == ua.NodeClass.Variable:
269
+ if browse_name not in self._exclude_variables:
270
+ found[nodeid] = node
271
+ # Variables may have child properties (EngineeringUnits, EURange)
272
+ # that aren't independently useful measurements - don't recurse.
273
+ return
274
+
275
+ if node_class != ua.NodeClass.Object:
276
+ return
277
+
278
+ for child in node.get_children():
279
+ self._walk_variables(child, visited, found)
280
+
281
+ def _discover_variables(self, objects: Node) -> dict[str, dict[str, Any]]:
282
+ """
283
+ Walk Objects/DeviceSet -> device -> FunctionalUnitSet -> functional
284
+ units, collecting readable Variable nodes for each functional unit
285
+ (filtered by self._functional_units if provided).
286
+ """
287
+ variables: dict[str, dict[str, Any]] = {}
288
+ for device in self._browse_device_set(objects):
289
+ units = self._browse_functional_units(device)
290
+ for fu_name, fu_node in units.items():
291
+ if self._functional_units and fu_name not in self._functional_units:
292
+ self.logger.info(f"Excluded functional unit: {fu_name}")
293
+ continue
294
+ visited: set[str] = set()
295
+ found: dict[str, Node] = {}
296
+ self._walk_variables(fu_node, visited, found)
297
+ for nodeid, node in found.items():
298
+ variables[nodeid] = {
299
+ "functional_unit": fu_name,
300
+ "browse_name": node.get_browse_name().Name,
301
+ "node": node,
302
+ }
303
+ self.logger.info(f"Found variable: {fu_name}/{node.get_browse_name().Name} ({nodeid})")
304
+ return variables
305
+
306
+ def _subscribe_to_variables(self) -> None:
307
+ """
308
+ Subscribe to discovered LADS variables and monitor data changes.
309
+ """
310
+ if not self._client:
311
+ self.logger.warning("Client is not connected.")
312
+ return
313
+
314
+ self._sub = self._client.create_subscription(self._interval * 1000, self) # second interval converted to ms
315
+
316
+ for nodeid, info in self._variables.items():
317
+ node = info["node"]
318
+ try:
319
+ handle = self._sub.subscribe_data_change(node)
320
+ self._handles.append(handle)
321
+ self.logger.info(f"Subscribed to: {info['functional_unit']}/{info['browse_name']} ({nodeid})")
322
+ # Send a dummy value to trigger the callback
323
+ self._dispatch_callback(
324
+ self._metadata_manager.experiment.measurement,
325
+ {
326
+ "functional_unit": info["functional_unit"],
327
+ "node": node.nodeid.Identifier,
328
+ "value": node.get_value(),
329
+ "timestamp": time.time(),
330
+ "data": None,
331
+ },
332
+ )
333
+
334
+ except Exception as e:
335
+ self.logger.error(f"Failed to subscribe to {nodeid}: {e}")
336
+ if "ServiceFault" in str(e):
337
+ self.logger.info("Retrying in 5 seconds...")
338
+ time.sleep(5)
339
+ continue # Try the next variable
340
+
341
+ def stop(self) -> None:
342
+ """
343
+ Stop the LADSWatcher and disconnect from the OPC UA server.
344
+ """
345
+ self.logger.info("Stopping LADSWatcher")
346
+ super().stop() # sets _running = False, exits connection loop
347
+
348
+ if self._thread and self._thread.is_alive():
349
+ self._thread.join(timeout=5.0)
350
+
351
+ self._cleanup_connection()
@@ -114,7 +114,16 @@ class ModbusWatcher(ExternalEventWatcher):
114
114
  self._emit(data)
115
115
  except Exception as e:
116
116
  logger.error(f"ModbusWatcher poll error: {e}")
117
- self._handle_exception(e)
117
+ try:
118
+ self._handle_exception(e)
119
+ except Exception:
120
+ # _handle_exception re-raises when no error_holder is
121
+ # configured (the default) -- this loop must survive
122
+ # regardless, or a single bad read (e.g. a Modbus
123
+ # timeout/CRC error) silently and permanently kills all
124
+ # future polling. See polling_watcher.py's _poll for the
125
+ # same fix and full rationale.
126
+ pass
118
127
  time.sleep(self._interval)
119
128
 
120
129
  def _read_registers(self) -> dict[str, Any]:
@@ -72,16 +72,32 @@ class PollingWatcher(EventWatcher):
72
72
  def _poll(self) -> None:
73
73
  """Background loop for fetching data and dispatching events."""
74
74
  while self._running:
75
- data = self._fetch_data()
76
-
77
- if data.get("measurement") is not None:
78
- self.measurement_message(data["measurement"])
79
-
80
- if data.get("start") is not None:
81
- self.start_message(data["start"])
82
-
83
- if data.get("stop") is not None:
84
- self.stop_message(data["stop"])
75
+ try:
76
+ data = self._fetch_data()
77
+
78
+ if data.get("measurement") is not None:
79
+ self.measurement_message(data["measurement"])
80
+
81
+ if data.get("start") is not None:
82
+ self.start_message(data["start"])
83
+
84
+ if data.get("stop") is not None:
85
+ self.stop_message(data["stop"])
86
+ except Exception as exc:
87
+ logger.error(f"PollingWatcher: error during poll cycle, will retry next interval: {exc}")
88
+ try:
89
+ self._handle_exception(exc)
90
+ except Exception:
91
+ # _handle_exception re-raises when no error_holder is
92
+ # configured (the default -- it's None throughout the
93
+ # constructor chain unless an adapter explicitly wires
94
+ # one up). This loop must survive regardless: without
95
+ # this, a single bad cycle (e.g. a transient hardware
96
+ # read glitch) silently and permanently kills this
97
+ # daemon thread -- no crash, no retry, no visible
98
+ # alert, just an experiment that quietly stops
99
+ # reporting data.
100
+ pass
85
101
 
86
102
  time.sleep(self._interval)
87
103
 
@@ -1,7 +1,8 @@
1
+ import gzip
1
2
  import json
2
3
  import os
3
4
  import threading
4
- from typing import Any
5
+ from typing import IO, Any, cast
5
6
 
6
7
  from leaf.error_handler.error_holder import ErrorHolder
7
8
  from leaf.error_handler.exceptions import ClientUnreachableError, SeverityLevel
@@ -30,7 +31,16 @@ class FILE(OutputModule):
30
31
  if directory:
31
32
  os.makedirs(directory, exist_ok=True)
32
33
 
33
- def _handle_file_error(self, error) -> None:
34
+ def _open(self, mode: str) -> IO[str]:
35
+ """
36
+ Open `self.filename` in text mode, transparently using gzip
37
+ when the filename ends in '.gz'.
38
+ """
39
+ if self.filename.endswith(".gz"):
40
+ return cast(IO[str], gzip.open(self.filename, mode + "t"))
41
+ return open(self.filename, mode)
42
+
43
+ def _handle_file_error(self, error: Exception) -> None:
34
44
  """
35
45
  Handles file-related exceptions consistently
36
46
  with a structured error message.
@@ -56,7 +66,7 @@ class FILE(OutputModule):
56
66
 
57
67
  self._handle_exception(ClientUnreachableError(message, output_module=self, severity=severity))
58
68
 
59
- def transmit(self, topic: str, data: str | dict | None = None) -> bool:
69
+ def transmit(self, topic: str, data: str | dict[str, Any] | None = None) -> bool:
60
70
  """
61
71
  Transmit data to the file associated with a specific topic.
62
72
  Appends a single line with the topic and data in compact JSON format.
@@ -69,7 +79,7 @@ class FILE(OutputModule):
69
79
  line = json.dumps({topic: [data]}, separators=(",", ":"))
70
80
 
71
81
  # Append to file (no reading required)
72
- with self._lock, open(self.filename, "a") as f:
82
+ with self._lock, self._open("a") as f:
73
83
  f.write(line + "\n")
74
84
 
75
85
  # Reset global failure counter on successful transmission
@@ -88,7 +98,7 @@ class FILE(OutputModule):
88
98
  self._handle_file_error(e)
89
99
  return self.fallback(topic, data)
90
100
 
91
- def retrieve(self, topic: str) -> list:
101
+ def retrieve(self, topic: str) -> list[dict[str, Any]] | None:
92
102
  """
93
103
  Retrieve all data associated with a specific topic by streaming line-by-line.
94
104
  Returns a list of all data entries for the topic.
@@ -98,7 +108,7 @@ class FILE(OutputModule):
98
108
  return []
99
109
 
100
110
  results = []
101
- with self._lock, open(self.filename) as f:
111
+ with self._lock, self._open("r") as f:
102
112
  for line in f:
103
113
  line = line.strip()
104
114
  if not line:
@@ -123,7 +133,7 @@ class FILE(OutputModule):
123
133
  self._handle_file_error(e)
124
134
  return None
125
135
 
126
- def pop(self, key: str = None) -> tuple[str, Any] | None:
136
+ def pop(self, key: str | None = None) -> tuple[Any | None, Any | None] | None:
127
137
  """
128
138
  Retrieve and remove the first occurrence of a topic from the file.
129
139
  If a specific key is provided, removes the first line with that topic.
@@ -134,7 +144,7 @@ class FILE(OutputModule):
134
144
  If None, removes the first line.
135
145
 
136
146
  Returns:
137
- Optional[tuple[str, Any]]: A tuple of (topic, data_list),
147
+ Optional[tuple[Any | None, Any | None]]: A tuple of (topic, data_list),
138
148
  or None if the file is empty or key not found.
139
149
  """
140
150
  try:
@@ -148,7 +158,7 @@ class FILE(OutputModule):
148
158
 
149
159
  with self._lock:
150
160
  # Read all lines and find the first matching one
151
- with open(self.filename) as f:
161
+ with self._open("r") as f:
152
162
  for line in f:
153
163
  line_content = line.strip()
154
164
  if not line_content:
@@ -181,7 +191,7 @@ class FILE(OutputModule):
181
191
  return None
182
192
 
183
193
  # Rewrite file without the removed line
184
- with open(self.filename, "w") as f:
194
+ with self._open("w") as f:
185
195
  f.writelines(lines)
186
196
 
187
197
  return found_topic, found_data
@@ -212,7 +212,9 @@ class MQTT(OutputModule):
212
212
  logger.warning(f"Topic '{topic}' contains MQTT-illegal characters; publishing as '{sanitized}' instead.")
213
213
  return sanitized
214
214
 
215
- def transmit(self, topic: str, data: str | dict | None = None, retain: bool = False) -> bool:
215
+ def transmit(
216
+ self, topic: str, data: str | dict | None = None, retain: bool = False, use_fallback: bool = True
217
+ ) -> bool:
216
218
  """
217
219
  Publish a message to the MQTT broker on a given topic.
218
220
 
@@ -220,6 +222,10 @@ class MQTT(OutputModule):
220
222
  topic (str): The topic to publish the message to.
221
223
  data (Optional[Union[str, dict]]): The message payload to be transmitted.
222
224
  retain (bool): Whether to retain the message on the broker.
225
+ use_fallback (bool): Whether a failed publish should be handed to the
226
+ fallback module. Set to False for messages that are only meaningful
227
+ live (e.g. the heartbeat), so they are dropped rather than buffered
228
+ and replayed later.
223
229
 
224
230
  Returns:
225
231
  bool: True if the message was successfully published, False otherwise.
@@ -228,7 +234,7 @@ class MQTT(OutputModule):
228
234
  if not self.is_enabled():
229
235
  logger.warning(f"{self.__class__.__name__} - transmit called with module disabled.")
230
236
  # Call the fallback if available
231
- if self._fallback is not None:
237
+ if use_fallback and self._fallback is not None:
232
238
  logger.debug("Calling fallback as module is disabled.")
233
239
  return self.fallback(topic, data)
234
240
  return False
@@ -245,6 +251,8 @@ class MQTT(OutputModule):
245
251
  # synthetic "start" message) can otherwise lose that race every time.
246
252
  if not self.client.is_connected():
247
253
  if not self._connected_event.wait(timeout=3) or not self.client.is_connected():
254
+ if not use_fallback:
255
+ return False
248
256
  return self.fallback(topic, data)
249
257
  if data == "":
250
258
  data = {}
@@ -263,6 +271,8 @@ class MQTT(OutputModule):
263
271
  if error is not None:
264
272
  self.sending_success[topic] = False
265
273
  self._handle_exception(error)
274
+ if not use_fallback:
275
+ return False
266
276
  return self.fallback(topic, data)
267
277
 
268
278
  # If successfully published, check if the fallback has data on the topic
@@ -270,7 +280,7 @@ class MQTT(OutputModule):
270
280
  if not self.sending_success[topic]:
271
281
  # To prevent recursion in case the fallback also tries to publish
272
282
  self.sending_success[topic] = True
273
- if self._fallback is not None:
283
+ if use_fallback and self._fallback is not None:
274
284
  while True:
275
285
  fallback_result = self._fallback.pop(topic)
276
286
  if fallback_result is not None:
@@ -544,7 +554,10 @@ class MQTT(OutputModule):
544
554
  }
545
555
  logger.debug(f"Publishing heartbeat to '{self._heartbeat_topic}': {payload}")
546
556
  try:
547
- self.transmit(self._heartbeat_topic, payload)
557
+ # use_fallback=False: a heartbeat is only meaningful live, so a
558
+ # failed publish is dropped rather than buffered in the fallback
559
+ # and replayed as a stale "alive" once the broker is back.
560
+ self.transmit(self._heartbeat_topic, payload, use_fallback=False)
548
561
  except LEAFError as e:
549
562
  # Must not propagate - an unhandled exception here (e.g. the broker
550
563
  # connection not being fully up yet) would silently kill this daemon