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,259 @@
1
+ """
2
+ Switch node - routes messages based on rules.
3
+ Similar to Node-RED's switch node.
4
+ Supports dynamic rules with multiple outputs.
5
+ """
6
+
7
+ import re
8
+ from typing import Any, Dict, List
9
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
10
+
11
+ _info = Info()
12
+ _info.add_text("Routes messages to different outputs based on configurable rules. Each rule creates an output port - messages are sent to outputs where rules match.")
13
+ _info.add_header("Inputs")
14
+ _info.add_bullets(
15
+ ("Input 0:", "Message to evaluate and route")
16
+ )
17
+ _info.add_header("Outputs")
18
+ _info.add_bullets(
19
+ ("Output N:", "One output per rule - message sent to matching rule outputs")
20
+ )
21
+ _info.add_header("Configuration")
22
+ _info.add_bullets(
23
+ ("Property:", "Message property to evaluate (e.g., payload, topic)"),
24
+ ("Rules:", "List of conditions - each creates an output port"),
25
+ ("Check all:", "If enabled, checks all rules; otherwise stops at first match")
26
+ )
27
+ _info.add_header("Operators")
28
+ _info.add_bullets(
29
+ ("eq/neq:", "Equals / not equals"),
30
+ ("lt/lte/gt/gte:", "Less than, greater than (or equal)"),
31
+ ("between:", "Value is in range (format: 'min,max')"),
32
+ ("contains/matches:", "String contains or regex matches"),
33
+ ("true/false/null/nnull:", "Boolean and null checks"),
34
+ ("empty/nempty:", "Check if value is empty"),
35
+ ("else:", "Catch-all (always matches)")
36
+ )
37
+
38
+
39
+ class SwitchNode(BaseNode):
40
+ """
41
+ Switch node - routes messages based on rules.
42
+ Each rule creates a separate output port.
43
+ Messages are routed to the first matching rule's output.
44
+ """
45
+ info = str(_info)
46
+ display_name = 'Switch'
47
+ icon = '⎇'
48
+ category = 'logic'
49
+ color = '#E9967A'
50
+ border_color = '#CA7C5F'
51
+ text_color = '#000000'
52
+ input_count = 1
53
+ output_count = 1 # Will be dynamically updated based on rules
54
+
55
+ DEFAULT_CONFIG = {
56
+ 'property': MessageKeys.PAYLOAD,
57
+ 'checkall': False,
58
+ 'rules': [
59
+ {'operator': 'eq', 'value': '', 'valueType': 'str'}
60
+ ]
61
+ }
62
+
63
+ properties = [
64
+ {
65
+ 'name': 'property',
66
+ 'label': 'Property',
67
+ 'type': 'text',
68
+ 'help': 'The message property to evaluate (e.g., payload, topic)'
69
+ },
70
+ {
71
+ 'name': 'rules',
72
+ 'label': 'Rules',
73
+ 'type': 'rules', # Custom type for rules editor
74
+ 'help': 'Define routing rules - each rule creates an output port'
75
+ },
76
+ {
77
+ 'name': 'checkall',
78
+ 'label': 'Check all rules',
79
+ 'type': 'checkbox',
80
+ 'default': DEFAULT_CONFIG['checkall'],
81
+ 'help': 'If checked, checks all rules and sends to all matching outputs. If unchecked, stops at first match.'
82
+ }
83
+ ]
84
+
85
+ def __init__(self, node_id=None, name="switch"):
86
+ super().__init__(node_id, name)
87
+ self._update_output_count()
88
+
89
+ def configure(self, config: Dict[str, Any]):
90
+ """Override configure to update output count when rules change."""
91
+ super().configure(config)
92
+ self._update_output_count()
93
+
94
+ def _update_output_count(self):
95
+ """Update the output count based on the number of rules."""
96
+ rules = self.config.get('rules', [])
97
+ self.output_count = max(1, len(rules))
98
+
99
+ def _convert_value(self, value_str: str, value_type: str) -> Any:
100
+ """
101
+ Convert string value to the appropriate type.
102
+ """
103
+ if value_type == 'num':
104
+ try:
105
+ return float(value_str) if '.' in value_str else int(value_str)
106
+ except (ValueError, TypeError):
107
+ return 0
108
+ elif value_type == 'bool':
109
+ return str(value_str).lower() in ('true', '1', 'yes', 'on')
110
+ elif value_type == 'json':
111
+ try:
112
+ import json
113
+ return json.loads(value_str)
114
+ except Exception:
115
+ return value_str
116
+ else: # 'str' or default
117
+ return str(value_str)
118
+
119
+ def _evaluate_rule(self, msg_value: Any, rule: Dict[str, Any]) -> bool:
120
+ """
121
+ Evaluate a single rule against a message value.
122
+
123
+ Supported operators:
124
+ - eq: equals (==)
125
+ - neq: not equals (!=)
126
+ - lt: less than (<)
127
+ - lte: less than or equal (<=)
128
+ - gt: greater than (>)
129
+ - gte: greater than or equal (>=)
130
+ - between: value is between two numbers
131
+ - contains: string contains substring
132
+ - matches: regex match
133
+ - true: value is truthy
134
+ - false: value is falsy
135
+ - null: value is None
136
+ - nnull: value is not None
137
+ - empty: value is empty (empty string, list, dict, etc.)
138
+ - nempty: value is not empty
139
+ - haskey: dict has key
140
+ - else: always matches (catch-all)
141
+ """
142
+ operator = rule.get('operator', 'eq')
143
+ rule_value = rule.get('value', '')
144
+ value_type = rule.get('valueType', 'str')
145
+
146
+ try:
147
+ # Type-specific comparisons
148
+ if operator == 'eq':
149
+ compare_value = self._convert_value(rule_value, value_type)
150
+ return msg_value == compare_value
151
+
152
+ elif operator == 'neq':
153
+ compare_value = self._convert_value(rule_value, value_type)
154
+ return msg_value != compare_value
155
+
156
+ elif operator == 'lt':
157
+ compare_value = self._convert_value(rule_value, value_type)
158
+ return msg_value < compare_value
159
+
160
+ elif operator == 'lte':
161
+ compare_value = self._convert_value(rule_value, value_type)
162
+ return msg_value <= compare_value
163
+
164
+ elif operator == 'gt':
165
+ compare_value = self._convert_value(rule_value, value_type)
166
+ return msg_value > compare_value
167
+
168
+ elif operator == 'gte':
169
+ compare_value = self._convert_value(rule_value, value_type)
170
+ return msg_value >= compare_value
171
+
172
+ elif operator == 'between':
173
+ # Expects rule_value to be "min,max"
174
+ parts = str(rule_value).split(',')
175
+ if len(parts) == 2:
176
+ min_val = self._convert_value(parts[0].strip(), value_type)
177
+ max_val = self._convert_value(parts[1].strip(), value_type)
178
+ return min_val <= msg_value <= max_val
179
+ return False
180
+
181
+ elif operator == 'contains':
182
+ return str(rule_value) in str(msg_value)
183
+
184
+ elif operator == 'matches':
185
+ # Regex match
186
+ pattern = str(rule_value)
187
+ return bool(re.search(pattern, str(msg_value)))
188
+
189
+ elif operator == 'true':
190
+ return bool(msg_value)
191
+
192
+ elif operator == 'false':
193
+ return not bool(msg_value)
194
+
195
+ elif operator == 'null':
196
+ return msg_value is None
197
+
198
+ elif operator == 'nnull':
199
+ return msg_value is not None
200
+
201
+ elif operator == 'empty':
202
+ if msg_value is None:
203
+ return True
204
+ if isinstance(msg_value, (str, list, dict, tuple)):
205
+ return len(msg_value) == 0
206
+ return False
207
+
208
+ elif operator == 'nempty':
209
+ if msg_value is None:
210
+ return False
211
+ if isinstance(msg_value, (str, list, dict, tuple)):
212
+ return len(msg_value) > 0
213
+ return True
214
+
215
+ elif operator == 'haskey':
216
+ if isinstance(msg_value, dict):
217
+ return str(rule_value) in msg_value
218
+ return False
219
+
220
+ elif operator == 'else':
221
+ # Catch-all rule - always matches
222
+ return True
223
+
224
+ else:
225
+ return False
226
+
227
+ except (TypeError, ValueError, AttributeError):
228
+ return False
229
+
230
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
231
+ """
232
+ Route message based on rules.
233
+ Each rule is checked in order, and messages are sent to matching output(s).
234
+ """
235
+ property_path = self.config.get('property', MessageKeys.PAYLOAD)
236
+ check_all = self.config.get('checkall', False)
237
+ rules = self.config.get('rules', [])
238
+
239
+ if not rules:
240
+ # No rules defined, send to output 0
241
+ self.send(msg, 0)
242
+ return
243
+
244
+ # Get the value to evaluate
245
+ msg_value = self._get_nested_value(msg, property_path)
246
+
247
+ # Check each rule
248
+ matched_any = False
249
+ for idx, rule in enumerate(rules):
250
+ if self._evaluate_rule(msg_value, rule):
251
+ self.send(msg, idx)
252
+ matched_any = True
253
+
254
+ # If not checking all rules, stop at first match
255
+ if not check_all:
256
+ break
257
+
258
+ # If no rules matched and we have an 'else' rule, it should have matched
259
+ # Otherwise, message is not sent anywhere (filtered out)
@@ -0,0 +1,3 @@
1
+ from .sync_node import SyncNode
2
+
3
+ __all__ = ['SyncNode']
@@ -0,0 +1,287 @@
1
+ """
2
+ Sync Node - buffers messages based on an index and releases them on command.
3
+ Useful for synchronizing multiple message streams or batch processing.
4
+ """
5
+
6
+ from typing import Any, Dict, List
7
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
8
+
9
+ _info = Info()
10
+ _info.add_text("Buffers messages by index and releases them on command. Useful for collecting messages out of order and releasing them in sequence, or for batch processing.")
11
+ _info.add_header("Inputs")
12
+ _info.add_bullets(
13
+ ("Input 0:", "Messages to buffer (with index property) or release command")
14
+ )
15
+ _info.add_header("Outputs")
16
+ _info.add_bullets(
17
+ ("Output 0:", "Buffered messages released in order, or as an array")
18
+ )
19
+ _info.add_header("Configuration")
20
+ _info.add_bullets(
21
+ ("Index Property:", "Property name containing the message index (e.g., 'index', 'id')"),
22
+ ("Release Payload:", "Payload value that triggers release (default: 'release')"),
23
+ ("Output Mode:", "Sequential (one by one) or Array (all at once)"),
24
+ ("Sort Order:", "Ascending, descending, or arrival order"),
25
+ ("Clear After Release:", "Whether to clear the buffer after releasing"),
26
+ ("Max Buffer Size:", "Maximum messages to buffer (oldest dropped when full)")
27
+ )
28
+ _info.add_header("Usage")
29
+ _info.add_text("Send messages with an index property to buffer them. Send a message with payload='release' to output all buffered messages in order.")
30
+
31
+
32
+ class SyncNode(BaseNode):
33
+ """
34
+ Sync node - buffers messages by index and releases them on command.
35
+ Messages are stored until a "release" payload is received.
36
+ """
37
+ info = str(_info)
38
+ display_name = 'Sync'
39
+ icon = '🔄'
40
+ category = 'logic'
41
+ color = '#E2D96E'
42
+ border_color = '#B8AF4A'
43
+ text_color = '#000000'
44
+ input_count = 1
45
+ output_count = 1
46
+
47
+ DEFAULT_CONFIG = {
48
+ 'index_property': 'index',
49
+ 'release_payload': 'release',
50
+ 'output_mode': 'sequential',
51
+ 'sort_order': 'ascending',
52
+ 'clear_on_release': 'true',
53
+ 'max_buffer_size': '1000',
54
+ 'allow_duplicates': 'false'
55
+ }
56
+
57
+ properties = [
58
+ {
59
+ 'name': 'index_property',
60
+ 'label': 'Index Property',
61
+ 'type': 'text',
62
+ 'default': DEFAULT_CONFIG['index_property'],
63
+ 'help': 'Property name containing the message index (e.g., "index", "id", "sequence")'
64
+ },
65
+ {
66
+ 'name': 'release_payload',
67
+ 'label': 'Release Payload',
68
+ 'type': 'text',
69
+ 'default': DEFAULT_CONFIG['release_payload'],
70
+ 'help': 'Payload value that triggers release (default: "release")'
71
+ },
72
+ {
73
+ 'name': 'output_mode',
74
+ 'label': 'Output Mode',
75
+ 'type': 'select',
76
+ 'options': [
77
+ {'value': 'sequential', 'label': 'Sequential (one by one)'},
78
+ {'value': 'array', 'label': 'Array (all at once)'}
79
+ ],
80
+ 'default': DEFAULT_CONFIG['output_mode'],
81
+ 'help': 'How to output buffered messages'
82
+ },
83
+ {
84
+ 'name': 'sort_order',
85
+ 'label': 'Sort Order',
86
+ 'type': 'select',
87
+ 'options': [
88
+ {'value': 'ascending', 'label': 'Ascending (0, 1, 2, ...)'},
89
+ {'value': 'descending', 'label': 'Descending (..., 2, 1, 0)'},
90
+ {'value': 'none', 'label': 'None (arrival order)'}
91
+ ],
92
+ 'default': DEFAULT_CONFIG['sort_order'],
93
+ 'help': 'How to sort messages before release',
94
+ 'showIf': {'output_mode': 'sequential'}
95
+ },
96
+ {
97
+ 'name': 'clear_on_release',
98
+ 'label': 'Clear After Release',
99
+ 'type': 'select',
100
+ 'options': [
101
+ {'value': 'true', 'label': 'Yes (clear buffer)'},
102
+ {'value': 'false', 'label': 'No (keep buffer)'}
103
+ ],
104
+ 'default': DEFAULT_CONFIG['clear_on_release'],
105
+ 'help': 'Clear buffer after releasing messages'
106
+ },
107
+ {
108
+ 'name': 'max_buffer_size',
109
+ 'label': 'Max Buffer Size',
110
+ 'type': 'text',
111
+ 'default': DEFAULT_CONFIG['max_buffer_size'],
112
+ 'help': 'Maximum number of messages to buffer (0 = unlimited)'
113
+ },
114
+ {
115
+ 'name': 'allow_duplicates',
116
+ 'label': 'Allow Duplicate Indices',
117
+ 'type': 'select',
118
+ 'options': [
119
+ {'value': 'false', 'label': 'No (keep first)'},
120
+ {'value': 'true', 'label': 'Yes (overwrite with latest)'}
121
+ ],
122
+ 'default': DEFAULT_CONFIG['allow_duplicates'],
123
+ 'help': 'Allow multiple messages for the same index (false = keep first, true = keep latest)'
124
+ }
125
+ ]
126
+
127
+ def __init__(self, node_id=None, name="sync"):
128
+ super().__init__(node_id, name)
129
+ # Buffer to store messages: {index: msg}
130
+ self._buffer: Dict[Any, Dict[str, Any]] = {}
131
+ # Track arrival order for non-sorted output
132
+ self._arrival_order: List[Any] = []
133
+
134
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
135
+ """
136
+ Buffer message by index or release buffered messages.
137
+ """
138
+ try:
139
+ payload = msg.get(MessageKeys.PAYLOAD)
140
+ release_trigger = self.config.get('release_payload', 'release')
141
+
142
+ # Check if this is a release command
143
+ if payload == release_trigger:
144
+ self._release_messages(msg)
145
+ return
146
+
147
+ # Otherwise, buffer the message
148
+ index_property = self.config.get('index_property', 'index')
149
+
150
+ # Get index from message
151
+ if index_property in msg:
152
+ msg_index = msg[index_property]
153
+ else:
154
+ # If no index property, use arrival order
155
+ msg_index = len(self._buffer)
156
+
157
+ # Check buffer size limit
158
+ max_size = self.get_config_int('max_buffer_size', 1000)
159
+ if max_size > 0 and len(self._buffer) >= max_size:
160
+ self.report_error(f"Buffer full ({max_size} messages), dropping oldest message")
161
+ # Remove oldest message
162
+ if self._arrival_order:
163
+ oldest_index = self._arrival_order.pop(0)
164
+ if oldest_index in self._buffer:
165
+ del self._buffer[oldest_index]
166
+
167
+ # Check if duplicate indices are allowed
168
+ allow_duplicates = self.get_config_bool('allow_duplicates', False)
169
+
170
+ if msg_index in self._buffer:
171
+ if allow_duplicates:
172
+ # Overwrite with latest message
173
+ self._buffer[msg_index] = msg
174
+ else:
175
+ # Keep first message, drop this one
176
+ # Silently ignore duplicate (already in buffer)
177
+ return
178
+ else:
179
+ # Store message in buffer (new index)
180
+ self._buffer[msg_index] = msg
181
+ # Track arrival order
182
+ self._arrival_order.append(msg_index)
183
+
184
+ except Exception as e:
185
+ self.report_error(f"Error buffering message: {str(e)}")
186
+
187
+ def _release_messages(self, trigger_msg: Dict[str, Any]):
188
+ """
189
+ Release buffered messages according to configuration.
190
+ """
191
+ try:
192
+ if not self._buffer:
193
+ # No messages to release
194
+ output_msg = self.create_message(
195
+ payload={
196
+ 'status': 'empty',
197
+ 'message': 'No messages in buffer'
198
+ },
199
+ topic=trigger_msg.get(MessageKeys.TOPIC, 'sync/release')
200
+ )
201
+ self.send(output_msg)
202
+ return
203
+
204
+ # Get sorted indices
205
+ indices = self._get_sorted_indices()
206
+
207
+ output_mode = self.config.get('output_mode', 'sequential')
208
+
209
+ if output_mode == 'sequential':
210
+ # Send messages one by one
211
+ for idx in indices:
212
+ if idx in self._buffer:
213
+ self.send(self._buffer[idx])
214
+
215
+ # Send completion message
216
+ completion_msg = self.create_message(
217
+ payload={
218
+ 'status': 'complete',
219
+ 'released_count': len(indices),
220
+ 'indices': indices
221
+ },
222
+ topic=trigger_msg.get(MessageKeys.TOPIC, 'sync/complete')
223
+ )
224
+ self.send(completion_msg)
225
+
226
+ elif output_mode == 'array':
227
+ # Send all messages as an array
228
+ messages = [self._buffer[idx] for idx in indices if idx in self._buffer]
229
+
230
+ array_msg = self.create_message(
231
+ payload={
232
+ 'messages': messages,
233
+ 'count': len(messages),
234
+ 'indices': indices
235
+ },
236
+ topic=trigger_msg.get(MessageKeys.TOPIC, 'sync/array')
237
+ )
238
+ self.send(array_msg)
239
+
240
+ # Clear buffer if configured
241
+ if self.get_config_bool('clear_on_release', True):
242
+ self._buffer.clear()
243
+ self._arrival_order.clear()
244
+
245
+ except Exception as e:
246
+ self.report_error(f"Error releasing messages: {str(e)}")
247
+
248
+ def _get_sorted_indices(self) -> List[Any]:
249
+ """
250
+ Get list of indices sorted according to configuration.
251
+ """
252
+ sort_order = self.config.get('sort_order', 'ascending')
253
+
254
+ if sort_order == 'none':
255
+ # Return in arrival order
256
+ return self._arrival_order.copy()
257
+
258
+ # Get indices and try to sort them
259
+ indices = list(self._buffer.keys())
260
+
261
+ try:
262
+ if sort_order == 'ascending':
263
+ return sorted(indices)
264
+ elif sort_order == 'descending':
265
+ return sorted(indices, reverse=True)
266
+ except TypeError:
267
+ # If indices can't be compared (mixed types), use arrival order
268
+ return self._arrival_order.copy()
269
+
270
+ return indices
271
+
272
+ def get_buffer_status(self) -> Dict[str, Any]:
273
+ """
274
+ Get current buffer status.
275
+ """
276
+ return {
277
+ 'buffer_size': len(self._buffer),
278
+ 'indices': list(self._buffer.keys()),
279
+ 'arrival_order': self._arrival_order.copy()
280
+ }
281
+
282
+ def clear_buffer(self):
283
+ """
284
+ Manually clear the buffer.
285
+ """
286
+ self._buffer.clear()
287
+ self._arrival_order.clear()
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library
@@ -0,0 +1,86 @@
1
+ """
2
+ Template for creating custom nodes.
3
+ This demonstrates how third-party developers can create their own nodes.
4
+ """
5
+
6
+ from typing import Any, Dict
7
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
8
+
9
+ # Build info content
10
+ _info = Info()
11
+ _info.add_text("Template node for creating custom third-party nodes.")
12
+ _info.add_text("Copy this file and modify to create your own nodes.")
13
+ _info.add_header("Inputs")
14
+ _info.add_bullet("Input 0:", "Any message to process through the template")
15
+ _info.add_header("Outputs")
16
+ _info.add_bullet("Output 0:", "Message with payload replaced by template output")
17
+ _info.add_header("Properties")
18
+ _info.add_bullet("Template:", "Text template with {{payload}} and {{topic}} placeholders")
19
+ _info.add_bullet("Output Format:", "Format type: plain, json, or html")
20
+ _info.add_header("Template Variables")
21
+ _info.add_bullet("{{payload}}:", "Replaced with the message payload")
22
+ _info.add_bullet("{{topic}}:", "Replaced with the message topic")
23
+
24
+
25
+ class TemplateNode(BaseNode):
26
+ """
27
+ Template node - example of a custom third-party node.
28
+ Copy this file and modify to create your own nodes.
29
+ """
30
+ # Visual properties
31
+ display_name = 'Template'
32
+ icon = '📄'
33
+ category = 'system' # Categories: input, output, function, logic, custom
34
+ color = '#FFA07A'
35
+ border_color = '#FF7F50'
36
+ text_color = '#000000'
37
+ input_count = 0
38
+ output_count = 0
39
+ info = str(_info)
40
+ hidden = True # Hide this template node from the palette
41
+
42
+ # Property schema for the properties panel
43
+ # This defines what fields appear in the UI when the node is selected
44
+ properties = [
45
+ {
46
+ 'name': 'template',
47
+ 'label': 'Template',
48
+ 'type': 'textarea' # Types: text, textarea, select, button
49
+ },
50
+ {
51
+ 'name': 'format',
52
+ 'label': 'Output Format',
53
+ 'type': 'select',
54
+ 'options': [
55
+ {'value': 'plain', 'label': 'Plain Text'},
56
+ {'value': 'json', 'label': 'JSON'},
57
+ {'value': 'html', 'label': 'HTML'}
58
+ ]
59
+ }
60
+ ]
61
+
62
+ def __init__(self, node_id=None, name="template"):
63
+ super().__init__(node_id, name)
64
+ self.configure({
65
+ 'template': 'Hello {{payload}}',
66
+ 'format': 'plain'
67
+ })
68
+
69
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
70
+ """
71
+ Process incoming messages.
72
+ This method is called when a message arrives at the node.
73
+ """
74
+ template = self.config.get('template', '')
75
+ format_type = self.config.get('format', 'plain')
76
+
77
+ # Simple template replacement (in production, use a proper template engine)
78
+ output = template.replace('{{payload}}', str(msg.get(MessageKeys.PAYLOAD, '')))
79
+ output = output.replace('{{topic}}', str(msg.get(MessageKeys.TOPIC, '')))
80
+
81
+ # Preserve original message properties (like frame_count) and update payload
82
+ # Note: send() handles deep copying, so we modify msg directly
83
+ msg[MessageKeys.PAYLOAD] = output
84
+
85
+ # Send to connected nodes
86
+ self.send(msg)
@@ -0,0 +1,2 @@
1
+ from .tracker_node import TrackerNode
2
+ from .supervision_tracker_node import SupervisionTrackerNode