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.
Files changed (227) hide show
  1. pynode/__init__.py +37 -0
  2. pynode/__main__.py +9 -0
  3. pynode/_perf.py +65 -0
  4. pynode/_version.py +24 -0
  5. pynode/api/__init__.py +15 -0
  6. pynode/api/helpers.py +79 -0
  7. pynode/api/nodes.py +423 -0
  8. pynode/api/services.py +124 -0
  9. pynode/api/sse.py +58 -0
  10. pynode/api/uploads.py +59 -0
  11. pynode/api/workflows.py +466 -0
  12. pynode/config.py +93 -0
  13. pynode/install_nodes.py +143 -0
  14. pynode/main.py +118 -0
  15. pynode/node_registry.py +156 -0
  16. pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
  17. pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
  18. pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
  19. pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
  20. pynode/nodes/BatchNode/__init__.py +3 -0
  21. pynode/nodes/BatchNode/batch_node.py +86 -0
  22. pynode/nodes/BatchNode/requirements.txt +1 -0
  23. pynode/nodes/CameraNode/__init__.py +4 -0
  24. pynode/nodes/CameraNode/camera_node.py +204 -0
  25. pynode/nodes/CameraNode/requirements.txt +2 -0
  26. pynode/nodes/ChangeNode/__init__.py +1 -0
  27. pynode/nodes/ChangeNode/change_node.py +255 -0
  28. pynode/nodes/ChangeNode/requirements.txt +1 -0
  29. pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
  30. pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
  31. pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
  32. pynode/nodes/CounterNode/__init__.py +3 -0
  33. pynode/nodes/CounterNode/counter_node.py +191 -0
  34. pynode/nodes/CropNode/__init__.py +4 -0
  35. pynode/nodes/CropNode/crop_node.py +317 -0
  36. pynode/nodes/CropNode/requirements.txt +2 -0
  37. pynode/nodes/DebugNode/__init__.py +4 -0
  38. pynode/nodes/DebugNode/debug_node.py +169 -0
  39. pynode/nodes/DebugNode/requirements.txt +1 -0
  40. pynode/nodes/DelayNode/delay_node.py +218 -0
  41. pynode/nodes/DelayNode/requirements.txt +1 -0
  42. pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
  43. pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
  44. pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
  45. pynode/nodes/ErrorNode/error_node.py +94 -0
  46. pynode/nodes/ErrorNode/requirements.txt +1 -0
  47. pynode/nodes/FilterNode/__init__.py +3 -0
  48. pynode/nodes/FilterNode/filter_node.py +102 -0
  49. pynode/nodes/FilterNode/requirements.txt +1 -0
  50. pynode/nodes/FrameSourceNode/__init__.py +4 -0
  51. pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
  52. pynode/nodes/FrameSourceNode/requirements.txt +1 -0
  53. pynode/nodes/FunctionNode/__init__.py +3 -0
  54. pynode/nodes/FunctionNode/function_node.py +168 -0
  55. pynode/nodes/FunctionNode/requirements.txt +1 -0
  56. pynode/nodes/GateNode/gate_node.py +60 -0
  57. pynode/nodes/GateNode/requirements.txt +1 -0
  58. pynode/nodes/GetiUploadNode/__init__.py +3 -0
  59. pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
  60. pynode/nodes/GetiUploadNode/requirements.txt +1 -0
  61. pynode/nodes/ImageFormatNode/__init__.py +4 -0
  62. pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
  63. pynode/nodes/ImageFormatNode/requirements.txt +2 -0
  64. pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
  65. pynode/nodes/ImageViewerNode/__init__.py +4 -0
  66. pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
  67. pynode/nodes/ImageViewerNode/requirements.txt +2 -0
  68. pynode/nodes/ImageWriterNode/__init__.py +3 -0
  69. pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
  70. pynode/nodes/ImageWriterNode/requirements.txt +1 -0
  71. pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
  72. pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
  73. pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
  74. pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
  75. pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
  76. pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
  77. pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
  78. pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
  79. pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
  80. pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
  81. pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
  82. pynode/nodes/InferenceNode/inference_node.py +451 -0
  83. pynode/nodes/InferenceNode/requirements.txt +13 -0
  84. pynode/nodes/InjectNode/inject_node.py +245 -0
  85. pynode/nodes/InjectNode/requirements.txt +1 -0
  86. pynode/nodes/JoinNode/__init__.py +3 -0
  87. pynode/nodes/JoinNode/join_node.py +156 -0
  88. pynode/nodes/JoinNode/requirements.txt +1 -0
  89. pynode/nodes/LabelFilterNode/__init__.py +3 -0
  90. pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
  91. pynode/nodes/LabelFilterNode/requirements.txt +1 -0
  92. pynode/nodes/MQTTNode/__init__.py +8 -0
  93. pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
  94. pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
  95. pynode/nodes/MQTTNode/mqtt_service.py +367 -0
  96. pynode/nodes/MQTTNode/requirements.txt +1 -0
  97. pynode/nodes/MessageWriterNode/__init__.py +7 -0
  98. pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
  99. pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
  100. pynode/nodes/OmronCameraNode/__init__.py +3 -0
  101. pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
  102. pynode/nodes/OmronCameraNode/requirements.txt +4 -0
  103. pynode/nodes/OpenCV/__init__.py +1 -0
  104. pynode/nodes/OpenCV/arithmetic_node.py +376 -0
  105. pynode/nodes/OpenCV/bitwise_node.py +127 -0
  106. pynode/nodes/OpenCV/blend_node.py +127 -0
  107. pynode/nodes/OpenCV/blob_detector_node.py +233 -0
  108. pynode/nodes/OpenCV/blur_node.py +141 -0
  109. pynode/nodes/OpenCV/color_space_node.py +139 -0
  110. pynode/nodes/OpenCV/colormap_node.py +262 -0
  111. pynode/nodes/OpenCV/contours_node.py +258 -0
  112. pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
  113. pynode/nodes/OpenCV/draw_node.py +312 -0
  114. pynode/nodes/OpenCV/edge_detector_node.py +177 -0
  115. pynode/nodes/OpenCV/equirectangular_node.py +447 -0
  116. pynode/nodes/OpenCV/fft_node.py +102 -0
  117. pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
  118. pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
  119. pynode/nodes/OpenCV/histogram_node.py +160 -0
  120. pynode/nodes/OpenCV/hough_circles_node.py +224 -0
  121. pynode/nodes/OpenCV/hough_lines_node.py +243 -0
  122. pynode/nodes/OpenCV/in_range_node.py +186 -0
  123. pynode/nodes/OpenCV/morphology_node.py +136 -0
  124. pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
  125. pynode/nodes/OpenCV/paste_node.py +244 -0
  126. pynode/nodes/OpenCV/perspective_node.py +174 -0
  127. pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
  128. pynode/nodes/OpenCV/requirements.txt +2 -0
  129. pynode/nodes/OpenCV/resize_node.py +175 -0
  130. pynode/nodes/OpenCV/rotate_node.py +159 -0
  131. pynode/nodes/OpenCV/template_match_node.py +227 -0
  132. pynode/nodes/OpenCV/threshold_node.py +167 -0
  133. pynode/nodes/PointInShapeNode/__init__.py +3 -0
  134. pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
  135. pynode/nodes/PointInShapeNode/requirements.txt +2 -0
  136. pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
  137. pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
  138. pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
  139. pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
  140. pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
  141. pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
  142. pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
  143. pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
  144. pynode/nodes/RESTEndpointNode/__init__.py +3 -0
  145. pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
  146. pynode/nodes/RangeNode/__init__.py +3 -0
  147. pynode/nodes/RangeNode/range_node.py +115 -0
  148. pynode/nodes/RangeNode/requirements.txt +1 -0
  149. pynode/nodes/RateProbeNode/__init__.py +3 -0
  150. pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
  151. pynode/nodes/RateProbeNode/requirements.txt +1 -0
  152. pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
  153. pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
  154. pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
  155. pynode/nodes/SliceNode/README.md +149 -0
  156. pynode/nodes/SliceNode/__init__.py +5 -0
  157. pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
  158. pynode/nodes/SliceNode/requirements.txt +3 -0
  159. pynode/nodes/SliceNode/slice_collector_node.py +370 -0
  160. pynode/nodes/SliceNode/slice_image_node.py +380 -0
  161. pynode/nodes/SplitNode/__init__.py +3 -0
  162. pynode/nodes/SplitNode/requirements.txt +1 -0
  163. pynode/nodes/SplitNode/split_node.py +155 -0
  164. pynode/nodes/SwitchNode/requirements.txt +1 -0
  165. pynode/nodes/SwitchNode/switch_node.py +259 -0
  166. pynode/nodes/SyncNode/__init__.py +3 -0
  167. pynode/nodes/SyncNode/sync_node.py +287 -0
  168. pynode/nodes/TemplateNode/requirements.txt +1 -0
  169. pynode/nodes/TemplateNode/template_node.py +86 -0
  170. pynode/nodes/TrackerNode/__init__.py +2 -0
  171. pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
  172. pynode/nodes/TrackerNode/tracker_node.py +745 -0
  173. pynode/nodes/UltralyticsNode/__init__.py +4 -0
  174. pynode/nodes/UltralyticsNode/requirements.txt +2 -0
  175. pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
  176. pynode/nodes/UnknownNode/__init__.py +93 -0
  177. pynode/nodes/VLLMNode/__init__.py +3 -0
  178. pynode/nodes/VLLMNode/vllm_node.py +345 -0
  179. pynode/nodes/VideoReaderNode/__init__.py +3 -0
  180. pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
  181. pynode/nodes/VideoWriterNode/__init__.py +3 -0
  182. pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
  183. pynode/nodes/WebhookNode/__init__.py +3 -0
  184. pynode/nodes/WebhookNode/webhook_node.py +591 -0
  185. pynode/nodes/__init__.py +109 -0
  186. pynode/nodes/base_node.py +562 -0
  187. pynode/nodes/image_utils.py +291 -0
  188. pynode/nodes/info.py +109 -0
  189. pynode/nodes/mDNSNode/__init__.py +13 -0
  190. pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
  191. pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
  192. pynode/nodes/mDNSNode/mdns_manager.py +399 -0
  193. pynode/nodes/mDNSNode/requirements.txt +1 -0
  194. pynode/nodes/messages.py +81 -0
  195. pynode/server.py +187 -0
  196. pynode/static/images/favicon4.png +0 -0
  197. pynode/static/index.html +200 -0
  198. pynode/static/js/README.md +139 -0
  199. pynode/static/js/auth.js +89 -0
  200. pynode/static/js/clipboard.js +152 -0
  201. pynode/static/js/config.js +14 -0
  202. pynode/static/js/connections.js +844 -0
  203. pynode/static/js/debug.js +524 -0
  204. pynode/static/js/events.js +775 -0
  205. pynode/static/js/history.js +199 -0
  206. pynode/static/js/main.js +55 -0
  207. pynode/static/js/minimap.js +242 -0
  208. pynode/static/js/nodes.js +995 -0
  209. pynode/static/js/palette.js +255 -0
  210. pynode/static/js/properties.js +1270 -0
  211. pynode/static/js/selection.js +241 -0
  212. pynode/static/js/state.js +211 -0
  213. pynode/static/js/ui-utils.js +28 -0
  214. pynode/static/js/viewport.js +204 -0
  215. pynode/static/js/workflow.js +469 -0
  216. pynode/static/js/workflows.js +344 -0
  217. pynode/static/style.css +2751 -0
  218. pynode/workflow_engine.py +483 -0
  219. pynode/workflow_manager.py +440 -0
  220. pynode_flow-0.2.0.dist-info/METADATA +520 -0
  221. pynode_flow-0.2.0.dist-info/RECORD +227 -0
  222. pynode_flow-0.2.0.dist-info/WHEEL +5 -0
  223. pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
  224. pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
  225. pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
  226. pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
  227. 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
@@ -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