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.

Files changed (72) hide show
  1. dexcontrol/__init__.py +45 -0
  2. dexcontrol/apps/dualsense_teleop_base.py +371 -0
  3. dexcontrol/config/__init__.py +14 -0
  4. dexcontrol/config/core/__init__.py +22 -0
  5. dexcontrol/config/core/arm.py +32 -0
  6. dexcontrol/config/core/chassis.py +22 -0
  7. dexcontrol/config/core/hand.py +42 -0
  8. dexcontrol/config/core/head.py +33 -0
  9. dexcontrol/config/core/misc.py +37 -0
  10. dexcontrol/config/core/torso.py +36 -0
  11. dexcontrol/config/sensors/__init__.py +4 -0
  12. dexcontrol/config/sensors/cameras/__init__.py +7 -0
  13. dexcontrol/config/sensors/cameras/gemini_camera.py +16 -0
  14. dexcontrol/config/sensors/cameras/rgb_camera.py +15 -0
  15. dexcontrol/config/sensors/imu/__init__.py +6 -0
  16. dexcontrol/config/sensors/imu/gemini_imu.py +15 -0
  17. dexcontrol/config/sensors/imu/nine_axis_imu.py +15 -0
  18. dexcontrol/config/sensors/lidar/__init__.py +6 -0
  19. dexcontrol/config/sensors/lidar/rplidar.py +15 -0
  20. dexcontrol/config/sensors/ultrasonic/__init__.py +6 -0
  21. dexcontrol/config/sensors/ultrasonic/ultrasonic.py +15 -0
  22. dexcontrol/config/sensors/vega_sensors.py +65 -0
  23. dexcontrol/config/vega.py +203 -0
  24. dexcontrol/core/__init__.py +0 -0
  25. dexcontrol/core/arm.py +324 -0
  26. dexcontrol/core/chassis.py +628 -0
  27. dexcontrol/core/component.py +834 -0
  28. dexcontrol/core/hand.py +170 -0
  29. dexcontrol/core/head.py +232 -0
  30. dexcontrol/core/misc.py +514 -0
  31. dexcontrol/core/torso.py +198 -0
  32. dexcontrol/proto/dexcontrol_msg_pb2.py +69 -0
  33. dexcontrol/proto/dexcontrol_msg_pb2.pyi +168 -0
  34. dexcontrol/proto/dexcontrol_query_pb2.py +73 -0
  35. dexcontrol/proto/dexcontrol_query_pb2.pyi +134 -0
  36. dexcontrol/robot.py +1091 -0
  37. dexcontrol/sensors/__init__.py +40 -0
  38. dexcontrol/sensors/camera/__init__.py +18 -0
  39. dexcontrol/sensors/camera/gemini_camera.py +139 -0
  40. dexcontrol/sensors/camera/rgb_camera.py +98 -0
  41. dexcontrol/sensors/imu/__init__.py +22 -0
  42. dexcontrol/sensors/imu/gemini_imu.py +139 -0
  43. dexcontrol/sensors/imu/nine_axis_imu.py +149 -0
  44. dexcontrol/sensors/lidar/__init__.py +3 -0
  45. dexcontrol/sensors/lidar/rplidar.py +164 -0
  46. dexcontrol/sensors/manager.py +185 -0
  47. dexcontrol/sensors/ultrasonic.py +110 -0
  48. dexcontrol/utils/__init__.py +15 -0
  49. dexcontrol/utils/constants.py +12 -0
  50. dexcontrol/utils/io_utils.py +26 -0
  51. dexcontrol/utils/motion_utils.py +194 -0
  52. dexcontrol/utils/os_utils.py +39 -0
  53. dexcontrol/utils/pb_utils.py +103 -0
  54. dexcontrol/utils/rate_limiter.py +167 -0
  55. dexcontrol/utils/reset_orbbec_camera_usb.py +98 -0
  56. dexcontrol/utils/subscribers/__init__.py +44 -0
  57. dexcontrol/utils/subscribers/base.py +260 -0
  58. dexcontrol/utils/subscribers/camera.py +328 -0
  59. dexcontrol/utils/subscribers/decoders.py +83 -0
  60. dexcontrol/utils/subscribers/generic.py +105 -0
  61. dexcontrol/utils/subscribers/imu.py +170 -0
  62. dexcontrol/utils/subscribers/lidar.py +195 -0
  63. dexcontrol/utils/subscribers/protobuf.py +106 -0
  64. dexcontrol/utils/timer.py +136 -0
  65. dexcontrol/utils/trajectory_utils.py +40 -0
  66. dexcontrol/utils/viz_utils.py +86 -0
  67. dexcontrol-0.2.1.dist-info/METADATA +369 -0
  68. dexcontrol-0.2.1.dist-info/RECORD +72 -0
  69. dexcontrol-0.2.1.dist-info/WHEEL +5 -0
  70. dexcontrol-0.2.1.dist-info/licenses/LICENSE +188 -0
  71. dexcontrol-0.2.1.dist-info/licenses/NOTICE +13 -0
  72. dexcontrol-0.2.1.dist-info/top_level.txt +1 -0
@@ -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)