osiris-bridge 0.5.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.
@@ -0,0 +1,9 @@
1
+ """osiris_agent package initializer."""
2
+
3
+ __version__ = '0.5.0'
4
+
5
+ __all__ = [
6
+ "agent_node",
7
+ "ros2_control_collector",
8
+ "tf_tree_collector",
9
+ ]
@@ -0,0 +1,199 @@
1
+ """Action monitoring: resolves action types and forwards goal status changes and feedback."""
2
+
3
+ import time
4
+
5
+ from rclpy.qos import QoSProfile, qos_profile_action_status_default
6
+ from rosidl_runtime_py import message_to_ordereddict
7
+ from rosidl_runtime_py.utilities import get_message
8
+
9
+ ACTION_FEEDBACK_MIN_INTERVAL = 0.2 # seconds between forwarded feedback messages per action (5 Hz cap)
10
+
11
+ # action_msgs/GoalStatus: SUCCEEDED, CANCELED, ABORTED.
12
+ TERMINAL_GOAL_STATUSES = (4, 5, 6)
13
+
14
+
15
+ class ActionMonitorMixin:
16
+
17
+ def _fetch_action_types(self, action_name: str, topic_type_map: dict) -> bool:
18
+ """Resolve and cache goal/result/feedback types for an action server.
19
+
20
+ Looks up the [action]/_action/feedback topic type, strips the
21
+ _FeedbackMessage suffix to derive the base action type, then imports
22
+ the _Goal / _Result / _Feedback message classes to introspect fields.
23
+
24
+ Returns True if types were resolved and enqueued, False if not yet
25
+ available (e.g. the feedback topic hasn't appeared in DDS yet).
26
+ """
27
+ if action_name in self._action_type_cache:
28
+ return self._action_type_cache[action_name] is not None
29
+
30
+ feedback_topic = f'{action_name}/_action/feedback'
31
+ types_list = topic_type_map.get(feedback_topic)
32
+ if not types_list:
33
+ return False
34
+
35
+ feedback_msg_type = types_list[0]
36
+ if not feedback_msg_type.endswith('_FeedbackMessage'):
37
+ return False
38
+
39
+ base_type = feedback_msg_type[:-len('_FeedbackMessage')]
40
+
41
+ try:
42
+ # base_type is e.g. 'nav2_msgs/action/NavigateToPose'
43
+ # Action sub-types (Goal/Result/Feedback) are nested on the action
44
+ # class itself — get_message() only handles message types, not actions.
45
+ import importlib
46
+ pkg, _, action_name_part = base_type.split('/', 2)
47
+ # action_name_part may be 'action/NavigateToPose' — take just the class name
48
+ class_name = action_name_part.split('/')[-1]
49
+ action_mod = importlib.import_module(f'{pkg}.action')
50
+ action_cls = getattr(action_mod, class_name)
51
+ # Use get_message() for the _FeedbackMessage type — it IS a standalone message
52
+ # type (unlike _Goal/_Result/_Feedback which are nested). The type string
53
+ # comes directly from the topic registry so it's always correct.
54
+ feedback_msg_cls = get_message(feedback_msg_type)
55
+
56
+ goal_cls = action_cls.Goal
57
+ result_cls = action_cls.Result
58
+ feedback_cls = action_cls.Feedback
59
+
60
+ def _fields(cls):
61
+ try:
62
+ return dict(cls.get_fields_and_field_types())
63
+ except Exception:
64
+ return {}
65
+
66
+ type_info = {
67
+ 'goal_type': f'{base_type}_Goal',
68
+ 'result_type': f'{base_type}_Result',
69
+ 'feedback_type': f'{base_type}_Feedback',
70
+ 'goal_fields': _fields(goal_cls),
71
+ 'result_fields': _fields(result_cls),
72
+ 'feedback_fields': _fields(feedback_cls),
73
+ # Wire message type (not the same string as feedback_type
74
+ # above — that's the nested class name, this is the
75
+ # standalone _FeedbackMessage actually subscribed to) kept
76
+ # so a later goals_enabled toggle-on can catch up the
77
+ # feedback subscription without redoing type resolution —
78
+ # see _catch_up_action_feedback.
79
+ '_feedback_msg_type': feedback_msg_type,
80
+ }
81
+ self._action_type_cache[action_name] = type_info
82
+ self.get_logger().info(f'[actions] resolved types for {action_name}: {base_type}')
83
+ self._graph_dirty = True
84
+ if self._goals_enabled:
85
+ self._subscribe_action_feedback(action_name, feedback_msg_cls)
86
+ return True
87
+ except Exception as e:
88
+ self.get_logger().warning(f'[actions] failed to resolve types for {action_name}: {e}')
89
+ self._action_type_cache[action_name] = None # mark failed — avoid retry spam
90
+ return False
91
+
92
+ def _subscribe_action_status(self, action_name: str):
93
+ if action_name in self._action_status_subs:
94
+ return
95
+ try:
96
+ from action_msgs.msg import GoalStatusArray
97
+ # Action status publishers use RELIABLE + TRANSIENT_LOCAL
98
+ # (rcl_action_qos_profile_status_default), not the plain default
99
+ # (VOLATILE) QoSProfile(depth=10) used elsewhere — a durability
100
+ # mismatch here means a subscription created after a goal already
101
+ # reached a terminal state (agent restart, resubscribe, or any
102
+ # gap in this specific subscription's lifetime) would never
103
+ # receive that goal's actual last status. qos_profile_action_
104
+ # status_default is rclpy's own copy of that exact profile.
105
+ sub = self.create_subscription(
106
+ GoalStatusArray,
107
+ f'{action_name}/_action/status',
108
+ lambda msg, a=action_name: self._on_action_status(msg, a),
109
+ qos_profile_action_status_default,
110
+ )
111
+ self._action_status_subs[action_name] = sub
112
+ self._action_status_unread.add(action_name)
113
+ self.get_logger().info(f'[actions] subscribed to status for {action_name}')
114
+ except Exception as e:
115
+ self.get_logger().warning(f'[actions] failed to subscribe to status for {action_name}: {e}')
116
+
117
+ def _subscribe_action_feedback(self, action_name: str, feedback_msg_cls):
118
+ if action_name in self._action_feedback_subs:
119
+ return
120
+ try:
121
+ sub = self.create_subscription(
122
+ feedback_msg_cls,
123
+ f'{action_name}/_action/feedback',
124
+ lambda msg, a=action_name: self._on_action_feedback(msg, a),
125
+ QoSProfile(depth=10),
126
+ )
127
+ self._action_feedback_subs[action_name] = sub
128
+ self.get_logger().info(f'[actions] subscribed to feedback for {action_name}')
129
+ except Exception as e:
130
+ self.get_logger().warning(f'[actions] failed to subscribe to feedback for {action_name}: {e}')
131
+
132
+ def _catch_up_action_feedback(self, action_name: str):
133
+ """Subscribes to feedback for an action whose type was already
134
+ resolved (cached) while goals_enabled was False. _fetch_action_types
135
+ early-returns once an action is cached, so simply re-calling it here
136
+ would silently no-op instead of subscribing — this reuses the
137
+ cached _feedback_msg_type instead of redoing type resolution."""
138
+ type_info = self._action_type_cache.get(action_name)
139
+ if not type_info:
140
+ return
141
+ try:
142
+ feedback_msg_cls = get_message(type_info['_feedback_msg_type'])
143
+ self._subscribe_action_feedback(action_name, feedback_msg_cls)
144
+ except Exception as e:
145
+ self.get_logger().warning(f'[actions] failed to catch up feedback subscription for {action_name}: {e}')
146
+
147
+ def _unsubscribe_action_monitoring(self, action_name: str):
148
+ sub = self._action_status_subs.pop(action_name, None)
149
+ if sub:
150
+ self.destroy_subscription(sub)
151
+ sub = self._action_feedback_subs.pop(action_name, None)
152
+ if sub:
153
+ self.destroy_subscription(sub)
154
+ self._action_goal_states.pop(action_name, None)
155
+ self._action_feedback_throttle.pop(action_name, None)
156
+ self._action_status_unread.discard(action_name)
157
+
158
+ def _on_action_status(self, msg, action_name: str):
159
+ # A new subscription's first message is the status the action server
160
+ # last published, kept for late joiners: after an agent restart it
161
+ # still lists the action's last goals, however long ago they ended.
162
+ # A goal already finished in it ended at a time this message doesn't
163
+ # tell, so it is marked replayed rather than taken for a goal that
164
+ # just ended.
165
+ first = action_name in self._action_status_unread
166
+ self._action_status_unread.discard(action_name)
167
+ prev = self._action_goal_states.get(action_name, {})
168
+ current = {bytes(s.goal_info.goal_id.uuid).hex(): s.status for s in msg.status_list}
169
+ for uuid_hex, status in current.items():
170
+ if uuid_hex not in prev or prev[uuid_hex] != status:
171
+ event = {
172
+ 'type': 'goal_event',
173
+ 'action': action_name,
174
+ 'goal_id': uuid_hex,
175
+ 'status': status,
176
+ 'timestamp': time.time(),
177
+ }
178
+ if first and status in TERMINAL_GOAL_STATUSES:
179
+ event['replayed'] = True
180
+ self._enqueue(event)
181
+ self._action_goal_states[action_name] = current
182
+
183
+ def _on_action_feedback(self, msg, action_name: str):
184
+ now = time.time()
185
+ if now - self._action_feedback_throttle.get(action_name, 0.0) < ACTION_FEEDBACK_MIN_INTERVAL:
186
+ return
187
+ self._action_feedback_throttle[action_name] = now
188
+ try:
189
+ feedback_data = message_to_ordereddict(msg.feedback)
190
+ except Exception as e:
191
+ self.get_logger().warning(f'[actions] feedback serialization failed for {action_name}: {e}')
192
+ return
193
+ self._enqueue({
194
+ 'type': 'action_feedback',
195
+ 'action': action_name,
196
+ 'goal_id': bytes(msg.goal_id.uuid).hex(),
197
+ 'feedback': feedback_data,
198
+ 'timestamp': now,
199
+ })
@@ -0,0 +1,455 @@
1
+ """Agent configuration: resolves every setting from the params file, the cloud agent_config or its default, and applies it."""
2
+
3
+ import time
4
+
5
+ from rclpy.parameter import Parameter
6
+
7
+ from .agent_config_validation import validate_agent_config
8
+ from .bt_collector import BTCollector
9
+ from .command_policy import CommandLimits
10
+ from .qos import STATUS_QOS
11
+ from .ros2_control_collector import Ros2ControlCollector
12
+ from .tf_tree_collector import TfTreeCollector
13
+
14
+ # Settings only the robot sets, with a ROS parameter or a params file. The
15
+ # cloud configuration never carries them, so setting one on the robot doesn't
16
+ # make the rest of the cloud configuration step aside.
17
+ ROBOT_ONLY_PARAMETERS = frozenset({'bag_output_dir'})
18
+
19
+
20
+ def cloud_config_overrides(parameter_overrides):
21
+ """The parameters set on the robot (on the command line or in a params
22
+ file) that make the agent run from its own settings instead of the cloud
23
+ configuration: all of them except the robot-only ones."""
24
+ return set(parameter_overrides) - ROBOT_ONLY_PARAMETERS
25
+
26
+
27
+ class AgentConfigMixin:
28
+
29
+ def _apply_agent_config_fallback(self):
30
+ self._agent_config_fallback_timer.cancel()
31
+ if self._agent_config_received:
32
+ return
33
+ self.get_logger().warning('No agent_config received within timeout — applying local param defaults')
34
+ self._apply_agent_config({})
35
+
36
+ def _resolve_config_value(self, name, config, local_default, cast=None):
37
+ """All-or-nothing per agent run, not a per-field override: passing
38
+ --params-file at all is a deliberate choice to run off that yaml
39
+ file, so every field resolves from it (yaml value if the file sets
40
+ this one, else its hardcoded declare_parameter default) and the
41
+ cloud agent_config is ignored entirely for the whole run — not just
42
+ for the specific fields the yaml file happens to set. Without a
43
+ params file, every field resolves from the cloud config if present,
44
+ else the hardcoded default. self._param_overrides being non-empty at
45
+ all (regardless of which names it contains) is what decides which
46
+ source every field uses — the two sources never mix within a run.
47
+ """
48
+ if self._param_overrides:
49
+ return local_default
50
+ if name in config:
51
+ return cast(config[name]) if cast else config[name]
52
+ return local_default
53
+
54
+ def _apply_agent_config(self, config: dict) -> None:
55
+ """Applies the gateway-pushed feature-toggle config (sent right after
56
+ auth_success on every connect, and by _apply_agent_config_fallback if
57
+ that never arrives). Precedence is all-or-nothing per run: with a
58
+ local yaml/CLI override, every field comes from it or its hardcoded
59
+ default and the cloud agent_config is ignored; otherwise each field
60
+ comes from the cloud agent_config, else its hardcoded default — see
61
+ _resolve_config_value. A robot with nothing set in agent_config, and
62
+ no yaml override either, runs on the hardcoded defaults. Single
63
+ dispatch point so each new toggle (Graph, Params, Nav2, Goals, BT,
64
+ TF Tree) has one place to land rather than scattering config reads
65
+ across the agent.
66
+
67
+ A cloud push is validated as a whole before anything changes, and a
68
+ push with any malformed field is rejected (see _reject_agent_config).
69
+ With a params file the cloud config is never read, so it is not
70
+ validated either.
71
+ """
72
+ if not self._param_overrides:
73
+ config, problems = validate_agent_config(config)
74
+ if problems:
75
+ self._reject_agent_config(problems)
76
+ return
77
+ self._agent_config_received = True
78
+
79
+ self._telemetry_enabled = self._resolve_config_value('telemetry_enabled', config, self._telemetry_enabled_default, bool)
80
+
81
+ # TF tree: resolve the final answer, then construct/destroy the
82
+ # collector at most once to reach it — never both in the same pass.
83
+ tf_tree_enabled = self._resolve_config_value('tf_tree_enabled', config, self._tf_tree_enabled_default, bool)
84
+ if tf_tree_enabled and self._tf_tree is None:
85
+ self._tf_tree = TfTreeCollector(
86
+ node=self,
87
+ event_callback=self._on_tf_tree_event,
88
+ logger=self.get_logger(),
89
+ )
90
+ self.get_logger().info('TF tree monitoring started')
91
+ elif not tf_tree_enabled and self._tf_tree is not None:
92
+ self._tf_tree.destroy()
93
+ self._tf_tree = None
94
+ self.get_logger().info('TF tree monitoring stopped')
95
+
96
+ # TF tree poll timer: independent of the collector above — _poll_tf_tree
97
+ # is a no-op whenever self._tf_tree is None, so the timer's period is
98
+ # resolved and (re)created here regardless of tf_tree_enabled, exactly
99
+ # like telemetry's own timer runs unconditionally.
100
+ tf_tree_poll_interval = self._resolve_config_value('tf_tree_poll_interval', config, self._tf_tree_poll_interval_default, float)
101
+ if self._tf_tree_poll_timer is None or self._tf_tree_poll_interval != tf_tree_poll_interval:
102
+ if self._tf_tree_poll_timer is not None:
103
+ self._tf_tree_poll_timer.cancel()
104
+ self._tf_tree_poll_timer = self.create_timer(tf_tree_poll_interval, self._poll_tf_tree)
105
+ self._tf_tree_poll_interval = tf_tree_poll_interval
106
+
107
+ # ros2_control: same construct/destroy-at-most-once pattern as TF
108
+ # tree, plus the same reconnect-if-changed handling as bt_conn below
109
+ # — poll_interval is a constructor arg baked into the collector
110
+ # (rate-limit check in Ros2ControlCollector.poll()), not re-read
111
+ # live, so a changed interval while already enabled needs an actual
112
+ # destroy+reconstruct to ever take effect. Its poll timer comes and
113
+ # goes with it: a hardware component changing state, or a controller
114
+ # switched twice within one interval, changes nothing in the ROS
115
+ # graph, so the graph check's own poll would leave the reported state
116
+ # stale. A period of 0 or less (possible from a params file, which
117
+ # isn't validated) gets no timer, since it would spin.
118
+ ros2_control_enabled = self._resolve_config_value('ros2_control_enabled', config, self._ros2_control_enabled_default, bool)
119
+ ros2_control_poll_interval = self._resolve_config_value('ros2_control_poll_interval', config, self._ros2_control_poll_interval_default, float)
120
+ if ros2_control_enabled and (self._ros2_control is None or self._ros2_control_poll_interval != ros2_control_poll_interval):
121
+ if self._ros2_control is not None:
122
+ self._ros2_control.destroy()
123
+ self._ros2_control = Ros2ControlCollector(
124
+ node=self,
125
+ event_callback=self._on_ros2_control_event,
126
+ logger=self.get_logger(),
127
+ poll_interval=ros2_control_poll_interval,
128
+ )
129
+ self._ros2_control_poll_interval = ros2_control_poll_interval
130
+ if self._ros2_control_poll_timer is not None:
131
+ self._ros2_control_poll_timer.cancel()
132
+ self._ros2_control_poll_timer = (
133
+ self.create_timer(ros2_control_poll_interval, self._poll_ros2_control)
134
+ if ros2_control_poll_interval > 0 else None
135
+ )
136
+ self.get_logger().info('ros2_control monitoring started')
137
+ elif not ros2_control_enabled and self._ros2_control is not None:
138
+ self._ros2_control.destroy()
139
+ self._ros2_control = None
140
+ if self._ros2_control_poll_timer is not None:
141
+ self._ros2_control_poll_timer.cancel()
142
+ self._ros2_control_poll_timer = None
143
+ self.get_logger().info('ros2_control monitoring stopped')
144
+
145
+ # Battery topic: the subscription's topic name is a constructor arg,
146
+ # so it's (re)created here whenever the resolved topic changes.
147
+ battery_topic = self._resolve_config_value('battery_topic', config, self._battery_topic_default)
148
+ if self._battery_sub is None or self._battery_topic != battery_topic:
149
+ if self._battery_sub is not None:
150
+ self.destroy_subscription(self._battery_sub)
151
+ self._battery_sub = None
152
+ try:
153
+ from sensor_msgs.msg import BatteryState as BatteryStateMsg
154
+ self._battery_sub = self.create_subscription(
155
+ BatteryStateMsg, battery_topic,
156
+ self._on_battery_state, STATUS_QOS,
157
+ )
158
+ self.get_logger().info(f'Battery state subscription active on {battery_topic}')
159
+ except Exception as e:
160
+ self.get_logger().warning(f'Battery state monitoring unavailable: {e}')
161
+ self._battery_topic = battery_topic
162
+
163
+ self._battery_check_enabled = self._resolve_config_value('battery_check_enabled', config, self._battery_check_enabled_default, bool)
164
+ self._battery_min_percent = self._resolve_config_value('battery_min_percent', config, self._battery_min_percent_default, float)
165
+
166
+ # Dock status topic — same (re)subscribe-on-change pattern as battery
167
+ # above, and subscribed whenever a topic is configured regardless of
168
+ # dock_check_enabled (same as _battery_sub existing independently of
169
+ # battery_check_enabled) — so the cache is already warm by the time
170
+ # the operator flips the check on, instead of guaranteeing an initial
171
+ # dock_status_unknown rejection while waiting for the first message.
172
+ # lazily imported since opennav_docking_msgs is Nav2-docking-specific,
173
+ # not guaranteed installed on every robot (same reasoning as
174
+ # nav2_msgs elsewhere).
175
+ self._dock_check_enabled = self._resolve_config_value('dock_check_enabled', config, self._dock_check_enabled_default, bool)
176
+ dock_status_topic = self._resolve_config_value('dock_status_topic', config, self._dock_status_topic_default)
177
+ if self._dock_status_sub is None or self._dock_status_topic != dock_status_topic:
178
+ if self._dock_status_sub is not None:
179
+ self.destroy_subscription(self._dock_status_sub)
180
+ self._dock_status_sub = None
181
+ self._last_dock_status = None
182
+ self._last_dock_status_time = None
183
+ if dock_status_topic:
184
+ try:
185
+ from opennav_docking_msgs.msg import DockStatus
186
+ self._dock_status_sub = self.create_subscription(
187
+ DockStatus, dock_status_topic,
188
+ self._on_dock_status, STATUS_QOS,
189
+ )
190
+ self.get_logger().info(f'Dock status subscription active on {dock_status_topic}')
191
+ except ImportError:
192
+ # Normal on a robot without Nav2 docking, and on Humble,
193
+ # whose opennav_docking_msgs has no DockStatus. The import
194
+ # is retried on each apply, so installing it later works.
195
+ if not self._dock_status_msg_missing_logged:
196
+ self._dock_status_msg_missing_logged = True
197
+ self.get_logger().info(
198
+ "Dock status is off: this robot's ROS install has no "
199
+ 'opennav_docking_msgs DockStatus message'
200
+ )
201
+ except Exception as e:
202
+ self.get_logger().warning(f'Dock status monitoring unavailable: {e}')
203
+ self._dock_status_topic = dock_status_topic
204
+
205
+ # Motion command gate. Only a literal true enables commands, so a
206
+ # malformed value in the cloud config can never switch them on. The
207
+ # limits are applied first, so the gate never opens with stale ones.
208
+ limits_default = self._command_limits_default
209
+ self._command_limits = CommandLimits(
210
+ max_speed_mps=self._resolve_config_value('command_max_speed_mps', config, limits_default.max_speed_mps, float),
211
+ max_distance_m=self._resolve_config_value('command_max_distance_m', config, limits_default.max_distance_m, float),
212
+ max_angle_deg=self._resolve_config_value('command_max_angle_deg', config, limits_default.max_angle_deg, float),
213
+ max_time_allowance_s=self._resolve_config_value('command_max_time_allowance_s', config, limits_default.max_time_allowance_s, float),
214
+ )
215
+ commands_enabled = self._resolve_config_value('commands_enabled', config, self._commands_enabled_default, lambda v: v is True)
216
+ if commands_enabled != self._commands_enabled:
217
+ self.get_logger().info(f'Robot commands {"enabled" if commands_enabled else "disabled"}')
218
+ self._commands_enabled = commands_enabled
219
+
220
+ # Goals: no single collector object to construct/destroy — just a set
221
+ # of per-action subscriptions. Turning on catches up on every action
222
+ # already known (self._active_actions, populated by the graph scan
223
+ # regardless of this flag); turning off tears all of them down.
224
+ goals_enabled = self._resolve_config_value('goals_enabled', config, self._goals_enabled_default, bool)
225
+ if goals_enabled and not self._goals_enabled:
226
+ for a in self._active_actions:
227
+ self._subscribe_action_status(a)
228
+ self._catch_up_action_feedback(a)
229
+ self.get_logger().info(f'Goal tracking started ({len(self._active_actions)} action(s))')
230
+ elif not goals_enabled and self._goals_enabled:
231
+ for a in list(set(self._action_status_subs) | set(self._action_feedback_subs)):
232
+ self._unsubscribe_action_monitoring(a)
233
+ self.get_logger().info('Goal tracking stopped')
234
+ self._goals_enabled = goals_enabled
235
+
236
+ # Params: no collector/subscription to construct/destroy either — just
237
+ # a cache. Turning on catches up on every node already known
238
+ # (self._active_nodes, populated by the graph scan regardless of this
239
+ # flag); turning off clears the cache so stale values don't keep
240
+ # showing in the Params pane after the user asked this to stop.
241
+ params_enabled = self._resolve_config_value('params_enabled', config, self._params_enabled_default, bool)
242
+ if params_enabled and not self._params_enabled:
243
+ for fqn in self._active_nodes:
244
+ self._fetch_node_parameters_async(fqn)
245
+ self.get_logger().info(f'Param fetching started ({len(self._active_nodes)} node(s))')
246
+ elif not params_enabled and self._params_enabled:
247
+ self._node_parameter_cache.clear()
248
+ self._pending_param_fetches.clear()
249
+ self._param_fetch_changes.clear()
250
+ self._graph_dirty = True
251
+ self.get_logger().info('Param fetching stopped')
252
+ self._params_enabled = params_enabled
253
+
254
+ # BT: mutually exclusive by construction — Nav2 BT and BT.CPP share
255
+ # the same event pipeline (_on_bt_event/_cached_bt_tree_event, no
256
+ # source tagging), so only one may ever be active. Tear down
257
+ # whichever isn't the resolved mode before starting the other, so a
258
+ # switch never briefly has both running. BT.CPP additionally
259
+ # reconnects if its host/port changed while already in btcpp mode —
260
+ # a running BTCollector is a live ZMQ connection bound to whatever
261
+ # host/port it was constructed with.
262
+ bt_mode = self._resolve_config_value('bt_mode', config, self._bt_mode_default)
263
+ bt_host = self._resolve_config_value('bt_host', config, self._bt_host_default)
264
+ bt_server_port = self._resolve_config_value('bt_server_port', config, self._bt_server_port_default, int)
265
+ bt_publisher_port = self._resolve_config_value('bt_publisher_port', config, self._bt_publisher_port_default, int)
266
+ bt_conn = (bt_host, bt_server_port, bt_publisher_port)
267
+
268
+ if bt_mode != 'nav2' and self._nav2_bt_monitor_initialized:
269
+ self._teardown_nav2_bt_monitor()
270
+ if bt_mode != 'btcpp' and self._bt_collector is not None:
271
+ self._bt_collector.stop()
272
+ self._bt_collector = None
273
+ self._bt_collector_conn = None
274
+
275
+ if bt_mode == 'nav2' and not self._nav2_bt_monitor_initialized:
276
+ self._init_nav2_bt_monitor()
277
+ elif bt_mode == 'btcpp' and (self._bt_collector is None or self._bt_collector_conn != bt_conn):
278
+ if self._bt_collector is not None:
279
+ self._bt_collector.stop()
280
+ self._bt_collector = BTCollector(
281
+ event_callback=self._on_bt_event,
282
+ host=bt_host,
283
+ server_port=bt_server_port,
284
+ publisher_port=bt_publisher_port,
285
+ logger=self.get_logger(),
286
+ )
287
+ self._bt_collector.start()
288
+ self._bt_collector_conn = bt_conn
289
+ self.get_logger().info(f'BT.CPP monitoring started ({bt_host}:{bt_server_port}/{bt_publisher_port})')
290
+
291
+ self._bt_mode = bt_mode
292
+
293
+ # graph_debounce_interval / topic_data_rate_hz /
294
+ # topic_data_max_bytes_per_sec / global_topic_data_max_bytes_per_sec /
295
+ # global_topic_data_max_msgs_per_sec / image_jpeg_quality /
296
+ # image_max_dimension / pointcloud_max_points /
297
+ # pointcloud_keep_intensity / pointcloud_keep_rgb /
298
+ # pointcloud_default_rate_hz: every consumer already calls
299
+ # self.get_parameter(...) fresh at time of use (a plain path string
300
+ # re-read on each bag list/download/record; a plain float re-read
301
+ # into a brand-new threading.Timer on every debounce trigger, not a
302
+ # fixed recurring ROS timer; a plain float/int re-read on every topic
303
+ # message in _on_topic_msg/_topic_data_over_budget) — so there's
304
+ # nothing to construct or defer here, just update the underlying ROS
305
+ # param when overridden. Same all-or-nothing rule as
306
+ # _resolve_config_value: a yaml params file being present at all
307
+ # (regardless of whether it sets these specific fields) means the
308
+ # cloud config is skipped for all of them — the ROS param already holds
309
+ # the yaml-or-hardcoded value and is simply left untouched.
310
+ # bag_output_dir isn't among them: it's set on the robot only (see
311
+ # ROBOT_ONLY_PARAMETERS), since it bounds what a bag download may read.
312
+ if not self._param_overrides and 'graph_debounce_interval' in config:
313
+ self.set_parameters([Parameter('graph_debounce_interval', Parameter.Type.DOUBLE, float(config['graph_debounce_interval']))])
314
+ if not self._param_overrides and 'topic_data_rate_hz' in config:
315
+ self.set_parameters([Parameter('topic_data_rate_hz', Parameter.Type.DOUBLE, float(config['topic_data_rate_hz']))])
316
+ if not self._param_overrides and 'topic_data_max_bytes_per_sec' in config:
317
+ self.set_parameters([Parameter('topic_data_max_bytes_per_sec', Parameter.Type.DOUBLE, float(config['topic_data_max_bytes_per_sec']))])
318
+ if not self._param_overrides and 'global_topic_data_max_bytes_per_sec' in config:
319
+ self.set_parameters([Parameter('global_topic_data_max_bytes_per_sec', Parameter.Type.DOUBLE, float(config['global_topic_data_max_bytes_per_sec']))])
320
+ if not self._param_overrides and 'global_topic_data_max_msgs_per_sec' in config:
321
+ self.set_parameters([Parameter('global_topic_data_max_msgs_per_sec', Parameter.Type.DOUBLE, float(config['global_topic_data_max_msgs_per_sec']))])
322
+ if not self._param_overrides and 'image_jpeg_quality' in config:
323
+ self.set_parameters([Parameter('image_jpeg_quality', Parameter.Type.INTEGER, int(config['image_jpeg_quality']))])
324
+ if not self._param_overrides and 'image_max_dimension' in config:
325
+ self.set_parameters([Parameter('image_max_dimension', Parameter.Type.INTEGER, int(config['image_max_dimension']))])
326
+ if not self._param_overrides and 'pointcloud_max_points' in config:
327
+ self.set_parameters([Parameter('pointcloud_max_points', Parameter.Type.INTEGER, int(config['pointcloud_max_points']))])
328
+ if not self._param_overrides and 'pointcloud_keep_intensity' in config:
329
+ self.set_parameters([Parameter('pointcloud_keep_intensity', Parameter.Type.BOOL, bool(config['pointcloud_keep_intensity']))])
330
+ if not self._param_overrides and 'pointcloud_keep_rgb' in config:
331
+ self.set_parameters([Parameter('pointcloud_keep_rgb', Parameter.Type.BOOL, bool(config['pointcloud_keep_rgb']))])
332
+ if not self._param_overrides and 'pointcloud_default_rate_hz' in config:
333
+ self.set_parameters([Parameter('pointcloud_default_rate_hz', Parameter.Type.DOUBLE, float(config['pointcloud_default_rate_hz']))])
334
+
335
+ # topic_limits: per-topic overrides of the defaults above (rate_hz,
336
+ # max_bytes_per_sec, jpeg_quality, max_dimension and the pointcloud_*
337
+ # keys in TOPIC_LIMIT_FIELDS; see _topic_limit_overrides,
338
+ # _on_topic_msg, _topic_data_over_budget). Not a set_parameters call
339
+ # like the scalars above — ROS2 declared parameters are a flat,
340
+ # known-in-advance namespace, a poor fit for a dynamic topic-name-
341
+ # keyed map — so this is plain instance state instead, consulted
342
+ # fresh on every topic message exactly like the scalar params are.
343
+ # Same all-or-nothing yaml-override rule: a params-file run clears
344
+ # it rather than leaving stale overrides from a previous cloud push.
345
+ with self._topic_limit_lock:
346
+ if self._param_overrides:
347
+ self._topic_limit_overrides = {}
348
+ elif 'topic_limits' in config:
349
+ self._topic_limit_overrides = dict(config['topic_limits'] or {})
350
+ # Snapshot for the resolved_agent_config ground-truth send below —
351
+ # taken under the same lock rather than re-read there unguarded.
352
+ _topic_limits_snapshot = dict(self._topic_limit_overrides)
353
+
354
+ # Deliberately includes every topic_data, image and point cloud field,
355
+ # not just the toggles, so a live push that only touches e.g.
356
+ # image_jpeg_quality still shows up in the log instead of looking like
357
+ # a no-op call. Read fresh off the ROS params (not local vars) so this
358
+ # is accurate regardless of which fields this particular call
359
+ # actually touched.
360
+ self.get_logger().info(
361
+ f'Applied agent_config: telemetry_enabled={self._telemetry_enabled}, '
362
+ f'tf_tree_enabled={tf_tree_enabled}, goals_enabled={goals_enabled}, '
363
+ f'params_enabled={params_enabled}, bt_mode={bt_mode}, '
364
+ f'commands_enabled={commands_enabled}, '
365
+ f'topic_data_rate_hz={self.get_parameter("topic_data_rate_hz").get_parameter_value().double_value:.1f}, '
366
+ f'topic_data_max_bytes_per_sec={self.get_parameter("topic_data_max_bytes_per_sec").get_parameter_value().double_value:.0f}, '
367
+ f'global_topic_data_max_bytes_per_sec={self.get_parameter("global_topic_data_max_bytes_per_sec").get_parameter_value().double_value:.0f}, '
368
+ f'global_topic_data_max_msgs_per_sec={self.get_parameter("global_topic_data_max_msgs_per_sec").get_parameter_value().double_value:.0f}, '
369
+ f'image_jpeg_quality={self.get_parameter("image_jpeg_quality").get_parameter_value().integer_value}, '
370
+ f'image_max_dimension={self.get_parameter("image_max_dimension").get_parameter_value().integer_value}, '
371
+ f'pointcloud_max_points={self.get_parameter("pointcloud_max_points").get_parameter_value().integer_value}, '
372
+ f'pointcloud_keep_intensity={self.get_parameter("pointcloud_keep_intensity").get_parameter_value().bool_value}, '
373
+ f'pointcloud_keep_rgb={self.get_parameter("pointcloud_keep_rgb").get_parameter_value().bool_value}, '
374
+ f'pointcloud_default_rate_hz={self.get_parameter("pointcloud_default_rate_hz").get_parameter_value().double_value:.1f}, '
375
+ f'topic_limits={_topic_limits_snapshot}'
376
+ )
377
+
378
+ # Ground truth for the client: what this agent is ACTUALLY running
379
+ # with right now, for every field — as opposed to the cloud
380
+ # agent_config, which can be completely irrelevant (yaml-file runs
381
+ # ignore it outright) or simply stale until this agent reconnects.
382
+ # Sent on every _apply_agent_config call (every connect/reconnect,
383
+ # and the 5s fallback), so the client always has a current answer,
384
+ # never a guess based on pane data being empty. The fields pushed
385
+ # through set_parameters() above (bag_output_dir,
386
+ # graph_debounce_interval and the topic_data, image and point cloud
387
+ # settings) are read fresh off the ROS params here rather than from
388
+ # local vars, since that is the one place their resolved value lives.
389
+ #
390
+ # yaml_override is metadata about the SOURCE these values were
391
+ # resolved from, not a config value itself — a yaml/CLI params file
392
+ # being passed at all means the client shouldn't claim a restart
393
+ # will sync Cloud Config (it never will, as long as that file keeps
394
+ # getting passed); it should instead tell the operator how to
395
+ # actually switch back to cloud-driven config.
396
+ self._resolved_agent_config = {
397
+ 'telemetry_enabled': self._telemetry_enabled,
398
+ 'goals_enabled': goals_enabled,
399
+ 'params_enabled': params_enabled,
400
+ 'tf_tree_enabled': tf_tree_enabled,
401
+ 'tf_tree_poll_interval': tf_tree_poll_interval,
402
+ 'ros2_control_enabled': ros2_control_enabled,
403
+ 'ros2_control_poll_interval': ros2_control_poll_interval,
404
+ 'battery_topic': battery_topic,
405
+ 'battery_check_enabled': self._battery_check_enabled,
406
+ 'battery_min_percent': self._battery_min_percent,
407
+ 'dock_check_enabled': self._dock_check_enabled,
408
+ 'dock_status_topic': dock_status_topic,
409
+ 'commands_enabled': commands_enabled,
410
+ 'command_max_speed_mps': self._command_limits.max_speed_mps,
411
+ 'command_max_distance_m': self._command_limits.max_distance_m,
412
+ 'command_max_angle_deg': self._command_limits.max_angle_deg,
413
+ 'command_max_time_allowance_s': self._command_limits.max_time_allowance_s,
414
+ 'bt_mode': bt_mode,
415
+ 'bt_host': bt_host,
416
+ 'bt_server_port': bt_server_port,
417
+ 'bt_publisher_port': bt_publisher_port,
418
+ 'bag_output_dir': self.get_parameter('bag_output_dir').get_parameter_value().string_value,
419
+ 'graph_debounce_interval': self.get_parameter('graph_debounce_interval').get_parameter_value().double_value,
420
+ 'topic_data_rate_hz': self.get_parameter('topic_data_rate_hz').get_parameter_value().double_value,
421
+ 'topic_data_max_bytes_per_sec': self.get_parameter('topic_data_max_bytes_per_sec').get_parameter_value().double_value,
422
+ 'global_topic_data_max_bytes_per_sec': self.get_parameter('global_topic_data_max_bytes_per_sec').get_parameter_value().double_value,
423
+ 'global_topic_data_max_msgs_per_sec': self.get_parameter('global_topic_data_max_msgs_per_sec').get_parameter_value().double_value,
424
+ 'image_jpeg_quality': self.get_parameter('image_jpeg_quality').get_parameter_value().integer_value,
425
+ 'image_max_dimension': self.get_parameter('image_max_dimension').get_parameter_value().integer_value,
426
+ 'pointcloud_max_points': self.get_parameter('pointcloud_max_points').get_parameter_value().integer_value,
427
+ 'pointcloud_keep_intensity': self.get_parameter('pointcloud_keep_intensity').get_parameter_value().bool_value,
428
+ 'pointcloud_keep_rgb': self.get_parameter('pointcloud_keep_rgb').get_parameter_value().bool_value,
429
+ 'pointcloud_default_rate_hz': self.get_parameter('pointcloud_default_rate_hz').get_parameter_value().double_value,
430
+ 'topic_limits': _topic_limits_snapshot,
431
+ }
432
+ self._send_resolved_agent_config()
433
+
434
+ def _send_resolved_agent_config(self):
435
+ self._enqueue({
436
+ 'type': 'resolved_agent_config',
437
+ 'yaml_override': bool(self._param_overrides),
438
+ 'config': self._resolved_agent_config,
439
+ 'timestamp': time.time(),
440
+ })
441
+
442
+ def _reject_agent_config(self, problems):
443
+ """Ignore a malformed push as a whole: every setting keeps its value.
444
+ Motion commands are the exception and get switched off, since the
445
+ rejected push may have been the one turning them off, and an agent
446
+ that can't read its config should not keep accepting motion. They
447
+ stay off until a valid push arrives."""
448
+ self.get_logger().error(f'Ignored agent_config, keeping the current settings: {"; ".join(problems)}')
449
+ if not self._commands_enabled:
450
+ return
451
+ self._commands_enabled = False
452
+ self.get_logger().warning('Robot commands disabled until a valid agent_config arrives')
453
+ if self._resolved_agent_config is not None:
454
+ self._resolved_agent_config = {**self._resolved_agent_config, 'commands_enabled': False}
455
+ self._send_resolved_agent_config()