pynode-flow 0.2.0__py3-none-any.whl
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.
- pynode/__init__.py +37 -0
- pynode/__main__.py +9 -0
- pynode/_perf.py +65 -0
- pynode/_version.py +24 -0
- pynode/api/__init__.py +15 -0
- pynode/api/helpers.py +79 -0
- pynode/api/nodes.py +423 -0
- pynode/api/services.py +124 -0
- pynode/api/sse.py +58 -0
- pynode/api/uploads.py +59 -0
- pynode/api/workflows.py +466 -0
- pynode/config.py +93 -0
- pynode/install_nodes.py +143 -0
- pynode/main.py +118 -0
- pynode/node_registry.py +156 -0
- pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
- pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
- pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
- pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
- pynode/nodes/BatchNode/__init__.py +3 -0
- pynode/nodes/BatchNode/batch_node.py +86 -0
- pynode/nodes/BatchNode/requirements.txt +1 -0
- pynode/nodes/CameraNode/__init__.py +4 -0
- pynode/nodes/CameraNode/camera_node.py +204 -0
- pynode/nodes/CameraNode/requirements.txt +2 -0
- pynode/nodes/ChangeNode/__init__.py +1 -0
- pynode/nodes/ChangeNode/change_node.py +255 -0
- pynode/nodes/ChangeNode/requirements.txt +1 -0
- pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
- pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
- pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
- pynode/nodes/CounterNode/__init__.py +3 -0
- pynode/nodes/CounterNode/counter_node.py +191 -0
- pynode/nodes/CropNode/__init__.py +4 -0
- pynode/nodes/CropNode/crop_node.py +317 -0
- pynode/nodes/CropNode/requirements.txt +2 -0
- pynode/nodes/DebugNode/__init__.py +4 -0
- pynode/nodes/DebugNode/debug_node.py +169 -0
- pynode/nodes/DebugNode/requirements.txt +1 -0
- pynode/nodes/DelayNode/delay_node.py +218 -0
- pynode/nodes/DelayNode/requirements.txt +1 -0
- pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
- pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
- pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
- pynode/nodes/ErrorNode/error_node.py +94 -0
- pynode/nodes/ErrorNode/requirements.txt +1 -0
- pynode/nodes/FilterNode/__init__.py +3 -0
- pynode/nodes/FilterNode/filter_node.py +102 -0
- pynode/nodes/FilterNode/requirements.txt +1 -0
- pynode/nodes/FrameSourceNode/__init__.py +4 -0
- pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
- pynode/nodes/FrameSourceNode/requirements.txt +1 -0
- pynode/nodes/FunctionNode/__init__.py +3 -0
- pynode/nodes/FunctionNode/function_node.py +168 -0
- pynode/nodes/FunctionNode/requirements.txt +1 -0
- pynode/nodes/GateNode/gate_node.py +60 -0
- pynode/nodes/GateNode/requirements.txt +1 -0
- pynode/nodes/GetiUploadNode/__init__.py +3 -0
- pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
- pynode/nodes/GetiUploadNode/requirements.txt +1 -0
- pynode/nodes/ImageFormatNode/__init__.py +4 -0
- pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
- pynode/nodes/ImageFormatNode/requirements.txt +2 -0
- pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
- pynode/nodes/ImageViewerNode/__init__.py +4 -0
- pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
- pynode/nodes/ImageViewerNode/requirements.txt +2 -0
- pynode/nodes/ImageWriterNode/__init__.py +3 -0
- pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
- pynode/nodes/ImageWriterNode/requirements.txt +1 -0
- pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
- pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
- pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
- pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
- pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
- pynode/nodes/InferenceNode/inference_node.py +451 -0
- pynode/nodes/InferenceNode/requirements.txt +13 -0
- pynode/nodes/InjectNode/inject_node.py +245 -0
- pynode/nodes/InjectNode/requirements.txt +1 -0
- pynode/nodes/JoinNode/__init__.py +3 -0
- pynode/nodes/JoinNode/join_node.py +156 -0
- pynode/nodes/JoinNode/requirements.txt +1 -0
- pynode/nodes/LabelFilterNode/__init__.py +3 -0
- pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
- pynode/nodes/LabelFilterNode/requirements.txt +1 -0
- pynode/nodes/MQTTNode/__init__.py +8 -0
- pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
- pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
- pynode/nodes/MQTTNode/mqtt_service.py +367 -0
- pynode/nodes/MQTTNode/requirements.txt +1 -0
- pynode/nodes/MessageWriterNode/__init__.py +7 -0
- pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
- pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
- pynode/nodes/OmronCameraNode/__init__.py +3 -0
- pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
- pynode/nodes/OmronCameraNode/requirements.txt +4 -0
- pynode/nodes/OpenCV/__init__.py +1 -0
- pynode/nodes/OpenCV/arithmetic_node.py +376 -0
- pynode/nodes/OpenCV/bitwise_node.py +127 -0
- pynode/nodes/OpenCV/blend_node.py +127 -0
- pynode/nodes/OpenCV/blob_detector_node.py +233 -0
- pynode/nodes/OpenCV/blur_node.py +141 -0
- pynode/nodes/OpenCV/color_space_node.py +139 -0
- pynode/nodes/OpenCV/colormap_node.py +262 -0
- pynode/nodes/OpenCV/contours_node.py +258 -0
- pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
- pynode/nodes/OpenCV/draw_node.py +312 -0
- pynode/nodes/OpenCV/edge_detector_node.py +177 -0
- pynode/nodes/OpenCV/equirectangular_node.py +447 -0
- pynode/nodes/OpenCV/fft_node.py +102 -0
- pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
- pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
- pynode/nodes/OpenCV/histogram_node.py +160 -0
- pynode/nodes/OpenCV/hough_circles_node.py +224 -0
- pynode/nodes/OpenCV/hough_lines_node.py +243 -0
- pynode/nodes/OpenCV/in_range_node.py +186 -0
- pynode/nodes/OpenCV/morphology_node.py +136 -0
- pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
- pynode/nodes/OpenCV/paste_node.py +244 -0
- pynode/nodes/OpenCV/perspective_node.py +174 -0
- pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
- pynode/nodes/OpenCV/requirements.txt +2 -0
- pynode/nodes/OpenCV/resize_node.py +175 -0
- pynode/nodes/OpenCV/rotate_node.py +159 -0
- pynode/nodes/OpenCV/template_match_node.py +227 -0
- pynode/nodes/OpenCV/threshold_node.py +167 -0
- pynode/nodes/PointInShapeNode/__init__.py +3 -0
- pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
- pynode/nodes/PointInShapeNode/requirements.txt +2 -0
- pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
- pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
- pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
- pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
- pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
- pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
- pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
- pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
- pynode/nodes/RESTEndpointNode/__init__.py +3 -0
- pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
- pynode/nodes/RangeNode/__init__.py +3 -0
- pynode/nodes/RangeNode/range_node.py +115 -0
- pynode/nodes/RangeNode/requirements.txt +1 -0
- pynode/nodes/RateProbeNode/__init__.py +3 -0
- pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
- pynode/nodes/RateProbeNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
- pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
- pynode/nodes/SliceNode/README.md +149 -0
- pynode/nodes/SliceNode/__init__.py +5 -0
- pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
- pynode/nodes/SliceNode/requirements.txt +3 -0
- pynode/nodes/SliceNode/slice_collector_node.py +370 -0
- pynode/nodes/SliceNode/slice_image_node.py +380 -0
- pynode/nodes/SplitNode/__init__.py +3 -0
- pynode/nodes/SplitNode/requirements.txt +1 -0
- pynode/nodes/SplitNode/split_node.py +155 -0
- pynode/nodes/SwitchNode/requirements.txt +1 -0
- pynode/nodes/SwitchNode/switch_node.py +259 -0
- pynode/nodes/SyncNode/__init__.py +3 -0
- pynode/nodes/SyncNode/sync_node.py +287 -0
- pynode/nodes/TemplateNode/requirements.txt +1 -0
- pynode/nodes/TemplateNode/template_node.py +86 -0
- pynode/nodes/TrackerNode/__init__.py +2 -0
- pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
- pynode/nodes/TrackerNode/tracker_node.py +745 -0
- pynode/nodes/UltralyticsNode/__init__.py +4 -0
- pynode/nodes/UltralyticsNode/requirements.txt +2 -0
- pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
- pynode/nodes/UnknownNode/__init__.py +93 -0
- pynode/nodes/VLLMNode/__init__.py +3 -0
- pynode/nodes/VLLMNode/vllm_node.py +345 -0
- pynode/nodes/VideoReaderNode/__init__.py +3 -0
- pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
- pynode/nodes/VideoWriterNode/__init__.py +3 -0
- pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
- pynode/nodes/WebhookNode/__init__.py +3 -0
- pynode/nodes/WebhookNode/webhook_node.py +591 -0
- pynode/nodes/__init__.py +109 -0
- pynode/nodes/base_node.py +562 -0
- pynode/nodes/image_utils.py +291 -0
- pynode/nodes/info.py +109 -0
- pynode/nodes/mDNSNode/__init__.py +13 -0
- pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
- pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
- pynode/nodes/mDNSNode/mdns_manager.py +399 -0
- pynode/nodes/mDNSNode/requirements.txt +1 -0
- pynode/nodes/messages.py +81 -0
- pynode/server.py +187 -0
- pynode/static/images/favicon4.png +0 -0
- pynode/static/index.html +200 -0
- pynode/static/js/README.md +139 -0
- pynode/static/js/auth.js +89 -0
- pynode/static/js/clipboard.js +152 -0
- pynode/static/js/config.js +14 -0
- pynode/static/js/connections.js +844 -0
- pynode/static/js/debug.js +524 -0
- pynode/static/js/events.js +775 -0
- pynode/static/js/history.js +199 -0
- pynode/static/js/main.js +55 -0
- pynode/static/js/minimap.js +242 -0
- pynode/static/js/nodes.js +995 -0
- pynode/static/js/palette.js +255 -0
- pynode/static/js/properties.js +1270 -0
- pynode/static/js/selection.js +241 -0
- pynode/static/js/state.js +211 -0
- pynode/static/js/ui-utils.js +28 -0
- pynode/static/js/viewport.js +204 -0
- pynode/static/js/workflow.js +469 -0
- pynode/static/js/workflows.js +344 -0
- pynode/static/style.css +2751 -0
- pynode/workflow_engine.py +483 -0
- pynode/workflow_manager.py +440 -0
- pynode_flow-0.2.0.dist-info/METADATA +520 -0
- pynode_flow-0.2.0.dist-info/RECORD +227 -0
- pynode_flow-0.2.0.dist-info/WHEEL +5 -0
- pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
- pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
- pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
- pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
- pynode_flow-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delay node - delays message delivery.
|
|
3
|
+
Similar to Node-RED's delay node.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import threading
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
11
|
+
|
|
12
|
+
_info = Info()
|
|
13
|
+
_info.add_text("Delays or rate-limits message delivery. Similar to Node-RED's delay node.")
|
|
14
|
+
_info.add_header("Input")
|
|
15
|
+
_info.add_bullets(
|
|
16
|
+
("msg:", "Any message to be delayed or rate-limited."),
|
|
17
|
+
)
|
|
18
|
+
_info.add_header("Output")
|
|
19
|
+
_info.add_bullets(
|
|
20
|
+
("msg:", "The original message after the configured delay."),
|
|
21
|
+
)
|
|
22
|
+
_info.add_header("Modes")
|
|
23
|
+
_info.add_bullets(
|
|
24
|
+
("Delay each message:", "Hold each message for a fixed time before sending."),
|
|
25
|
+
("Delay by message count:", "Release messages after N more messages arrive."),
|
|
26
|
+
("Rate limit:", "Limit throughput to N messages per time period."),
|
|
27
|
+
)
|
|
28
|
+
_info.add_header("Properties")
|
|
29
|
+
_info.add_bullets(
|
|
30
|
+
("Delay (seconds):", "Time to delay each message."),
|
|
31
|
+
("Delay (messages):", "Number of messages to buffer before releasing."),
|
|
32
|
+
("Rate/Time:", "Messages per time period for rate limiting."),
|
|
33
|
+
("Intermediate Messages:", "Drop or queue messages during rate limiting."),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DelayNode(BaseNode):
|
|
38
|
+
"""
|
|
39
|
+
Delay node - delays message delivery (non-blocking).
|
|
40
|
+
Similar to Node-RED's delay node.
|
|
41
|
+
"""
|
|
42
|
+
info = str(_info)
|
|
43
|
+
display_name = 'Delay'
|
|
44
|
+
icon = '⧗'
|
|
45
|
+
category = 'function'
|
|
46
|
+
color = '#E6E0F8'
|
|
47
|
+
border_color = '#9F93C6'
|
|
48
|
+
text_color = '#000000'
|
|
49
|
+
input_count = 1
|
|
50
|
+
output_count = 1
|
|
51
|
+
|
|
52
|
+
DEFAULT_CONFIG = {
|
|
53
|
+
'mode': 'delay',
|
|
54
|
+
'timeout': 0,
|
|
55
|
+
'delay_count': 1,
|
|
56
|
+
'rate': 1,
|
|
57
|
+
'rate_time': 1,
|
|
58
|
+
'rate_drop': 'drop',
|
|
59
|
+
'drop_messages': False
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
properties = [
|
|
63
|
+
{
|
|
64
|
+
'name': 'mode',
|
|
65
|
+
'label': 'Mode',
|
|
66
|
+
'type': 'select',
|
|
67
|
+
'options': [
|
|
68
|
+
{'value': 'delay', 'label': 'Delay each message'},
|
|
69
|
+
{'value': 'delay_count', 'label': 'Delay by message count'},
|
|
70
|
+
{'value': 'rate', 'label': 'Rate limit'}
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
'name': 'timeout',
|
|
75
|
+
'label': 'Delay (seconds)',
|
|
76
|
+
'type': 'number',
|
|
77
|
+
'default': DEFAULT_CONFIG['timeout'],
|
|
78
|
+
'help': 'Delay time in seconds (for time-based delay)',
|
|
79
|
+
'showIf': {'mode': ['delay']}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
'name': 'delay_count',
|
|
83
|
+
'label': 'Delay (messages)',
|
|
84
|
+
'type': 'number',
|
|
85
|
+
'default': DEFAULT_CONFIG['delay_count'],
|
|
86
|
+
'min': 1,
|
|
87
|
+
'help': 'Number of messages to delay by (for count-based delay)',
|
|
88
|
+
'showIf': {'mode': 'delay_count'}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
'name': 'rate',
|
|
92
|
+
'label': 'Rate Limit (count)',
|
|
93
|
+
'type': 'number',
|
|
94
|
+
'default': DEFAULT_CONFIG['rate'],
|
|
95
|
+
'showIf': {'mode': 'rate'}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
'name': 'rate_time',
|
|
99
|
+
'label': 'Per Time (seconds)',
|
|
100
|
+
'type': 'number',
|
|
101
|
+
'default': DEFAULT_CONFIG['rate_time'],
|
|
102
|
+
'showIf': {'mode': 'rate'}
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
'name': 'rate_drop',
|
|
106
|
+
'label': 'Intermediate Messages',
|
|
107
|
+
'type': 'select',
|
|
108
|
+
'options': [
|
|
109
|
+
{'value': 'drop', 'label': 'Drop'},
|
|
110
|
+
{'value': 'queue', 'label': 'Queue'}
|
|
111
|
+
],
|
|
112
|
+
'showIf': {'mode': 'rate'}
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def __init__(self, node_id=None, name="delay"):
|
|
117
|
+
super().__init__(node_id, name)
|
|
118
|
+
self.last_send_time = 0
|
|
119
|
+
self.queued_messages = []
|
|
120
|
+
self.processing_queue = False
|
|
121
|
+
self.queue_lock = threading.Lock()
|
|
122
|
+
# Buffer for count-based delay
|
|
123
|
+
self._message_buffer: deque = deque()
|
|
124
|
+
|
|
125
|
+
def on_start(self):
|
|
126
|
+
"""Initialize on start."""
|
|
127
|
+
super().on_start()
|
|
128
|
+
self._message_buffer.clear()
|
|
129
|
+
|
|
130
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
131
|
+
"""
|
|
132
|
+
Handle message based on mode: delay each message, delay by count, or rate limit.
|
|
133
|
+
"""
|
|
134
|
+
mode = self.config.get('mode', 'delay')
|
|
135
|
+
|
|
136
|
+
if mode == 'delay':
|
|
137
|
+
# Delay each message by time
|
|
138
|
+
timeout = self.get_config_float('timeout', 1)
|
|
139
|
+
timer = threading.Timer(timeout, self.send, args=(msg,))
|
|
140
|
+
timer.daemon = True
|
|
141
|
+
timer.start()
|
|
142
|
+
elif mode == 'delay_count':
|
|
143
|
+
# Delay by message count
|
|
144
|
+
self._delay_by_count(msg)
|
|
145
|
+
else:
|
|
146
|
+
# Rate limiting mode
|
|
147
|
+
self._rate_limit(msg)
|
|
148
|
+
|
|
149
|
+
def _delay_by_count(self, msg: Dict[str, Any]):
|
|
150
|
+
"""
|
|
151
|
+
Delay messages by a number of messages.
|
|
152
|
+
Each message is released after N more messages have arrived.
|
|
153
|
+
"""
|
|
154
|
+
delay_count = self.get_config_int('delay_count', 1)
|
|
155
|
+
|
|
156
|
+
# Add message to buffer
|
|
157
|
+
self._message_buffer.append(msg)
|
|
158
|
+
|
|
159
|
+
# If buffer has more messages than delay count, release the oldest
|
|
160
|
+
while len(self._message_buffer) > delay_count:
|
|
161
|
+
oldest_msg = self._message_buffer.popleft()
|
|
162
|
+
self.send(oldest_msg)
|
|
163
|
+
|
|
164
|
+
def _rate_limit(self, msg: Dict[str, Any]):
|
|
165
|
+
"""
|
|
166
|
+
Rate limit messages - only send at specified rate.
|
|
167
|
+
"""
|
|
168
|
+
rate = self.get_config_int('rate', 1)
|
|
169
|
+
rate_time = self.get_config_float('rate_time', 1)
|
|
170
|
+
rate_drop = self.config.get('rate_drop', 'drop')
|
|
171
|
+
|
|
172
|
+
# Calculate interval: time / count
|
|
173
|
+
interval = rate_time / rate
|
|
174
|
+
|
|
175
|
+
with self.queue_lock:
|
|
176
|
+
current_time = time.time()
|
|
177
|
+
time_since_last = current_time - self.last_send_time
|
|
178
|
+
|
|
179
|
+
if time_since_last >= interval and not self.queued_messages:
|
|
180
|
+
# Enough time has passed and no queue, send immediately
|
|
181
|
+
self.send(msg)
|
|
182
|
+
self.last_send_time = current_time
|
|
183
|
+
else:
|
|
184
|
+
# Too soon or queue exists, handle based on drop/queue setting
|
|
185
|
+
if rate_drop == 'queue':
|
|
186
|
+
self.queued_messages.append(msg)
|
|
187
|
+
# Start processing queue if not already running
|
|
188
|
+
if not self.processing_queue:
|
|
189
|
+
self.processing_queue = True
|
|
190
|
+
delay = max(0, interval - time_since_last)
|
|
191
|
+
timer = threading.Timer(delay, self._process_queued)
|
|
192
|
+
timer.daemon = True
|
|
193
|
+
timer.start()
|
|
194
|
+
# else: drop (do nothing)
|
|
195
|
+
|
|
196
|
+
def _process_queued(self):
|
|
197
|
+
"""Process next queued message if available."""
|
|
198
|
+
with self.queue_lock:
|
|
199
|
+
if not self.queued_messages:
|
|
200
|
+
self.processing_queue = False
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
msg = self.queued_messages.pop(0)
|
|
204
|
+
current_time = time.time()
|
|
205
|
+
self.send(msg)
|
|
206
|
+
self.last_send_time = current_time
|
|
207
|
+
|
|
208
|
+
# Schedule next message if queue not empty
|
|
209
|
+
if self.queued_messages:
|
|
210
|
+
rate = self.get_config_int('rate', 1)
|
|
211
|
+
rate_time = self.get_config_float('rate_time', 1)
|
|
212
|
+
interval = rate_time / rate
|
|
213
|
+
|
|
214
|
+
timer = threading.Timer(interval, self._process_queued)
|
|
215
|
+
timer.daemon = True
|
|
216
|
+
timer.start()
|
|
217
|
+
else:
|
|
218
|
+
self.processing_queue = False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Draw Predictions Node - draws detection predictions (bounding boxes, labels) on images.
|
|
3
|
+
Takes predictions from UltralyticsNode and original image, outputs annotated image.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import cv2
|
|
8
|
+
import logging
|
|
9
|
+
import numpy as np
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_info = Info()
|
|
16
|
+
_info.add_text("Draws detection results (bounding boxes and labels) on images.")
|
|
17
|
+
_info.add_header("Inputs")
|
|
18
|
+
_info.add_bullets(
|
|
19
|
+
("Input 0:", "Message with payload.predictions (from YOLO or Inference) and payload.image (base64)"),
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Outputs")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("Output 0:", "Annotated image with bounding boxes and labels drawn"),
|
|
24
|
+
)
|
|
25
|
+
_info.add_header("Configuration")
|
|
26
|
+
_info.add_bullets(
|
|
27
|
+
("Line Width:", "Thickness of bounding box lines"),
|
|
28
|
+
("Font Scale:", "Size of label text"),
|
|
29
|
+
("Text Color:", "Color of label text"),
|
|
30
|
+
("Show Confidence:", "Display confidence scores on labels"),
|
|
31
|
+
("Show Class:", "Display class names on labels"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DrawPredictionsNode(BaseNode):
|
|
36
|
+
"""
|
|
37
|
+
Draw Predictions Node - visualizes YOLO or Inference detection results on images.
|
|
38
|
+
Expects msg.payload.predictions (from YOLO or Inference) and msg.payload.image (base64).
|
|
39
|
+
"""
|
|
40
|
+
display_name = 'Draw Predictions'
|
|
41
|
+
info = str(_info)
|
|
42
|
+
icon = '🎨'
|
|
43
|
+
category = 'vision'
|
|
44
|
+
color = '#FF6B9D'
|
|
45
|
+
border_color = '#CC5680'
|
|
46
|
+
text_color = '#FFFFFF'
|
|
47
|
+
input_count = 1
|
|
48
|
+
output_count = 1
|
|
49
|
+
ui_component = 'toggle'
|
|
50
|
+
ui_component_config = {
|
|
51
|
+
'action': 'toggle_drawing',
|
|
52
|
+
'label': 'Draw'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# UI-triggerable actions (see BaseNode.actions)
|
|
56
|
+
actions = ['toggle_drawing']
|
|
57
|
+
|
|
58
|
+
DEFAULT_CONFIG = {
|
|
59
|
+
'detections_path': 'payload.detections',
|
|
60
|
+
'line_width': '2',
|
|
61
|
+
'font_scale': '1',
|
|
62
|
+
'text_thickness': '2',
|
|
63
|
+
'text_color': 'white',
|
|
64
|
+
'show_confidence': 'true',
|
|
65
|
+
'show_class': 'true'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
properties = [
|
|
69
|
+
{
|
|
70
|
+
'name': 'detections_path',
|
|
71
|
+
'label': 'Detections/Tracks Path',
|
|
72
|
+
'type': 'text',
|
|
73
|
+
'default': DEFAULT_CONFIG['detections_path'],
|
|
74
|
+
'description': 'Dot-separated path to detections/tracks list (e.g. payload.detections or payload.tracks)'
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
'name': 'enabled',
|
|
78
|
+
'label': 'Drawing Enabled',
|
|
79
|
+
'type': 'toggle',
|
|
80
|
+
'action': 'toggle_drawing',
|
|
81
|
+
'stateField': 'drawingEnabled',
|
|
82
|
+
'help': 'Toggle drawing on/off'
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
'name': 'line_width',
|
|
86
|
+
'label': 'Line Width',
|
|
87
|
+
'type': 'text',
|
|
88
|
+
'default': DEFAULT_CONFIG['line_width'],
|
|
89
|
+
'help': 'Thickness of bounding box lines'
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
'name': 'font_scale',
|
|
93
|
+
'label': 'Font Scale',
|
|
94
|
+
'type': 'text',
|
|
95
|
+
'default': DEFAULT_CONFIG['font_scale'],
|
|
96
|
+
'help': 'Size of label text'
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
'name': 'text_thickness',
|
|
100
|
+
'label': 'Text Thickness',
|
|
101
|
+
'type': 'text',
|
|
102
|
+
'default': DEFAULT_CONFIG['text_thickness'],
|
|
103
|
+
'help': 'Thickness of label text'
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
'name': 'text_color',
|
|
107
|
+
'label': 'Text Color',
|
|
108
|
+
'type': 'select',
|
|
109
|
+
'options': [
|
|
110
|
+
{'value': 'white', 'label': 'White'},
|
|
111
|
+
{'value': 'black', 'label': 'Black'},
|
|
112
|
+
{'value': 'red', 'label': 'Red'},
|
|
113
|
+
{'value': 'green', 'label': 'Green'},
|
|
114
|
+
{'value': 'blue', 'label': 'Blue'},
|
|
115
|
+
{'value': 'yellow', 'label': 'Yellow'}
|
|
116
|
+
],
|
|
117
|
+
'default': DEFAULT_CONFIG['text_color'],
|
|
118
|
+
'help': 'Color of label text'
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
'name': 'show_confidence',
|
|
122
|
+
'label': 'Show Confidence',
|
|
123
|
+
'type': 'select',
|
|
124
|
+
'options': [
|
|
125
|
+
{'value': 'true', 'label': 'Yes'},
|
|
126
|
+
{'value': 'false', 'label': 'No'}
|
|
127
|
+
],
|
|
128
|
+
'default': DEFAULT_CONFIG['show_confidence'],
|
|
129
|
+
'help': 'Display confidence scores on labels'
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
'name': 'show_class',
|
|
133
|
+
'label': 'Show Class Name',
|
|
134
|
+
'type': 'select',
|
|
135
|
+
'options': [
|
|
136
|
+
{'value': 'true', 'label': 'Yes'},
|
|
137
|
+
{'value': 'false', 'label': 'No'}
|
|
138
|
+
],
|
|
139
|
+
'default': DEFAULT_CONFIG['show_class'],
|
|
140
|
+
'help': 'Display class names on labels'
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
def __init__(self, node_id=None, name="Draw Predictions"):
|
|
145
|
+
super().__init__(node_id, name)
|
|
146
|
+
self.drawing_enabled = True # Drawing is enabled by default
|
|
147
|
+
self.configure({
|
|
148
|
+
'line_width': '2',
|
|
149
|
+
'font_scale': '0.5',
|
|
150
|
+
'text_thickness': '1',
|
|
151
|
+
'text_color': 'white',
|
|
152
|
+
'show_confidence': 'true',
|
|
153
|
+
'show_class': 'true'
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
# Color palette for different classes
|
|
157
|
+
self.colors = [
|
|
158
|
+
(255, 0, 0), # Red
|
|
159
|
+
(0, 255, 0), # Green
|
|
160
|
+
(0, 0, 255), # Blue
|
|
161
|
+
(255, 255, 0), # Yellow
|
|
162
|
+
(255, 0, 255), # Magenta
|
|
163
|
+
(0, 255, 255), # Cyan
|
|
164
|
+
(255, 128, 0), # Orange
|
|
165
|
+
(128, 0, 255), # Purple
|
|
166
|
+
(0, 255, 128), # Spring Green
|
|
167
|
+
(255, 0, 128), # Rose
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
# Text color mapping
|
|
171
|
+
self.text_colors = {
|
|
172
|
+
'white': (255, 255, 255),
|
|
173
|
+
'black': (0, 0, 0),
|
|
174
|
+
'red': (0, 0, 255), # BGR format
|
|
175
|
+
'green': (0, 255, 0),
|
|
176
|
+
'blue': (255, 0, 0),
|
|
177
|
+
'yellow': (0, 255, 255)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _decode_image(self, image_data: str) -> Optional[np.ndarray]:
|
|
181
|
+
"""Decode base64 image to numpy array."""
|
|
182
|
+
try:
|
|
183
|
+
# Remove data URL prefix if present
|
|
184
|
+
if ',' in image_data:
|
|
185
|
+
image_data = image_data.split(',', 1)[1]
|
|
186
|
+
|
|
187
|
+
# Decode base64
|
|
188
|
+
img_bytes = base64.b64decode(image_data)
|
|
189
|
+
nparr = np.frombuffer(img_bytes, np.uint8)
|
|
190
|
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
191
|
+
return img
|
|
192
|
+
except Exception as e:
|
|
193
|
+
self.report_error(f"Failed to decode image: {str(e)}")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _encode_image(self, img: np.ndarray) -> Optional[str]:
|
|
197
|
+
"""Encode numpy array to base64 JPEG."""
|
|
198
|
+
try:
|
|
199
|
+
_, buffer = cv2.imencode('.jpg', img)
|
|
200
|
+
img_bytes = buffer.tobytes()
|
|
201
|
+
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
|
202
|
+
return img_base64
|
|
203
|
+
except Exception as e:
|
|
204
|
+
self.report_error(f"Failed to encode image: {str(e)}")
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def _get_color(self, class_id: int) -> tuple:
|
|
208
|
+
"""Get color for a class ID."""
|
|
209
|
+
return self.colors[class_id % len(self.colors)]
|
|
210
|
+
|
|
211
|
+
def toggle_drawing(self):
|
|
212
|
+
"""Toggle drawing on/off."""
|
|
213
|
+
self.drawing_enabled = not self.drawing_enabled
|
|
214
|
+
status = "enabled" if self.drawing_enabled else "disabled"
|
|
215
|
+
logger.info(f"Draw Predictions ({self.id}): Drawing {status}")
|
|
216
|
+
|
|
217
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
218
|
+
"""Serialize node to dictionary for API/storage, including drawing state."""
|
|
219
|
+
data = super().to_dict()
|
|
220
|
+
data['drawingEnabled'] = self.drawing_enabled
|
|
221
|
+
return data
|
|
222
|
+
|
|
223
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
224
|
+
"""Draw predictions/tracks on image, with configurable path and track_id support."""
|
|
225
|
+
# If drawing is disabled, pass through the message unchanged
|
|
226
|
+
if not self.drawing_enabled:
|
|
227
|
+
self.send(msg)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Get detections/tracks path from config
|
|
231
|
+
detections_path = self.config.get('detections_path', self.DEFAULT_CONFIG['detections_path'])
|
|
232
|
+
|
|
233
|
+
detections = self._get_nested_value(msg, detections_path)
|
|
234
|
+
payload = msg.get(MessageKeys.PAYLOAD, {})
|
|
235
|
+
image_data = payload.get(MessageKeys.IMAGE.PATH)
|
|
236
|
+
|
|
237
|
+
if image_data is None:
|
|
238
|
+
self.report_error(f"No image data in message. Expected msg.{MessageKeys.PAYLOAD}.{MessageKeys.IMAGE.PATH}")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Decode image using base node helper and make a copy to avoid modifying upstream data
|
|
242
|
+
img, input_format = self.decode_image({MessageKeys.IMAGE.PATH: image_data})
|
|
243
|
+
if img is None or input_format is None:
|
|
244
|
+
self.report_error("Failed to decode image from payload")
|
|
245
|
+
return
|
|
246
|
+
img = img.copy()
|
|
247
|
+
|
|
248
|
+
# Check if there are any detections/tracks
|
|
249
|
+
if isinstance(detections, np.ndarray):
|
|
250
|
+
has_detections = len(detections) > 0
|
|
251
|
+
else:
|
|
252
|
+
has_detections = bool(detections)
|
|
253
|
+
|
|
254
|
+
if not has_detections:
|
|
255
|
+
self.send(msg)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Get configuration
|
|
259
|
+
line_width = self.get_config_int('line_width', 2)
|
|
260
|
+
font_scale = self.get_config_float('font_scale', 0.5)
|
|
261
|
+
text_thickness = self.get_config_int('text_thickness', 1)
|
|
262
|
+
text_color_name = self.config.get('text_color', 'white')
|
|
263
|
+
text_color = self.text_colors.get(text_color_name, (255, 255, 255))
|
|
264
|
+
show_confidence = self.get_config_bool('show_confidence', True)
|
|
265
|
+
show_class = self.get_config_bool('show_class', True)
|
|
266
|
+
|
|
267
|
+
# Draw each detection/track
|
|
268
|
+
for i, det in enumerate(detections):
|
|
269
|
+
try:
|
|
270
|
+
bbox = det.get('bbox', [0, 0, 0, 0])
|
|
271
|
+
if isinstance(bbox, np.ndarray):
|
|
272
|
+
bbox = bbox.tolist()
|
|
273
|
+
x1, y1, x2, y2 = bbox
|
|
274
|
+
class_id = det.get('class_id', 0)
|
|
275
|
+
class_name = det.get('class_name', 'unknown')
|
|
276
|
+
confidence = det.get('confidence', 0.0)
|
|
277
|
+
track_id = det.get('track_id', None)
|
|
278
|
+
|
|
279
|
+
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
|
280
|
+
color = self._get_color(class_id)
|
|
281
|
+
cv2.rectangle(img, (x1, y1), (x2, y2), color, line_width)
|
|
282
|
+
|
|
283
|
+
# Build label text
|
|
284
|
+
label_parts = []
|
|
285
|
+
if track_id is not None:
|
|
286
|
+
label_parts.append(f"TrackID: {track_id}")
|
|
287
|
+
if show_class:
|
|
288
|
+
label_parts.append(class_name)
|
|
289
|
+
if show_confidence:
|
|
290
|
+
label_parts.append(f"({confidence:.2f})")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if label_parts:
|
|
294
|
+
label = ' '.join(label_parts)
|
|
295
|
+
(text_width, text_height), baseline = cv2.getTextSize(
|
|
296
|
+
label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1
|
|
297
|
+
)
|
|
298
|
+
# Clamp label position to image bounds
|
|
299
|
+
img_h, img_w = img.shape[:2]
|
|
300
|
+
# Default: draw above the box with proper padding for tall characters
|
|
301
|
+
padding = int(4 * font_scale) # Scale padding with font size
|
|
302
|
+
# Add extra height for tall characters like parentheses
|
|
303
|
+
extra_height = int(text_height * 0.3) # 30% extra for ascenders
|
|
304
|
+
label_top = y1 - text_height - baseline - padding - extra_height
|
|
305
|
+
label_bottom = y1 # Touch the bounding box directly
|
|
306
|
+
text_org_y = y1 - baseline - padding
|
|
307
|
+
# Gradually clamp label so it stays within image bounds
|
|
308
|
+
if label_top < 0:
|
|
309
|
+
shift = -label_top
|
|
310
|
+
label_top += shift
|
|
311
|
+
label_bottom += shift
|
|
312
|
+
text_org_y += shift
|
|
313
|
+
if label_bottom > img_h:
|
|
314
|
+
shift = label_bottom - img_h
|
|
315
|
+
label_top -= shift
|
|
316
|
+
label_bottom -= shift
|
|
317
|
+
text_org_y -= shift
|
|
318
|
+
# Clamp horizontally
|
|
319
|
+
label_left = x1
|
|
320
|
+
label_right = x1 + text_width
|
|
321
|
+
|
|
322
|
+
# If text overflows right edge, shift it left
|
|
323
|
+
if label_right > img_w:
|
|
324
|
+
shift = label_right - img_w
|
|
325
|
+
label_left -= shift
|
|
326
|
+
label_right -= shift
|
|
327
|
+
|
|
328
|
+
# Ensure left edge doesn't go below 0
|
|
329
|
+
label_left = max(label_left, 0)
|
|
330
|
+
label_right = min(label_left + text_width, img_w)
|
|
331
|
+
# Draw label background
|
|
332
|
+
cv2.rectangle(
|
|
333
|
+
img,
|
|
334
|
+
(label_left, label_top),
|
|
335
|
+
(label_right, label_bottom),
|
|
336
|
+
color,
|
|
337
|
+
-1 # Filled
|
|
338
|
+
)
|
|
339
|
+
# Draw label text
|
|
340
|
+
cv2.putText(
|
|
341
|
+
img,
|
|
342
|
+
label,
|
|
343
|
+
(label_left, text_org_y),
|
|
344
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
345
|
+
font_scale,
|
|
346
|
+
text_color,
|
|
347
|
+
text_thickness,
|
|
348
|
+
cv2.LINE_AA
|
|
349
|
+
)
|
|
350
|
+
except Exception as e:
|
|
351
|
+
import traceback
|
|
352
|
+
self.report_error(f"Failed to draw detection {i}: {str(e)}\n{traceback.format_exc()}")
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
# Encode image back to same format as input using base node helper
|
|
356
|
+
encoded_image = self.encode_image(img, input_format)
|
|
357
|
+
if encoded_image is not None:
|
|
358
|
+
if not isinstance(msg.get(MessageKeys.PAYLOAD), dict):
|
|
359
|
+
msg[MessageKeys.PAYLOAD] = {}
|
|
360
|
+
msg[MessageKeys.PAYLOAD][MessageKeys.IMAGE.PATH] = encoded_image
|
|
361
|
+
else:
|
|
362
|
+
self.report_error("Failed to encode output image")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
self.send(msg)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error node - captures and displays error messages from any node in the workflow.
|
|
3
|
+
Automatically receives error events and displays them in the debug panel.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
8
|
+
|
|
9
|
+
# Build info content
|
|
10
|
+
_info = Info()
|
|
11
|
+
_info.add_text("Captures and displays error messages from any node in the workflow.")
|
|
12
|
+
_info.add_text("Unlike debug nodes that need to be wired, error nodes automatically receive errors from all nodes.")
|
|
13
|
+
_info.add_header("Properties")
|
|
14
|
+
_info.add_bullet("Filter by Node:", "Only show errors from nodes matching this filter (leave empty for all errors)")
|
|
15
|
+
_info.add_header("Features")
|
|
16
|
+
_info.add_bullet("Auto-capture:", "Receives errors from all workflow nodes automatically")
|
|
17
|
+
_info.add_bullet("History:", "Keeps the last 100 errors")
|
|
18
|
+
_info.add_bullet("Filtering:", "Can filter errors by node name")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ErrorNode(BaseNode):
|
|
22
|
+
"""
|
|
23
|
+
Error node - displays error messages in the debug panel.
|
|
24
|
+
Unlike debug nodes that need to be wired, error nodes automatically
|
|
25
|
+
capture errors from all nodes in the workflow.
|
|
26
|
+
"""
|
|
27
|
+
display_name = 'Error'
|
|
28
|
+
icon = '⚠️'
|
|
29
|
+
category = 'system'
|
|
30
|
+
color = '#FF6B6B'
|
|
31
|
+
border_color = '#E63946'
|
|
32
|
+
text_color = '#FFFFFF'
|
|
33
|
+
input_count = 0 # No input - errors come automatically
|
|
34
|
+
output_count = 0 # No output - errors are displayed only
|
|
35
|
+
info = str(_info)
|
|
36
|
+
hidden = True # Hide from palette; it's a system node
|
|
37
|
+
|
|
38
|
+
properties = [
|
|
39
|
+
{
|
|
40
|
+
'name': 'filter',
|
|
41
|
+
'label': 'Filter by Node',
|
|
42
|
+
'type': 'text',
|
|
43
|
+
'placeholder': 'Leave empty for all errors'
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
def __init__(self, node_id=None, name="error"):
|
|
48
|
+
super().__init__(node_id, name)
|
|
49
|
+
self.configure({'filter': ''})
|
|
50
|
+
self.errors = []
|
|
51
|
+
self.max_errors = 100 # Keep last 100 errors
|
|
52
|
+
self.is_system_node = False # Can be set to True for the system error node
|
|
53
|
+
|
|
54
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
55
|
+
"""
|
|
56
|
+
This won't be called since input_count = 0.
|
|
57
|
+
Errors are captured via handle_error() method.
|
|
58
|
+
"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def handle_error(self, source_node_id: str, source_node_name: str, error_msg: str):
|
|
62
|
+
"""
|
|
63
|
+
Handle error messages from any node in the workflow.
|
|
64
|
+
This is called by the workflow engine when any node reports an error.
|
|
65
|
+
"""
|
|
66
|
+
# Check filter
|
|
67
|
+
node_filter = self.config.get('filter', '').strip()
|
|
68
|
+
if node_filter and node_filter.lower() not in source_node_name.lower():
|
|
69
|
+
return # Skip this error
|
|
70
|
+
|
|
71
|
+
# Create error entry
|
|
72
|
+
import time
|
|
73
|
+
error_entry = {
|
|
74
|
+
'timestamp': time.time(),
|
|
75
|
+
'source_node_id': source_node_id,
|
|
76
|
+
'source_node_name': source_node_name,
|
|
77
|
+
'message': error_msg,
|
|
78
|
+
'type': 'error'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Add to errors list
|
|
82
|
+
self.errors.append(error_entry)
|
|
83
|
+
|
|
84
|
+
# Trim to max size
|
|
85
|
+
if len(self.errors) > self.max_errors:
|
|
86
|
+
self.errors = self.errors[-self.max_errors:]
|
|
87
|
+
|
|
88
|
+
def get_errors(self):
|
|
89
|
+
"""Get all captured errors (returns a copy so clearing doesn't affect it)."""
|
|
90
|
+
return list(self.errors)
|
|
91
|
+
|
|
92
|
+
def clear_errors(self):
|
|
93
|
+
"""Clear all captured errors."""
|
|
94
|
+
self.errors.clear()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|