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,562 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Node class for the Python Node-RED-like system.
|
|
3
|
+
All custom nodes should inherit from this class.
|
|
4
|
+
|
|
5
|
+
``Info``, ``MessageKeys``/``sort_msg_keys`` and the image helpers
|
|
6
|
+
(``process_image``, plus the functions behind ``BaseNode.decode_image`` /
|
|
7
|
+
``BaseNode.encode_image``) live in ``pynode.nodes.info``,
|
|
8
|
+
``pynode.nodes.messages`` and ``pynode.nodes.image_utils`` respectively, and
|
|
9
|
+
are re-exported here so existing imports keep working::
|
|
10
|
+
|
|
11
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys, \
|
|
12
|
+
process_image, sort_msg_keys
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from time import time
|
|
16
|
+
import uuid
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
import copy
|
|
20
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from pynode.nodes import image_utils
|
|
23
|
+
from pynode.nodes.image_utils import process_image # noqa: F401 (re-export)
|
|
24
|
+
from pynode.nodes.info import Info # noqa: F401 (re-export)
|
|
25
|
+
from pynode.nodes.messages import MessageKeys, sort_msg_keys
|
|
26
|
+
|
|
27
|
+
# Sentinel so create_message() can distinguish "payload not given" from an
|
|
28
|
+
# explicitly-passed payload=None (which must be included in the message).
|
|
29
|
+
_UNSET = object()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseNode:
|
|
33
|
+
"""
|
|
34
|
+
Base class for all nodes in the workflow system.
|
|
35
|
+
Messages follow Node-RED structure with 'payload' and 'topic' fields.
|
|
36
|
+
|
|
37
|
+
Subclasses can define a DEFAULT_CONFIG class attribute which will be
|
|
38
|
+
automatically applied during initialization. This eliminates the need
|
|
39
|
+
to manually call self.configure(self.DEFAULT_CONFIG) in __init__.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Visual properties (can be overridden by subclasses)
|
|
43
|
+
display_name = 'Base Node' # Display name (without 'Node' suffix)
|
|
44
|
+
icon = '◆' # Icon character or emoji
|
|
45
|
+
category = 'custom'
|
|
46
|
+
color = '#2d2d30'
|
|
47
|
+
border_color = '#555'
|
|
48
|
+
text_color = '#d4d4d4'
|
|
49
|
+
input_count = 1 # Number of input ports (0 for input-only nodes)
|
|
50
|
+
output_count = 1 # Number of output ports (0 for output-only nodes)
|
|
51
|
+
hidden = False # If true, node is hidden from palette
|
|
52
|
+
|
|
53
|
+
# Info text displayed in the Information panel when node is selected
|
|
54
|
+
# Supports HTML for formatting
|
|
55
|
+
info = ''
|
|
56
|
+
|
|
57
|
+
# Default configuration - subclasses can override this
|
|
58
|
+
# Will be auto-applied during __init__
|
|
59
|
+
DEFAULT_CONFIG: Dict[str, Any] = {}
|
|
60
|
+
|
|
61
|
+
# Property schema for the properties panel
|
|
62
|
+
# Format: [{'name': 'propName', 'label': 'Display Label', 'type': 'text|textarea|select|button', 'options': [...], 'action': 'methodName'}]
|
|
63
|
+
properties = [
|
|
64
|
+
{
|
|
65
|
+
'name': MessageKeys.DROP_MESSAGES,
|
|
66
|
+
'label': 'Drop Messages When Busy',
|
|
67
|
+
'type': 'checkbox',
|
|
68
|
+
'default': True
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Action methods that may be triggered from the UI via
|
|
73
|
+
# POST /api/nodes/<node_id>/<action>.
|
|
74
|
+
# Only method names listed here can be invoked through that endpoint;
|
|
75
|
+
# anything not declared (or starting with '_') returns 404.
|
|
76
|
+
# These are the names referenced by 'action' keys in `properties` button/
|
|
77
|
+
# toggle entries and in `ui_component_config`.
|
|
78
|
+
# Format: ['method_name', ...]
|
|
79
|
+
actions: List[str] = []
|
|
80
|
+
|
|
81
|
+
# API routes that this node type exposes.
|
|
82
|
+
# The server will register these as Flask routes at startup.
|
|
83
|
+
# Nodes do NOT need to import Flask - the server handles request/response.
|
|
84
|
+
# Format: [{'route': '/sub_path', 'methods': ['GET'], 'handler': 'method_name'}, ...]
|
|
85
|
+
# The server creates /api/nodes/<node_id>/<route> for each entry.
|
|
86
|
+
# Handler methods receive (request_data: dict) and return a result dict,
|
|
87
|
+
# or just return a dict for GET requests with no input.
|
|
88
|
+
# For file uploads, handler receives (file_storage, filename) parameters.
|
|
89
|
+
api_routes: List[Dict[str, Any]] = []
|
|
90
|
+
|
|
91
|
+
# SSE broadcast handlers for real-time UI updates.
|
|
92
|
+
# The SSE broadcast worker calls these methods and pushes results to clients.
|
|
93
|
+
# Format: [{'type': 'event_type', 'handler': 'method_name', 'throttle': None|seconds}, ...]
|
|
94
|
+
# 'type' is the SSE event type sent to the client.
|
|
95
|
+
# 'handler' is the method name to call on the node instance.
|
|
96
|
+
# 'throttle' (optional): minimum interval in seconds between broadcasts (None = every cycle).
|
|
97
|
+
sse_handlers: List[Dict[str, Any]] = []
|
|
98
|
+
|
|
99
|
+
def __init__(self, node_id: Optional[str] = None, name: str = ""):
|
|
100
|
+
"""
|
|
101
|
+
Initialize a base node.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
node_id: Unique identifier for the node. Auto-generated if not provided.
|
|
105
|
+
name: Human-readable name for the node.
|
|
106
|
+
"""
|
|
107
|
+
self.id = node_id or str(uuid.uuid4())
|
|
108
|
+
self.name = name or self.__class__.__name__
|
|
109
|
+
self.type = self.__class__.__name__
|
|
110
|
+
|
|
111
|
+
# Connections: output_index -> [(target_node, target_input_index), ...]
|
|
112
|
+
self.outputs: Dict[int, List[tuple]] = {}
|
|
113
|
+
|
|
114
|
+
# Configuration for the node
|
|
115
|
+
self.config: Dict[str, Any] = {}
|
|
116
|
+
|
|
117
|
+
# Initialize drop_while_busy to default (will be overridden by configure if needed)
|
|
118
|
+
self.drop_while_busy = True
|
|
119
|
+
self.drop_count = 0
|
|
120
|
+
|
|
121
|
+
# Auto-apply DEFAULT_CONFIG if defined by subclass
|
|
122
|
+
if self.DEFAULT_CONFIG:
|
|
123
|
+
self.configure(self.DEFAULT_CONFIG)
|
|
124
|
+
|
|
125
|
+
# Node state
|
|
126
|
+
self.enabled = True
|
|
127
|
+
|
|
128
|
+
# Non-blocking message queue
|
|
129
|
+
self._message_queue = queue.Queue(maxsize=1000) # Limit queue size to prevent memory issues
|
|
130
|
+
self._worker_thread = None
|
|
131
|
+
self._stop_worker_flag = False
|
|
132
|
+
self._processing = False # True when actively processing a message
|
|
133
|
+
|
|
134
|
+
# Error handling
|
|
135
|
+
self._workflow_engine = None # Will be set by workflow engine
|
|
136
|
+
|
|
137
|
+
def set_workflow_engine(self, engine):
|
|
138
|
+
"""Set reference to the workflow engine for error reporting."""
|
|
139
|
+
self._workflow_engine = engine
|
|
140
|
+
|
|
141
|
+
def report_error(self, error_msg: str):
|
|
142
|
+
"""
|
|
143
|
+
Report an error to all ErrorNodes in the workflow.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
error_msg: The error message to report
|
|
147
|
+
"""
|
|
148
|
+
if self._workflow_engine:
|
|
149
|
+
self._workflow_engine.broadcast_error(self.id, self.name, error_msg)
|
|
150
|
+
|
|
151
|
+
def connect(self, target_node: 'BaseNode', output_index: int = 0, target_input_index: int = 0):
|
|
152
|
+
"""
|
|
153
|
+
Connect this node's output to another node's input.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
target_node: The node to connect to
|
|
157
|
+
output_index: Which output port to use (default 0)
|
|
158
|
+
target_input_index: Which input port on target (default 0)
|
|
159
|
+
"""
|
|
160
|
+
if output_index not in self.outputs:
|
|
161
|
+
self.outputs[output_index] = []
|
|
162
|
+
|
|
163
|
+
self.outputs[output_index].append((target_node, target_input_index))
|
|
164
|
+
|
|
165
|
+
def disconnect(self, target_node: 'BaseNode', output_index: int = 0):
|
|
166
|
+
"""
|
|
167
|
+
Disconnect this node from a target node.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
target_node: The node to disconnect from
|
|
171
|
+
output_index: Which output port to disconnect
|
|
172
|
+
"""
|
|
173
|
+
if output_index in self.outputs:
|
|
174
|
+
self.outputs[output_index] = [
|
|
175
|
+
(node, idx) for node, idx in self.outputs[output_index]
|
|
176
|
+
if node.id != target_node.id
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def create_message(self, payload: Any = _UNSET, topic: str = "", **kwargs) -> Dict[str, Any]:
|
|
180
|
+
"""
|
|
181
|
+
Create a message in Node-RED format.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
payload: The message payload (any type, optional). Omitted from
|
|
185
|
+
the message when not given; an explicitly-passed None IS
|
|
186
|
+
included (payload=None -> {'payload': None, ...}).
|
|
187
|
+
topic: Optional topic string
|
|
188
|
+
**kwargs: Additional message properties
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Message dictionary with _msgid, _timestamp, and optional payload/topic
|
|
192
|
+
"""
|
|
193
|
+
msg = {
|
|
194
|
+
MessageKeys.MSG_ID: str(uuid.uuid4()),
|
|
195
|
+
MessageKeys.TIMESTAMP_ORIG: time()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Only include payload if explicitly provided (even if None was passed
|
|
199
|
+
# explicitly). The _UNSET sentinel distinguishes create_message() from
|
|
200
|
+
# create_message(payload=None).
|
|
201
|
+
if payload is not _UNSET:
|
|
202
|
+
msg[MessageKeys.PAYLOAD] = payload
|
|
203
|
+
|
|
204
|
+
if topic:
|
|
205
|
+
msg[MessageKeys.TOPIC] = topic
|
|
206
|
+
|
|
207
|
+
msg.update(kwargs)
|
|
208
|
+
return msg
|
|
209
|
+
|
|
210
|
+
def send(self, msg: Dict[str, Any], output_index: int = 0):
|
|
211
|
+
"""
|
|
212
|
+
Send a message to connected nodes (non-blocking).
|
|
213
|
+
Messages are queued and processed asynchronously.
|
|
214
|
+
Each recipient gets a deep copy to prevent cross-talk between branches
|
|
215
|
+
and to prevent downstream modifications from affecting the sender.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
msg: Message dictionary (must have 'payload' and 'topic')
|
|
219
|
+
output_index: Which output port to send from (default 0)
|
|
220
|
+
"""
|
|
221
|
+
if not self.enabled:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if output_index in self.outputs:
|
|
225
|
+
connections = self.outputs[output_index]
|
|
226
|
+
|
|
227
|
+
for target_node, target_input in connections:
|
|
228
|
+
if target_node.enabled:
|
|
229
|
+
# Check if target node prefers direct processing (no outputs = sink node)
|
|
230
|
+
if target_node.output_count == 0 and hasattr(target_node, 'on_input_direct'):
|
|
231
|
+
# Deep copy for direct processing to prevent cross-talk
|
|
232
|
+
msg_to_send = copy.deepcopy(msg)
|
|
233
|
+
msg_to_send[MessageKeys.TIMESTAMP_EMIT] = time()
|
|
234
|
+
msg_to_send[MessageKeys.AGE] = (
|
|
235
|
+
msg_to_send[MessageKeys.TIMESTAMP_EMIT]
|
|
236
|
+
- msg_to_send.get(MessageKeys.TIMESTAMP_ORIG, msg_to_send[MessageKeys.TIMESTAMP_EMIT])
|
|
237
|
+
)
|
|
238
|
+
msg_to_send[MessageKeys.QUEUE_LENGTH] = self._message_queue.qsize()
|
|
239
|
+
msg_to_send = sort_msg_keys(msg_to_send)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
target_node.on_input_direct(msg_to_send, target_input)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
target_node.report_error(f"Error in direct processing: {e}")
|
|
245
|
+
else:
|
|
246
|
+
# Check if target wants to drop messages when busy (before expensive copy)
|
|
247
|
+
# Drop if node is processing OR has queued messages
|
|
248
|
+
if target_node.drop_while_busy and (target_node._processing or not target_node._message_queue.empty()):
|
|
249
|
+
# Drop message instead of queuing
|
|
250
|
+
target_node.drop_count += 1
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Deep copy message for each recipient to prevent cross-talk
|
|
254
|
+
msg_to_send = copy.deepcopy(msg)
|
|
255
|
+
msg_to_send[MessageKeys.TIMESTAMP_EMIT] = time()
|
|
256
|
+
msg_to_send[MessageKeys.AGE] = (
|
|
257
|
+
msg_to_send[MessageKeys.TIMESTAMP_EMIT]
|
|
258
|
+
- msg_to_send.get(MessageKeys.TIMESTAMP_ORIG, msg_to_send[MessageKeys.TIMESTAMP_EMIT])
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Add this node's drop count to message for monitoring
|
|
262
|
+
# (how many messages THIS node dropped before sending this one)
|
|
263
|
+
msg_to_send[MessageKeys.DROP_COUNT] = self.drop_count
|
|
264
|
+
msg_to_send[MessageKeys.QUEUE_LENGTH] = self._message_queue.qsize()
|
|
265
|
+
|
|
266
|
+
msg_to_send = sort_msg_keys(msg_to_send)
|
|
267
|
+
|
|
268
|
+
# Queue message for target node (non-blocking)
|
|
269
|
+
try:
|
|
270
|
+
target_node._message_queue.put_nowait((msg_to_send, target_input))
|
|
271
|
+
except queue.Full:
|
|
272
|
+
# Queue full - drop message or handle overflow
|
|
273
|
+
target_node.report_error("Message queue full, dropping message")
|
|
274
|
+
|
|
275
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
276
|
+
"""
|
|
277
|
+
Called when this node receives a message.
|
|
278
|
+
Override this method in subclasses to implement node logic.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
msg: Message dictionary with 'payload', 'topic', etc.
|
|
282
|
+
input_index: Which input port received the message
|
|
283
|
+
"""
|
|
284
|
+
# Default behavior: pass through
|
|
285
|
+
self.send(msg)
|
|
286
|
+
|
|
287
|
+
def _start_worker(self):
|
|
288
|
+
"""
|
|
289
|
+
Start the worker thread to process queued messages.
|
|
290
|
+
"""
|
|
291
|
+
if self._worker_thread is None or not self._worker_thread.is_alive():
|
|
292
|
+
self._stop_worker_flag = False
|
|
293
|
+
self._worker_thread = threading.Thread(target=self._process_messages, daemon=True)
|
|
294
|
+
self._worker_thread.start()
|
|
295
|
+
|
|
296
|
+
def _process_messages(self):
|
|
297
|
+
"""
|
|
298
|
+
Worker thread that processes messages from the queue.
|
|
299
|
+
Uses adaptive timeout to balance responsiveness with CPU usage.
|
|
300
|
+
"""
|
|
301
|
+
idle_count = 0
|
|
302
|
+
while not self._stop_worker_flag:
|
|
303
|
+
try:
|
|
304
|
+
# Adaptive timeout: faster when active, slower when idle
|
|
305
|
+
timeout = 0.01 if idle_count < 10 else 0.1
|
|
306
|
+
msg, input_index = self._message_queue.get(timeout=timeout)
|
|
307
|
+
idle_count = 0 # Reset on successful message
|
|
308
|
+
|
|
309
|
+
# Process the message
|
|
310
|
+
self._processing = True
|
|
311
|
+
try:
|
|
312
|
+
self.on_input(msg, input_index)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
self.report_error(f"Error processing message: {e}")
|
|
315
|
+
finally:
|
|
316
|
+
self._processing = False
|
|
317
|
+
self._message_queue.task_done()
|
|
318
|
+
|
|
319
|
+
except queue.Empty:
|
|
320
|
+
# No message available, increase idle count
|
|
321
|
+
idle_count += 1
|
|
322
|
+
continue
|
|
323
|
+
except Exception as e:
|
|
324
|
+
self.report_error(f"Worker thread error: {e}")
|
|
325
|
+
|
|
326
|
+
def on_start(self):
|
|
327
|
+
"""
|
|
328
|
+
Called when the node is started/deployed.
|
|
329
|
+
Override to implement initialization logic.
|
|
330
|
+
"""
|
|
331
|
+
# Start message processing worker
|
|
332
|
+
self._start_worker()
|
|
333
|
+
|
|
334
|
+
def on_stop(self):
|
|
335
|
+
"""
|
|
336
|
+
Called when the node is stopped.
|
|
337
|
+
Override to implement cleanup logic.
|
|
338
|
+
"""
|
|
339
|
+
# Stop message processing worker
|
|
340
|
+
self._stop_worker_flag = True
|
|
341
|
+
if self._worker_thread and self._worker_thread.is_alive():
|
|
342
|
+
self._worker_thread.join(timeout=2.0)
|
|
343
|
+
|
|
344
|
+
def configure(self, config: Dict[str, Any]):
|
|
345
|
+
"""
|
|
346
|
+
Configure the node with settings.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
config: Configuration dictionary
|
|
350
|
+
"""
|
|
351
|
+
self.config.update(config)
|
|
352
|
+
# Update drop_while_busy flag from config
|
|
353
|
+
# Handle both boolean and string values for compatibility
|
|
354
|
+
self.drop_while_busy = self.get_config_bool(MessageKeys.DROP_MESSAGES, True)
|
|
355
|
+
|
|
356
|
+
def get_config_bool(self, key: str, default: bool = False) -> bool:
|
|
357
|
+
"""
|
|
358
|
+
Get a boolean configuration value, with support for string representations of booleans.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
key: The configuration key
|
|
362
|
+
default: Default value if the key is not present
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Boolean value of the configuration setting
|
|
366
|
+
"""
|
|
367
|
+
val = self.config.get(key, default)
|
|
368
|
+
return val.lower() in ('true', '1', 'yes') if isinstance(val, str) else bool(val)
|
|
369
|
+
|
|
370
|
+
def get_config_int(self, key: str, default: int = 0) -> int:
|
|
371
|
+
"""
|
|
372
|
+
Get an integer configuration value.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
key: The configuration key
|
|
376
|
+
default: Default value if the key is not present
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Integer value of the configuration setting
|
|
380
|
+
"""
|
|
381
|
+
return int(self.config.get(key, default))
|
|
382
|
+
|
|
383
|
+
def get_config_float(self, key: str, default: float = 0.0) -> float:
|
|
384
|
+
"""
|
|
385
|
+
Get a float configuration value.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
key: The configuration key
|
|
389
|
+
default: Default value if the key is not present
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Float value of the configuration setting
|
|
393
|
+
"""
|
|
394
|
+
return float(self.config.get(key, default))
|
|
395
|
+
|
|
396
|
+
def _get_nested_value(self, obj: Dict, path: str) -> Any:
|
|
397
|
+
"""
|
|
398
|
+
Get a value from a nested path using dot notation.
|
|
399
|
+
Supports array indexing like 'items[0]' and optional 'msg.' prefix.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
obj: The object to get the value from (usually a message dict)
|
|
403
|
+
path: Dot-separated path string (e.g., 'payload.data', 'items[0].name')
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
The value at the path, or None if not found
|
|
407
|
+
|
|
408
|
+
Examples:
|
|
409
|
+
self._get_nested_value(msg, 'payload') # msg['payload']
|
|
410
|
+
self._get_nested_value(msg, 'payload.data') # msg['payload']['data']
|
|
411
|
+
self._get_nested_value(msg, 'items[0].name') # msg['items'][0]['name']
|
|
412
|
+
self._get_nested_value(msg, 'msg.payload') # msg['payload'] (msg. prefix stripped)
|
|
413
|
+
"""
|
|
414
|
+
import re
|
|
415
|
+
|
|
416
|
+
if not path:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
# Handle msg. prefix for compatibility
|
|
420
|
+
if path.startswith('msg.'):
|
|
421
|
+
path = path[4:]
|
|
422
|
+
|
|
423
|
+
parts = path.split('.')
|
|
424
|
+
current = obj
|
|
425
|
+
|
|
426
|
+
for part in parts:
|
|
427
|
+
if current is None:
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
# Handle array indexing like 'items[0]'
|
|
431
|
+
match = re.match(r'(\w+)\[(\d+)\]', part)
|
|
432
|
+
if match:
|
|
433
|
+
key, index = match.groups()
|
|
434
|
+
if isinstance(current, dict) and key in current:
|
|
435
|
+
current = current[key]
|
|
436
|
+
if isinstance(current, (list, tuple)) and int(index) < len(current):
|
|
437
|
+
current = current[int(index)]
|
|
438
|
+
else:
|
|
439
|
+
return None
|
|
440
|
+
else:
|
|
441
|
+
return None
|
|
442
|
+
elif isinstance(current, dict) and part in current:
|
|
443
|
+
current = current[part]
|
|
444
|
+
else:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
return current
|
|
448
|
+
|
|
449
|
+
def _set_nested_value(self, obj: Dict, path: str, value: Any) -> bool:
|
|
450
|
+
"""
|
|
451
|
+
Set a value at a nested path like 'payload.image.width'
|
|
452
|
+
Supports array indexing and msg. prefix handling.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
obj: The object to set the value in
|
|
456
|
+
path: Dot-separated path string
|
|
457
|
+
value: The value to set
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
True if successful, False otherwise
|
|
461
|
+
"""
|
|
462
|
+
import re
|
|
463
|
+
|
|
464
|
+
# Handle msg. prefix
|
|
465
|
+
if path.startswith('msg.'):
|
|
466
|
+
path = path[4:]
|
|
467
|
+
|
|
468
|
+
parts = path.split('.')
|
|
469
|
+
current = obj
|
|
470
|
+
|
|
471
|
+
# Navigate to parent of target
|
|
472
|
+
for part in parts[:-1]:
|
|
473
|
+
# Handle array indexing
|
|
474
|
+
match = re.match(r'(\w+)\[(\d+)\]', part)
|
|
475
|
+
if match:
|
|
476
|
+
key, index = match.groups()
|
|
477
|
+
if key not in current:
|
|
478
|
+
current[key] = []
|
|
479
|
+
current = current[key]
|
|
480
|
+
index = int(index)
|
|
481
|
+
while len(current) <= index:
|
|
482
|
+
current.append({})
|
|
483
|
+
current = current[index]
|
|
484
|
+
else:
|
|
485
|
+
if part not in current:
|
|
486
|
+
current[part] = {}
|
|
487
|
+
current = current[part]
|
|
488
|
+
|
|
489
|
+
# Set the final value
|
|
490
|
+
final_key = parts[-1]
|
|
491
|
+
match = re.match(r'(\w+)\[(\d+)\]', final_key)
|
|
492
|
+
if match:
|
|
493
|
+
key, index = match.groups()
|
|
494
|
+
if key not in current:
|
|
495
|
+
current[key] = []
|
|
496
|
+
while len(current[key]) <= int(index):
|
|
497
|
+
current[key].append(None)
|
|
498
|
+
current[key][int(index)] = value
|
|
499
|
+
else:
|
|
500
|
+
current[final_key] = value
|
|
501
|
+
|
|
502
|
+
return True
|
|
503
|
+
|
|
504
|
+
# Image processing helper methods (thin delegates; the implementations
|
|
505
|
+
# live in pynode.nodes.image_utils and report failures through this
|
|
506
|
+
# node's report_error).
|
|
507
|
+
def decode_image(self, payload: Any) -> Tuple[Any, Optional[str]]:
|
|
508
|
+
"""
|
|
509
|
+
Decode image from various formats into a numpy array.
|
|
510
|
+
Returns (image, format_type) tuple where format_type indicates the input format.
|
|
511
|
+
|
|
512
|
+
Supported formats:
|
|
513
|
+
- Direct numpy array
|
|
514
|
+
- Dict with 'format', 'encoding', 'data' (camera node format)
|
|
515
|
+
- Direct base64 string
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
payload: Image payload in any supported format
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Tuple of (image as numpy array or None, format_identifier string or None)
|
|
522
|
+
"""
|
|
523
|
+
return image_utils.decode_image(payload, report_error=self.report_error)
|
|
524
|
+
|
|
525
|
+
def encode_image(self, image: Any, format_type: str) -> Any:
|
|
526
|
+
"""
|
|
527
|
+
Encode numpy array image back to the original format.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
image: Numpy array image
|
|
531
|
+
format_type: Format identifier from decode_image()
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Encoded image in the specified format, or None on error
|
|
535
|
+
"""
|
|
536
|
+
return image_utils.encode_image(image, format_type,
|
|
537
|
+
report_error=self.report_error)
|
|
538
|
+
|
|
539
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
540
|
+
"""
|
|
541
|
+
Serialize node to dictionary for API/storage.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Dictionary representation of the node
|
|
545
|
+
"""
|
|
546
|
+
return {
|
|
547
|
+
'id': self.id,
|
|
548
|
+
'type': self.type,
|
|
549
|
+
'name': self.name,
|
|
550
|
+
# Shallow copy so callers can't mutate the node's config in place
|
|
551
|
+
'config': dict(self.config),
|
|
552
|
+
'enabled': self.enabled,
|
|
553
|
+
'inputCount': self.input_count,
|
|
554
|
+
'outputCount': self.output_count,
|
|
555
|
+
'outputs': {
|
|
556
|
+
str(idx): [(node.id, target_idx) for node, target_idx in connections]
|
|
557
|
+
for idx, connections in self.outputs.items()
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
def __repr__(self):
|
|
562
|
+
return f"<{self.type}(id={self.id}, name={self.name})>"
|