3636"""
3737
3838from dataclasses import dataclass
39+ from enum import Enum
3940import threading
4041import time
4142from typing import Dict
4243
4344from diagnostic_msgs .msg import DiagnosticArray , DiagnosticStatus
4445from greenwave_monitor_interfaces .srv import ManageTopic , SetExpectedFrequency
46+ from rcl_interfaces .msg import ParameterEvent , ParameterType , ParameterValue
47+ from rcl_interfaces .srv import GetParameters , ListParameters
4548import rclpy
4649from rclpy .node import Node
4750
51+ # Parameter name constants
52+ TOPIC_PARAM_PREFIX = 'topics.'
53+ FREQ_SUFFIX = '.expected_frequency'
54+ TOL_SUFFIX = '.tolerance'
55+ DEFAULT_TOLERANCE_PERCENT = 5.0
56+
57+
58+ class TopicParamField (Enum ):
59+ """Type of topic parameter field."""
60+
61+ NONE = 0
62+ FREQUENCY = 1
63+ TOLERANCE = 2
64+
65+
66+ def parse_topic_param_name (param_name : str ) -> tuple [str , TopicParamField ]:
67+ """Parse parameter name to extract topic name and field type."""
68+ if not param_name .startswith (TOPIC_PARAM_PREFIX ):
69+ return ('' , TopicParamField .NONE )
70+
71+ topic_and_field = param_name [len (TOPIC_PARAM_PREFIX ):]
72+
73+ if topic_and_field .endswith (FREQ_SUFFIX ):
74+ topic_name = topic_and_field [:- len (FREQ_SUFFIX )]
75+ return (topic_name , TopicParamField .FREQUENCY )
76+ elif topic_and_field .endswith (TOL_SUFFIX ):
77+ topic_name = topic_and_field [:- len (TOL_SUFFIX )]
78+ return (topic_name , TopicParamField .TOLERANCE )
79+
80+ return ('' , TopicParamField .NONE )
81+
82+
83+ def param_value_to_float (value : ParameterValue ) -> float | None :
84+ """Convert a ParameterValue to float if it's a numeric type."""
85+ if value .type == ParameterType .PARAMETER_DOUBLE :
86+ return value .double_value
87+ elif value .type == ParameterType .PARAMETER_INTEGER :
88+ return float (value .integer_value )
89+ return None
90+
4891
4992@dataclass
5093class UiDiagnosticData :
@@ -119,6 +162,13 @@ def _setup_ros_components(self):
119162 100
120163 )
121164
165+ self .param_events_subscription = self .node .create_subscription (
166+ ParameterEvent ,
167+ '/parameter_events' ,
168+ self ._on_parameter_event ,
169+ 10
170+ )
171+
122172 manage_service_name = f'{ self .monitor_node_name } /manage_topic'
123173 set_freq_service_name = f'{ self .monitor_node_name } /set_expected_frequency'
124174
@@ -134,6 +184,108 @@ def _setup_ros_components(self):
134184 set_freq_service_name
135185 )
136186
187+ # Parameter service clients for querying initial state
188+ self .list_params_client = self .node .create_client (
189+ ListParameters ,
190+ f'{ self .monitor_node_name } /list_parameters'
191+ )
192+ self .get_params_client = self .node .create_client (
193+ GetParameters ,
194+ f'{ self .monitor_node_name } /get_parameters'
195+ )
196+
197+ # Query initial parameters after a short delay to let services come up
198+ self ._initial_params_timer = self .node .create_timer (
199+ 0.1 , self ._fetch_initial_parameters_callback )
200+
201+ def _fetch_initial_parameters_callback (self ):
202+ """Timer callback to fetch initial parameters (retries until services ready)."""
203+ # Check if services are available (non-blocking)
204+ if not self .list_params_client .service_is_ready ():
205+ return # Timer will retry
206+
207+ if not self .get_params_client .service_is_ready ():
208+ return # Timer will retry
209+
210+ # Cancel timer now that services are ready
211+ if self ._initial_params_timer is not None :
212+ self ._initial_params_timer .cancel ()
213+ self ._initial_params_timer = None
214+
215+ # List all parameters (prefixes filter is unreliable with nested names)
216+ list_request = ListParameters .Request ()
217+ list_request .prefixes = ['topics' ]
218+ list_request .depth = 10
219+
220+ list_future = self .list_params_client .call_async (list_request )
221+ list_future .add_done_callback (self ._on_list_parameters_response )
222+
223+ def _on_list_parameters_response (self , future ):
224+ """Handle response from list_parameters service."""
225+ try :
226+ if future .result () is None :
227+ return
228+
229+ all_param_names = future .result ().result .names
230+
231+ # Filter to only topic parameters (prefixes filter is unreliable)
232+ param_names = [n for n in all_param_names if n .startswith (TOPIC_PARAM_PREFIX )]
233+
234+ if not param_names :
235+ return
236+
237+ # Store for use in get callback
238+ self ._pending_param_names = param_names
239+
240+ # Get values for topic parameters only
241+ get_request = GetParameters .Request ()
242+ get_request .names = param_names
243+
244+ get_future = self .get_params_client .call_async (get_request )
245+ get_future .add_done_callback (self ._on_get_parameters_response )
246+ except Exception as e :
247+ self .node .get_logger ().debug (f'Error listing parameters: { e } ' )
248+
249+ def _on_get_parameters_response (self , future ):
250+ """Handle response from get_parameters service."""
251+ try :
252+ if future .result () is None :
253+ return
254+
255+ param_names = getattr (self , '_pending_param_names' , [])
256+ values = future .result ().values
257+
258+ # Parse parameters into expected_frequencies
259+ topic_configs : Dict [str , Dict [str , float ]] = {}
260+
261+ for name , value in zip (param_names , values ):
262+ numeric_value = param_value_to_float (value )
263+ if numeric_value is None :
264+ continue
265+
266+ topic_name , field = parse_topic_param_name (name )
267+ if not topic_name or field == TopicParamField .NONE :
268+ continue
269+
270+ if topic_name not in topic_configs :
271+ topic_configs [topic_name ] = {}
272+
273+ if field == TopicParamField .FREQUENCY :
274+ topic_configs [topic_name ]['freq' ] = numeric_value
275+ elif field == TopicParamField .TOLERANCE :
276+ topic_configs [topic_name ]['tol' ] = numeric_value
277+
278+ # Update expected_frequencies with valid configs
279+ with self .data_lock :
280+ for topic_name , config in topic_configs .items ():
281+ freq = config .get ('freq' , 0.0 )
282+ tol = config .get ('tol' , DEFAULT_TOLERANCE_PERCENT )
283+ if freq > 0 :
284+ self .expected_frequencies [topic_name ] = (freq , tol )
285+
286+ except Exception as e :
287+ self .node .get_logger ().debug (f'Error fetching parameters: { e } ' )
288+
137289 def _extract_topic_name (self , diagnostic_name : str ) -> str :
138290 """
139291 Extract topic name from diagnostic status name.
@@ -168,6 +320,35 @@ def _on_diagnostics(self, msg: DiagnosticArray):
168320 topic_name = self ._extract_topic_name (status .name )
169321 self .ui_diagnostics [topic_name ] = ui_data
170322
323+ def _on_parameter_event (self , msg : ParameterEvent ):
324+ """Process parameter change events from the monitor node."""
325+ # Only process events from the monitor node
326+ if self .monitor_node_name not in msg .node :
327+ return
328+
329+ # Process changed and new parameters
330+ for param in msg .changed_parameters + msg .new_parameters :
331+ value = param_value_to_float (param .value )
332+ if value is None :
333+ continue
334+
335+ topic_name , field = parse_topic_param_name (param .name )
336+ if not topic_name or field == TopicParamField .NONE :
337+ continue
338+
339+ with self .data_lock :
340+ current = self .expected_frequencies .get (topic_name , (0.0 , 0.0 ))
341+
342+ if field == TopicParamField .FREQUENCY :
343+ if value > 0 :
344+ self .expected_frequencies [topic_name ] = (value , current [1 ])
345+ elif topic_name in self .expected_frequencies :
346+ del self .expected_frequencies [topic_name ]
347+
348+ elif field == TopicParamField .TOLERANCE :
349+ if current [0 ] > 0 : # Only update if frequency is set
350+ self .expected_frequencies [topic_name ] = (current [0 ], value )
351+
171352 def toggle_topic_monitoring (self , topic_name : str ):
172353 """Toggle monitoring for a topic."""
173354 if not self .manage_topic_client .wait_for_service (timeout_sec = 1.0 ):
0 commit comments