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.
- osiris_agent/__init__.py +9 -0
- osiris_agent/action_monitor.py +199 -0
- osiris_agent/agent_config.py +455 -0
- osiris_agent/agent_config_validation.py +170 -0
- osiris_agent/agent_node.py +644 -0
- osiris_agent/bag_recorder.py +297 -0
- osiris_agent/bin/graph_watcher_aarch64 +0 -0
- osiris_agent/bin/graph_watcher_aarch64_jazzy +0 -0
- osiris_agent/bin/graph_watcher_aarch64_lyrical +0 -0
- osiris_agent/bin/graph_watcher_x86_64 +0 -0
- osiris_agent/bin/graph_watcher_x86_64_jazzy +0 -0
- osiris_agent/bt_collector.py +805 -0
- osiris_agent/command_policy.py +147 -0
- osiris_agent/commands.py +478 -0
- osiris_agent/connection.py +330 -0
- osiris_agent/graph_monitor.py +621 -0
- osiris_agent/graph_watcher_launcher.py +163 -0
- osiris_agent/lifecycle.py +76 -0
- osiris_agent/nav2_bt_monitor.py +257 -0
- osiris_agent/node_parameters.py +121 -0
- osiris_agent/payload_encoding.py +226 -0
- osiris_agent/qos.py +41 -0
- osiris_agent/ros2_control_collector.py +331 -0
- osiris_agent/snapshots.py +679 -0
- osiris_agent/telemetry.py +296 -0
- osiris_agent/tf_listener.py +88 -0
- osiris_agent/tf_tree_collector.py +291 -0
- osiris_agent/topic_bridge.py +453 -0
- osiris_agent/topic_names.py +29 -0
- osiris_bridge-0.5.0.dist-info/METADATA +262 -0
- osiris_bridge-0.5.0.dist-info/RECORD +35 -0
- osiris_bridge-0.5.0.dist-info/WHEEL +5 -0
- osiris_bridge-0.5.0.dist-info/entry_points.txt +2 -0
- osiris_bridge-0.5.0.dist-info/licenses/LICENSE +190 -0
- osiris_bridge-0.5.0.dist-info/top_level.txt +1 -0
osiris_agent/__init__.py
ADDED
|
@@ -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()
|