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,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Change node - modifies message properties.
|
|
3
|
+
Similar to Node-RED's change node.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
10
|
+
|
|
11
|
+
_info = Info()
|
|
12
|
+
_info.add_text("Modifies message properties using configurable rules. Similar to Node-RED's change node.")
|
|
13
|
+
_info.add_header("Input")
|
|
14
|
+
_info.add_bullets(
|
|
15
|
+
("msg:", "Any message to be modified according to the configured rules."),
|
|
16
|
+
)
|
|
17
|
+
_info.add_header("Output")
|
|
18
|
+
_info.add_bullets(
|
|
19
|
+
("msg:", "The modified message after all rules have been applied."),
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Operations")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("Set:", "Set a property to a value or copy from another property."),
|
|
24
|
+
("Change:", "Search and replace text within a property."),
|
|
25
|
+
("Delete:", "Remove a property from the message."),
|
|
26
|
+
("Move:", "Move a property to a different location."),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChangeNode(BaseNode):
|
|
31
|
+
"""
|
|
32
|
+
Change node - modifies message properties.
|
|
33
|
+
Similar to Node-RED's change node.
|
|
34
|
+
|
|
35
|
+
Supports operations:
|
|
36
|
+
- set: Set a property to a value or another property's value
|
|
37
|
+
- change: Search and replace within a property
|
|
38
|
+
- delete: Remove a property
|
|
39
|
+
- move: Move a property to another location
|
|
40
|
+
"""
|
|
41
|
+
info = str(_info)
|
|
42
|
+
display_name = 'Change'
|
|
43
|
+
icon = '✎'
|
|
44
|
+
category = 'function'
|
|
45
|
+
color = '#E6E0F8'
|
|
46
|
+
border_color = '#9F93C6'
|
|
47
|
+
text_color = '#000000'
|
|
48
|
+
input_count = 1
|
|
49
|
+
output_count = 1
|
|
50
|
+
|
|
51
|
+
# Custom UI component for rule editing
|
|
52
|
+
ui_component = 'change-rules-editor'
|
|
53
|
+
ui_component_config = {
|
|
54
|
+
'operations': ['set', 'change', 'move', 'delete'],
|
|
55
|
+
'value_types': [
|
|
56
|
+
{'value': 'str', 'label': 'String'},
|
|
57
|
+
{'value': 'num', 'label': 'Number'},
|
|
58
|
+
{'value': 'bool', 'label': 'Boolean'},
|
|
59
|
+
{'value': 'json', 'label': 'JSON'},
|
|
60
|
+
{'value': 'msg', 'label': 'msg.'},
|
|
61
|
+
{'value': 'flow', 'label': 'flow.'},
|
|
62
|
+
{'value': 'global', 'label': 'global.'}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
DEFAULT_CONFIG = {
|
|
67
|
+
'rules': []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
properties = [
|
|
71
|
+
{
|
|
72
|
+
'name': 'rules',
|
|
73
|
+
'label': 'Rules',
|
|
74
|
+
'type': 'changeRules',
|
|
75
|
+
'default': [],
|
|
76
|
+
'help': 'Define rules to modify message properties'
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def __init__(self, node_id=None, name="change"):
|
|
81
|
+
super().__init__(node_id, name)
|
|
82
|
+
|
|
83
|
+
def _delete_nested_value(self, obj: Dict, path: str) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Delete a value at a nested path
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
obj: The object to delete from
|
|
89
|
+
path: Dot-separated path string
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if deleted, False otherwise
|
|
93
|
+
"""
|
|
94
|
+
# Handle msg. prefix
|
|
95
|
+
if path.startswith('msg.'):
|
|
96
|
+
path = path[4:]
|
|
97
|
+
|
|
98
|
+
parts = path.split('.')
|
|
99
|
+
current = obj
|
|
100
|
+
|
|
101
|
+
# Navigate to parent of target
|
|
102
|
+
for part in parts[:-1]:
|
|
103
|
+
match = re.match(r'(\w+)\[(\d+)\]', part)
|
|
104
|
+
if match:
|
|
105
|
+
key, index = match.groups()
|
|
106
|
+
if isinstance(current, dict) and key in current:
|
|
107
|
+
current = current[key]
|
|
108
|
+
if isinstance(current, (list, tuple)) and int(index) < len(current):
|
|
109
|
+
current = current[int(index)]
|
|
110
|
+
else:
|
|
111
|
+
return False
|
|
112
|
+
else:
|
|
113
|
+
return False
|
|
114
|
+
elif isinstance(current, dict) and part in current:
|
|
115
|
+
current = current[part]
|
|
116
|
+
else:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
# Delete the final key
|
|
120
|
+
final_key = parts[-1]
|
|
121
|
+
match = re.match(r'(\w+)\[(\d+)\]', final_key)
|
|
122
|
+
if match:
|
|
123
|
+
key, index = match.groups()
|
|
124
|
+
if isinstance(current, dict) and key in current:
|
|
125
|
+
if isinstance(current[key], list) and int(index) < len(current[key]):
|
|
126
|
+
del current[key][int(index)]
|
|
127
|
+
return True
|
|
128
|
+
elif isinstance(current, dict) and final_key in current:
|
|
129
|
+
del current[final_key]
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def _resolve_value(self, msg: Dict, value: Any, value_type: str) -> Any:
|
|
135
|
+
"""
|
|
136
|
+
Resolve a value based on its type.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
msg: The message object
|
|
140
|
+
value: The value or path
|
|
141
|
+
value_type: Type of the value ('str', 'num', 'bool', 'json', 'msg', etc.)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The resolved value
|
|
145
|
+
"""
|
|
146
|
+
if value_type == 'msg':
|
|
147
|
+
# Get value from message path
|
|
148
|
+
return self._get_nested_value(msg, value)
|
|
149
|
+
elif value_type == 'num':
|
|
150
|
+
try:
|
|
151
|
+
return float(value) if '.' in str(value) else int(value)
|
|
152
|
+
except (ValueError, TypeError):
|
|
153
|
+
return 0
|
|
154
|
+
elif value_type == 'bool':
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
return value
|
|
157
|
+
return str(value).lower() in ('true', '1', 'yes', 'on')
|
|
158
|
+
elif value_type == 'json':
|
|
159
|
+
if isinstance(value, str):
|
|
160
|
+
try:
|
|
161
|
+
return json.loads(value)
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
return value
|
|
164
|
+
return value
|
|
165
|
+
elif value_type == 'str':
|
|
166
|
+
return str(value) if value is not None else ''
|
|
167
|
+
else:
|
|
168
|
+
# Default: return as-is
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
172
|
+
"""
|
|
173
|
+
Apply change rules to the message.
|
|
174
|
+
"""
|
|
175
|
+
rules = self.config.get('rules', [])
|
|
176
|
+
|
|
177
|
+
# Handle rules as JSON string (from UI)
|
|
178
|
+
if isinstance(rules, str):
|
|
179
|
+
try:
|
|
180
|
+
rules = json.loads(rules)
|
|
181
|
+
except json.JSONDecodeError:
|
|
182
|
+
rules = []
|
|
183
|
+
|
|
184
|
+
for rule in rules:
|
|
185
|
+
if not isinstance(rule, dict):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
rule_type = rule.get('type', rule.get('t', 'set'))
|
|
189
|
+
property_path = rule.get('path', rule.get('p', rule.get('property', f'msg.{MessageKeys.PAYLOAD}')))
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
if rule_type == 'set':
|
|
193
|
+
# Set property to a value
|
|
194
|
+
value = rule.get('value', rule.get('to', ''))
|
|
195
|
+
value_type = rule.get('valueType', rule.get('tot', 'str'))
|
|
196
|
+
|
|
197
|
+
# Handle path value type (read from another msg property)
|
|
198
|
+
if value_type == 'path':
|
|
199
|
+
resolved_value = self._get_nested_value(msg, 'msg.' + str(value) if not str(value).startswith('msg.') else value)
|
|
200
|
+
elif value_type == 'date':
|
|
201
|
+
import time
|
|
202
|
+
resolved_value = int(time.time() * 1000)
|
|
203
|
+
elif value_type == 'env':
|
|
204
|
+
import os
|
|
205
|
+
resolved_value = os.environ.get(str(value), '')
|
|
206
|
+
else:
|
|
207
|
+
resolved_value = self._resolve_value(msg, value, value_type)
|
|
208
|
+
|
|
209
|
+
self._set_nested_value(msg, property_path, resolved_value)
|
|
210
|
+
|
|
211
|
+
elif rule_type == 'change':
|
|
212
|
+
# Search and replace within a property
|
|
213
|
+
search = rule.get('search', rule.get('from', ''))
|
|
214
|
+
search_type = rule.get('searchType', rule.get('fromt', 'str'))
|
|
215
|
+
replace = rule.get('replace', rule.get('to', ''))
|
|
216
|
+
replace_type = rule.get('replaceType', rule.get('tot', 'str'))
|
|
217
|
+
|
|
218
|
+
current_value = self._get_nested_value(msg, property_path)
|
|
219
|
+
|
|
220
|
+
# Resolve replace value
|
|
221
|
+
if replace_type == 'path':
|
|
222
|
+
replace_value = self._get_nested_value(msg, 'msg.' + str(replace) if not str(replace).startswith('msg.') else replace)
|
|
223
|
+
else:
|
|
224
|
+
replace_value = str(replace)
|
|
225
|
+
|
|
226
|
+
if current_value is not None:
|
|
227
|
+
if isinstance(current_value, str):
|
|
228
|
+
if search_type == 'regex':
|
|
229
|
+
try:
|
|
230
|
+
new_value = re.sub(search, str(replace_value) if replace_value else '', current_value)
|
|
231
|
+
self._set_nested_value(msg, property_path, new_value)
|
|
232
|
+
except re.error as e:
|
|
233
|
+
self.report_error(f"Invalid regex pattern: {search}")
|
|
234
|
+
else:
|
|
235
|
+
new_value = current_value.replace(str(search), str(replace_value) if replace_value else '')
|
|
236
|
+
self._set_nested_value(msg, property_path, new_value)
|
|
237
|
+
|
|
238
|
+
elif rule_type == 'delete':
|
|
239
|
+
# Delete a property
|
|
240
|
+
self._delete_nested_value(msg, property_path)
|
|
241
|
+
|
|
242
|
+
elif rule_type == 'move':
|
|
243
|
+
# Move property to another location
|
|
244
|
+
to_path = rule.get('toPath', rule.get('to', rule.get('toProperty', '')))
|
|
245
|
+
|
|
246
|
+
if to_path:
|
|
247
|
+
current_value = self._get_nested_value(msg, property_path)
|
|
248
|
+
if current_value is not None:
|
|
249
|
+
self._set_nested_value(msg, to_path, current_value)
|
|
250
|
+
self._delete_nested_value(msg, property_path)
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self.report_error(f"Error applying rule {rule_type} on {property_path}: {str(e)}")
|
|
254
|
+
|
|
255
|
+
self.send(msg)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Confidence Filter Node - filters messages based on detection confidence.
|
|
3
|
+
Passes through messages that contain detections meeting the confidence threshold.
|
|
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("Splits detection arrays based on confidence scores, sending high-confidence detections to output 0 and low-confidence to output 1.")
|
|
11
|
+
_info.add_header("Input")
|
|
12
|
+
_info.add_bullets(
|
|
13
|
+
(f"{MessageKeys.PAYLOAD}.detections:", "Array of detection objects, each containing a confidence score."),
|
|
14
|
+
)
|
|
15
|
+
_info.add_header("Outputs")
|
|
16
|
+
_info.add_bullets(
|
|
17
|
+
("Output 0:", "Message with detections having confidence >= threshold."),
|
|
18
|
+
("Output 1:", "Message with detections having confidence < threshold."),
|
|
19
|
+
)
|
|
20
|
+
_info.add_header("Properties")
|
|
21
|
+
_info.add_bullets(
|
|
22
|
+
("Threshold:", "Minimum confidence score (0-1) to separate detections."),
|
|
23
|
+
("Threshold Source:", "Use configured value or read from msg.threshold."),
|
|
24
|
+
("Detections Path:", "Path to the detections array in the message."),
|
|
25
|
+
("Confidence Field:", "Name of the confidence field within each detection."),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfidenceFilterNode(BaseNode):
|
|
30
|
+
"""
|
|
31
|
+
Confidence Filter node - splits detection arrays based on confidence scores.
|
|
32
|
+
Always outputs to both ports:
|
|
33
|
+
Output 0: Detections with confidence >= threshold
|
|
34
|
+
Output 1: Detections with confidence < threshold
|
|
35
|
+
"""
|
|
36
|
+
info = str(_info)
|
|
37
|
+
display_name = 'Confidence Filter'
|
|
38
|
+
icon = '📊'
|
|
39
|
+
category = 'vision'
|
|
40
|
+
color = '#C7E9C0'
|
|
41
|
+
border_color = '#74C476'
|
|
42
|
+
text_color = '#000000'
|
|
43
|
+
input_count = 1
|
|
44
|
+
output_count = 2 # Output 0: >= threshold, Output 1: < threshold
|
|
45
|
+
|
|
46
|
+
DEFAULT_CONFIG = {
|
|
47
|
+
'threshold_source': 'manual', # or 'msg'
|
|
48
|
+
'threshold': 0.5,
|
|
49
|
+
'detections_path': f'{MessageKeys.PAYLOAD}.{MessageKeys.CV.DETECTIONS}',
|
|
50
|
+
'confidence_field': MessageKeys.CV.CONFIDENCE
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
properties = [
|
|
54
|
+
{
|
|
55
|
+
'name': 'threshold_source',
|
|
56
|
+
'label': 'Threshold Source',
|
|
57
|
+
'type': 'select',
|
|
58
|
+
'options': [
|
|
59
|
+
{'value': 'manual', 'label': 'Use manual value'},
|
|
60
|
+
{'value': 'msg', 'label': 'Use msg.threshold'}
|
|
61
|
+
],
|
|
62
|
+
'default': DEFAULT_CONFIG['threshold_source'],
|
|
63
|
+
'help': 'Where to read the threshold value from'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
'name': 'threshold',
|
|
67
|
+
'label': 'Confidence Threshold',
|
|
68
|
+
'type': 'number',
|
|
69
|
+
'default': DEFAULT_CONFIG['threshold'],
|
|
70
|
+
'min': 0,
|
|
71
|
+
'max': 1,
|
|
72
|
+
'step': 0.01,
|
|
73
|
+
'help': 'Minimum confidence score (0-1) to pass to output 0',
|
|
74
|
+
'showIf': {'threshold_source': 'manual'}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
'name': 'detections_path',
|
|
78
|
+
'label': 'Detections Path',
|
|
79
|
+
'type': 'text',
|
|
80
|
+
'default': DEFAULT_CONFIG['detections_path'],
|
|
81
|
+
'help': f'Path to detections array in message (e.g., "{MessageKeys.PAYLOAD}.{MessageKeys.CV.DETECTIONS}")'
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
'name': 'confidence_field',
|
|
85
|
+
'label': 'Confidence Field',
|
|
86
|
+
'type': 'text',
|
|
87
|
+
'default': DEFAULT_CONFIG['confidence_field'],
|
|
88
|
+
'help': 'Name of the confidence field in each detection object'
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def __init__(self, node_id=None, name="confidence filter"):
|
|
93
|
+
super().__init__(node_id, name)
|
|
94
|
+
|
|
95
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
96
|
+
"""
|
|
97
|
+
Split detections based on confidence threshold.
|
|
98
|
+
Always outputs to both ports:
|
|
99
|
+
Output 0: Detections with confidence >= threshold
|
|
100
|
+
Output 1: Detections with confidence < threshold
|
|
101
|
+
"""
|
|
102
|
+
# Get threshold from msg or config based on setting
|
|
103
|
+
threshold_source = self.config.get('threshold_source', 'manual')
|
|
104
|
+
if threshold_source == MessageKeys.MSG and MessageKeys.CV.THRESHOLD in msg:
|
|
105
|
+
try:
|
|
106
|
+
threshold = float(msg[MessageKeys.CV.THRESHOLD])
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
threshold = self.get_config_float(MessageKeys.CV.THRESHOLD, 0.5)
|
|
109
|
+
else:
|
|
110
|
+
threshold = self.get_config_float(MessageKeys.CV.THRESHOLD, 0.5)
|
|
111
|
+
|
|
112
|
+
detections_path = self.config.get('detections_path', f'{MessageKeys.PAYLOAD}.{MessageKeys.CV.DETECTIONS}')
|
|
113
|
+
confidence_field = self.config.get('confidence_field', MessageKeys.CV.CONFIDENCE)
|
|
114
|
+
|
|
115
|
+
# Get detections array from message
|
|
116
|
+
detections = self._get_nested_value(msg, detections_path)
|
|
117
|
+
|
|
118
|
+
if not isinstance(detections, list):
|
|
119
|
+
# No detections array found, send empty arrays to both outputs
|
|
120
|
+
# Use shallow copies - send() will deep copy for downstream isolation
|
|
121
|
+
msg[MessageKeys.CV.THRESHOLD] = threshold
|
|
122
|
+
high_msg = msg.copy()
|
|
123
|
+
low_msg = msg.copy()
|
|
124
|
+
|
|
125
|
+
# Copy payload dict since shallow copy shares nested objects
|
|
126
|
+
payload = msg.get(MessageKeys.PAYLOAD, {})
|
|
127
|
+
if isinstance(payload, dict):
|
|
128
|
+
high_msg[MessageKeys.PAYLOAD] = payload.copy()
|
|
129
|
+
low_msg[MessageKeys.PAYLOAD] = payload.copy()
|
|
130
|
+
|
|
131
|
+
# Set empty detection arrays
|
|
132
|
+
high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = []
|
|
133
|
+
low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = []
|
|
134
|
+
|
|
135
|
+
# Update detection counts if they exist
|
|
136
|
+
if MessageKeys.CV.DETECTION_COUNT in msg:
|
|
137
|
+
high_msg[MessageKeys.CV.DETECTION_COUNT] = 0
|
|
138
|
+
low_msg[MessageKeys.CV.DETECTION_COUNT] = 0
|
|
139
|
+
|
|
140
|
+
elif MessageKeys.PAYLOAD in msg and isinstance(msg[MessageKeys.PAYLOAD], dict) and MessageKeys.CV.DETECTION_COUNT in msg[MessageKeys.PAYLOAD]:
|
|
141
|
+
high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = 0
|
|
142
|
+
low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = 0
|
|
143
|
+
|
|
144
|
+
else:
|
|
145
|
+
high_msg[MessageKeys.CV.DETECTION_COUNT] = 0
|
|
146
|
+
low_msg[MessageKeys.CV.DETECTION_COUNT] = 0
|
|
147
|
+
|
|
148
|
+
self.send(high_msg, 0)
|
|
149
|
+
self.send(low_msg, 1)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Split detections based on confidence
|
|
153
|
+
high_confidence_detections = []
|
|
154
|
+
low_confidence_detections = []
|
|
155
|
+
|
|
156
|
+
for detection in detections:
|
|
157
|
+
if not isinstance(detection, dict):
|
|
158
|
+
# Invalid detection, add to low confidence
|
|
159
|
+
low_confidence_detections.append(detection)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
confidence = detection.get(confidence_field)
|
|
163
|
+
if confidence is None:
|
|
164
|
+
# No confidence field, add to low confidence
|
|
165
|
+
low_confidence_detections.append(detection)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
confidence = float(confidence)
|
|
170
|
+
if confidence >= threshold:
|
|
171
|
+
high_confidence_detections.append(detection)
|
|
172
|
+
else:
|
|
173
|
+
low_confidence_detections.append(detection)
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
# Invalid confidence value, add to low confidence
|
|
176
|
+
low_confidence_detections.append(detection)
|
|
177
|
+
|
|
178
|
+
# Create output messages - use shallow copies since send() deep copies
|
|
179
|
+
msg[MessageKeys.CV.THRESHOLD] = threshold
|
|
180
|
+
high_msg = msg.copy()
|
|
181
|
+
low_msg = msg.copy()
|
|
182
|
+
|
|
183
|
+
# Copy payload dict since shallow copy shares nested objects
|
|
184
|
+
payload = msg.get(MessageKeys.PAYLOAD, {})
|
|
185
|
+
if isinstance(payload, dict):
|
|
186
|
+
high_msg[MessageKeys.PAYLOAD] = payload.copy()
|
|
187
|
+
low_msg[MessageKeys.PAYLOAD] = payload.copy()
|
|
188
|
+
|
|
189
|
+
# Set filtered detection arrays directly on payload
|
|
190
|
+
high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = high_confidence_detections
|
|
191
|
+
low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = low_confidence_detections
|
|
192
|
+
|
|
193
|
+
# Update detection counts if they exist
|
|
194
|
+
if MessageKeys.CV.DETECTION_COUNT in msg:
|
|
195
|
+
high_msg[MessageKeys.CV.DETECTION_COUNT] = len(high_confidence_detections)
|
|
196
|
+
low_msg[MessageKeys.CV.DETECTION_COUNT] = len(low_confidence_detections)
|
|
197
|
+
|
|
198
|
+
elif MessageKeys.PAYLOAD in msg and isinstance(msg[MessageKeys.PAYLOAD], dict) and MessageKeys.CV.DETECTION_COUNT in msg[MessageKeys.PAYLOAD]:
|
|
199
|
+
high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = len(high_confidence_detections)
|
|
200
|
+
low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = len(low_confidence_detections)
|
|
201
|
+
|
|
202
|
+
else:
|
|
203
|
+
high_msg[MessageKeys.CV.DETECTION_COUNT] = len(high_confidence_detections)
|
|
204
|
+
low_msg[MessageKeys.CV.DETECTION_COUNT] = len(low_confidence_detections)
|
|
205
|
+
|
|
206
|
+
# Always send to both outputs
|
|
207
|
+
self.send(high_msg, 0) # High confidence detections
|
|
208
|
+
self.send(low_msg, 1) # Low confidence detections
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No external dependencies - uses only Python standard library
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Counter Node - counts messages and displays the count with a reset button.
|
|
3
|
+
Demonstrates the use of UI components (button + rate-display).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
8
|
+
|
|
9
|
+
_info = Info()
|
|
10
|
+
_info.add_text("Counts incoming messages and outputs the running total. Includes a reset button to restart the count.")
|
|
11
|
+
_info.add_header("Input")
|
|
12
|
+
_info.add_bullets(
|
|
13
|
+
("msg:", "Any message triggers the counter increment."),
|
|
14
|
+
)
|
|
15
|
+
_info.add_header("Output")
|
|
16
|
+
_info.add_bullets(
|
|
17
|
+
("payload.count:", "Current count value."),
|
|
18
|
+
("payload.original_payload:", "The original payload from the input message."),
|
|
19
|
+
("payload.display:", "Formatted count for display."),
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Properties")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("Initial Value:", "Starting count value."),
|
|
24
|
+
("Increment By:", "Amount to add per message."),
|
|
25
|
+
)
|
|
26
|
+
_info.add_header("UI")
|
|
27
|
+
_info.add_text("Click the reset button on the node to reset the counter to initial value.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CounterNode(BaseNode):
|
|
31
|
+
"""
|
|
32
|
+
Counter node - counts incoming messages and displays the total.
|
|
33
|
+
Can be reset via button click.
|
|
34
|
+
"""
|
|
35
|
+
info = str(_info)
|
|
36
|
+
display_name = 'Counter'
|
|
37
|
+
icon = '🔢'
|
|
38
|
+
category = 'node probes'
|
|
39
|
+
color = '#E2D96E'
|
|
40
|
+
border_color = '#B8AF4A'
|
|
41
|
+
text_color = '#000000'
|
|
42
|
+
input_count = 1
|
|
43
|
+
output_count = 1
|
|
44
|
+
|
|
45
|
+
# Counter display UI component with reset button
|
|
46
|
+
ui_component = 'counter-display'
|
|
47
|
+
ui_component_config = {
|
|
48
|
+
'format': '{value}',
|
|
49
|
+
'action': 'reset_counter',
|
|
50
|
+
'tooltip': 'Reset Count'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# UI-triggerable actions (see BaseNode.actions)
|
|
54
|
+
actions = ['reset_counter']
|
|
55
|
+
|
|
56
|
+
# get_count_sse does its own change-detection (returns None when the count
|
|
57
|
+
# is unchanged), so a low throttle just bounds the cheap comparison rate
|
|
58
|
+
# while letting visible updates through almost immediately.
|
|
59
|
+
sse_handlers = [
|
|
60
|
+
{'type': 'counter', 'handler': 'get_count_sse', 'throttle': 0.1},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
DEFAULT_CONFIG = {
|
|
64
|
+
'initial_value': '0',
|
|
65
|
+
'increment': '1',
|
|
66
|
+
'retain_payload': 'false',
|
|
67
|
+
MessageKeys.DROP_MESSAGES: 'false'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
properties = [
|
|
71
|
+
{
|
|
72
|
+
'name': MessageKeys.DROP_MESSAGES,
|
|
73
|
+
'label': 'Drop Messages When Busy',
|
|
74
|
+
'type': 'checkbox',
|
|
75
|
+
'default': False
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
'name': 'initial_value',
|
|
79
|
+
'label': 'Initial Value',
|
|
80
|
+
'type': 'text',
|
|
81
|
+
'default': DEFAULT_CONFIG['initial_value'],
|
|
82
|
+
'help': 'Starting count value'
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
'name': 'increment',
|
|
86
|
+
'label': 'Increment By',
|
|
87
|
+
'type': 'text',
|
|
88
|
+
'default': DEFAULT_CONFIG['increment'],
|
|
89
|
+
'help': 'Amount to increment per message'
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
'name': 'retain_payload',
|
|
93
|
+
'label': 'Retain Original Payload',
|
|
94
|
+
'type': 'select',
|
|
95
|
+
'options': [
|
|
96
|
+
{'value': 'false', 'label': 'No - Replace payload with count info'},
|
|
97
|
+
{'value': 'true', 'label': 'Yes - Merge count info into payload'}
|
|
98
|
+
],
|
|
99
|
+
'default': DEFAULT_CONFIG['retain_payload'],
|
|
100
|
+
'help': 'Whether to keep original payload data or replace it'
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def __init__(self, node_id=None, name="counter"):
|
|
105
|
+
super().__init__(node_id, name)
|
|
106
|
+
self.count = 0
|
|
107
|
+
# Last value emitted via SSE; None means nothing emitted yet so the
|
|
108
|
+
# first poll always broadcasts the current count.
|
|
109
|
+
self._last_sse_count = None
|
|
110
|
+
self._reset_to_initial()
|
|
111
|
+
|
|
112
|
+
def _reset_to_initial(self):
|
|
113
|
+
"""Reset counter to initial value from config."""
|
|
114
|
+
self.count = self.get_config_int('initial_value', 0)
|
|
115
|
+
|
|
116
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
117
|
+
"""
|
|
118
|
+
Increment counter and pass through with count information.
|
|
119
|
+
"""
|
|
120
|
+
# Get increment value
|
|
121
|
+
increment = self.get_config_int('increment', 1)
|
|
122
|
+
|
|
123
|
+
# Increment counter
|
|
124
|
+
self.count += increment
|
|
125
|
+
|
|
126
|
+
# Build count info
|
|
127
|
+
count_info = {
|
|
128
|
+
'count': self.count,
|
|
129
|
+
'display': self.get_count_display()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Check if we should retain original payload
|
|
133
|
+
if self.get_config_bool('retain_payload', False):
|
|
134
|
+
original_payload = msg.get(MessageKeys.PAYLOAD, {})
|
|
135
|
+
if isinstance(original_payload, dict):
|
|
136
|
+
new_payload = original_payload.copy()
|
|
137
|
+
new_payload.update(count_info)
|
|
138
|
+
else:
|
|
139
|
+
new_payload = {
|
|
140
|
+
'original_data': original_payload,
|
|
141
|
+
**count_info
|
|
142
|
+
}
|
|
143
|
+
else:
|
|
144
|
+
new_payload = count_info
|
|
145
|
+
|
|
146
|
+
msg[MessageKeys.PAYLOAD] = new_payload
|
|
147
|
+
msg[MessageKeys.TOPIC] = msg.get(MessageKeys.TOPIC, 'counter')
|
|
148
|
+
|
|
149
|
+
self.send(msg)
|
|
150
|
+
|
|
151
|
+
def get_count(self) -> int:
|
|
152
|
+
"""Get the current count value."""
|
|
153
|
+
return self.count
|
|
154
|
+
|
|
155
|
+
def get_count_display(self) -> str:
|
|
156
|
+
"""Get formatted count string for display."""
|
|
157
|
+
return str(self.count)
|
|
158
|
+
|
|
159
|
+
def reset_counter(self):
|
|
160
|
+
"""
|
|
161
|
+
Action triggered by the reset button.
|
|
162
|
+
Resets counter to initial value and sends notification.
|
|
163
|
+
"""
|
|
164
|
+
self._reset_to_initial()
|
|
165
|
+
|
|
166
|
+
# Send reset notification
|
|
167
|
+
msg = self.create_message(
|
|
168
|
+
payload={
|
|
169
|
+
'count': self.count,
|
|
170
|
+
'action': 'reset',
|
|
171
|
+
'display': self.get_count_display()
|
|
172
|
+
},
|
|
173
|
+
topic='counter/reset'
|
|
174
|
+
)
|
|
175
|
+
self.send(msg)
|
|
176
|
+
|
|
177
|
+
def get_count_sse(self):
|
|
178
|
+
"""SSE handler: return counter data for broadcast.
|
|
179
|
+
|
|
180
|
+
Change-detection: returns None when the count has not changed since
|
|
181
|
+
the last emitted value, so the broadcast worker skips redundant
|
|
182
|
+
messages (it treats a None result as "nothing to send").
|
|
183
|
+
"""
|
|
184
|
+
count = self.get_count()
|
|
185
|
+
if count == self._last_sse_count:
|
|
186
|
+
return None
|
|
187
|
+
self._last_sse_count = count
|
|
188
|
+
return {
|
|
189
|
+
'display': self.get_count_display(),
|
|
190
|
+
'count': count,
|
|
191
|
+
}
|