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.
- reachy_mini/apps/app.py +24 -21
- reachy_mini/apps/manager.py +17 -3
- reachy_mini/apps/sources/hf_auth.py +92 -0
- reachy_mini/apps/sources/hf_space.py +1 -1
- reachy_mini/apps/sources/local_common_venv.py +199 -24
- reachy_mini/apps/templates/main.py.j2 +4 -3
- reachy_mini/daemon/app/dashboard/static/js/apps.js +9 -1
- reachy_mini/daemon/app/dashboard/static/js/appstore.js +228 -0
- reachy_mini/daemon/app/dashboard/static/js/logs.js +148 -0
- reachy_mini/daemon/app/dashboard/templates/logs.html +37 -0
- reachy_mini/daemon/app/dashboard/templates/sections/appstore.html +92 -0
- reachy_mini/daemon/app/dashboard/templates/sections/cache.html +82 -0
- reachy_mini/daemon/app/dashboard/templates/sections/daemon.html +5 -0
- reachy_mini/daemon/app/dashboard/templates/settings.html +1 -0
- reachy_mini/daemon/app/main.py +172 -7
- reachy_mini/daemon/app/models.py +8 -0
- reachy_mini/daemon/app/routers/apps.py +56 -0
- reachy_mini/daemon/app/routers/cache.py +58 -0
- reachy_mini/daemon/app/routers/hf_auth.py +57 -0
- reachy_mini/daemon/app/routers/logs.py +124 -0
- reachy_mini/daemon/app/routers/state.py +25 -1
- reachy_mini/daemon/app/routers/wifi_config.py +75 -0
- reachy_mini/daemon/app/services/bluetooth/bluetooth_service.py +1 -1
- reachy_mini/daemon/app/services/bluetooth/commands/WIFI_RESET.sh +8 -0
- reachy_mini/daemon/app/services/wireless/launcher.sh +8 -2
- reachy_mini/daemon/app/services/wireless/reachy-mini-daemon.service +13 -0
- reachy_mini/daemon/backend/abstract.py +29 -9
- reachy_mini/daemon/backend/mockup_sim/__init__.py +12 -0
- reachy_mini/daemon/backend/mockup_sim/backend.py +176 -0
- reachy_mini/daemon/backend/mujoco/backend.py +0 -5
- reachy_mini/daemon/backend/robot/backend.py +78 -5
- reachy_mini/daemon/daemon.py +46 -7
- reachy_mini/daemon/utils.py +71 -15
- reachy_mini/io/zenoh_client.py +26 -0
- reachy_mini/io/zenoh_server.py +10 -6
- reachy_mini/kinematics/nn_kinematics.py +2 -2
- reachy_mini/kinematics/placo_kinematics.py +15 -15
- reachy_mini/media/__init__.py +55 -1
- reachy_mini/media/audio_base.py +185 -13
- reachy_mini/media/audio_control_utils.py +60 -5
- reachy_mini/media/audio_gstreamer.py +97 -16
- reachy_mini/media/audio_sounddevice.py +120 -19
- reachy_mini/media/audio_utils.py +110 -5
- reachy_mini/media/camera_base.py +182 -11
- reachy_mini/media/camera_constants.py +132 -4
- reachy_mini/media/camera_gstreamer.py +42 -2
- reachy_mini/media/camera_opencv.py +83 -5
- reachy_mini/media/camera_utils.py +95 -7
- reachy_mini/media/media_manager.py +139 -6
- reachy_mini/media/webrtc_client_gstreamer.py +142 -13
- reachy_mini/media/webrtc_daemon.py +72 -7
- reachy_mini/motion/recorded_move.py +76 -2
- reachy_mini/reachy_mini.py +196 -40
- reachy_mini/tools/reflash_motors.py +1 -1
- reachy_mini/tools/scan_motors.py +86 -0
- reachy_mini/tools/setup_motor.py +49 -31
- reachy_mini/utils/interpolation.py +1 -1
- reachy_mini/utils/wireless_version/startup_check.py +278 -21
- reachy_mini/utils/wireless_version/update.py +44 -1
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/METADATA +7 -6
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/RECORD +65 -53
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/WHEEL +0 -0
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/entry_points.txt +0 -0
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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(
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
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.
|
|
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 [
|
|
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", (
|