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,399 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
import threading
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Try to import zeroconf for mDNS support
|
|
9
|
+
try:
|
|
10
|
+
from zeroconf import ServiceListener as ZeroconfServiceListener
|
|
11
|
+
MDNS_AVAILABLE = True
|
|
12
|
+
# Use the real ServiceListener from zeroconf
|
|
13
|
+
ServiceListenerBase = ZeroconfServiceListener
|
|
14
|
+
except ImportError:
|
|
15
|
+
MDNS_AVAILABLE = False
|
|
16
|
+
# Create dummy base class if zeroconf not available
|
|
17
|
+
class ServiceListenerBase: # type: ignore
|
|
18
|
+
"""Dummy ServiceListener base class when zeroconf is not available"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MDNSServiceListener(ServiceListenerBase): # type: ignore
|
|
23
|
+
"""Listener for mDNS service discovery events"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, discovery_manager=None, standalone_mode=False):
|
|
26
|
+
self.discovery_manager = discovery_manager
|
|
27
|
+
self.standalone_mode = standalone_mode
|
|
28
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
29
|
+
self.discovered_services = {} # For standalone mode
|
|
30
|
+
|
|
31
|
+
def add_service(self, zc, service_type: str, name: str) -> None:
|
|
32
|
+
"""Called when a service is discovered"""
|
|
33
|
+
if not MDNS_AVAILABLE:
|
|
34
|
+
return
|
|
35
|
+
info = zc.get_service_info(service_type, name)
|
|
36
|
+
|
|
37
|
+
if self.standalone_mode and info:
|
|
38
|
+
# Standalone mode - display service info
|
|
39
|
+
self.discovered_services[name] = info
|
|
40
|
+
self._display_service_info(name, info, "DISCOVERED")
|
|
41
|
+
elif self.discovery_manager and info:
|
|
42
|
+
# Managed mode - notify discovery manager
|
|
43
|
+
# (Existing discovery manager integration would go here)
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def update_service(self, zc, service_type: str, name: str) -> None:
|
|
47
|
+
"""Called when a service is updated"""
|
|
48
|
+
if not MDNS_AVAILABLE:
|
|
49
|
+
return
|
|
50
|
+
info = zc.get_service_info(service_type, name)
|
|
51
|
+
|
|
52
|
+
if self.standalone_mode and info:
|
|
53
|
+
self.discovered_services[name] = info
|
|
54
|
+
self._display_service_info(name, info, "UPDATED")
|
|
55
|
+
|
|
56
|
+
def remove_service(self, zc, service_type: str, name: str) -> None:
|
|
57
|
+
"""Called when a service is removed"""
|
|
58
|
+
if self.standalone_mode:
|
|
59
|
+
if name in self.discovered_services:
|
|
60
|
+
del self.discovered_services[name]
|
|
61
|
+
self.logger.info(f"[REMOVED] Service: {name}")
|
|
62
|
+
elif self.discovery_manager:
|
|
63
|
+
# Extract node_id from service name
|
|
64
|
+
node_id = name.split('.')[0] if '.' in name else name
|
|
65
|
+
if node_id in self.discovery_manager.discovered_nodes:
|
|
66
|
+
node_name = self.discovery_manager.discovered_nodes[node_id].node_name
|
|
67
|
+
self.discovery_manager.discovered_nodes[node_id].mark_offline()
|
|
68
|
+
self.logger.info(f"mDNS service removed: {node_name} ({node_id})")
|
|
69
|
+
|
|
70
|
+
def _display_service_info(self, name: str, info, status: str):
|
|
71
|
+
"""Display service information in standalone mode"""
|
|
72
|
+
self.logger.info(f"\n[{status}] Service: {name}")
|
|
73
|
+
|
|
74
|
+
# Display addresses
|
|
75
|
+
if info.addresses:
|
|
76
|
+
addresses = [socket.inet_ntoa(addr) for addr in info.addresses]
|
|
77
|
+
self.logger.info(f" Address: {', '.join(addresses)}")
|
|
78
|
+
|
|
79
|
+
# Display port
|
|
80
|
+
self.logger.info(f" Port: {info.port}")
|
|
81
|
+
|
|
82
|
+
# Display server
|
|
83
|
+
if info.server:
|
|
84
|
+
self.logger.info(f" Server: {info.server}")
|
|
85
|
+
|
|
86
|
+
# Display properties
|
|
87
|
+
if info.properties:
|
|
88
|
+
self.logger.info(" Properties:")
|
|
89
|
+
for key, value in info.properties.items():
|
|
90
|
+
# Try to decode bytes to string
|
|
91
|
+
try:
|
|
92
|
+
if isinstance(value, bytes):
|
|
93
|
+
decoded_value = value.decode('utf-8')
|
|
94
|
+
# Try to parse as JSON for better display
|
|
95
|
+
try:
|
|
96
|
+
parsed = json.loads(decoded_value)
|
|
97
|
+
self.logger.info(f" {key}: {json.dumps(parsed, indent=6)}")
|
|
98
|
+
except Exception:
|
|
99
|
+
self.logger.info(f" {key}: {decoded_value}")
|
|
100
|
+
else:
|
|
101
|
+
self.logger.info(f" {key}: {value}")
|
|
102
|
+
except Exception:
|
|
103
|
+
self.logger.info(f" {key}: {value}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MDNSBroadcaster:
|
|
107
|
+
"""Handles mDNS service broadcasting for node discovery"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, node_id: str, node_info: Dict[str, Any], service_port: int, service_type: str = "_http._tcp.local."):
|
|
110
|
+
"""
|
|
111
|
+
Initialize mDNS broadcaster
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
node_id: Unique identifier for this node
|
|
115
|
+
node_info: Dictionary containing node information
|
|
116
|
+
service_port: Port number where the service is running
|
|
117
|
+
service_type: mDNS service type (default: "_http._tcp.local.")
|
|
118
|
+
"""
|
|
119
|
+
self.node_id = node_id
|
|
120
|
+
self.node_info = node_info
|
|
121
|
+
self.service_port = service_port
|
|
122
|
+
self.service_type = service_type
|
|
123
|
+
self.zeroconf = None
|
|
124
|
+
self.service_info = None
|
|
125
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
126
|
+
self.running = False
|
|
127
|
+
|
|
128
|
+
if not MDNS_AVAILABLE:
|
|
129
|
+
self.logger.warning("zeroconf package not available. mDNS broadcasting disabled.")
|
|
130
|
+
self.logger.info("Install with: pip install zeroconf")
|
|
131
|
+
|
|
132
|
+
def start(self):
|
|
133
|
+
"""Start mDNS service broadcasting"""
|
|
134
|
+
if not MDNS_AVAILABLE:
|
|
135
|
+
self.logger.warning("Cannot start mDNS: zeroconf package not installed")
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
if self.running:
|
|
139
|
+
self.logger.warning("mDNS broadcaster already running")
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
from zeroconf import Zeroconf, ServiceInfo
|
|
144
|
+
|
|
145
|
+
self.zeroconf = Zeroconf()
|
|
146
|
+
|
|
147
|
+
service_name = f"{self.node_id}.{self.service_type}"
|
|
148
|
+
|
|
149
|
+
# Get local IP address
|
|
150
|
+
# Priority: 1. HOST_IP env var (for Docker), 2. Network detection, 3. Hostname resolution
|
|
151
|
+
import os
|
|
152
|
+
local_ip = os.environ.get('HOST_IP')
|
|
153
|
+
|
|
154
|
+
if local_ip:
|
|
155
|
+
self.logger.info(f"Using HOST_IP from environment: {local_ip}")
|
|
156
|
+
else:
|
|
157
|
+
try:
|
|
158
|
+
# Connect to external address to determine local network IP
|
|
159
|
+
temp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
160
|
+
temp_sock.connect(("8.8.8.8", 80))
|
|
161
|
+
local_ip = temp_sock.getsockname()[0]
|
|
162
|
+
temp_sock.close()
|
|
163
|
+
except Exception as e:
|
|
164
|
+
# Fallback to hostname resolution if the above fails
|
|
165
|
+
self.logger.warning(f"Failed to get network IP via connection test: {e}")
|
|
166
|
+
hostname = socket.gethostname()
|
|
167
|
+
local_ip = socket.gethostbyname(hostname)
|
|
168
|
+
|
|
169
|
+
hostname = socket.gethostname()
|
|
170
|
+
|
|
171
|
+
# Prepare service properties from node_info
|
|
172
|
+
# Convert all values to strings and handle complex types as JSON
|
|
173
|
+
properties = {}
|
|
174
|
+
for key, value in self.node_info.items():
|
|
175
|
+
if isinstance(value, (dict, list)):
|
|
176
|
+
# Serialize complex types as JSON
|
|
177
|
+
properties[key] = json.dumps(value)
|
|
178
|
+
else:
|
|
179
|
+
# Convert simple types to strings
|
|
180
|
+
properties[key] = str(value)
|
|
181
|
+
|
|
182
|
+
# Create service info
|
|
183
|
+
self.service_info = ServiceInfo(
|
|
184
|
+
self.service_type,
|
|
185
|
+
service_name,
|
|
186
|
+
addresses=[socket.inet_aton(local_ip)],
|
|
187
|
+
port=self.service_port,
|
|
188
|
+
properties=properties,
|
|
189
|
+
server=f"{hostname}.local."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Register service
|
|
193
|
+
self.zeroconf.register_service(self.service_info)
|
|
194
|
+
self.running = True
|
|
195
|
+
|
|
196
|
+
self.logger.info(f"mDNS service registered: {service_name} on {local_ip}:{self.service_port}")
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
self.logger.error(f"Failed to start mDNS broadcaster: {str(e)}")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def stop(self):
|
|
204
|
+
"""Stop mDNS service broadcasting"""
|
|
205
|
+
if not self.running:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if self.zeroconf and self.service_info:
|
|
210
|
+
self.zeroconf.unregister_service(self.service_info)
|
|
211
|
+
self.zeroconf.close()
|
|
212
|
+
|
|
213
|
+
self.running = False
|
|
214
|
+
self.logger.info("mDNS service unregistered")
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
self.logger.error(f"Error stopping mDNS broadcaster: {str(e)}")
|
|
218
|
+
|
|
219
|
+
def update_info(self, node_info: Dict[str, Any]):
|
|
220
|
+
"""Update node information and re-register service"""
|
|
221
|
+
self.node_info = node_info
|
|
222
|
+
if self.running:
|
|
223
|
+
self.stop()
|
|
224
|
+
self.start()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
"""Standalone mDNS broadcaster for testing"""
|
|
230
|
+
import argparse
|
|
231
|
+
import sys
|
|
232
|
+
|
|
233
|
+
class KeyValueAction(argparse.Action):
|
|
234
|
+
"""Custom action to parse key=value pairs"""
|
|
235
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
236
|
+
if not hasattr(namespace, 'properties'):
|
|
237
|
+
namespace.properties = {}
|
|
238
|
+
for value in values:
|
|
239
|
+
if '=' not in value:
|
|
240
|
+
raise argparse.ArgumentTypeError(f"Invalid format: '{value}'. Expected key=value")
|
|
241
|
+
key, val = value.split('=', 1)
|
|
242
|
+
# Try to parse value as JSON for lists/dicts/booleans/numbers
|
|
243
|
+
try:
|
|
244
|
+
namespace.properties[key] = json.loads(val)
|
|
245
|
+
except json.JSONDecodeError:
|
|
246
|
+
# If not valid JSON, treat as string
|
|
247
|
+
namespace.properties[key] = val
|
|
248
|
+
|
|
249
|
+
parser = argparse.ArgumentParser(
|
|
250
|
+
description='mDNS service broadcaster and listener for device discovery',
|
|
251
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
252
|
+
epilog='''
|
|
253
|
+
Examples:
|
|
254
|
+
# Listen mode - discover services on the network
|
|
255
|
+
python mdns_manager.py --listen
|
|
256
|
+
python mdns_manager.py --listen --service-type _http._tcp.local.
|
|
257
|
+
|
|
258
|
+
# Broadcast mode - advertise this service
|
|
259
|
+
python mdns_manager.py --node-id my-node --node-name "My Node" --port 5000
|
|
260
|
+
|
|
261
|
+
# Add custom properties with key=value pairs
|
|
262
|
+
python mdns_manager.py --node-id prod-01 --node-name "Production" --port 5555 \\
|
|
263
|
+
--property platform=Windows cpu_count=8 memory_gb=16
|
|
264
|
+
|
|
265
|
+
# Properties support JSON values for complex types
|
|
266
|
+
python mdns_manager.py --node-id gpu-node --node-name "GPU Node" \\
|
|
267
|
+
--property available_engines='["ultralytics","onnx"]' \\
|
|
268
|
+
gpu='{"available":true,"type":"NVIDIA","count":2}'
|
|
269
|
+
|
|
270
|
+
# Mix of simple and complex properties
|
|
271
|
+
python mdns_manager.py --node-id test --node-name "Test" \\
|
|
272
|
+
--property platform=Linux cpu_count=4 version=1.2.3 \\
|
|
273
|
+
tags='["production","inference"]'
|
|
274
|
+
'''
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Arguments (required only for broadcast mode)
|
|
278
|
+
parser.add_argument('--node-id',
|
|
279
|
+
help='Unique identifier for this node (required for broadcast)')
|
|
280
|
+
parser.add_argument('--node-name',
|
|
281
|
+
help='Human-readable name for this node (required for broadcast)')
|
|
282
|
+
parser.add_argument('--port',
|
|
283
|
+
type=int,
|
|
284
|
+
help='Service port number (required for broadcast)')
|
|
285
|
+
parser.add_argument('--service-type',
|
|
286
|
+
type=str,
|
|
287
|
+
default='_http._tcp.local.',
|
|
288
|
+
help='mDNS service type (default: _http._tcp.local.)')
|
|
289
|
+
|
|
290
|
+
# Generic property arguments
|
|
291
|
+
parser.add_argument('--property',
|
|
292
|
+
nargs='+',
|
|
293
|
+
action=KeyValueAction,
|
|
294
|
+
metavar='KEY=VALUE',
|
|
295
|
+
help='Additional properties as key=value pairs (supports JSON values)')
|
|
296
|
+
|
|
297
|
+
# Debug options
|
|
298
|
+
parser.add_argument('--debug',
|
|
299
|
+
action='store_true',
|
|
300
|
+
help='Enable debug logging')
|
|
301
|
+
parser.add_argument('--quiet',
|
|
302
|
+
action='store_true',
|
|
303
|
+
help='Suppress all but error messages')
|
|
304
|
+
|
|
305
|
+
# Listen mode
|
|
306
|
+
parser.add_argument('--listen',
|
|
307
|
+
action='store_true',
|
|
308
|
+
help='Listen for mDNS services instead of broadcasting')
|
|
309
|
+
|
|
310
|
+
args = parser.parse_args()
|
|
311
|
+
|
|
312
|
+
# Validate arguments - in broadcast mode, node-id, node-name, and port are required
|
|
313
|
+
if not args.listen:
|
|
314
|
+
if not args.node_id or not args.node_name or not args.port:
|
|
315
|
+
parser.error("--node-id, --node-name, and --port are required when not in listen mode")
|
|
316
|
+
|
|
317
|
+
# Configure logging
|
|
318
|
+
if args.debug:
|
|
319
|
+
log_level = logging.DEBUG
|
|
320
|
+
elif args.quiet:
|
|
321
|
+
log_level = logging.ERROR
|
|
322
|
+
else:
|
|
323
|
+
log_level = logging.INFO
|
|
324
|
+
|
|
325
|
+
logging.basicConfig(
|
|
326
|
+
level=log_level,
|
|
327
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
logger = logging.getLogger(__name__)
|
|
331
|
+
|
|
332
|
+
# Check if zeroconf is available
|
|
333
|
+
if not MDNS_AVAILABLE:
|
|
334
|
+
logger.error("zeroconf package not installed. Install with: pip install zeroconf")
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
|
|
337
|
+
# Listen mode
|
|
338
|
+
if args.listen:
|
|
339
|
+
from zeroconf import Zeroconf, ServiceBrowser
|
|
340
|
+
|
|
341
|
+
logger.info("Starting mDNS listener...")
|
|
342
|
+
logger.info(f"Listening for service type: {args.service_type}")
|
|
343
|
+
logger.info("Press Ctrl+C to stop\n")
|
|
344
|
+
|
|
345
|
+
zeroconf = Zeroconf()
|
|
346
|
+
listener = MDNSServiceListener(standalone_mode=True)
|
|
347
|
+
browser = ServiceBrowser(zeroconf, args.service_type, listener)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
# Keep running until interrupted
|
|
351
|
+
while True:
|
|
352
|
+
import time
|
|
353
|
+
time.sleep(1)
|
|
354
|
+
except KeyboardInterrupt:
|
|
355
|
+
logger.info("\n\nReceived interrupt signal")
|
|
356
|
+
finally:
|
|
357
|
+
logger.info("Stopping mDNS listener...")
|
|
358
|
+
zeroconf.close()
|
|
359
|
+
logger.info("mDNS listener stopped")
|
|
360
|
+
|
|
361
|
+
sys.exit(0)
|
|
362
|
+
|
|
363
|
+
# Broadcast mode (default)
|
|
364
|
+
# Build node info dictionary from properties
|
|
365
|
+
node_info = {
|
|
366
|
+
'node_name': args.node_name,
|
|
367
|
+
'api_port': args.port
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Add any additional properties provided via --property
|
|
371
|
+
if hasattr(args, 'properties') and args.properties:
|
|
372
|
+
node_info.update(args.properties)
|
|
373
|
+
|
|
374
|
+
logger.info(f"Starting mDNS broadcaster for node: {args.node_name} ({args.node_id})")
|
|
375
|
+
logger.info(f"Service port: {args.port}")
|
|
376
|
+
|
|
377
|
+
# Log all properties
|
|
378
|
+
if hasattr(args, 'properties') and args.properties:
|
|
379
|
+
logger.info("Node properties:")
|
|
380
|
+
for key, value in args.properties.items():
|
|
381
|
+
logger.info(f" {key}: {value}")
|
|
382
|
+
|
|
383
|
+
# Create and start broadcaster
|
|
384
|
+
mdns_broadcaster = MDNSBroadcaster(args.node_id, node_info, args.port, args.service_type)
|
|
385
|
+
|
|
386
|
+
if mdns_broadcaster.start():
|
|
387
|
+
logger.info("mDNS broadcaster started successfully")
|
|
388
|
+
logger.info("Service will be discoverable on the local network")
|
|
389
|
+
try:
|
|
390
|
+
input("\nPress Enter to stop the broadcaster...\n")
|
|
391
|
+
except KeyboardInterrupt:
|
|
392
|
+
logger.info("\nReceived interrupt signal")
|
|
393
|
+
finally:
|
|
394
|
+
logger.info("Stopping mDNS broadcaster...")
|
|
395
|
+
mdns_broadcaster.stop()
|
|
396
|
+
logger.info("mDNS broadcaster stopped")
|
|
397
|
+
else:
|
|
398
|
+
logger.error("Failed to start mDNS broadcaster")
|
|
399
|
+
sys.exit(1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zeroconf>=0.38.0
|
pynode/nodes/messages.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Message key definitions and message helpers shared by all nodes.
|
|
2
|
+
|
|
3
|
+
Extracted from ``base_node.py``; ``pynode.nodes.base_node`` re-exports
|
|
4
|
+
``MessageKeys`` and ``sort_msg_keys`` so existing imports keep working.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sort_msg_keys(msg: Dict[str, Any]) -> Dict[str, Any]:
|
|
12
|
+
"""
|
|
13
|
+
Sort message dict with underscore keys first, then alphabetically.
|
|
14
|
+
Useful for displaying debug messages with metadata fields first.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
msg: Message dictionary to sort
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
New dictionary with sorted keys
|
|
21
|
+
"""
|
|
22
|
+
return dict(sorted(msg.items(), key=lambda x: (not x[0].startswith('_'), x[0])))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#Message key definitions to standardize message strings across all nodes
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class MessageKeys:
|
|
28
|
+
|
|
29
|
+
# Image-specific keys
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class IMAGE:
|
|
32
|
+
PATH: str = 'image'
|
|
33
|
+
FORMAT: str = 'format'
|
|
34
|
+
ENCODING: str = 'encoding'
|
|
35
|
+
DATA: str = 'data'
|
|
36
|
+
WIDTH: str = 'width'
|
|
37
|
+
HEIGHT: str = 'height'
|
|
38
|
+
JPEG_QUALITY: str = 'jpeg_quality'
|
|
39
|
+
ENCODE_JPEG: str = 'encode_jpeg'
|
|
40
|
+
|
|
41
|
+
# Camera-specific keys
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CAMERA:
|
|
44
|
+
DEVICE_INDEX: str = 'device_index'
|
|
45
|
+
SOURCE: str = 'source'
|
|
46
|
+
SOURCE_TYPE: str = 'source_type'
|
|
47
|
+
FPS: str = 'fps'
|
|
48
|
+
WIDTH: str = 'width'
|
|
49
|
+
HEIGHT: str = 'height'
|
|
50
|
+
JPEG_QUALITY: str = 'jpeg_quality'
|
|
51
|
+
ENCODE_JPEG: str = 'encode_jpeg'
|
|
52
|
+
|
|
53
|
+
class VIDEO:
|
|
54
|
+
LOOP: str = 'loop'
|
|
55
|
+
SOURCE: str = 'source'
|
|
56
|
+
|
|
57
|
+
class CV:
|
|
58
|
+
BBOX: str = 'bbox'
|
|
59
|
+
BBOX_FORMAT: str = 'bbox_format'
|
|
60
|
+
CONFIDENCE: str = 'confidence'
|
|
61
|
+
CLASS_ID: str = 'class_id'
|
|
62
|
+
CLASS_NAME: str = 'class_name'
|
|
63
|
+
DETECTIONS: str = 'detections'
|
|
64
|
+
DETECTION_COUNT: str = 'detection_count'
|
|
65
|
+
TRACK_ID: str = 'track_id'
|
|
66
|
+
MASK: str = 'mask'
|
|
67
|
+
SEGMENTATION: str = 'segmentation'
|
|
68
|
+
THRESHOLD: str = 'threshold'
|
|
69
|
+
|
|
70
|
+
# Message-level keys
|
|
71
|
+
MSG = 'msg'
|
|
72
|
+
MSG_ID: str = '_msgid'
|
|
73
|
+
ORIGINAL_MSG: str = '_original_msg'
|
|
74
|
+
TIMESTAMP_ORIG: str = '_timestamp_orig'
|
|
75
|
+
TIMESTAMP_EMIT: str = '_timestamp_emit'
|
|
76
|
+
AGE: str = '_age'
|
|
77
|
+
DROP_COUNT: str = 'drop_count'
|
|
78
|
+
DROP_MESSAGES: str = 'drop_messages'
|
|
79
|
+
PAYLOAD: str = 'payload'
|
|
80
|
+
TOPIC: str = 'topic'
|
|
81
|
+
QUEUE_LENGTH: str = '_queue_length'
|
pynode/server.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask app factory for the Node-RED-like system.
|
|
3
|
+
|
|
4
|
+
``create_app()`` builds a Flask app with its own ``WorkflowManager``
|
|
5
|
+
(``app.extensions['workflow_manager']``), CORS, optional API-key auth, JSON
|
|
6
|
+
error handlers and all API blueprints (see ``pynode.api``). A module-level
|
|
7
|
+
default instance (``app``) is kept for backwards compatibility so
|
|
8
|
+
``from pynode.server import app`` keeps working (pynode.main, user code).
|
|
9
|
+
|
|
10
|
+
Route implementations live in the ``pynode.api`` blueprints; the mutable
|
|
11
|
+
workflow state lives in ``pynode.workflow_manager``; static node-type
|
|
12
|
+
registries live in ``pynode.node_registry``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hmac
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from flask import Flask, request, send_from_directory
|
|
20
|
+
from flask_cors import CORS
|
|
21
|
+
|
|
22
|
+
from pynode.config import resolve_workflows_dir
|
|
23
|
+
from pynode.api import register_blueprints
|
|
24
|
+
from pynode.api.helpers import _json_error
|
|
25
|
+
from pynode.api.uploads import ALLOWED_UPLOAD_SUBDIRS # noqa: F401 (re-export)
|
|
26
|
+
from pynode.workflow_manager import BASE_DIR, MAX_BACKUPS, PKG_DIR, WorkflowManager
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_cors_origins(value):
|
|
32
|
+
"""Parse a comma-separated origins string into a flask-cors ``origins`` arg.
|
|
33
|
+
|
|
34
|
+
``None``, empty/blank strings, or any list containing ``*`` mean "all
|
|
35
|
+
origins" (today's default behavior). Otherwise returns the list of
|
|
36
|
+
origins with whitespace stripped.
|
|
37
|
+
"""
|
|
38
|
+
if value is None:
|
|
39
|
+
return '*'
|
|
40
|
+
origins = [o.strip() for o in value.split(',') if o.strip()]
|
|
41
|
+
if not origins or '*' in origins:
|
|
42
|
+
return '*'
|
|
43
|
+
return origins
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_app(config=None):
|
|
47
|
+
"""Create and configure a PyNode Flask app.
|
|
48
|
+
|
|
49
|
+
``config`` may contain:
|
|
50
|
+
|
|
51
|
+
- ``CORS_ORIGINS``: comma-separated allowed origins (default: the
|
|
52
|
+
PYNODE_CORS_ORIGINS env var, else ``*``).
|
|
53
|
+
- ``PYNODE_API_KEY``: API key required on /api/ requests (default: the
|
|
54
|
+
PYNODE_API_KEY env var, else '' = auth disabled).
|
|
55
|
+
- ``DATA_DIR``: PyNode data directory; workflows are persisted under
|
|
56
|
+
``<DATA_DIR>/workflows/`` (default: ``pynode.config.resolve_data_dir()``,
|
|
57
|
+
which honors the PYNODE_DATA_DIR env var).
|
|
58
|
+
- ``WORKFLOWS_DIR`` / ``WORKFLOW_FILE`` / ``UPLOAD_BASE_DIR``: persistence
|
|
59
|
+
and upload paths for this app's WorkflowManager (override ``DATA_DIR``).
|
|
60
|
+
|
|
61
|
+
Any remaining keys are applied to ``app.config`` (e.g. ``TESTING``).
|
|
62
|
+
"""
|
|
63
|
+
cfg = dict(config or {})
|
|
64
|
+
|
|
65
|
+
app = Flask(__name__, static_folder=os.path.join(PKG_DIR, 'static'),
|
|
66
|
+
static_url_path='')
|
|
67
|
+
|
|
68
|
+
# Enable CORS for the frontend. Origins are configurable via config or
|
|
69
|
+
# the PYNODE_CORS_ORIGINS env var (comma-separated); default is open
|
|
70
|
+
# ('*'), matching the original CORS(app) behavior.
|
|
71
|
+
cors_origins = cfg.pop('CORS_ORIGINS', os.environ.get('PYNODE_CORS_ORIGINS'))
|
|
72
|
+
CORS(app, origins=_parse_cors_origins(cors_origins))
|
|
73
|
+
|
|
74
|
+
# Optional API key authentication (see _require_api_key below).
|
|
75
|
+
app.config['PYNODE_API_KEY'] = cfg.pop(
|
|
76
|
+
'PYNODE_API_KEY', os.environ.get('PYNODE_API_KEY', ''))
|
|
77
|
+
|
|
78
|
+
# Per-app workflow state / persistence / SSE broadcast owner. Explicit
|
|
79
|
+
# WORKFLOWS_DIR wins; otherwise the workflows dir is derived from
|
|
80
|
+
# DATA_DIR (or, when neither is given, from pynode.config resolution:
|
|
81
|
+
# PYNODE_DATA_DIR env var > source checkout root > ~/.pynode).
|
|
82
|
+
data_dir = cfg.pop('DATA_DIR', None)
|
|
83
|
+
workflows_dir = cfg.pop('WORKFLOWS_DIR', None)
|
|
84
|
+
if workflows_dir is None:
|
|
85
|
+
workflows_dir = resolve_workflows_dir(cli_data_dir=data_dir)
|
|
86
|
+
manager = WorkflowManager(
|
|
87
|
+
workflows_dir=workflows_dir,
|
|
88
|
+
workflow_file=cfg.pop('WORKFLOW_FILE', None),
|
|
89
|
+
upload_base_dir=cfg.pop('UPLOAD_BASE_DIR', None),
|
|
90
|
+
)
|
|
91
|
+
app.extensions['workflow_manager'] = manager
|
|
92
|
+
logger.info(f"Workflow data directory: {manager.workflows_dir}")
|
|
93
|
+
|
|
94
|
+
# Remaining config keys (e.g. TESTING) go straight to app.config.
|
|
95
|
+
app.config.update(cfg)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Optional API key authentication.
|
|
99
|
+
# When app.config['PYNODE_API_KEY'] is a non-empty string, every /api/
|
|
100
|
+
# request must present the key via the X-API-Key header or the api_key
|
|
101
|
+
# query parameter (the latter exists for EventSource, which cannot set
|
|
102
|
+
# headers). Static assets and the index page stay open so the UI can load
|
|
103
|
+
# and prompt the user for the key. Empty/unset key = auth disabled
|
|
104
|
+
# (default behavior).
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
@app.before_request
|
|
108
|
+
def _require_api_key():
|
|
109
|
+
# Read dynamically from app.config (not captured at creation) so tests
|
|
110
|
+
# and pynode.main can set/clear the key at runtime.
|
|
111
|
+
key = app.config.get('PYNODE_API_KEY') or ''
|
|
112
|
+
if not key:
|
|
113
|
+
return None # Auth disabled
|
|
114
|
+
if request.method == 'OPTIONS':
|
|
115
|
+
return None # CORS preflight requests cannot carry custom headers
|
|
116
|
+
if not request.path.startswith('/api/'):
|
|
117
|
+
return None # Static assets / index stay open
|
|
118
|
+
provided = request.headers.get('X-API-Key') or request.args.get('api_key') or ''
|
|
119
|
+
if not hmac.compare_digest(provided, key):
|
|
120
|
+
return _json_error('Invalid or missing API key', 401)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# JSON error envelopes: API clients always get JSON, never Flask's HTML
|
|
125
|
+
# error pages. Error contract: {'success': False, 'error': str}.
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@app.errorhandler(400)
|
|
129
|
+
def _handle_400(e):
|
|
130
|
+
return _json_error(getattr(e, 'description', None) or 'Bad request', 400)
|
|
131
|
+
|
|
132
|
+
@app.errorhandler(404)
|
|
133
|
+
def _handle_404(e):
|
|
134
|
+
return _json_error('Not found', 404)
|
|
135
|
+
|
|
136
|
+
@app.errorhandler(405)
|
|
137
|
+
def _handle_405(e):
|
|
138
|
+
return _json_error('Method not allowed', 405)
|
|
139
|
+
|
|
140
|
+
@app.errorhandler(500)
|
|
141
|
+
def _handle_500(e):
|
|
142
|
+
return _json_error('Internal server error', 500)
|
|
143
|
+
|
|
144
|
+
@app.route('/')
|
|
145
|
+
def index():
|
|
146
|
+
"""Serve the main UI."""
|
|
147
|
+
return send_from_directory(os.path.join(PKG_DIR, 'static'), 'index.html')
|
|
148
|
+
|
|
149
|
+
register_blueprints(app)
|
|
150
|
+
|
|
151
|
+
return app
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ----------------------------------------------------------------------
|
|
155
|
+
# Backwards-compatible module-level default instance.
|
|
156
|
+
# ``from pynode.server import app`` (pynode.main, user code) keeps working;
|
|
157
|
+
# the module-level functions below operate on this default app's manager.
|
|
158
|
+
# ----------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
app = create_app()
|
|
161
|
+
|
|
162
|
+
_default_manager = app.extensions['workflow_manager']
|
|
163
|
+
|
|
164
|
+
# Informational re-exports describing the default instance's paths.
|
|
165
|
+
WORKFLOWS_DIR = _default_manager.workflows_dir
|
|
166
|
+
WORKFLOW_FILE = _default_manager.workflow_file
|
|
167
|
+
UPLOAD_BASE_DIR = _default_manager.upload_base_dir
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def save_workflow_to_disk():
|
|
171
|
+
"""Save the default app's workflows to disk (BC wrapper)."""
|
|
172
|
+
_default_manager.save_workflow_to_disk()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_workflow_from_disk():
|
|
176
|
+
"""Load workflows from disk into the default app (BC wrapper)."""
|
|
177
|
+
_default_manager.load_workflow_from_disk()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def start_debug_broadcast():
|
|
181
|
+
"""Start the default app's SSE debug broadcast worker (BC wrapper)."""
|
|
182
|
+
_default_manager.start_debug_broadcast()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def stop_debug_broadcast():
|
|
186
|
+
"""Stop the default app's SSE debug broadcast worker (BC wrapper)."""
|
|
187
|
+
_default_manager.stop_debug_broadcast()
|
|
Binary file
|