dexcontrol 0.2.1__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.
Potentially problematic release.
This version of dexcontrol might be problematic. Click here for more details.
- dexcontrol/__init__.py +45 -0
- dexcontrol/apps/dualsense_teleop_base.py +371 -0
- dexcontrol/config/__init__.py +14 -0
- dexcontrol/config/core/__init__.py +22 -0
- dexcontrol/config/core/arm.py +32 -0
- dexcontrol/config/core/chassis.py +22 -0
- dexcontrol/config/core/hand.py +42 -0
- dexcontrol/config/core/head.py +33 -0
- dexcontrol/config/core/misc.py +37 -0
- dexcontrol/config/core/torso.py +36 -0
- dexcontrol/config/sensors/__init__.py +4 -0
- dexcontrol/config/sensors/cameras/__init__.py +7 -0
- dexcontrol/config/sensors/cameras/gemini_camera.py +16 -0
- dexcontrol/config/sensors/cameras/rgb_camera.py +15 -0
- dexcontrol/config/sensors/imu/__init__.py +6 -0
- dexcontrol/config/sensors/imu/gemini_imu.py +15 -0
- dexcontrol/config/sensors/imu/nine_axis_imu.py +15 -0
- dexcontrol/config/sensors/lidar/__init__.py +6 -0
- dexcontrol/config/sensors/lidar/rplidar.py +15 -0
- dexcontrol/config/sensors/ultrasonic/__init__.py +6 -0
- dexcontrol/config/sensors/ultrasonic/ultrasonic.py +15 -0
- dexcontrol/config/sensors/vega_sensors.py +65 -0
- dexcontrol/config/vega.py +203 -0
- dexcontrol/core/__init__.py +0 -0
- dexcontrol/core/arm.py +324 -0
- dexcontrol/core/chassis.py +628 -0
- dexcontrol/core/component.py +834 -0
- dexcontrol/core/hand.py +170 -0
- dexcontrol/core/head.py +232 -0
- dexcontrol/core/misc.py +514 -0
- dexcontrol/core/torso.py +198 -0
- dexcontrol/proto/dexcontrol_msg_pb2.py +69 -0
- dexcontrol/proto/dexcontrol_msg_pb2.pyi +168 -0
- dexcontrol/proto/dexcontrol_query_pb2.py +73 -0
- dexcontrol/proto/dexcontrol_query_pb2.pyi +134 -0
- dexcontrol/robot.py +1091 -0
- dexcontrol/sensors/__init__.py +40 -0
- dexcontrol/sensors/camera/__init__.py +18 -0
- dexcontrol/sensors/camera/gemini_camera.py +139 -0
- dexcontrol/sensors/camera/rgb_camera.py +98 -0
- dexcontrol/sensors/imu/__init__.py +22 -0
- dexcontrol/sensors/imu/gemini_imu.py +139 -0
- dexcontrol/sensors/imu/nine_axis_imu.py +149 -0
- dexcontrol/sensors/lidar/__init__.py +3 -0
- dexcontrol/sensors/lidar/rplidar.py +164 -0
- dexcontrol/sensors/manager.py +185 -0
- dexcontrol/sensors/ultrasonic.py +110 -0
- dexcontrol/utils/__init__.py +15 -0
- dexcontrol/utils/constants.py +12 -0
- dexcontrol/utils/io_utils.py +26 -0
- dexcontrol/utils/motion_utils.py +194 -0
- dexcontrol/utils/os_utils.py +39 -0
- dexcontrol/utils/pb_utils.py +103 -0
- dexcontrol/utils/rate_limiter.py +167 -0
- dexcontrol/utils/reset_orbbec_camera_usb.py +98 -0
- dexcontrol/utils/subscribers/__init__.py +44 -0
- dexcontrol/utils/subscribers/base.py +260 -0
- dexcontrol/utils/subscribers/camera.py +328 -0
- dexcontrol/utils/subscribers/decoders.py +83 -0
- dexcontrol/utils/subscribers/generic.py +105 -0
- dexcontrol/utils/subscribers/imu.py +170 -0
- dexcontrol/utils/subscribers/lidar.py +195 -0
- dexcontrol/utils/subscribers/protobuf.py +106 -0
- dexcontrol/utils/timer.py +136 -0
- dexcontrol/utils/trajectory_utils.py +40 -0
- dexcontrol/utils/viz_utils.py +86 -0
- dexcontrol-0.2.1.dist-info/METADATA +369 -0
- dexcontrol-0.2.1.dist-info/RECORD +72 -0
- dexcontrol-0.2.1.dist-info/WHEEL +5 -0
- dexcontrol-0.2.1.dist-info/licenses/LICENSE +188 -0
- dexcontrol-0.2.1.dist-info/licenses/NOTICE +13 -0
- dexcontrol-0.2.1.dist-info/top_level.txt +1 -0
dexcontrol/core/misc.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# Copyright (c) 2025 Dexmate CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 with Commons Clause License
|
|
4
|
+
# Condition v1.0 [see LICENSE for details].
|
|
5
|
+
|
|
6
|
+
"""Miscellaneous robot components module.
|
|
7
|
+
|
|
8
|
+
This module provides classes for various auxiliary robot components such as Battery,
|
|
9
|
+
EStop (emergency stop), and UltraSonicSensor.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
import zenoh
|
|
17
|
+
from google.protobuf.message import Message
|
|
18
|
+
from loguru import logger
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from dexcontrol.config.core import BatteryConfig, EStopConfig, HeartbeatConfig
|
|
23
|
+
from dexcontrol.core.component import RobotComponent
|
|
24
|
+
from dexcontrol.proto import dexcontrol_msg_pb2, dexcontrol_query_pb2
|
|
25
|
+
from dexcontrol.utils.constants import DISABLE_HEARTBEAT_ENV_VAR
|
|
26
|
+
from dexcontrol.utils.os_utils import resolve_key_name
|
|
27
|
+
from dexcontrol.utils.subscribers.generic import GenericZenohSubscriber
|
|
28
|
+
|
|
29
|
+
# Type variable for Message subclasses
|
|
30
|
+
M = TypeVar("M", bound=Message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Battery(RobotComponent):
|
|
34
|
+
"""Battery component that monitors and displays battery status information.
|
|
35
|
+
|
|
36
|
+
This class provides methods to monitor battery state including voltage, current,
|
|
37
|
+
temperature and power consumption. It can display the information in either a
|
|
38
|
+
formatted rich table or plain text format.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
_console: Rich console instance for formatted output.
|
|
42
|
+
_monitor_thread: Background thread for battery monitoring.
|
|
43
|
+
_shutdown_event: Event to signal thread shutdown.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, configs: BatteryConfig, zenoh_session: zenoh.Session) -> None:
|
|
47
|
+
"""Initialize the Battery component.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
configs: Battery configuration containing subscription topics.
|
|
51
|
+
zenoh_session: Active Zenoh session for communication.
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(
|
|
54
|
+
configs.state_sub_topic, zenoh_session, dexcontrol_msg_pb2.BMSState
|
|
55
|
+
)
|
|
56
|
+
self._console = Console()
|
|
57
|
+
self._shutdown_event = threading.Event()
|
|
58
|
+
self._monitor_thread = threading.Thread(
|
|
59
|
+
target=self._battery_monitor, daemon=True
|
|
60
|
+
)
|
|
61
|
+
self._monitor_thread.start()
|
|
62
|
+
|
|
63
|
+
def _battery_monitor(self) -> None:
|
|
64
|
+
"""Background thread that periodically checks battery level and warns if low."""
|
|
65
|
+
while not self._shutdown_event.is_set():
|
|
66
|
+
try:
|
|
67
|
+
if self.is_active():
|
|
68
|
+
battery_level = self.get_status()["percentage"]
|
|
69
|
+
if battery_level < 20:
|
|
70
|
+
logger.warning(
|
|
71
|
+
f"Battery level is low ({battery_level:.1f}%). "
|
|
72
|
+
"Please charge the battery."
|
|
73
|
+
)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.debug(f"Battery monitor error: {e}")
|
|
76
|
+
|
|
77
|
+
# Check every 30 seconds (low frequency)
|
|
78
|
+
self._shutdown_event.wait(30.0)
|
|
79
|
+
|
|
80
|
+
def get_status(self) -> dict[str, float]:
|
|
81
|
+
"""Gets the current battery state information.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Dictionary containing battery metrics including:
|
|
85
|
+
- percentage: Battery charge level (0-100)
|
|
86
|
+
- temperature: Battery temperature in Celsius
|
|
87
|
+
- current: Current draw in Amperes
|
|
88
|
+
- voltage: Battery voltage
|
|
89
|
+
- power: Power consumption in Watts
|
|
90
|
+
"""
|
|
91
|
+
state = self._get_state()
|
|
92
|
+
return {
|
|
93
|
+
"percentage": float(state.percentage),
|
|
94
|
+
"temperature": float(state.temperature),
|
|
95
|
+
"current": float(state.current),
|
|
96
|
+
"voltage": float(state.voltage),
|
|
97
|
+
"power": float(state.current * state.voltage),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
def show(self) -> None:
|
|
101
|
+
"""Displays the current battery status as a formatted table with color indicators."""
|
|
102
|
+
state = self._get_state()
|
|
103
|
+
|
|
104
|
+
table = Table(title="Battery Status")
|
|
105
|
+
table.add_column("Parameter", style="cyan")
|
|
106
|
+
table.add_column("Value")
|
|
107
|
+
|
|
108
|
+
battery_style = self._get_battery_level_style(state.percentage)
|
|
109
|
+
table.add_row("Battery Level", f"[{battery_style}]{state.percentage:.1f}%[/]")
|
|
110
|
+
|
|
111
|
+
temp_style = self._get_temperature_style(state.temperature)
|
|
112
|
+
table.add_row("Temperature", f"[{temp_style}]{state.temperature:.1f}°C[/]")
|
|
113
|
+
|
|
114
|
+
power = state.current * state.voltage
|
|
115
|
+
power_style = self._get_power_style(power)
|
|
116
|
+
table.add_row(
|
|
117
|
+
"Power",
|
|
118
|
+
f"[{power_style}]{power:.2f}W[/] ([blue]{state.current:.2f}A[/] "
|
|
119
|
+
f"× [blue]{state.voltage:.2f}V[/])",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self._console.print(table)
|
|
123
|
+
|
|
124
|
+
def shutdown(self) -> None:
|
|
125
|
+
"""Shuts down the battery component and stops monitoring thread."""
|
|
126
|
+
self._shutdown_event.set()
|
|
127
|
+
if self._monitor_thread.is_alive():
|
|
128
|
+
self._monitor_thread.join(timeout=1.0)
|
|
129
|
+
super().shutdown()
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _get_battery_level_style(percentage: float) -> str:
|
|
133
|
+
"""Returns the appropriate style based on battery percentage.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
percentage: Battery charge level (0-100).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Rich text style string for color formatting.
|
|
140
|
+
"""
|
|
141
|
+
if percentage < 30:
|
|
142
|
+
return "bold red"
|
|
143
|
+
elif percentage < 60:
|
|
144
|
+
return "bold yellow"
|
|
145
|
+
else:
|
|
146
|
+
return "bold dark_green"
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _get_temperature_style(temperature: float) -> str:
|
|
150
|
+
"""Returns the appropriate style based on temperature value.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
temperature: Battery temperature in Celsius.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Rich text style string for color formatting.
|
|
157
|
+
"""
|
|
158
|
+
if temperature < -1:
|
|
159
|
+
return "bold red" # Too cold
|
|
160
|
+
elif temperature <= 30:
|
|
161
|
+
return "bold dark_green" # Normal range
|
|
162
|
+
elif temperature <= 38:
|
|
163
|
+
return "bold orange" # Getting warm
|
|
164
|
+
else:
|
|
165
|
+
return "bold red" # Too hot
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _get_power_style(power: float) -> str:
|
|
169
|
+
"""Returns the appropriate style based on power consumption.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
power: Power consumption in Watts.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Rich text style string for color formatting.
|
|
176
|
+
"""
|
|
177
|
+
if power < 200:
|
|
178
|
+
return "bold dark_green"
|
|
179
|
+
elif power <= 500:
|
|
180
|
+
return "bold orange"
|
|
181
|
+
else:
|
|
182
|
+
return "bold red"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class EStop(RobotComponent):
|
|
186
|
+
"""EStop component that monitors and controls emergency stop functionality.
|
|
187
|
+
|
|
188
|
+
This class provides methods to monitor EStop state and activate/deactivate
|
|
189
|
+
the software emergency stop.
|
|
190
|
+
|
|
191
|
+
Attributes:
|
|
192
|
+
_estop_query_name: Zenoh query name for setting EStop state.
|
|
193
|
+
_monitor_thread: Background thread for EStop monitoring.
|
|
194
|
+
_shutdown_event: Event to signal thread shutdown.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
configs: EStopConfig,
|
|
200
|
+
zenoh_session: zenoh.Session,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Initialize the EStop component.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
configs: EStop configuration containing subscription topics.
|
|
206
|
+
zenoh_session: Active Zenoh session for communication.
|
|
207
|
+
"""
|
|
208
|
+
super().__init__(
|
|
209
|
+
configs.state_sub_topic, zenoh_session, dexcontrol_msg_pb2.EStopState
|
|
210
|
+
)
|
|
211
|
+
self._estop_query_name = configs.estop_query_name
|
|
212
|
+
self._shutdown_event = threading.Event()
|
|
213
|
+
self._monitor_thread = threading.Thread(target=self._estop_monitor, daemon=True)
|
|
214
|
+
self._monitor_thread.start()
|
|
215
|
+
|
|
216
|
+
def _estop_monitor(self) -> None:
|
|
217
|
+
"""Background thread that continuously monitors EStop button state."""
|
|
218
|
+
while not self._shutdown_event.is_set():
|
|
219
|
+
try:
|
|
220
|
+
if self.is_active() and self.is_button_pressed():
|
|
221
|
+
logger.critical(
|
|
222
|
+
"E-STOP BUTTON PRESSED! Exiting program immediately."
|
|
223
|
+
)
|
|
224
|
+
# Don't call self.shutdown() here as it would try to join the current thread
|
|
225
|
+
# os._exit(1) will terminate the entire process immediately
|
|
226
|
+
os._exit(1)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.debug(f"EStop monitor error: {e}")
|
|
229
|
+
|
|
230
|
+
# Check every 100ms for responsive emergency stop
|
|
231
|
+
self._shutdown_event.wait(0.1)
|
|
232
|
+
|
|
233
|
+
def _set_estop(self, enable: bool) -> None:
|
|
234
|
+
"""Sets the software emergency stop (E-Stop) state of the robot.
|
|
235
|
+
|
|
236
|
+
This controls the software E-Stop, which is separate from the physical button
|
|
237
|
+
on the robot. The robot will stop if either the software or hardware E-Stop is
|
|
238
|
+
activated.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
enable: If True, activates the software E-Stop. If False, deactivates it.
|
|
242
|
+
"""
|
|
243
|
+
query_msg = dexcontrol_query_pb2.SetEstop(enable=enable)
|
|
244
|
+
self._zenoh_session.get(
|
|
245
|
+
resolve_key_name(self._estop_query_name),
|
|
246
|
+
handler=lambda reply: logger.info(f"Set E-Stop to {enable}"),
|
|
247
|
+
payload=query_msg.SerializeToString(),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def get_status(self) -> dict[str, bool]:
|
|
251
|
+
"""Gets the current EStop state information.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Dictionary containing EStop metrics including:
|
|
255
|
+
- button_pressed: EStop button pressed
|
|
256
|
+
- software_estop_enabled: Software EStop enabled
|
|
257
|
+
"""
|
|
258
|
+
state = self._get_state()
|
|
259
|
+
return {
|
|
260
|
+
"button_pressed": state.button_pressed,
|
|
261
|
+
"software_estop_enabled": state.software_estop_enabled,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def is_button_pressed(self) -> bool:
|
|
265
|
+
"""Checks if the EStop button is pressed."""
|
|
266
|
+
return self._get_state().button_pressed
|
|
267
|
+
|
|
268
|
+
def is_software_estop_enabled(self) -> bool:
|
|
269
|
+
"""Checks if the software EStop is enabled."""
|
|
270
|
+
return self._get_state().software_estop_enabled
|
|
271
|
+
|
|
272
|
+
def activate(self) -> None:
|
|
273
|
+
"""Activates the software emergency stop (E-Stop)."""
|
|
274
|
+
self._set_estop(True)
|
|
275
|
+
|
|
276
|
+
def deactivate(self) -> None:
|
|
277
|
+
"""Deactivates the software emergency stop (E-Stop)."""
|
|
278
|
+
self._set_estop(False)
|
|
279
|
+
|
|
280
|
+
def toggle(self) -> None:
|
|
281
|
+
"""Toggles the software emergency stop (E-Stop) state of the robot."""
|
|
282
|
+
self._set_estop(not self.is_software_estop_enabled())
|
|
283
|
+
|
|
284
|
+
def shutdown(self) -> None:
|
|
285
|
+
"""Shuts down the EStop component and stops monitoring thread."""
|
|
286
|
+
self._shutdown_event.set()
|
|
287
|
+
if self._monitor_thread.is_alive():
|
|
288
|
+
self._monitor_thread.join(timeout=1.0)
|
|
289
|
+
super().shutdown()
|
|
290
|
+
|
|
291
|
+
def show(self) -> None:
|
|
292
|
+
"""Displays the current EStop status as a formatted table with color indicators."""
|
|
293
|
+
state = self._get_state()
|
|
294
|
+
|
|
295
|
+
table = Table(title="E-Stop Status")
|
|
296
|
+
table.add_column("Parameter", style="cyan")
|
|
297
|
+
table.add_column("Value")
|
|
298
|
+
|
|
299
|
+
button_style = "bold red" if state.button_pressed else "bold dark_green"
|
|
300
|
+
table.add_row("Button Pressed", f"[{button_style}]{state.button_pressed}[/]")
|
|
301
|
+
|
|
302
|
+
software_style = (
|
|
303
|
+
"bold red" if state.software_estop_enabled else "bold dark_green"
|
|
304
|
+
)
|
|
305
|
+
table.add_row(
|
|
306
|
+
"Software E-Stop Enabled",
|
|
307
|
+
f"[{software_style}]{state.software_estop_enabled}[/]",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
console = Console()
|
|
311
|
+
console.print(table)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class Heartbeat:
|
|
315
|
+
"""Heartbeat monitor that ensures the low-level controller is functioning properly.
|
|
316
|
+
|
|
317
|
+
This class monitors a heartbeat signal from the low-level controller and exits
|
|
318
|
+
the program immediately if no heartbeat is received within the specified timeout.
|
|
319
|
+
This provides a critical safety mechanism to prevent the robot from operating
|
|
320
|
+
when the low-level controller is not functioning properly.
|
|
321
|
+
|
|
322
|
+
Attributes:
|
|
323
|
+
_subscriber: Zenoh subscriber for heartbeat data.
|
|
324
|
+
_monitor_thread: Background thread for heartbeat monitoring.
|
|
325
|
+
_shutdown_event: Event to signal thread shutdown.
|
|
326
|
+
_timeout_seconds: Timeout in seconds before triggering emergency exit.
|
|
327
|
+
_enabled: Whether heartbeat monitoring is enabled.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
configs: HeartbeatConfig,
|
|
333
|
+
zenoh_session: zenoh.Session,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Initialize the Heartbeat monitor.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
configs: Heartbeat configuration containing topic and timeout settings.
|
|
339
|
+
zenoh_session: Active Zenoh session for communication.
|
|
340
|
+
"""
|
|
341
|
+
self._timeout_seconds = configs.timeout_seconds
|
|
342
|
+
self._enabled = configs.enabled
|
|
343
|
+
self._shutdown_event = threading.Event()
|
|
344
|
+
|
|
345
|
+
if not self._enabled:
|
|
346
|
+
logger.info("Heartbeat monitoring is DISABLED via configuration")
|
|
347
|
+
# Create a dummy subscriber that's never active
|
|
348
|
+
self._subscriber = None
|
|
349
|
+
self._monitor_thread = None
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# Create a generic subscriber for the heartbeat topic
|
|
353
|
+
self._subscriber = GenericZenohSubscriber(
|
|
354
|
+
topic=configs.heartbeat_topic,
|
|
355
|
+
zenoh_session=zenoh_session,
|
|
356
|
+
decoder=self._decode_heartbeat,
|
|
357
|
+
name="heartbeat_monitor",
|
|
358
|
+
enable_fps_tracking=False,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Start monitoring thread
|
|
362
|
+
self._monitor_thread = threading.Thread(
|
|
363
|
+
target=self._heartbeat_monitor, daemon=True
|
|
364
|
+
)
|
|
365
|
+
self._monitor_thread.start()
|
|
366
|
+
|
|
367
|
+
logger.info(f"Heartbeat monitor started with {self._timeout_seconds}s timeout")
|
|
368
|
+
|
|
369
|
+
def _decode_heartbeat(self, data: zenoh.ZBytes) -> float:
|
|
370
|
+
"""Decode heartbeat data from raw bytes.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
data: Raw Zenoh bytes containing heartbeat value.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Decoded heartbeat timestamp value in seconds.
|
|
377
|
+
"""
|
|
378
|
+
timestamp = int.from_bytes(data.to_bytes(), byteorder="little")
|
|
379
|
+
# convert it from microseconds to seconds
|
|
380
|
+
return timestamp / 1000000.0
|
|
381
|
+
|
|
382
|
+
def _heartbeat_monitor(self) -> None:
|
|
383
|
+
"""Background thread that continuously monitors heartbeat signal."""
|
|
384
|
+
if not self._enabled or self._subscriber is None:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
while not self._shutdown_event.is_set():
|
|
388
|
+
try:
|
|
389
|
+
# Check if fresh data is being received within the timeout period
|
|
390
|
+
if self._subscriber.is_active():
|
|
391
|
+
if not self._subscriber.is_data_fresh(self._timeout_seconds):
|
|
392
|
+
time_since_fresh = self._subscriber.get_time_since_last_data()
|
|
393
|
+
if time_since_fresh is not None:
|
|
394
|
+
logger.critical(
|
|
395
|
+
f"HEARTBEAT TIMEOUT! No fresh heartbeat data received for {time_since_fresh:.2f}s "
|
|
396
|
+
f"(timeout: {self._timeout_seconds}s). Low-level controller may have failed. "
|
|
397
|
+
"Exiting program immediately for safety."
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
logger.critical(
|
|
401
|
+
f"HEARTBEAT TIMEOUT! No heartbeat data ever received "
|
|
402
|
+
f"(timeout: {self._timeout_seconds}s). Low-level controller may have failed. "
|
|
403
|
+
"Exiting program immediately for safety."
|
|
404
|
+
)
|
|
405
|
+
# Exit immediately for safety
|
|
406
|
+
os._exit(1)
|
|
407
|
+
|
|
408
|
+
# Check every 50ms for responsive monitoring
|
|
409
|
+
self._shutdown_event.wait(0.05)
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.error(f"Heartbeat monitor error: {e}")
|
|
413
|
+
# Continue monitoring even if there's an error
|
|
414
|
+
self._shutdown_event.wait(0.1)
|
|
415
|
+
|
|
416
|
+
def get_status(self) -> dict[str, bool | float | float | None]:
|
|
417
|
+
"""Gets the current heartbeat status information.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Dictionary containing heartbeat metrics including:
|
|
421
|
+
- is_active: Whether heartbeat signal is being received (bool)
|
|
422
|
+
- last_value: Last received heartbeat value (Optional[float])
|
|
423
|
+
- time_since_last: Time since last fresh data in seconds (Optional[float])
|
|
424
|
+
- timeout_seconds: Configured timeout value (float)
|
|
425
|
+
- enabled: Whether heartbeat monitoring is enabled (bool)
|
|
426
|
+
"""
|
|
427
|
+
if not self._enabled or self._subscriber is None:
|
|
428
|
+
return {
|
|
429
|
+
"is_active": False,
|
|
430
|
+
"last_value": None,
|
|
431
|
+
"time_since_last": None,
|
|
432
|
+
"timeout_seconds": self._timeout_seconds,
|
|
433
|
+
"enabled": False,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
last_value = self._subscriber.get_latest_data()
|
|
437
|
+
time_since_last = self._subscriber.get_time_since_last_data()
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
"is_active": self._subscriber.is_active(),
|
|
441
|
+
"last_value": last_value,
|
|
442
|
+
"time_since_last": time_since_last,
|
|
443
|
+
"timeout_seconds": self._timeout_seconds,
|
|
444
|
+
"enabled": True,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def is_active(self) -> bool:
|
|
448
|
+
"""Check if heartbeat signal is being received.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
True if heartbeat is active, False otherwise.
|
|
452
|
+
"""
|
|
453
|
+
if not self._enabled or self._subscriber is None:
|
|
454
|
+
return False
|
|
455
|
+
return self._subscriber.is_active()
|
|
456
|
+
|
|
457
|
+
def shutdown(self) -> None:
|
|
458
|
+
"""Shuts down the heartbeat monitor and stops monitoring thread."""
|
|
459
|
+
if not self._enabled:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
logger.info("Shutting down heartbeat monitor")
|
|
463
|
+
self._shutdown_event.set()
|
|
464
|
+
if self._monitor_thread and self._monitor_thread.is_alive():
|
|
465
|
+
self._monitor_thread.join(timeout=1.0)
|
|
466
|
+
if self._subscriber:
|
|
467
|
+
self._subscriber.shutdown()
|
|
468
|
+
|
|
469
|
+
def show(self) -> None:
|
|
470
|
+
"""Displays the current heartbeat status as a formatted table with color indicators."""
|
|
471
|
+
status = self.get_status()
|
|
472
|
+
|
|
473
|
+
table = Table(title="Heartbeat Monitor Status")
|
|
474
|
+
table.add_column("Parameter", style="cyan")
|
|
475
|
+
table.add_column("Value")
|
|
476
|
+
|
|
477
|
+
# Enabled status
|
|
478
|
+
enabled = status.get("enabled", True)
|
|
479
|
+
if not enabled:
|
|
480
|
+
table.add_row("Status", "[yellow]DISABLED[/]")
|
|
481
|
+
table.add_row(
|
|
482
|
+
"Reason", f"[yellow]Disabled via {DISABLE_HEARTBEAT_ENV_VAR}[/]"
|
|
483
|
+
)
|
|
484
|
+
console = Console()
|
|
485
|
+
console.print(table)
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
# Active status
|
|
489
|
+
active_style = "bold dark_green" if status["is_active"] else "bold red"
|
|
490
|
+
table.add_row("Signal Active", f"[{active_style}]{status['is_active']}[/]")
|
|
491
|
+
|
|
492
|
+
# Last heartbeat value
|
|
493
|
+
last_value = status["last_value"]
|
|
494
|
+
if last_value is not None:
|
|
495
|
+
table.add_row("Last Value", f"[blue]{last_value:.6f}s[/]")
|
|
496
|
+
else:
|
|
497
|
+
table.add_row("Last Value", "[red]No data[/]")
|
|
498
|
+
|
|
499
|
+
# Time since last heartbeat
|
|
500
|
+
time_since = status["time_since_last"]
|
|
501
|
+
timeout_seconds = status["timeout_seconds"]
|
|
502
|
+
if time_since is not None and isinstance(timeout_seconds, (int, float)):
|
|
503
|
+
time_style = (
|
|
504
|
+
"bold red" if time_since > timeout_seconds * 0.8 else "bold dark_green"
|
|
505
|
+
)
|
|
506
|
+
table.add_row("Time Since Last", f"[{time_style}]{time_since:.2f}s[/]")
|
|
507
|
+
else:
|
|
508
|
+
table.add_row("Time Since Last", "[yellow]N/A[/]")
|
|
509
|
+
|
|
510
|
+
# Timeout setting
|
|
511
|
+
table.add_row("Timeout", f"[blue]{timeout_seconds}s[/]")
|
|
512
|
+
|
|
513
|
+
console = Console()
|
|
514
|
+
console.print(table)
|