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.
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/PKG-INFO +13 -1
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/README.md +12 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/config/configuration.yaml +8 -5
- leaf_framework-0.10.16/leaf/modules/input_modules/lads_watcher.py +351 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/modbus_watcher.py +10 -1
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/polling_watcher.py +26 -10
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/file.py +20 -10
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/mqtt.py +17 -4
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/output_module.py +9 -6
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/start.py +3 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/components/header.py +4 -2
- leaf_framework-0.10.16/leaf/ui/components/update_notice.py +209 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/interface.py +8 -1
- leaf_framework-0.10.16/leaf/ui/update_checker.py +275 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/pyproject.toml +1 -1
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/LICENSE +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/adapters/equipment_adapter.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/data/marketplace.yaml +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/error_handler/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/error_handler/error_holder.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/error_handler/exceptions.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/handler.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/measurements.yaml +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/measurement_handler/terms.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/.gitkeep +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/ble_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/db_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/event_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/external_api_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/external_event_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/file_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/http_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/opc_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/simple_watcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/fluorescence.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/measurement_module.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/optical_density.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/ph.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/measurement_modules/temperature.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/keydb.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/sql.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/output_modules/topic_store.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/control.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/error.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/external_event_phase.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/initialisation.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/measure.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/phase.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/start.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/phase_modules/stop.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/continous_module.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/discrete_module.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/external_event_process.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/process_module.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/process_modules/upload_module.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/abstract_leaf.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/equipment_actions.yaml +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/equipment_data.yaml +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/metadata.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/py.typed +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/register/topic_utilities.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/discovery.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/loader.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/registry.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/registry/utils.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/adapter_ui_extension.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/components/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/constants.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/desktop_launcher.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/images/icon.icns +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/images/icon.ico +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/images/icon.svg +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/markdown/configuration_help.md +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/markdown/documentation_main.md +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/markdown/documentation_protips.md +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/scripts.js +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/styles.css +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/adapters.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/configuration.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/documentation.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/logs.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/tabs/monitoring.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/ui/utils.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/config_utils.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/logger/__init__.py +0 -0
- {leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/utility/logger/logger_utils.py +0 -0
- {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.
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
- plugin: SQL
|
|
19
|
-
|
|
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()
|
{leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/modbus_watcher.py
RENAMED
|
@@ -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
|
-
|
|
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]:
|
{leaf_framework-0.10.14 → leaf_framework-0.10.16}/leaf/modules/input_modules/polling_watcher.py
RENAMED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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[
|
|
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[
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|