reachy-mini 1.2.5rc1__py3-none-any.whl → 1.2.11__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.
Files changed (65) hide show
  1. reachy_mini/apps/app.py +24 -21
  2. reachy_mini/apps/manager.py +17 -3
  3. reachy_mini/apps/sources/hf_auth.py +92 -0
  4. reachy_mini/apps/sources/hf_space.py +1 -1
  5. reachy_mini/apps/sources/local_common_venv.py +199 -24
  6. reachy_mini/apps/templates/main.py.j2 +4 -3
  7. reachy_mini/daemon/app/dashboard/static/js/apps.js +9 -1
  8. reachy_mini/daemon/app/dashboard/static/js/appstore.js +228 -0
  9. reachy_mini/daemon/app/dashboard/static/js/logs.js +148 -0
  10. reachy_mini/daemon/app/dashboard/templates/logs.html +37 -0
  11. reachy_mini/daemon/app/dashboard/templates/sections/appstore.html +92 -0
  12. reachy_mini/daemon/app/dashboard/templates/sections/cache.html +82 -0
  13. reachy_mini/daemon/app/dashboard/templates/sections/daemon.html +5 -0
  14. reachy_mini/daemon/app/dashboard/templates/settings.html +1 -0
  15. reachy_mini/daemon/app/main.py +172 -7
  16. reachy_mini/daemon/app/models.py +8 -0
  17. reachy_mini/daemon/app/routers/apps.py +56 -0
  18. reachy_mini/daemon/app/routers/cache.py +58 -0
  19. reachy_mini/daemon/app/routers/hf_auth.py +57 -0
  20. reachy_mini/daemon/app/routers/logs.py +124 -0
  21. reachy_mini/daemon/app/routers/state.py +25 -1
  22. reachy_mini/daemon/app/routers/wifi_config.py +75 -0
  23. reachy_mini/daemon/app/services/bluetooth/bluetooth_service.py +1 -1
  24. reachy_mini/daemon/app/services/bluetooth/commands/WIFI_RESET.sh +8 -0
  25. reachy_mini/daemon/app/services/wireless/launcher.sh +8 -2
  26. reachy_mini/daemon/app/services/wireless/reachy-mini-daemon.service +13 -0
  27. reachy_mini/daemon/backend/abstract.py +29 -9
  28. reachy_mini/daemon/backend/mockup_sim/__init__.py +12 -0
  29. reachy_mini/daemon/backend/mockup_sim/backend.py +176 -0
  30. reachy_mini/daemon/backend/mujoco/backend.py +0 -5
  31. reachy_mini/daemon/backend/robot/backend.py +78 -5
  32. reachy_mini/daemon/daemon.py +46 -7
  33. reachy_mini/daemon/utils.py +71 -15
  34. reachy_mini/io/zenoh_client.py +26 -0
  35. reachy_mini/io/zenoh_server.py +10 -6
  36. reachy_mini/kinematics/nn_kinematics.py +2 -2
  37. reachy_mini/kinematics/placo_kinematics.py +15 -15
  38. reachy_mini/media/__init__.py +55 -1
  39. reachy_mini/media/audio_base.py +185 -13
  40. reachy_mini/media/audio_control_utils.py +60 -5
  41. reachy_mini/media/audio_gstreamer.py +97 -16
  42. reachy_mini/media/audio_sounddevice.py +120 -19
  43. reachy_mini/media/audio_utils.py +110 -5
  44. reachy_mini/media/camera_base.py +182 -11
  45. reachy_mini/media/camera_constants.py +132 -4
  46. reachy_mini/media/camera_gstreamer.py +42 -2
  47. reachy_mini/media/camera_opencv.py +83 -5
  48. reachy_mini/media/camera_utils.py +95 -7
  49. reachy_mini/media/media_manager.py +139 -6
  50. reachy_mini/media/webrtc_client_gstreamer.py +142 -13
  51. reachy_mini/media/webrtc_daemon.py +72 -7
  52. reachy_mini/motion/recorded_move.py +76 -2
  53. reachy_mini/reachy_mini.py +196 -40
  54. reachy_mini/tools/reflash_motors.py +1 -1
  55. reachy_mini/tools/scan_motors.py +86 -0
  56. reachy_mini/tools/setup_motor.py +49 -31
  57. reachy_mini/utils/interpolation.py +1 -1
  58. reachy_mini/utils/wireless_version/startup_check.py +278 -21
  59. reachy_mini/utils/wireless_version/update.py +44 -1
  60. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/METADATA +7 -6
  61. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/RECORD +65 -53
  62. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/WHEEL +0 -0
  63. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/entry_points.txt +0 -0
  64. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/licenses/LICENSE +0 -0
  65. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/top_level.txt +0 -0
@@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
13
13
 
14
14
  from ....daemon.backend.abstract import Backend
15
15
  from ..dependencies import get_backend, ws_get_backend
16
- from ..models import AnyPose, FullState, as_any_pose
16
+ from ..models import AnyPose, DoAInfo, FullState, as_any_pose
17
17
 
18
18
  router = APIRouter(prefix="/state")
19
19
 
@@ -54,6 +54,23 @@ async def get_antenna_joint_positions(
54
54
  return (pos[0], pos[1])
55
55
 
56
56
 
57
+ @router.get("/doa")
58
+ async def get_doa(
59
+ backend: Backend = Depends(get_backend),
60
+ ) -> DoAInfo | None:
61
+ """Get the Direction of Arrival from the microphone array.
62
+
63
+ Returns the angle in radians (0=left, π/2=front, π=right) and speech detection status.
64
+ Returns None if the audio device is not available.
65
+ """
66
+ if not backend.audio:
67
+ return None
68
+ result = backend.audio.get_DoA()
69
+ if result is None:
70
+ return None
71
+ return DoAInfo(angle=result[0], speech_detected=result[1])
72
+
73
+
57
74
  @router.get("/full")
58
75
  async def get_full_state(
59
76
  with_control_mode: bool = True,
@@ -66,6 +83,7 @@ async def get_full_state(
66
83
  with_antenna_positions: bool = True,
67
84
  with_target_antenna_positions: bool = False,
68
85
  with_passive_joints: bool = False,
86
+ with_doa: bool = False,
69
87
  use_pose_matrix: bool = False,
70
88
  backend: Backend = Depends(get_backend),
71
89
  ) -> FullState:
@@ -100,6 +118,10 @@ async def get_full_state(
100
118
  result["passive_joints"] = list(joints.values())
101
119
  else:
102
120
  result["passive_joints"] = None
121
+ if with_doa and backend.audio:
122
+ doa_result = backend.audio.get_DoA()
123
+ if doa_result:
124
+ result["doa"] = DoAInfo(angle=doa_result[0], speech_detected=doa_result[1])
103
125
 
104
126
  result["timestamp"] = datetime.now(timezone.utc)
105
127
  return FullState.model_validate(result)
@@ -118,6 +140,7 @@ async def ws_full_state(
118
140
  with_antenna_positions: bool = True,
119
141
  with_target_antenna_positions: bool = False,
120
142
  with_passive_joints: bool = False,
143
+ with_doa: bool = False,
121
144
  use_pose_matrix: bool = False,
122
145
  backend: Backend = Depends(ws_get_backend),
123
146
  ) -> None:
@@ -137,6 +160,7 @@ async def ws_full_state(
137
160
  with_antenna_positions=with_antenna_positions,
138
161
  with_target_antenna_positions=with_target_antenna_positions,
139
162
  with_passive_joints=with_passive_joints,
163
+ with_doa=with_doa,
140
164
  use_pose_matrix=use_pose_matrix,
141
165
  backend=backend,
142
166
  )
@@ -149,6 +149,81 @@ def scan_wifi() -> list[str]:
149
149
  return ssids
150
150
 
151
151
 
152
+ @router.post("/forget")
153
+ def forget_wifi_network(ssid: str) -> None:
154
+ """Forget a saved WiFi network. Falls back to Hotspot if forgetting the active network."""
155
+ if ssid == "Hotspot":
156
+ raise HTTPException(status_code=400, detail="Cannot forget Hotspot connection.")
157
+
158
+ if not check_if_connection_exists(ssid):
159
+ raise HTTPException(
160
+ status_code=404, detail=f"Network '{ssid}' not found in saved networks."
161
+ )
162
+
163
+ if busy_lock.locked():
164
+ raise HTTPException(status_code=409, detail="Another operation is in progress.")
165
+
166
+ def forget() -> None:
167
+ global error
168
+ with busy_lock:
169
+ try:
170
+ error = None
171
+ was_active = check_if_connection_active(ssid)
172
+ logger.info(f"Forgetting WiFi network '{ssid}'...")
173
+ remove_connection(ssid)
174
+
175
+ if was_active:
176
+ logger.info("Was connected, falling back to hotspot...")
177
+ setup_wifi_connection(
178
+ name="Hotspot",
179
+ ssid=HOTSPOT_SSID,
180
+ password=HOTSPOT_PASSWORD,
181
+ is_hotspot=True,
182
+ )
183
+ except Exception as e:
184
+ error = e
185
+ logger.error(f"Failed to forget network '{ssid}': {e}")
186
+
187
+ Thread(target=forget).start()
188
+
189
+
190
+ @router.post("/forget_all")
191
+ def forget_all_wifi_networks() -> None:
192
+ """Forget all saved WiFi networks (except Hotspot). Falls back to Hotspot."""
193
+ if busy_lock.locked():
194
+ raise HTTPException(status_code=409, detail="Another operation is in progress.")
195
+
196
+ def forget_all() -> None:
197
+ global error
198
+ with busy_lock:
199
+ try:
200
+ error = None
201
+ connections = get_wifi_connections()
202
+ forgotten = []
203
+
204
+ for conn in connections:
205
+ if conn.name != "Hotspot":
206
+ remove_connection(conn.name)
207
+ forgotten.append(conn.name)
208
+
209
+ logger.info(f"Forgotten {len(forgotten)} networks: {forgotten}")
210
+
211
+ # Always ensure we have connectivity after forgetting all
212
+ if get_current_wifi_mode() == WifiMode.DISCONNECTED:
213
+ logger.info("No connection left, setting up hotspot...")
214
+ setup_wifi_connection(
215
+ name="Hotspot",
216
+ ssid=HOTSPOT_SSID,
217
+ password=HOTSPOT_PASSWORD,
218
+ is_hotspot=True,
219
+ )
220
+ except Exception as e:
221
+ error = e
222
+ logger.error(f"Failed to forget networks: {e}")
223
+
224
+ Thread(target=forget_all).start()
225
+
226
+
152
227
  # NMCLI WRAPPERS
153
228
  def scan_available_wifi() -> list[nmcli.data.device.DeviceWifi]:
154
229
  """Scan for available WiFi networks."""
@@ -278,7 +278,7 @@ class CommandCharacteristic(Characteristic):
278
278
 
279
279
 
280
280
  class ResponseCharacteristic(Characteristic):
281
- """Response Characteristic.""" ""
281
+ """Response Characteristic."""
282
282
 
283
283
  def __init__(self, bus, index, service):
284
284
  """Initialize the Response Characteristic."""
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+
3
+ nmcli --escape yes -t -f NAME,TYPE connection show | grep ':802-11-wireless$' | while IFS= read -r line; do
4
+ conn="${line%:802-11-wireless}"
5
+ conn="${conn//\\:/\:}"
6
+ [ "$conn" != "Hotspot" ] && nmcli connection delete "$conn" 2>/dev/null
7
+ done
8
+ systemctl restart reachy-mini-daemon.service
@@ -1,5 +1,11 @@
1
1
  #!/bin/bash
2
- /venvs/src/reachy_mini/src/reachy_mini/daemon/app/services/wireless/generate_asoundrc.sh
2
+ # Get the directory where this script is located
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+
5
+ "$SCRIPT_DIR/generate_asoundrc.sh"
3
6
  source /venvs/mini_daemon/bin/activate
4
7
  export GST_PLUGIN_PATH=$GST_PLUGIN_PATH:/opt/gst-plugins-rs/lib/aarch64-linux-gnu/
5
- python -m reachy_mini.daemon.app.main --wireless-version --no-autostart
8
+ export PATH=$PATH:/opt/uv
9
+
10
+ # Run Python in unbuffered mode (-u) to ensure logs are immediately forwarded to systemd
11
+ python -u -m reachy_mini.daemon.app.main --wireless-version --no-autostart
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=Reachy Mini AP Launcher Service
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ ExecStart=/venvs/mini_daemon/lib/python3.12/site-packages/reachy_mini/daemon/app/services/wireless/launcher.sh
8
+ Restart=on-failure
9
+ User=pollen
10
+ WorkingDirectory=/venvs/mini_daemon/lib/python3.12/site-packages/reachy_mini/daemon/app/services/wireless
11
+
12
+ [Install]
13
+ WantedBy=multi-user.target
@@ -25,6 +25,7 @@ from numpy.typing import NDArray
25
25
  from scipy.spatial.transform import Rotation as R
26
26
 
27
27
  if typing.TYPE_CHECKING:
28
+ from reachy_mini.daemon.backend.mockup_sim.backend import MockupSimBackendStatus
28
29
  from reachy_mini.daemon.backend.mujoco.backend import MujocoBackendStatus
29
30
  from reachy_mini.daemon.backend.robot.backend import RobotBackendStatus
30
31
  from reachy_mini.kinematics import AnyKinematics
@@ -131,6 +132,7 @@ class Backend:
131
132
  self.joint_positions_publisher: zenoh.Publisher | None = None
132
133
  self.pose_publisher: zenoh.Publisher | None = None
133
134
  self.recording_publisher: zenoh.Publisher | None = None
135
+ self.imu_publisher: zenoh.Publisher | None = None
134
136
  self.error: str | None = None # To store any error that occurs during execution
135
137
  self.is_recording = False # Flag to indicate if recording is active
136
138
  self.recorded_data: list[dict[str, Any]] = [] # List to store recorded data
@@ -182,7 +184,9 @@ class Backend:
182
184
 
183
185
  # Guard to ensure only one play_move/goto is executed at a time (goto itself uses play_move, so we need an RLock)
184
186
  self._play_move_lock = threading.RLock()
185
- self._active_move_depth = 0 # Tracks nested acquisitions within the owning thread
187
+ self._active_move_depth = (
188
+ 0 # Tracks nested acquisitions within the owning thread
189
+ )
186
190
 
187
191
  # Life cycle methods
188
192
  def wrapped_run(self) -> None:
@@ -202,13 +206,18 @@ class Backend:
202
206
  raise NotImplementedError("The method run should be overridden by subclasses.")
203
207
 
204
208
  def close(self) -> None:
205
- """Close the backend.
209
+ """Close the backend and release resources.
206
210
 
207
- This method is a placeholder and should be overridden by subclasses.
211
+ Subclasses should override this method to add their own cleanup logic,
212
+ and call super().close() at the end to ensure audio resources are released.
213
+
214
+ Note: This base implementation handles common cleanup (audio).
215
+ Subclasses must still implement their own cleanup for backend-specific resources.
208
216
  """
209
- raise NotImplementedError(
210
- "The method close should be overridden by subclasses."
211
- )
217
+ self.logger.debug("Backend.close() - cleaning up audio resources")
218
+ if self.audio is not None:
219
+ self.audio.close()
220
+ self.audio = None
212
221
 
213
222
  @property
214
223
  def is_move_running(self) -> bool:
@@ -228,7 +237,9 @@ class Backend:
228
237
  self._active_move_depth -= 1
229
238
  self._play_move_lock.release()
230
239
 
231
- def get_status(self) -> "RobotBackendStatus | MujocoBackendStatus":
240
+ def get_status(
241
+ self,
242
+ ) -> "RobotBackendStatus | MujocoBackendStatus | MockupSimBackendStatus":
232
243
  """Return backend statistics.
233
244
 
234
245
  This method is a placeholder and should be overridden by subclasses.
@@ -256,6 +267,15 @@ class Backend:
256
267
  """
257
268
  self.pose_publisher = publisher
258
269
 
270
+ def set_imu_publisher(self, publisher: zenoh.Publisher) -> None:
271
+ """Set the publisher for IMU data.
272
+
273
+ Args:
274
+ publisher: A publisher object that will be used to publish IMU data.
275
+
276
+ """
277
+ self.imu_publisher = publisher
278
+
259
279
  def update_target_head_joints_from_ik(
260
280
  self,
261
281
  pose: Annotated[NDArray[np.float64], (4, 4)] | None = None,
@@ -388,8 +408,8 @@ class Backend:
388
408
 
389
409
  try:
390
410
  if initial_goto_duration > 0.0:
391
- start_head_pose, start_antennas_positions, start_body_yaw = move.evaluate(
392
- 0.0
411
+ start_head_pose, start_antennas_positions, start_body_yaw = (
412
+ move.evaluate(0.0)
393
413
  )
394
414
  await self.goto_target(
395
415
  head=start_head_pose,
@@ -0,0 +1,12 @@
1
+ """Mockup Simulation Backend for Reachy Mini Daemon.
2
+
3
+ A lightweight simulation backend that doesn't require MuJoCo.
4
+ Uses only kinematics (no physics simulation).
5
+ """
6
+
7
+ from reachy_mini.daemon.backend.mockup_sim.backend import (
8
+ MockupSimBackend,
9
+ MockupSimBackendStatus,
10
+ )
11
+
12
+ __all__ = ["MockupSimBackend", "MockupSimBackendStatus"]
@@ -0,0 +1,176 @@
1
+ """Mockup Simulation Backend for Reachy Mini.
2
+
3
+ A lightweight simulation backend that doesn't require MuJoCo.
4
+ Target positions become current positions immediately (no physics).
5
+ The kinematics engine is still used for FK/IK computations.
6
+
7
+ Apps open the webcam/microphone directly (like with a real robot).
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Annotated
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ from ..abstract import Backend, MotorControlMode
19
+
20
+
21
+ class MockupSimBackend(Backend):
22
+ """Lightweight simulated Reachy Mini without MuJoCo.
23
+
24
+ This backend provides a simple simulation where target positions
25
+ are applied immediately without physics simulation.
26
+
27
+ Apps access webcam/microphone directly (not via UDP streaming).
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ check_collision: bool = False,
33
+ kinematics_engine: str = "AnalyticalKinematics",
34
+ use_audio: bool = True,
35
+ ) -> None:
36
+ """Initialize the MockupSimBackend.
37
+
38
+ Args:
39
+ check_collision: If True, enable collision checking. Default is False.
40
+ kinematics_engine: Kinematics engine to use. Defaults to "AnalyticalKinematics".
41
+ use_audio: If True, use audio. Default is True.
42
+
43
+ """
44
+ super().__init__(
45
+ check_collision=check_collision,
46
+ kinematics_engine=kinematics_engine,
47
+ use_audio=use_audio,
48
+ )
49
+
50
+ from reachy_mini.reachy_mini import (
51
+ SLEEP_ANTENNAS_JOINT_POSITIONS,
52
+ SLEEP_HEAD_JOINT_POSITIONS,
53
+ )
54
+
55
+ # Initialize with sleep positions
56
+ self._head_joint_positions: npt.NDArray[np.float64] = np.array(
57
+ SLEEP_HEAD_JOINT_POSITIONS, dtype=np.float64
58
+ )
59
+ self._antenna_joint_positions: npt.NDArray[np.float64] = np.array(
60
+ SLEEP_ANTENNAS_JOINT_POSITIONS, dtype=np.float64
61
+ )
62
+
63
+ self._motor_control_mode = MotorControlMode.Enabled
64
+
65
+ # Control loop frequency
66
+ self.control_frequency = 50.0 # Hz
67
+
68
+ def run(self) -> None:
69
+ """Run the simulation loop.
70
+
71
+ In mockup-sim mode, target positions are applied immediately.
72
+ """
73
+ control_period = 1.0 / self.control_frequency
74
+
75
+ # Initialize kinematics with current positions
76
+ self.update_head_kinematics_model(
77
+ self._head_joint_positions,
78
+ self._antenna_joint_positions,
79
+ )
80
+
81
+ while not self.should_stop.is_set():
82
+ start_t = time.time()
83
+
84
+ # Apply target positions immediately (no physics)
85
+ if self.target_head_joint_positions is not None:
86
+ self._head_joint_positions = self.target_head_joint_positions.copy()
87
+ if self.target_antenna_joint_positions is not None:
88
+ self._antenna_joint_positions = (
89
+ self.target_antenna_joint_positions.copy()
90
+ )
91
+
92
+ # Update current states
93
+ self.current_head_joint_positions = self._head_joint_positions.copy()
94
+ self.current_antenna_joint_positions = self._antenna_joint_positions.copy()
95
+
96
+ # Update kinematics model (computes FK)
97
+ self.update_head_kinematics_model(
98
+ self.current_head_joint_positions,
99
+ self.current_antenna_joint_positions,
100
+ )
101
+
102
+ # Update target head joint positions from IK if necessary
103
+ if self.ik_required:
104
+ try:
105
+ self.update_target_head_joints_from_ik(
106
+ self.target_head_pose, self.target_body_yaw
107
+ )
108
+ except ValueError:
109
+ pass # IK failed, keep current positions
110
+
111
+ # Publish joint positions via Zenoh
112
+ if (
113
+ self.joint_positions_publisher is not None
114
+ and self.pose_publisher is not None
115
+ and not self.is_shutting_down
116
+ ):
117
+ self.joint_positions_publisher.put(
118
+ json.dumps(
119
+ {
120
+ "head_joint_positions": self.current_head_joint_positions.tolist(),
121
+ "antennas_joint_positions": self.current_antenna_joint_positions.tolist(),
122
+ }
123
+ ).encode("utf-8")
124
+ )
125
+ self.pose_publisher.put(
126
+ json.dumps(
127
+ {
128
+ "head_pose": self.get_present_head_pose().tolist(),
129
+ }
130
+ ).encode("utf-8")
131
+ )
132
+
133
+ self.ready.set()
134
+
135
+ # Sleep to maintain control frequency
136
+ elapsed = time.time() - start_t
137
+ time.sleep(max(0, control_period - elapsed))
138
+
139
+ def get_status(self) -> "MockupSimBackendStatus":
140
+ """Get the status of the backend."""
141
+ return MockupSimBackendStatus(motor_control_mode=self._motor_control_mode)
142
+
143
+ def get_present_head_joint_positions(
144
+ self,
145
+ ) -> Annotated[npt.NDArray[np.float64], (7,)]:
146
+ """Get the current joint positions of the head."""
147
+ return self._head_joint_positions.copy()
148
+
149
+ def get_present_antenna_joint_positions(
150
+ self,
151
+ ) -> Annotated[npt.NDArray[np.float64], (2,)]:
152
+ """Get the current joint positions of the antennas."""
153
+ return self._antenna_joint_positions.copy()
154
+
155
+ def get_motor_control_mode(self) -> MotorControlMode:
156
+ """Get the motor control mode."""
157
+ return self._motor_control_mode
158
+
159
+ def set_motor_control_mode(self, mode: MotorControlMode) -> None:
160
+ """Set the motor control mode."""
161
+ self._motor_control_mode = mode
162
+
163
+ def set_motor_torque_ids(self, ids: list[str], on: bool) -> None:
164
+ """Set the motor torque state for specific motor names.
165
+
166
+ No-op in mockup-sim mode.
167
+ """
168
+ pass
169
+
170
+
171
+ @dataclass
172
+ class MockupSimBackendStatus:
173
+ """Status of the MockupSim backend."""
174
+
175
+ motor_control_mode: MotorControlMode
176
+ error: str | None = None
@@ -339,11 +339,6 @@ class MujocoBackend(Backend):
339
339
  mj_current_head_pose[2, 3] -= 0.177
340
340
  return mj_current_head_pose
341
341
 
342
- def close(self) -> None:
343
- """Close the Mujoco backend."""
344
- # TODO Do something in mujoco here ?
345
- pass
346
-
347
342
  def get_status(self) -> "MujocoBackendStatus":
348
343
  """Get the status of the Mujoco backend.
349
344
 
@@ -17,6 +17,7 @@ from typing import Annotated, Any
17
17
  import log_throttling
18
18
  import numpy as np
19
19
  import numpy.typing as npt
20
+ import zenoh
20
21
  from reachy_mini_motor_controller import ReachyMiniPyControlLoop
21
22
 
22
23
  from reachy_mini.utils.hardware_config.parser import parse_yaml_config
@@ -70,7 +71,7 @@ class RobotBackend(Backend):
70
71
  seconds=1.0 / self.control_loop_frequency
71
72
  ),
72
73
  allowed_retries=5,
73
- stats_pub_period=None,
74
+ stats_pub_period=timedelta(seconds=1.0),
74
75
  )
75
76
 
76
77
  self.name2id = self.c.get_motor_name_id()
@@ -108,7 +109,29 @@ class RobotBackend(Backend):
108
109
  self.target_antenna_joint_current = None # Placeholder for antenna joint torque
109
110
  self.target_head_joint_current = None # Placeholder for head joint torque
110
111
 
111
- self.hardware_error_check_frequency = hardware_error_check_frequency # seconds
112
+ if hardware_error_check_frequency <= 0:
113
+ raise ValueError(
114
+ "hardware_error_check_frequency must be positive and non-zero (Hz)."
115
+ )
116
+
117
+ self.hardware_error_check_period = (
118
+ 1.0 / hardware_error_check_frequency
119
+ ) # seconds
120
+
121
+ # Initialize IMU for wireless version
122
+ if wireless_version:
123
+ try:
124
+ from bmi088 import BMI088
125
+
126
+ self.bmi088 = BMI088(i2c_bus=4)
127
+ self.logger.info("BMI088 IMU initialized successfully")
128
+ except Exception as e:
129
+ self.logger.warning(f"Failed to initialize IMU: {e}")
130
+ self.bmi088 = None
131
+ else:
132
+ self.bmi088 = None
133
+
134
+ self.imu_publisher: zenoh.Publisher | None = None
112
135
 
113
136
  def run(self) -> None:
114
137
  """Run the control loop for the robot backend.
@@ -234,6 +257,12 @@ class RobotBackend(Backend):
234
257
  )
235
258
  )
236
259
 
260
+ # Publish IMU data if available
261
+ if self.imu_publisher is not None and self.bmi088 is not None:
262
+ imu_data = self.get_imu_data()
263
+ if imu_data is not None:
264
+ self.imu_publisher.put(json.dumps(imu_data))
265
+
237
266
  self.last_alive = time.time()
238
267
 
239
268
  self.ready.set() # Mark the backend as ready
@@ -264,6 +293,9 @@ class RobotBackend(Backend):
264
293
  self._status.control_loop_stats["nb_error"] = self._stats[
265
294
  "nb_error"
266
295
  ]
296
+ self._status.control_loop_stats["motor_controller"] = str(
297
+ self.c.get_stats()
298
+ )
267
299
 
268
300
  self._stats["timestamps"].clear()
269
301
  self._stats["nb_error"] = 0
@@ -271,7 +303,7 @@ class RobotBackend(Backend):
271
303
 
272
304
  if (
273
305
  time.time() - self.last_hardware_error_check_time
274
- > self.hardware_error_check_frequency
306
+ > self.hardware_error_check_period
275
307
  ):
276
308
  hardware_errors = self.read_hardware_errors()
277
309
  if hardware_errors:
@@ -282,10 +314,11 @@ class RobotBackend(Backend):
282
314
  self.last_hardware_error_check_time = time.time()
283
315
 
284
316
  def close(self) -> None:
285
- """Close the motor controller connection."""
317
+ """Close the motor controller connection and release resources."""
286
318
  if self.c is not None:
287
319
  self.c.close()
288
320
  self.c = None
321
+ super().close()
289
322
 
290
323
  def get_status(self) -> "RobotBackendStatus":
291
324
  """Get the current status of the robot backend."""
@@ -378,7 +411,11 @@ class RobotBackend(Backend):
378
411
 
379
412
  """
380
413
  assert self.c is not None, "Motor controller not initialized or already closed."
381
- assert mode in [0, 3, 5], (
414
+ assert mode in [
415
+ 0,
416
+ 3,
417
+ 5,
418
+ ], (
382
419
  "Invalid operation mode. Must be one of [0 (torque), 3 (position), 5 (current-limiting position)]."
383
420
  )
384
421
 
@@ -437,6 +474,42 @@ class RobotBackend(Backend):
437
474
  """
438
475
  return np.array(self.get_all_joint_positions()[1])
439
476
 
477
+ def get_imu_data(self) -> dict[str, list[float] | float] | None:
478
+ """Get current IMU data (accelerometer, gyroscope, quaternion, temperature).
479
+
480
+ Returns:
481
+ dict with 'accelerometer', 'gyroscope', 'quaternion', and 'temperature' keys,
482
+ or None if IMU is not available.
483
+
484
+ """
485
+ if self.bmi088 is None:
486
+ return None
487
+
488
+ try:
489
+ # Read accelerometer (returns tuple of x, y, z in m/s^2)
490
+ accel_x, accel_y, accel_z = self.bmi088.read_accelerometer(m_per_s2=True)
491
+
492
+ # Read gyroscope (returns tuple of x, y, z in rad/s)
493
+ gyro_x, gyro_y, gyro_z = self.bmi088.read_gyroscope(deg_per_s=False)
494
+
495
+ # Get quaternion orientation (dt = control loop period)
496
+ dt = 1.0 / self.control_loop_frequency # 0.02 seconds at 50Hz
497
+ quat = self.bmi088.get_quat(dt)
498
+
499
+ # Read temperature in Celsius
500
+ temperature = self.bmi088.read_temperature()
501
+
502
+ # Convert all numpy types to native Python floats for JSON serialization
503
+ return {
504
+ "accelerometer": [float(accel_x), float(accel_y), float(accel_z)],
505
+ "gyroscope": [float(gyro_x), float(gyro_y), float(gyro_z)],
506
+ "quaternion": [float(q) for q in quat],
507
+ "temperature": float(temperature),
508
+ }
509
+ except Exception as e:
510
+ self.logger.error(f"Error reading IMU data: {e}")
511
+ return None
512
+
440
513
  def compensate_head_gravity(self) -> None:
441
514
  """Calculate the currents necessary to compensate for gravity."""
442
515
  assert self.kinematics_engine == "Placo", (