Skip to content

Commit 1050174

Browse files
jehanshah8Jehan Shah
andauthored
Fix/mqtt publish hanging (#81)
* Fix robot status hang issue with best-effort publishing Replace blocking _send_robot_status with non-blocking best-effort approach: - Remove wait_for_publish() calls that could hang indefinitely - Use direct client.publish() with exception handling - Maintain status accuracy through InOrbit's multi-layer detection system - Eliminate threading complexity while preserving functionality * Add get_state protocol handler with callback mechanism to EdgeSDK - Add get_state handling to _handle_in_cmd method for InOrbit protocol compliance - Add set_online_status_callback() method for connectors to provide status logic - Add _handle_get_state() method that uses callback or defaults to online * Fix get_state tests to use dynamic module version --------- Co-authored-by: Jehan Shah <jehan@inorbit.ai>
1 parent a8ee949 commit 1050174

3 files changed

Lines changed: 159 additions & 48 deletions

File tree

inorbit_edge/robot.py

Lines changed: 66 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ def __init__(self, robot_id, robot_name, api_key=None, **kwargs) -> None:
309309
self.map_data_mutex = threading.Lock()
310310
self.map_files: dict[str, RobotMap] = {} # label to map data
311311

312+
# Callback for determining robot online status
313+
self._online_status_callback = None
314+
312315
self.message_handlers[MQTT_INITIAL_POSE] = self._handle_initial_pose
313316
self.message_handlers[MQTT_CUSTOM_COMMAND] = self._handle_custom_command
314317
self.message_handlers[MQTT_CUSTOM_COMMAND_MESSAGE] = self._handle_custom_message
@@ -473,11 +476,10 @@ def _on_connect(self, client, userdata, flags, reason_code, properties):
473476
)
474477
return
475478

476-
# Send robot online status.
477-
# This method is blocking so do it on a separate thread just in case.
478-
threading.Thread(
479-
target=self._send_robot_status, kwargs={"robot_status": "1"}
480-
).start()
479+
# Send robot online status (best effort)
480+
# If this fails and InOrbit is getting data, it will detect discrepancy
481+
# and request a status update via get_state command.
482+
self._send_robot_status(online=True)
481483

482484
# Subscribe to interesting topics
483485
self.client.subscribe(
@@ -620,6 +622,8 @@ def _handle_in_cmd(self, msg):
620622
self._handle_load_module(args[1], args[2])
621623
if args[0] == "unload_module" and len(args) >= 2:
622624
self._handle_unload_module(args[1])
625+
if args[0] == "get_state":
626+
self._handle_get_state()
623627

624628
def _handle_load_module(self, module_name, run_level):
625629
"""Handles a load_module command"""
@@ -631,6 +635,22 @@ def _handle_unload_module(self, module_name):
631635
if module_name == INORBIT_MODULE_CAMERAS:
632636
self._stop_cameras_streaming()
633637

638+
def _handle_get_state(self):
639+
"""Handle get_state command from InOrbit."""
640+
is_online = True # Default assumption
641+
642+
if self._online_status_callback:
643+
try:
644+
is_online = self._online_status_callback()
645+
except Exception as e:
646+
self.logger.error(f"Online status callback failed: {e}")
647+
# Fall back to default (True) on callback error
648+
649+
self._send_robot_status(online=is_online)
650+
self.logger.debug(
651+
f"Responded to get_state: robot {'online' if is_online else 'offline'}"
652+
)
653+
634654
def _start_cameras_streaming(self):
635655
"""Start streaming on all registered cameras"""
636656
with self.camera_streaming_mutex:
@@ -893,6 +913,17 @@ def register_command_callback(self, callback):
893913

894914
self.command_callbacks.append(callback)
895915

916+
def set_online_status_callback(self, callback):
917+
"""Set callback to determine robot online status.
918+
919+
Args:
920+
callback: A callable that returns bool indicating if robot is online.
921+
Should return True if robot is online, False otherwise.
922+
Will be called when InOrbit requests status via get_state command.
923+
"""
924+
if callable(callback):
925+
self._online_status_callback = callback
926+
896927
def unregister_command_callback(self, callback):
897928
"""Unregisters the specified callback"""
898929
# TODO: Implement
@@ -922,48 +953,36 @@ def _resend_modules(self):
922953
qos=1,
923954
)
924955

925-
def _send_robot_status(self, robot_status):
926-
"""Sends robot online/offline status message.
927-
928-
This method blocks until either the message
929-
is sent or the client errors out.
956+
def _send_robot_status(self, online=True):
957+
"""Send robot online/offline status (best effort, non-blocking).
930958
931959
Args:
932-
robot_status (Union[bool,str]): Connection status
933-
It supports ``bool`` and ``str`` values ("0" or "1")
934-
935-
Raises:
936-
ValueError: on invalid ``robot_status``
960+
online (bool): True for online status, False for offline status.
937961
"""
962+
status_value = "1" if online else "0"
963+
status_str = "online" if online else "offline"
938964

939-
# Validate ``robot_status`` parameter.
940-
if isinstance(robot_status, bool):
941-
robot_status = "1" if robot_status else "0"
942-
943-
if robot_status not in ["0", "1"]:
944-
raise ValueError("Robot status must be boolean, '0' or '1'")
945-
946-
# Every time we connect or disconnect to the service, send
947-
# updated status including online/offline bit
948-
status_message = "{}|{}|{}|{}".format(
949-
robot_status, self.robot_api_key, self.agent_version, self.robot_name
950-
)
951-
ret = self.publish(
952-
self._get_robot_subtopic(subtopic=MQTT_SUBTOPIC_STATE),
953-
status_message,
954-
qos=1,
955-
retain=True,
956-
)
957-
self.logger.info("Publishing status {}. ret = {}.".format(robot_status, ret))
958-
959-
# TODO: handle errors while waiting for publish. Consider that
960-
# this method would typically run on a separate thread.
961-
ret.wait_for_publish()
962-
published = ret.is_published()
963-
964-
self.logger.info(
965-
"Robot status '{}' published: {:b}.".format(robot_status, published)
966-
)
965+
try:
966+
status_message = (
967+
f"{status_value}|{self.robot_api_key}|"
968+
f"{self.agent_version}|{self.robot_name}"
969+
)
970+
self.client.publish(
971+
self._get_robot_subtopic(subtopic=MQTT_SUBTOPIC_STATE),
972+
status_message,
973+
qos=1,
974+
retain=True,
975+
)
976+
self.logger.debug(f"{status_str.capitalize()} status sent successfully")
977+
except Exception as e:
978+
fallback_msg = (
979+
"InOrbit will detect via data messages"
980+
if online
981+
else "InOrbit will detect via data absence"
982+
)
983+
self.logger.debug(
984+
f"{status_str.capitalize()} status failed: {e} - {fallback_msg}"
985+
)
967986

968987
def _is_connected(self):
969988
return self.client.is_connected()
@@ -1036,7 +1055,10 @@ def disconnect(self):
10361055
"""Ends session, disconnecting from cloud services"""
10371056
self.logger.info("Ending robot session")
10381057
self._stop_cameras_streaming()
1039-
self._send_robot_status(robot_status="0")
1058+
1059+
# Send offline status (best effort, non-blocking)
1060+
# InOrbit will detect offline via data absence if this fails
1061+
self._send_robot_status(online=False)
10401062

10411063
# TODO: Unsubscribe from topics
10421064

inorbit_edge/tests/test_robot_session.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,8 @@ def test_robot_session_connect(mock_mqtt_client, mock_inorbit_api, mock_sleep):
8080
assert robot_session.robot_api_key == "robot_apikey_123"
8181
# check publish state was called with the correct API key
8282
robot_session.client.publish.assert_any_call(
83-
topic="r/id_123/state",
84-
payload="1|robot_apikey_123|{}.edgesdk_py|name_123".format(
85-
get_module_version()
86-
),
83+
"r/id_123/state",
84+
"1|robot_apikey_123|{}.edgesdk_py|name_123".format(get_module_version()),
8785
qos=1,
8886
retain=True,
8987
)

inorbit_edge/tests/test_robot_session_callbacks.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
MapRequest,
1616
)
1717
from inorbit_edge.robot import RobotSession
18+
from inorbit_edge import get_module_version
1819
from inorbit_edge.tests.utils.helpers import test_robot_session_connect_helper
1920

2021

@@ -38,6 +39,96 @@ def test_builtin_callbacks(mock_mqtt_client, mock_inorbit_api, mock_sleep):
3839
robot_session.client.subscribe.assert_any_call(topic="r/id_123/ros/loc/mapreq")
3940

4041

42+
def test_responds_to_get_state_with_default_online_status(
43+
mock_mqtt_client, mock_inorbit_api, mock_sleep
44+
):
45+
"""Test that get_state command responds with default online status."""
46+
robot_session = RobotSession(
47+
robot_id="id_123", robot_name="name_123", api_key="apikey_123"
48+
)
49+
robot_session.connect()
50+
robot_session._on_connect(None, None, None, 0, None)
51+
52+
# Simulate get_state command
53+
robot_session._handle_in_cmd(b"get_state")
54+
55+
# Verify online status was published
56+
robot_session.client.publish.assert_any_call(
57+
"r/id_123/state",
58+
"1|robot_apikey_123|{}.edgesdk_py|name_123".format(get_module_version()),
59+
qos=1,
60+
retain=True,
61+
)
62+
63+
64+
def test_responds_to_get_state_with_callback_online_status(
65+
mock_mqtt_client, mock_inorbit_api, mock_sleep
66+
):
67+
"""Test that get_state command uses callback for online status."""
68+
robot_session = RobotSession(
69+
robot_id="id_123", robot_name="name_123", api_key="apikey_123"
70+
)
71+
robot_session.connect()
72+
robot_session._on_connect(None, None, None, 0, None)
73+
74+
# Set callback that returns False (offline)
75+
robot_session.set_online_status_callback(lambda: False)
76+
77+
# Simulate get_state command
78+
robot_session._handle_in_cmd(b"get_state")
79+
80+
# Verify offline status was published
81+
robot_session.client.publish.assert_any_call(
82+
"r/id_123/state",
83+
"0|robot_apikey_123|{}.edgesdk_py|name_123".format(get_module_version()),
84+
qos=1,
85+
retain=True,
86+
)
87+
88+
89+
def test_get_state_handles_callback_exception(
90+
mock_mqtt_client, mock_inorbit_api, mock_sleep
91+
):
92+
"""Test that get_state handles callback exceptions gracefully."""
93+
robot_session = RobotSession(
94+
robot_id="id_123", robot_name="name_123", api_key="apikey_123"
95+
)
96+
robot_session.connect()
97+
robot_session._on_connect(None, None, None, 0, None)
98+
99+
# Set callback that raises exception
100+
def failing_callback():
101+
raise RuntimeError("Test error")
102+
103+
robot_session.set_online_status_callback(failing_callback)
104+
105+
# Simulate get_state command - should not raise exception
106+
robot_session._handle_in_cmd(b"get_state")
107+
108+
# Verify default online status was published despite callback error
109+
robot_session.client.publish.assert_any_call(
110+
"r/id_123/state",
111+
"1|robot_apikey_123|{}.edgesdk_py|name_123".format(get_module_version()),
112+
qos=1,
113+
retain=True,
114+
)
115+
116+
117+
def test_set_online_status_callback_ignores_non_callable(
118+
mock_mqtt_client, mock_inorbit_api, mock_sleep
119+
):
120+
"""Test that set_online_status_callback ignores non-callable values."""
121+
robot_session = RobotSession(
122+
robot_id="id_123", robot_name="name_123", api_key="apikey_123"
123+
)
124+
125+
# Try to set non-callable
126+
robot_session.set_online_status_callback("not_callable")
127+
128+
# Should remain None
129+
assert robot_session._online_status_callback is None
130+
131+
41132
def test_robot_session_register_command_callback(
42133
mock_mqtt_client, mock_inorbit_api, mock_sleep
43134
):

0 commit comments

Comments
 (0)