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,491 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST Endpoint Node - Exposes HTTP REST endpoints for external integration.
|
|
3
|
+
|
|
4
|
+
This node creates REST API endpoints that can be called externally to inject
|
|
5
|
+
messages into the workflow. Supports GET, POST, PUT, DELETE methods with
|
|
6
|
+
configurable paths and response handling.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional, Dict, Any, List
|
|
13
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
14
|
+
from urllib.parse import urlparse, parse_qs
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from ..base_node import BaseNode, MessageKeys, Info
|
|
19
|
+
except ImportError:
|
|
20
|
+
from base_node import BaseNode, MessageKeys, Info
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_info = Info()
|
|
25
|
+
_info.add_text("Exposes HTTP REST endpoints for external integration. Creates configurable API endpoints that can receive HTTP requests and inject them into the workflow.")
|
|
26
|
+
_info.add_header("Inputs")
|
|
27
|
+
_info.add_bullets(("Input 0:", "Response control messages (when responseMode is 'wait')"))
|
|
28
|
+
_info.add_header("Outputs")
|
|
29
|
+
_info.add_bullets(
|
|
30
|
+
("Output 0:", "HTTP request data with:"),
|
|
31
|
+
)
|
|
32
|
+
_info.add_bullets(
|
|
33
|
+
("req.method:", "HTTP method (GET, POST, etc.)"),
|
|
34
|
+
("req.path:", "Request path"),
|
|
35
|
+
("req.query:", "Query parameters"),
|
|
36
|
+
("req.headers:", "Request headers"),
|
|
37
|
+
("req.body:", "Request body (parsed JSON or raw)"),
|
|
38
|
+
("payload:", "Request body or query parameters"),
|
|
39
|
+
)
|
|
40
|
+
_info.add_header("Configuration")
|
|
41
|
+
_info.add_bullets(
|
|
42
|
+
("Port:", "HTTP server port (1024-65535)"),
|
|
43
|
+
("Endpoint Path:", "URL path for the endpoint"),
|
|
44
|
+
("Methods:", "Allowed HTTP methods"),
|
|
45
|
+
("Response Mode:", "How to respond to requests (immediate, wait, custom)"),
|
|
46
|
+
("Authentication:", "Optional auth (none, basic, bearer, API key)"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RESTEndpointNode(BaseNode):
|
|
51
|
+
"""
|
|
52
|
+
REST Endpoint Node - Creates HTTP endpoints for external integration.
|
|
53
|
+
|
|
54
|
+
This node starts an HTTP server that listens for incoming requests on
|
|
55
|
+
configurable paths. When a request is received, it creates a message
|
|
56
|
+
containing the request details and sends it through the workflow.
|
|
57
|
+
|
|
58
|
+
Features:
|
|
59
|
+
- Configurable HTTP methods (GET, POST, PUT, DELETE, PATCH)
|
|
60
|
+
- Custom endpoint paths
|
|
61
|
+
- Request body parsing (JSON, form data)
|
|
62
|
+
- Query parameter extraction
|
|
63
|
+
- Header access
|
|
64
|
+
- Configurable response handling
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# Visual properties
|
|
68
|
+
display_name = 'REST Endpoint'
|
|
69
|
+
info = str(_info)
|
|
70
|
+
icon = '🌐'
|
|
71
|
+
category = 'network'
|
|
72
|
+
color = '#87CEEB'
|
|
73
|
+
border_color = '#4682B4'
|
|
74
|
+
text_color = '#000000'
|
|
75
|
+
input_count = 1 # For dynamic response control
|
|
76
|
+
output_count = 1
|
|
77
|
+
|
|
78
|
+
DEFAULT_CONFIG = {
|
|
79
|
+
'port': 9999,
|
|
80
|
+
'path': '/api/webhook',
|
|
81
|
+
'methods': 'POST',
|
|
82
|
+
'responseMode': 'immediate',
|
|
83
|
+
'responseCode': 200,
|
|
84
|
+
'responseBody': '{"status": "received"}',
|
|
85
|
+
'cors': True,
|
|
86
|
+
'authType': 'none',
|
|
87
|
+
'authCredentials': ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
properties = [
|
|
91
|
+
{
|
|
92
|
+
'name': 'port',
|
|
93
|
+
'label': 'Port',
|
|
94
|
+
'type': 'number',
|
|
95
|
+
'placeholder': DEFAULT_CONFIG['port'],
|
|
96
|
+
'default': DEFAULT_CONFIG['port']
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
'name': 'path',
|
|
100
|
+
'label': 'Endpoint Path',
|
|
101
|
+
'type': 'text',
|
|
102
|
+
'placeholder': '/api/webhook',
|
|
103
|
+
'default': DEFAULT_CONFIG['path']
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
'name': 'methods',
|
|
107
|
+
'label': 'Allowed Methods',
|
|
108
|
+
'type': 'select',
|
|
109
|
+
'options': [
|
|
110
|
+
{'value': 'GET', 'label': 'GET'},
|
|
111
|
+
{'value': 'POST', 'label': 'POST'},
|
|
112
|
+
{'value': 'PUT', 'label': 'PUT'},
|
|
113
|
+
{'value': 'DELETE', 'label': 'DELETE'},
|
|
114
|
+
{'value': 'PATCH', 'label': 'PATCH'},
|
|
115
|
+
{'value': 'ALL', 'label': 'All Methods'}
|
|
116
|
+
],
|
|
117
|
+
'default': DEFAULT_CONFIG['methods']
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
'name': 'responseMode',
|
|
121
|
+
'label': 'Response Mode',
|
|
122
|
+
'type': 'select',
|
|
123
|
+
'options': [
|
|
124
|
+
{'value': 'immediate', 'label': 'Immediate (200 OK)'},
|
|
125
|
+
{'value': 'wait', 'label': 'Wait for Response'},
|
|
126
|
+
{'value': 'custom', 'label': 'Custom Status'}
|
|
127
|
+
],
|
|
128
|
+
'default': DEFAULT_CONFIG['responseMode']
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
'name': 'responseCode',
|
|
132
|
+
'label': 'Response Code',
|
|
133
|
+
'type': 'text',
|
|
134
|
+
'placeholder': '200',
|
|
135
|
+
'default': DEFAULT_CONFIG['responseCode']
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
'name': 'responseBody',
|
|
139
|
+
'label': 'Response Body',
|
|
140
|
+
'type': 'text',
|
|
141
|
+
'placeholder': DEFAULT_CONFIG['responseBody'],
|
|
142
|
+
'default': DEFAULT_CONFIG['responseBody']
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
'name': 'cors',
|
|
146
|
+
'label': 'Enable CORS',
|
|
147
|
+
'type': 'checkbox',
|
|
148
|
+
'default': DEFAULT_CONFIG['cors']
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
'name': 'authType',
|
|
152
|
+
'label': 'Authentication',
|
|
153
|
+
'type': 'select',
|
|
154
|
+
'options': [
|
|
155
|
+
{'value': 'none', 'label': 'None'},
|
|
156
|
+
{'value': 'basic', 'label': 'Basic Auth'},
|
|
157
|
+
{'value': 'bearer', 'label': 'Bearer Token'},
|
|
158
|
+
{'value': 'apikey', 'label': 'API Key'}
|
|
159
|
+
],
|
|
160
|
+
'default': DEFAULT_CONFIG['authType']
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
'name': 'authCredentials',
|
|
164
|
+
'label': 'Auth Credentials',
|
|
165
|
+
'type': 'text',
|
|
166
|
+
'placeholder': 'username:password or token',
|
|
167
|
+
'showIf': {'authType': ['basic', 'bearer', 'apikey']},
|
|
168
|
+
'default': DEFAULT_CONFIG['authCredentials']
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def __init__(self, node_id: Optional[str] = None, name: str = "rest-endpoint"):
|
|
173
|
+
super().__init__(node_id, name)
|
|
174
|
+
self.configure(self.DEFAULT_CONFIG.copy())
|
|
175
|
+
self.server: Optional[HTTPServer] = None
|
|
176
|
+
self.server_thread: Optional[threading.Thread] = None
|
|
177
|
+
self._running = False
|
|
178
|
+
self._request_count = 0
|
|
179
|
+
self._pending_responses: Dict[str, Any] = {}
|
|
180
|
+
self._response_events: Dict[str, threading.Event] = {}
|
|
181
|
+
|
|
182
|
+
def on_deploy(self):
|
|
183
|
+
"""Called when the workflow is deployed - start the server."""
|
|
184
|
+
self._start_server()
|
|
185
|
+
|
|
186
|
+
def on_start(self):
|
|
187
|
+
"""Called when the workflow starts."""
|
|
188
|
+
if not self._running:
|
|
189
|
+
self._start_server()
|
|
190
|
+
|
|
191
|
+
def on_stop(self):
|
|
192
|
+
"""Called when the workflow stops."""
|
|
193
|
+
self._stop_server()
|
|
194
|
+
|
|
195
|
+
def on_close(self):
|
|
196
|
+
"""Called when the node is removed."""
|
|
197
|
+
self._stop_server()
|
|
198
|
+
|
|
199
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
200
|
+
"""
|
|
201
|
+
Handle incoming messages for response control.
|
|
202
|
+
|
|
203
|
+
When responseMode is 'wait', this allows the workflow to send
|
|
204
|
+
a response back to the HTTP client.
|
|
205
|
+
"""
|
|
206
|
+
request_id = msg.get('_request_id')
|
|
207
|
+
if request_id and request_id in self._response_events:
|
|
208
|
+
# Store the response data
|
|
209
|
+
self._pending_responses[request_id] = {
|
|
210
|
+
'status': msg.get('statusCode', 200),
|
|
211
|
+
'body': msg.get(MessageKeys.PAYLOAD, ''),
|
|
212
|
+
'headers': msg.get('headers', {})
|
|
213
|
+
}
|
|
214
|
+
# Signal that response is ready
|
|
215
|
+
self._response_events[request_id].set()
|
|
216
|
+
else:
|
|
217
|
+
# Pass through if not a response message
|
|
218
|
+
self.send(msg)
|
|
219
|
+
|
|
220
|
+
def _create_handler(self):
|
|
221
|
+
"""Create a request handler class with access to node instance."""
|
|
222
|
+
node = self
|
|
223
|
+
|
|
224
|
+
class RESTRequestHandler(BaseHTTPRequestHandler):
|
|
225
|
+
def log_message(self, format, *args):
|
|
226
|
+
logger.debug(f"REST Endpoint [{node.name}]: {format % args}")
|
|
227
|
+
|
|
228
|
+
def _send_cors_headers(self):
|
|
229
|
+
if node.config.get('cors', True):
|
|
230
|
+
self.send_header('Access-Control-Allow-Origin', '*')
|
|
231
|
+
self.send_header('Access-Control-Allow-Methods',
|
|
232
|
+
'GET, POST, PUT, DELETE, PATCH, OPTIONS')
|
|
233
|
+
self.send_header('Access-Control-Allow-Headers',
|
|
234
|
+
'Content-Type, Authorization, X-API-Key')
|
|
235
|
+
|
|
236
|
+
def _check_auth(self) -> bool:
|
|
237
|
+
auth_type = node.config.get('authType', 'none')
|
|
238
|
+
credentials = node.config.get('authCredentials', '')
|
|
239
|
+
|
|
240
|
+
if auth_type == 'none':
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
if auth_type == 'basic':
|
|
244
|
+
auth_header = self.headers.get('Authorization', '')
|
|
245
|
+
if auth_header.startswith('Basic '):
|
|
246
|
+
import base64
|
|
247
|
+
try:
|
|
248
|
+
decoded = base64.b64decode(auth_header[6:]).decode('utf-8')
|
|
249
|
+
return decoded == credentials
|
|
250
|
+
except Exception:
|
|
251
|
+
return False
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
if auth_type == 'bearer':
|
|
255
|
+
auth_header = self.headers.get('Authorization', '')
|
|
256
|
+
if auth_header.startswith('Bearer '):
|
|
257
|
+
return auth_header[7:] == credentials
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
if auth_type == 'apikey':
|
|
261
|
+
api_key = self.headers.get('X-API-Key', '')
|
|
262
|
+
return api_key == credentials
|
|
263
|
+
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
def _check_method(self, method: str) -> bool:
|
|
267
|
+
allowed = node.config.get('methods', 'POST')
|
|
268
|
+
if allowed == 'ALL':
|
|
269
|
+
return True
|
|
270
|
+
return method == allowed
|
|
271
|
+
|
|
272
|
+
def _check_path(self) -> bool:
|
|
273
|
+
expected_path = node.config.get('path', '/api/webhook')
|
|
274
|
+
parsed = urlparse(self.path)
|
|
275
|
+
return parsed.path == expected_path
|
|
276
|
+
|
|
277
|
+
def _handle_request(self, method: str):
|
|
278
|
+
# Check path
|
|
279
|
+
if not self._check_path():
|
|
280
|
+
self.send_response(404)
|
|
281
|
+
self._send_cors_headers()
|
|
282
|
+
self.send_header('Content-Type', 'application/json')
|
|
283
|
+
self.end_headers()
|
|
284
|
+
self.wfile.write(b'{"error": "Not Found"}')
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
# Check method
|
|
288
|
+
if not self._check_method(method):
|
|
289
|
+
self.send_response(405)
|
|
290
|
+
self._send_cors_headers()
|
|
291
|
+
self.send_header('Content-Type', 'application/json')
|
|
292
|
+
self.end_headers()
|
|
293
|
+
self.wfile.write(b'{"error": "Method Not Allowed"}')
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Check auth
|
|
297
|
+
if not self._check_auth():
|
|
298
|
+
self.send_response(401)
|
|
299
|
+
self._send_cors_headers()
|
|
300
|
+
self.send_header('Content-Type', 'application/json')
|
|
301
|
+
self.end_headers()
|
|
302
|
+
self.wfile.write(b'{"error": "Unauthorized"}')
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# Parse request
|
|
306
|
+
parsed = urlparse(self.path)
|
|
307
|
+
query_params = parse_qs(parsed.query)
|
|
308
|
+
|
|
309
|
+
# Read body for POST/PUT/PATCH
|
|
310
|
+
body = None
|
|
311
|
+
content_length = self.headers.get('Content-Length')
|
|
312
|
+
if content_length:
|
|
313
|
+
raw_body = self.rfile.read(int(content_length))
|
|
314
|
+
content_type = self.headers.get('Content-Type', '')
|
|
315
|
+
|
|
316
|
+
if 'application/json' in content_type:
|
|
317
|
+
try:
|
|
318
|
+
body = json.loads(raw_body.decode('utf-8'))
|
|
319
|
+
except json.JSONDecodeError:
|
|
320
|
+
body = raw_body.decode('utf-8')
|
|
321
|
+
elif 'application/x-www-form-urlencoded' in content_type:
|
|
322
|
+
body = parse_qs(raw_body.decode('utf-8'))
|
|
323
|
+
else:
|
|
324
|
+
try:
|
|
325
|
+
body = raw_body.decode('utf-8')
|
|
326
|
+
except UnicodeDecodeError:
|
|
327
|
+
body = raw_body
|
|
328
|
+
|
|
329
|
+
# Extract headers
|
|
330
|
+
headers = dict(self.headers)
|
|
331
|
+
|
|
332
|
+
# Create request ID for response tracking
|
|
333
|
+
import uuid
|
|
334
|
+
request_id = str(uuid.uuid4())
|
|
335
|
+
|
|
336
|
+
# Build message
|
|
337
|
+
msg = node.create_message(
|
|
338
|
+
payload=body if body is not None else query_params,
|
|
339
|
+
topic=f"http/{method.lower()}{parsed.path}"
|
|
340
|
+
)
|
|
341
|
+
msg['req'] = {
|
|
342
|
+
'method': method,
|
|
343
|
+
'path': parsed.path,
|
|
344
|
+
'query': query_params,
|
|
345
|
+
'headers': headers,
|
|
346
|
+
'body': body,
|
|
347
|
+
'url': self.path,
|
|
348
|
+
'client_address': self.client_address[0]
|
|
349
|
+
}
|
|
350
|
+
msg['_request_id'] = request_id
|
|
351
|
+
|
|
352
|
+
node._request_count += 1
|
|
353
|
+
|
|
354
|
+
# Handle response based on mode
|
|
355
|
+
response_mode = node.config.get('responseMode', 'immediate')
|
|
356
|
+
|
|
357
|
+
if response_mode == 'wait':
|
|
358
|
+
# Set up event for waiting
|
|
359
|
+
event = threading.Event()
|
|
360
|
+
node._response_events[request_id] = event
|
|
361
|
+
|
|
362
|
+
# Send message through workflow
|
|
363
|
+
node.send(msg)
|
|
364
|
+
|
|
365
|
+
# Wait for response (with timeout)
|
|
366
|
+
if event.wait(timeout=30):
|
|
367
|
+
response_data = node._pending_responses.pop(request_id, {})
|
|
368
|
+
status = response_data.get('status', 200)
|
|
369
|
+
body_content = response_data.get('body', '')
|
|
370
|
+
extra_headers = response_data.get('headers', {})
|
|
371
|
+
|
|
372
|
+
self.send_response(status)
|
|
373
|
+
self._send_cors_headers()
|
|
374
|
+
for k, v in extra_headers.items():
|
|
375
|
+
self.send_header(k, v)
|
|
376
|
+
if 'Content-Type' not in extra_headers:
|
|
377
|
+
self.send_header('Content-Type', 'application/json')
|
|
378
|
+
self.end_headers()
|
|
379
|
+
|
|
380
|
+
if isinstance(body_content, (dict, list)):
|
|
381
|
+
self.wfile.write(json.dumps(body_content).encode('utf-8'))
|
|
382
|
+
else:
|
|
383
|
+
self.wfile.write(str(body_content).encode('utf-8'))
|
|
384
|
+
else:
|
|
385
|
+
# Timeout
|
|
386
|
+
self.send_response(504)
|
|
387
|
+
self._send_cors_headers()
|
|
388
|
+
self.send_header('Content-Type', 'application/json')
|
|
389
|
+
self.end_headers()
|
|
390
|
+
self.wfile.write(b'{"error": "Gateway Timeout"}')
|
|
391
|
+
|
|
392
|
+
# Cleanup
|
|
393
|
+
node._response_events.pop(request_id, None)
|
|
394
|
+
node._pending_responses.pop(request_id, None)
|
|
395
|
+
else:
|
|
396
|
+
# Immediate response
|
|
397
|
+
node.send(msg)
|
|
398
|
+
|
|
399
|
+
status = node.config.get('responseCode', 200)
|
|
400
|
+
body_str = node.config.get('responseBody', '{"status": "received"}')
|
|
401
|
+
|
|
402
|
+
self.send_response(status)
|
|
403
|
+
self._send_cors_headers()
|
|
404
|
+
self.send_header('Content-Type', 'application/json')
|
|
405
|
+
self.end_headers()
|
|
406
|
+
self.wfile.write(body_str.encode('utf-8'))
|
|
407
|
+
|
|
408
|
+
def do_OPTIONS(self):
|
|
409
|
+
self.send_response(200)
|
|
410
|
+
self._send_cors_headers()
|
|
411
|
+
self.end_headers()
|
|
412
|
+
|
|
413
|
+
def do_GET(self):
|
|
414
|
+
self._handle_request('GET')
|
|
415
|
+
|
|
416
|
+
def do_POST(self):
|
|
417
|
+
self._handle_request('POST')
|
|
418
|
+
|
|
419
|
+
def do_PUT(self):
|
|
420
|
+
self._handle_request('PUT')
|
|
421
|
+
|
|
422
|
+
def do_DELETE(self):
|
|
423
|
+
self._handle_request('DELETE')
|
|
424
|
+
|
|
425
|
+
def do_PATCH(self):
|
|
426
|
+
self._handle_request('PATCH')
|
|
427
|
+
|
|
428
|
+
return RESTRequestHandler
|
|
429
|
+
|
|
430
|
+
def _start_server(self):
|
|
431
|
+
"""Start the HTTP server."""
|
|
432
|
+
if self._running:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
port = int(self.config.get('port', 8080))
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
handler = self._create_handler()
|
|
439
|
+
self.server = HTTPServer(('0.0.0.0', port), handler)
|
|
440
|
+
self._running = True
|
|
441
|
+
|
|
442
|
+
self.server_thread = threading.Thread(
|
|
443
|
+
target=self._run_server,
|
|
444
|
+
daemon=True,
|
|
445
|
+
name=f"RESTEndpoint-{self.id}"
|
|
446
|
+
)
|
|
447
|
+
self.server_thread.start()
|
|
448
|
+
|
|
449
|
+
path = self.config.get('path', '/api/webhook')
|
|
450
|
+
logger.info(f"REST Endpoint [{self.name}] started on port {port} at {path}")
|
|
451
|
+
self.set_status('running', f"Listening on :{port}{path}")
|
|
452
|
+
|
|
453
|
+
except OSError as e:
|
|
454
|
+
logger.error(f"REST Endpoint [{self.name}] failed to start: {e}")
|
|
455
|
+
self.set_status('error', f"Port {port} in use")
|
|
456
|
+
self._running = False
|
|
457
|
+
|
|
458
|
+
def _run_server(self):
|
|
459
|
+
"""Server main loop."""
|
|
460
|
+
while self._running and self.server:
|
|
461
|
+
try:
|
|
462
|
+
self.server.handle_request()
|
|
463
|
+
except Exception as e:
|
|
464
|
+
if self._running:
|
|
465
|
+
logger.error(f"REST Endpoint [{self.name}] error: {e}")
|
|
466
|
+
|
|
467
|
+
def _stop_server(self):
|
|
468
|
+
"""Stop the HTTP server."""
|
|
469
|
+
self._running = False
|
|
470
|
+
|
|
471
|
+
if self.server:
|
|
472
|
+
try:
|
|
473
|
+
self.server.shutdown()
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
self.server = None
|
|
477
|
+
|
|
478
|
+
# Clear pending responses
|
|
479
|
+
for event in self._response_events.values():
|
|
480
|
+
event.set()
|
|
481
|
+
self._response_events.clear()
|
|
482
|
+
self._pending_responses.clear()
|
|
483
|
+
|
|
484
|
+
logger.info(f"REST Endpoint [{self.name}] stopped")
|
|
485
|
+
self.set_status('stopped', 'Server stopped')
|
|
486
|
+
|
|
487
|
+
def restart_server(self):
|
|
488
|
+
"""Restart the HTTP server."""
|
|
489
|
+
self._stop_server()
|
|
490
|
+
time.sleep(0.5)
|
|
491
|
+
self._start_server()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Range Node - scales/maps values from one range to another.
|
|
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("Maps numeric values from one range to another using linear interpolation. Useful for scaling sensor data, normalizing values, or converting between units.")
|
|
10
|
+
_info.add_header("Inputs")
|
|
11
|
+
_info.add_bullets(
|
|
12
|
+
("Input 0:", "Message with numeric payload to scale")
|
|
13
|
+
)
|
|
14
|
+
_info.add_header("Outputs")
|
|
15
|
+
_info.add_bullets(
|
|
16
|
+
("Output 0:", "Message with scaled payload value")
|
|
17
|
+
)
|
|
18
|
+
_info.add_header("Configuration")
|
|
19
|
+
_info.add_bullets(
|
|
20
|
+
("Input Min/Max:", "The expected range of input values"),
|
|
21
|
+
("Output Min/Max:", "The desired range of output values"),
|
|
22
|
+
("Clamp:", "If enabled, output is constrained to the output range")
|
|
23
|
+
)
|
|
24
|
+
_info.add_header("Example")
|
|
25
|
+
_info.add_text("Input range 0-100 to output range 0-1: A value of 50 becomes 0.5")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RangeNode(BaseNode):
|
|
29
|
+
"""
|
|
30
|
+
Range Node - maps numeric values from one range to another.
|
|
31
|
+
Similar to Node-RED's range node.
|
|
32
|
+
"""
|
|
33
|
+
info = str(_info)
|
|
34
|
+
display_name = 'Range'
|
|
35
|
+
icon = '📊'
|
|
36
|
+
category = 'function'
|
|
37
|
+
color = '#87CEEB'
|
|
38
|
+
border_color = '#4682B4'
|
|
39
|
+
text_color = '#000000'
|
|
40
|
+
input_count = 1
|
|
41
|
+
output_count = 1
|
|
42
|
+
|
|
43
|
+
properties = [
|
|
44
|
+
{
|
|
45
|
+
'name': 'min_in',
|
|
46
|
+
'label': 'Input Min',
|
|
47
|
+
'type': 'number',
|
|
48
|
+
'default': 0
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
'name': 'max_in',
|
|
52
|
+
'label': 'Input Max',
|
|
53
|
+
'type': 'number',
|
|
54
|
+
'default': 100
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
'name': 'min_out',
|
|
58
|
+
'label': 'Output Min',
|
|
59
|
+
'type': 'number',
|
|
60
|
+
'default': 0
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
'name': 'max_out',
|
|
64
|
+
'label': 'Output Max',
|
|
65
|
+
'type': 'number',
|
|
66
|
+
'default': 1
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
'name': 'clamp',
|
|
70
|
+
'label': 'Clamp to Output Range',
|
|
71
|
+
'type': 'checkbox',
|
|
72
|
+
'default': True
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def __init__(self, node_id=None, name="range"):
|
|
77
|
+
super().__init__(node_id, name)
|
|
78
|
+
self.configure({
|
|
79
|
+
'min_in': 0,
|
|
80
|
+
'max_in': 100,
|
|
81
|
+
'min_out': 0,
|
|
82
|
+
'max_out': 1,
|
|
83
|
+
'clamp': True,
|
|
84
|
+
'drop_messages': False
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
88
|
+
"""Map payload value from input range to output range."""
|
|
89
|
+
try:
|
|
90
|
+
payload = msg.get(MessageKeys.PAYLOAD)
|
|
91
|
+
value = float(payload)
|
|
92
|
+
|
|
93
|
+
min_in = self.get_config_float('min_in', 0)
|
|
94
|
+
max_in = self.get_config_float('max_in', 100)
|
|
95
|
+
min_out = self.get_config_float('min_out', 0)
|
|
96
|
+
max_out = self.get_config_float('max_out', 1)
|
|
97
|
+
clamp = self.get_config_bool('clamp', True)
|
|
98
|
+
|
|
99
|
+
# Map value
|
|
100
|
+
if max_in == min_in:
|
|
101
|
+
mapped = min_out
|
|
102
|
+
else:
|
|
103
|
+
mapped = ((value - min_in) / (max_in - min_in)) * (max_out - min_out) + min_out
|
|
104
|
+
|
|
105
|
+
# Clamp if enabled
|
|
106
|
+
if clamp:
|
|
107
|
+
mapped = max(min(mapped, max(min_out, max_out)), min(min_out, max_out))
|
|
108
|
+
|
|
109
|
+
# Preserve original message properties (like frame_count)
|
|
110
|
+
# Note: send() handles deep copying, so we modify msg directly
|
|
111
|
+
msg[MessageKeys.PAYLOAD] = mapped
|
|
112
|
+
self.send(msg)
|
|
113
|
+
|
|
114
|
+
except (ValueError, TypeError) as e:
|
|
115
|
+
self.report_error(f"Invalid numeric value: {e}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|