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,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filter Node - filters messages based on conditions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
7
|
+
|
|
8
|
+
_info = Info()
|
|
9
|
+
_info.add_text("Filters messages based on various conditions. Only passes messages that meet the specified criteria.")
|
|
10
|
+
_info.add_header("Inputs")
|
|
11
|
+
_info.add_bullets(
|
|
12
|
+
("Input 0:", "Any message to be filtered.")
|
|
13
|
+
)
|
|
14
|
+
_info.add_header("Outputs")
|
|
15
|
+
_info.add_bullets(
|
|
16
|
+
("Output 0:", "Messages that pass the filter.")
|
|
17
|
+
)
|
|
18
|
+
_info.add_header("Filter Modes")
|
|
19
|
+
_info.add_bullets(
|
|
20
|
+
("Block:", "Block unless payload value changes."),
|
|
21
|
+
("Dedupe:", "Block duplicate payloads (same as Block)."),
|
|
22
|
+
("Drop First:", "Drop the first N messages, pass the rest."),
|
|
23
|
+
("Keep First:", "Keep only the first N messages, drop the rest.")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FilterNode(BaseNode):
|
|
28
|
+
"""
|
|
29
|
+
Filter Node - only passes messages that meet specified conditions.
|
|
30
|
+
"""
|
|
31
|
+
display_name = 'Filter'
|
|
32
|
+
icon = '🔍'
|
|
33
|
+
category = 'function'
|
|
34
|
+
color = '#FFB6C1'
|
|
35
|
+
border_color = '#FF69B4'
|
|
36
|
+
text_color = '#000000'
|
|
37
|
+
input_count = 1
|
|
38
|
+
output_count = 1
|
|
39
|
+
info = str(_info)
|
|
40
|
+
|
|
41
|
+
properties = [
|
|
42
|
+
{
|
|
43
|
+
'name': 'mode',
|
|
44
|
+
'label': 'Mode',
|
|
45
|
+
'type': 'select',
|
|
46
|
+
'options': [
|
|
47
|
+
{'value': 'block', 'label': 'Block unless value changes'},
|
|
48
|
+
{'value': 'dedupe', 'label': 'Deduplicate (block duplicates)'},
|
|
49
|
+
{'value': 'drop_first', 'label': 'Drop first N messages'},
|
|
50
|
+
{'value': 'keep_first', 'label': 'Keep only first N messages'}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
'name': 'count',
|
|
55
|
+
'label': 'Count',
|
|
56
|
+
'type': 'number',
|
|
57
|
+
'default': 1
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def __init__(self, node_id=None, name="filter"):
|
|
62
|
+
super().__init__(node_id, name)
|
|
63
|
+
self.configure({
|
|
64
|
+
'mode': 'dedupe',
|
|
65
|
+
'count': 1,
|
|
66
|
+
'drop_messages': False
|
|
67
|
+
})
|
|
68
|
+
self.last_value = None
|
|
69
|
+
self.message_count = 0
|
|
70
|
+
|
|
71
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
72
|
+
"""Filter messages based on configured mode."""
|
|
73
|
+
mode = self.config.get('mode', 'dedupe')
|
|
74
|
+
payload = msg.get(MessageKeys.PAYLOAD)
|
|
75
|
+
|
|
76
|
+
should_send = False
|
|
77
|
+
|
|
78
|
+
if mode == 'block':
|
|
79
|
+
# Block unless value changes
|
|
80
|
+
if payload != self.last_value:
|
|
81
|
+
should_send = True
|
|
82
|
+
self.last_value = payload
|
|
83
|
+
elif mode == 'dedupe':
|
|
84
|
+
# Deduplicate
|
|
85
|
+
if payload != self.last_value:
|
|
86
|
+
should_send = True
|
|
87
|
+
self.last_value = payload
|
|
88
|
+
elif mode == 'drop_first':
|
|
89
|
+
# Drop first N messages
|
|
90
|
+
count = self.get_config_int('count', 1)
|
|
91
|
+
self.message_count += 1
|
|
92
|
+
if self.message_count > count:
|
|
93
|
+
should_send = True
|
|
94
|
+
elif mode == 'keep_first':
|
|
95
|
+
# Keep only first N messages
|
|
96
|
+
count = self.get_config_int('count', 1)
|
|
97
|
+
self.message_count += 1
|
|
98
|
+
if self.message_count <= count:
|
|
99
|
+
should_send = True
|
|
100
|
+
|
|
101
|
+
if should_send:
|
|
102
|
+
self.send(msg)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frame Source node - captures frames from various video sources.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import base64
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict
|
|
13
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
14
|
+
|
|
15
|
+
_info = Info()
|
|
16
|
+
_info.add_text("Captures frames from various video sources and outputs them as messages. Supports webcams, video files, RTSP streams, image folders, and specialized cameras.")
|
|
17
|
+
_info.add_header("Outputs")
|
|
18
|
+
_info.add_bullets(
|
|
19
|
+
("Output 0:", "Frame message with image data (numpy array or JPEG encoded).")
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Source Types")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("Webcam:", "Local USB webcam (source = device index, e.g. 0)"),
|
|
24
|
+
("Video File:", "MP4, AVI, or other video files"),
|
|
25
|
+
("RTSP Stream:", "IP camera RTSP URL"),
|
|
26
|
+
("Image Folder:", "Directory of images to cycle through"),
|
|
27
|
+
("Basler:", "Basler industrial camera"),
|
|
28
|
+
("RealSense:", "Intel RealSense depth camera (includes depth data)"),
|
|
29
|
+
("Screen Capture:", "Desktop screen recording"),
|
|
30
|
+
("GenICam:", "GenICam-compatible industrial cameras"),
|
|
31
|
+
("Audio Spectrogram:", "Audio visualization as frames")
|
|
32
|
+
)
|
|
33
|
+
_info.add_header("Output Format")
|
|
34
|
+
_info.add_bullets(
|
|
35
|
+
(f"{MessageKeys.PAYLOAD}.image:", "Image data as numpy array or base64 JPEG"),
|
|
36
|
+
(f"{MessageKeys.PAYLOAD}.frame_count:", "Frame sequence number"),
|
|
37
|
+
(f"{MessageKeys.PAYLOAD}.width/height:", "Frame dimensions"),
|
|
38
|
+
(f"{MessageKeys.PAYLOAD}.loop:", "Indicates if the video/folder is looping")
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Add FrameSource package to path
|
|
42
|
+
_framesource_path = Path(__file__).parent / 'FrameSource'
|
|
43
|
+
if str(_framesource_path) not in sys.path:
|
|
44
|
+
sys.path.insert(0, str(_framesource_path))
|
|
45
|
+
|
|
46
|
+
from frame_source import FrameSourceFactory
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FrameSourceNode(BaseNode):
|
|
50
|
+
"""
|
|
51
|
+
Frame Source node - captures frames from a webcam and outputs them as messages.
|
|
52
|
+
"""
|
|
53
|
+
display_name = 'Frame Source'
|
|
54
|
+
icon = '📷'
|
|
55
|
+
category = 'input'
|
|
56
|
+
color = '#C0DEED'
|
|
57
|
+
border_color = '#7FA7C9'
|
|
58
|
+
text_color = '#000000'
|
|
59
|
+
input_count = 0 # No input
|
|
60
|
+
output_count = 1
|
|
61
|
+
info = str(_info)
|
|
62
|
+
|
|
63
|
+
api_routes = [
|
|
64
|
+
{
|
|
65
|
+
'route': 'upload_video',
|
|
66
|
+
'methods': ['POST'],
|
|
67
|
+
'handler': 'handle_upload_video',
|
|
68
|
+
'type': 'file_upload',
|
|
69
|
+
'allowed_extensions': {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg'},
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
DEFAULT_CONFIG = {
|
|
74
|
+
MessageKeys.CAMERA.SOURCE_TYPE: 'webcam',
|
|
75
|
+
MessageKeys.CAMERA.SOURCE: 0,
|
|
76
|
+
MessageKeys.CAMERA.FPS: 30,
|
|
77
|
+
MessageKeys.CAMERA.WIDTH: 640,
|
|
78
|
+
MessageKeys.CAMERA.HEIGHT: 480,
|
|
79
|
+
MessageKeys.CAMERA.ENCODE_JPEG: False,
|
|
80
|
+
MessageKeys.CAMERA.JPEG_QUALITY: 75,
|
|
81
|
+
MessageKeys.VIDEO.LOOP: True
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
properties = [
|
|
85
|
+
{
|
|
86
|
+
'name': MessageKeys.CAMERA.SOURCE_TYPE,
|
|
87
|
+
'label': 'Source Type',
|
|
88
|
+
'type': 'select',
|
|
89
|
+
'options': [
|
|
90
|
+
{'value': 'webcam', 'label': 'Webcam'},
|
|
91
|
+
{'value': 'video_file', 'label': 'Video File'},
|
|
92
|
+
{'value': 'ipcam', 'label': 'RTSP Stream'},
|
|
93
|
+
{'value': 'folder', 'label': 'Image Folder'},
|
|
94
|
+
{'value': 'basler', 'label': 'Basler Camera'},
|
|
95
|
+
{'value': 'realsense', 'label': 'RealSense'},
|
|
96
|
+
{'value': 'screen', 'label': 'Screen Capture'},
|
|
97
|
+
{'value': 'genicam', 'label': 'GenICam Camera'},
|
|
98
|
+
{'value': 'audio_spectrogram', 'label': 'Audio Spectrogram'}
|
|
99
|
+
],
|
|
100
|
+
'default': DEFAULT_CONFIG[MessageKeys.CAMERA.SOURCE_TYPE],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
'name': MessageKeys.CAMERA.SOURCE,
|
|
104
|
+
'label': 'Video File',
|
|
105
|
+
'type': 'file',
|
|
106
|
+
'accept': '.mp4,.avi,.mkv,.mov,.wmv,.flv,.webm,.m4v,.mpg,.mpeg',
|
|
107
|
+
'uploadRoute': 'upload_video',
|
|
108
|
+
'placeholder': 'Upload or enter video file path...',
|
|
109
|
+
'showIf': {MessageKeys.CAMERA.SOURCE_TYPE: 'video_file'},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
'name': MessageKeys.CAMERA.SOURCE,
|
|
113
|
+
'label': 'Source',
|
|
114
|
+
'type': 'text',
|
|
115
|
+
'default': str(DEFAULT_CONFIG[MessageKeys.CAMERA.SOURCE]),
|
|
116
|
+
'showIf': {MessageKeys.CAMERA.SOURCE_TYPE: ['webcam', 'ipcam', 'folder', 'basler', 'realsense', 'screen', 'genicam', 'audio_spectrogram']},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
'name': MessageKeys.CAMERA.FPS,
|
|
120
|
+
'label': 'Frame Rate (FPS)',
|
|
121
|
+
'type': 'number',
|
|
122
|
+
'default': DEFAULT_CONFIG[MessageKeys.CAMERA.FPS]
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
'name': MessageKeys.VIDEO.LOOP,
|
|
126
|
+
'label': 'Loop Video/Folder',
|
|
127
|
+
'type': 'checkbox',
|
|
128
|
+
'default': DEFAULT_CONFIG[MessageKeys.VIDEO.LOOP],
|
|
129
|
+
'showIf': {MessageKeys.CAMERA.SOURCE_TYPE: ['video_file', 'folder']}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
'name': MessageKeys.CAMERA.WIDTH,
|
|
133
|
+
'label': 'Width',
|
|
134
|
+
'type': 'number',
|
|
135
|
+
'default': DEFAULT_CONFIG[MessageKeys.CAMERA.WIDTH]
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
'name': MessageKeys.CAMERA.HEIGHT,
|
|
139
|
+
'label': 'Height',
|
|
140
|
+
'type': 'number',
|
|
141
|
+
'default': DEFAULT_CONFIG[MessageKeys.CAMERA.HEIGHT]
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
'name': MessageKeys.CAMERA.ENCODE_JPEG,
|
|
145
|
+
'label': 'Encode as JPEG',
|
|
146
|
+
'type': 'checkbox',
|
|
147
|
+
'default': DEFAULT_CONFIG[MessageKeys.CAMERA.ENCODE_JPEG]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
'name': MessageKeys.CAMERA.JPEG_QUALITY,
|
|
151
|
+
'label': 'JPEG Quality (1-100)',
|
|
152
|
+
'type': 'number',
|
|
153
|
+
'default': DEFAULT_CONFIG[MessageKeys.CAMERA.JPEG_QUALITY]
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
def __init__(self, node_id=None, name="camera"):
|
|
158
|
+
super().__init__(node_id, name)
|
|
159
|
+
self.camera = None
|
|
160
|
+
self.capture_thread = None
|
|
161
|
+
self.running = False
|
|
162
|
+
self.frame_count = 0
|
|
163
|
+
|
|
164
|
+
def handle_upload_video(self, file_bytes, filename):
|
|
165
|
+
"""Handle video file upload via the dynamic API route."""
|
|
166
|
+
try:
|
|
167
|
+
# Save to a videos directory next to this node
|
|
168
|
+
videos_dir = os.path.join(os.path.dirname(__file__), 'videos')
|
|
169
|
+
os.makedirs(videos_dir, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
file_path = os.path.join(videos_dir, os.path.basename(filename))
|
|
172
|
+
with open(file_path, 'wb') as f:
|
|
173
|
+
f.write(file_bytes)
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
'success': True,
|
|
177
|
+
'file_path': file_path,
|
|
178
|
+
'filename': filename
|
|
179
|
+
}
|
|
180
|
+
except Exception as e:
|
|
181
|
+
return {
|
|
182
|
+
'success': False,
|
|
183
|
+
'error': str(e)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def on_start(self):
|
|
187
|
+
"""Start the camera capture when workflow starts."""
|
|
188
|
+
super().on_start() # Start base node worker thread
|
|
189
|
+
|
|
190
|
+
source_type = self.config.get(MessageKeys.CAMERA.SOURCE_TYPE, 'webcam')
|
|
191
|
+
source = self.config.get(MessageKeys.CAMERA.SOURCE, 0)
|
|
192
|
+
fps = self.get_config_int(MessageKeys.CAMERA.FPS, 30)
|
|
193
|
+
width = self.get_config_int(MessageKeys.CAMERA.WIDTH, 640)
|
|
194
|
+
height = self.get_config_int(MessageKeys.CAMERA.HEIGHT, 480)
|
|
195
|
+
loop = self.get_config_bool(MessageKeys.VIDEO.LOOP, False)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
# Create camera with config including resolution and fps
|
|
199
|
+
self.camera = FrameSourceFactory.create(
|
|
200
|
+
source_type,
|
|
201
|
+
source=source,
|
|
202
|
+
width=width,
|
|
203
|
+
height=height,
|
|
204
|
+
fps=fps,
|
|
205
|
+
loop=loop
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if not self.camera.connect():
|
|
209
|
+
self.report_error(f"Failed to connect to {source_type} source: {source}")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
self.frame_count = 0 # Reset frame counter on start
|
|
213
|
+
|
|
214
|
+
# Start capture thread
|
|
215
|
+
self.running = True
|
|
216
|
+
self.capture_thread = threading.Thread(target=self._capture_loop, args=(fps,), daemon=True)
|
|
217
|
+
self.capture_thread.start()
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
self.report_error(f"Error starting camera: {e}")
|
|
221
|
+
|
|
222
|
+
def on_stop(self):
|
|
223
|
+
"""Stop the camera capture when workflow stops."""
|
|
224
|
+
super().on_stop() # Stop base node worker thread
|
|
225
|
+
|
|
226
|
+
self.running = False
|
|
227
|
+
|
|
228
|
+
if self.capture_thread:
|
|
229
|
+
self.capture_thread.join(timeout=2.0)
|
|
230
|
+
self.capture_thread = None
|
|
231
|
+
|
|
232
|
+
if self.camera:
|
|
233
|
+
self.camera.release()
|
|
234
|
+
self.camera = None
|
|
235
|
+
|
|
236
|
+
def on_close(self):
|
|
237
|
+
"""Cleanup when node is deleted."""
|
|
238
|
+
self.on_stop()
|
|
239
|
+
|
|
240
|
+
def _capture_loop(self, fps):
|
|
241
|
+
"""Capture frames in a loop and send them as messages."""
|
|
242
|
+
frame_interval = 1.0 / fps
|
|
243
|
+
encode_jpeg = self.config.get(MessageKeys.CAMERA.ENCODE_JPEG, False)
|
|
244
|
+
|
|
245
|
+
while self.running and self.camera and self.camera.isOpened():
|
|
246
|
+
start_time = time.time()
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
ret, frame = self.camera.read()
|
|
250
|
+
|
|
251
|
+
# Check if frame has 4 channels (RGBD data)
|
|
252
|
+
if ret and frame is not None and len(frame.shape) == 3 and frame.shape[2] == 4:
|
|
253
|
+
# Split RGB and depth channels
|
|
254
|
+
rgb_frame = frame[:, :, :3]
|
|
255
|
+
depth_channel = frame[:, :, 3]
|
|
256
|
+
frame = rgb_frame
|
|
257
|
+
has_depth = True
|
|
258
|
+
else:
|
|
259
|
+
has_depth = False
|
|
260
|
+
depth_channel = None
|
|
261
|
+
|
|
262
|
+
if not ret or frame is None:
|
|
263
|
+
self.report_error("Failed to capture frame")
|
|
264
|
+
time.sleep(frame_interval)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
# Prepare the payload
|
|
268
|
+
if encode_jpeg:
|
|
269
|
+
# Encode frame as JPEG with quality setting
|
|
270
|
+
jpeg_quality = self.get_config_int(MessageKeys.IMAGE.JPEG_QUALITY, 75)
|
|
271
|
+
encode_params = [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality]
|
|
272
|
+
ret, buffer = cv2.imencode('.jpg', frame, encode_params)
|
|
273
|
+
if ret:
|
|
274
|
+
# Convert to bytes (more efficient than base64)
|
|
275
|
+
jpeg_bytes = buffer.tobytes()
|
|
276
|
+
# Convert to base64 for JSON transmission
|
|
277
|
+
jpeg_base64 = base64.b64encode(jpeg_bytes).decode('utf-8')
|
|
278
|
+
payload = {
|
|
279
|
+
MessageKeys.IMAGE.FORMAT: 'jpeg',
|
|
280
|
+
MessageKeys.IMAGE.ENCODING: 'base64',
|
|
281
|
+
MessageKeys.IMAGE.DATA: jpeg_base64,
|
|
282
|
+
MessageKeys.IMAGE.WIDTH: frame.shape[1],
|
|
283
|
+
MessageKeys.IMAGE.HEIGHT: frame.shape[0]
|
|
284
|
+
}
|
|
285
|
+
else:
|
|
286
|
+
self.report_error("Failed to encode JPEG")
|
|
287
|
+
continue
|
|
288
|
+
else:
|
|
289
|
+
# Send raw frame as numpy array
|
|
290
|
+
payload = {
|
|
291
|
+
MessageKeys.IMAGE.FORMAT: 'bgr',
|
|
292
|
+
MessageKeys.IMAGE.ENCODING: 'numpy',
|
|
293
|
+
MessageKeys.IMAGE.DATA: frame,
|
|
294
|
+
MessageKeys.IMAGE.WIDTH: frame.shape[1],
|
|
295
|
+
MessageKeys.IMAGE.HEIGHT: frame.shape[0]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Create message payload
|
|
299
|
+
message_payload: Dict[str, Any] = {MessageKeys.IMAGE.PATH: payload}
|
|
300
|
+
|
|
301
|
+
# Add depth data if available (compatible with RealsenseDepthNode)
|
|
302
|
+
if has_depth:
|
|
303
|
+
message_payload['depth'] = depth_channel
|
|
304
|
+
|
|
305
|
+
# Increment frame counter
|
|
306
|
+
self.frame_count += 1
|
|
307
|
+
|
|
308
|
+
# Create and send message
|
|
309
|
+
msg = self.create_message(payload=message_payload, topic='camera/frame', frame_count=self.frame_count)
|
|
310
|
+
self.send(msg)
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
self.report_error(f"Error capturing frame: {e}")
|
|
314
|
+
|
|
315
|
+
# Maintain frame rate
|
|
316
|
+
elapsed = time.time() - start_time
|
|
317
|
+
sleep_time = max(0, frame_interval - elapsed)
|
|
318
|
+
if sleep_time > 0:
|
|
319
|
+
time.sleep(sleep_time)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
framesource[full]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Function node - executes custom Python code on messages.
|
|
3
|
+
Similar to Node-RED's function node.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import copy
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
10
|
+
|
|
11
|
+
_info = Info()
|
|
12
|
+
_info.add_text("Executes custom Python code on incoming messages. Similar to Node-RED's function node.")
|
|
13
|
+
_info.add_header("Inputs")
|
|
14
|
+
_info.add_bullets(
|
|
15
|
+
("Input 0:", "Any message to process.")
|
|
16
|
+
)
|
|
17
|
+
_info.add_header("Outputs")
|
|
18
|
+
_info.add_bullets(
|
|
19
|
+
("Output 0-N:", "Processed message(s). Configure number of outputs in properties.")
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Available Variables")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("msg:", "The incoming message dictionary."),
|
|
24
|
+
("node:", "Reference to this node (for node.send(), node.report_error())."),
|
|
25
|
+
("time:", "Python time module.")
|
|
26
|
+
)
|
|
27
|
+
_info.add_header("Example")
|
|
28
|
+
_info.add_code(f"msg['{MessageKeys.PAYLOAD}'] = msg['{MessageKeys.PAYLOAD}'] * 2\nreturn msg").text("Doubles the payload value and returns the modified message.").end()
|
|
29
|
+
_info.add_header("Multiple Outputs")
|
|
30
|
+
_info.add_code(f"return [msg, None, {{'{MessageKeys.PAYLOAD}': 'alt'}}]").text("Returns array to send to different outputs. None skips that output.").end()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FunctionNode(BaseNode):
|
|
34
|
+
"""
|
|
35
|
+
Function node - executes custom Python code on messages.
|
|
36
|
+
Similar to Node-RED's function node.
|
|
37
|
+
"""
|
|
38
|
+
display_name = 'Function'
|
|
39
|
+
icon = 'ƒ'
|
|
40
|
+
category = 'function'
|
|
41
|
+
color = '#E6E0F8'
|
|
42
|
+
border_color = '#9F93C6'
|
|
43
|
+
text_color = '#000000'
|
|
44
|
+
input_count = 1
|
|
45
|
+
output_count = 1
|
|
46
|
+
info = str(_info)
|
|
47
|
+
|
|
48
|
+
properties = [
|
|
49
|
+
{
|
|
50
|
+
'name': 'func',
|
|
51
|
+
'label': 'Function',
|
|
52
|
+
'type': 'textarea'
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
'name': 'outputs',
|
|
56
|
+
'label': 'Outputs',
|
|
57
|
+
'type': 'number',
|
|
58
|
+
'default': 1,
|
|
59
|
+
'min': 1,
|
|
60
|
+
'max': 10
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(self, node_id=None, name="function"):
|
|
65
|
+
super().__init__(node_id, name)
|
|
66
|
+
|
|
67
|
+
def configure(self, config: Dict[str, Any]):
|
|
68
|
+
"""Configure the node and update output_count based on outputs setting."""
|
|
69
|
+
super().configure(config)
|
|
70
|
+
self.output_count = self.get_config_int('outputs', 1)
|
|
71
|
+
|
|
72
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
73
|
+
"""
|
|
74
|
+
Execute the function code on the incoming message.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
func_code = self.config.get('func', 'return msg')
|
|
78
|
+
|
|
79
|
+
# Wrap the user's code in a function to support return statements
|
|
80
|
+
wrapped_code = f'''def user_function(msg, node, time):
|
|
81
|
+
{chr(10).join(" " + line for line in func_code.split(chr(10)))}
|
|
82
|
+
|
|
83
|
+
result = user_function(msg, node, time)
|
|
84
|
+
'''
|
|
85
|
+
|
|
86
|
+
# Create a deep copy to avoid modifying the original and handle non-serializable objects
|
|
87
|
+
try:
|
|
88
|
+
msg_copy = copy.deepcopy(msg)
|
|
89
|
+
except Exception:
|
|
90
|
+
# If deep copy fails, use shallow copy and let user handle it
|
|
91
|
+
msg_copy = msg.copy()
|
|
92
|
+
|
|
93
|
+
# Create a safe execution context with helpful utilities
|
|
94
|
+
context = {
|
|
95
|
+
'msg': msg_copy,
|
|
96
|
+
'node': self,
|
|
97
|
+
'time': time,
|
|
98
|
+
# Add common utilities
|
|
99
|
+
'isinstance': isinstance,
|
|
100
|
+
'dict': dict,
|
|
101
|
+
'list': list,
|
|
102
|
+
'str': str,
|
|
103
|
+
'int': int,
|
|
104
|
+
'float': float,
|
|
105
|
+
'set': set,
|
|
106
|
+
'tuple': tuple
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Execute the wrapped function
|
|
110
|
+
exec(wrapped_code, context)
|
|
111
|
+
|
|
112
|
+
# Get the result
|
|
113
|
+
result = context.get('result')
|
|
114
|
+
|
|
115
|
+
if result is not None:
|
|
116
|
+
if isinstance(result, list):
|
|
117
|
+
# Multiple outputs
|
|
118
|
+
for idx, out_msg in enumerate(result):
|
|
119
|
+
if out_msg is not None:
|
|
120
|
+
self.send(out_msg, idx)
|
|
121
|
+
else:
|
|
122
|
+
# Single output
|
|
123
|
+
self.send(result)
|
|
124
|
+
|
|
125
|
+
except SyntaxError as e:
|
|
126
|
+
# Provide helpful syntax error messages with line numbers
|
|
127
|
+
line_num = e.lineno - 1 if e.lineno else 0 # Adjust for wrapper function
|
|
128
|
+
user_lines = func_code.split('\n')
|
|
129
|
+
|
|
130
|
+
error_context = f"Syntax error on line {line_num}: {e.msg}\n"
|
|
131
|
+
if 0 <= line_num - 1 < len(user_lines):
|
|
132
|
+
error_context += f" {line_num}: {user_lines[line_num - 1]}\n"
|
|
133
|
+
if 0 <= line_num < len(user_lines):
|
|
134
|
+
error_context += f"→ {line_num + 1}: {user_lines[line_num]}\n"
|
|
135
|
+
if line_num + 1 < len(user_lines):
|
|
136
|
+
error_context += f" {line_num + 2}: {user_lines[line_num + 1]}\n"
|
|
137
|
+
|
|
138
|
+
self.report_error(error_context)
|
|
139
|
+
error_msg = self.create_message(
|
|
140
|
+
payload={'error': error_context, 'type': 'SyntaxError'},
|
|
141
|
+
topic='error'
|
|
142
|
+
)
|
|
143
|
+
self.send(error_msg)
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
# Provide more helpful error messages
|
|
147
|
+
error_str = str(e)
|
|
148
|
+
payload_type = type(msg.get(MessageKeys.PAYLOAD)).__name__
|
|
149
|
+
|
|
150
|
+
# Add context-specific hints
|
|
151
|
+
if "does not support item assignment" in error_str:
|
|
152
|
+
hint = f"Hint: msg['{MessageKeys.PAYLOAD}'] is a {payload_type}, not a dict. "
|
|
153
|
+
if payload_type in ['float', 'int', 'str', 'bool']:
|
|
154
|
+
hint += f"To add an index, wrap it: msg['{MessageKeys.PAYLOAD}'] = {{'value': msg['{MessageKeys.PAYLOAD}'], 'index': 0}}"
|
|
155
|
+
error_str = hint + " Error: " + error_str
|
|
156
|
+
|
|
157
|
+
# Report error to the error system
|
|
158
|
+
self.report_error(f"Function error: {error_str}")
|
|
159
|
+
# Also send error downstream for debugging
|
|
160
|
+
error_msg = self.create_message(
|
|
161
|
+
payload={
|
|
162
|
+
'error': error_str,
|
|
163
|
+
'original_payload': msg.get(MessageKeys.PAYLOAD),
|
|
164
|
+
'payload_type': payload_type
|
|
165
|
+
},
|
|
166
|
+
topic='error'
|
|
167
|
+
)
|
|
168
|
+
self.send(error_msg)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gate node - allows messages to pass through or blocks them.
|
|
3
|
+
Can be toggled on/off directly from the UI without redeployment.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
8
|
+
|
|
9
|
+
_info = Info()
|
|
10
|
+
_info.add_text("Allows or blocks messages from passing through. Can be toggled on/off in real-time from the UI without redeploying.")
|
|
11
|
+
_info.add_header("Inputs")
|
|
12
|
+
_info.add_bullets(
|
|
13
|
+
("Input 0:", "Any message.")
|
|
14
|
+
)
|
|
15
|
+
_info.add_header("Outputs")
|
|
16
|
+
_info.add_bullets(
|
|
17
|
+
("Output 0:", "Messages pass through when gate is open.")
|
|
18
|
+
)
|
|
19
|
+
_info.add_header("Usage")
|
|
20
|
+
_info.add_bullets(
|
|
21
|
+
("Toggle:", "Click the toggle button on the node to open/close the gate."),
|
|
22
|
+
("Open:", "Messages pass through unchanged."),
|
|
23
|
+
("Closed:", "Messages are silently discarded.")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GateNode(BaseNode):
|
|
28
|
+
"""
|
|
29
|
+
Gate node - allows or blocks messages based on enabled state.
|
|
30
|
+
Can be toggled in real-time from the UI.
|
|
31
|
+
"""
|
|
32
|
+
display_name = 'Gate'
|
|
33
|
+
icon = '🚪'
|
|
34
|
+
category = 'logic'
|
|
35
|
+
color = '#FFE5B4'
|
|
36
|
+
border_color = '#FFD700'
|
|
37
|
+
text_color = '#000000'
|
|
38
|
+
input_count = 1
|
|
39
|
+
output_count = 1
|
|
40
|
+
info = str(_info)
|
|
41
|
+
ui_component = 'toggle'
|
|
42
|
+
ui_component_config = {
|
|
43
|
+
'action': 'toggle_gate',
|
|
44
|
+
'label': 'Open'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
properties = [] # No properties panel needed
|
|
48
|
+
|
|
49
|
+
def __init__(self, node_id=None, name="gate"):
|
|
50
|
+
super().__init__(node_id, name)
|
|
51
|
+
self.enabled = True # Gate is open (enabled) by default
|
|
52
|
+
self.configure({})
|
|
53
|
+
|
|
54
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
55
|
+
"""
|
|
56
|
+
Pass message through if gate is enabled, otherwise discard it.
|
|
57
|
+
"""
|
|
58
|
+
if self.enabled:
|
|
59
|
+
self.send(msg)
|
|
60
|
+
# If gate is disabled, message is silently discarded
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|