V4C38 Cursor commited on
Commit
9de9b10
·
0 Parent(s):

v1: ESP32 Reachy Mini motion controller

Browse files

Co-authored-by: Cursor <cursoragent@cursor.com>

.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ *.egg-info/
README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ESP32 Motion Controller — Reachy Mini
3
+ emoji: 🕹️
4
+ colorFrom: gray
5
+ colorTo: yellow
6
+ sdk: static
7
+ pinned: false
8
+ short_description: ESP32 handheld motion controller for Reachy Mini
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ - esp32
13
+ - websocket
14
+ - imu
15
+ ---
16
+
17
+ # ESP32 Motion Controller
18
+
19
+ WebSocket bridge for an ESP32 handheld IMU controller that drives Reachy Mini's head with clutch-style relative motion, IK-safe clamping, antenna idle animation, and body follow only when head exceeds the neck yaw threshold.
20
+
21
+ See the [repository README](https://github.com/V4C38/esp32-reachy-mini-controller) and [`PROTOCOL.md`](../PROTOCOL.md).
22
+
23
+ ## Local install
24
+
25
+ ```bash
26
+ pip install -e .
27
+ python -m esp32_motion_controller.main
28
+ ```
29
+
30
+ WebSocket: `ws://<host>:8766/ws`
31
+
32
+
33
+ Live Space: https://huggingface.co/spaces/V4C38/esp32_motion_controller
34
+
35
+ ## Publish to Hugging Face
36
+
37
+ Tags required: `reachy_mini`, `reachy_mini_python_app`.
38
+
39
+ ```bash
40
+ # from reachy-mini-app/
41
+ hf auth login
42
+ hf upload YOUR_USER/esp32_motion_controller . --repo-type space \
43
+ --exclude ".venv/*" \
44
+ --exclude "build/*" \
45
+ --exclude "dist/*" \
46
+ --exclude "*.egg-info/*" \
47
+ --exclude "**/__pycache__/*" \
48
+ --exclude "*.pyc" \
49
+ --exclude ".pytest_cache/*" \
50
+ --exclude ".DS_Store"
51
+ ```
52
+
53
+ Ensure README frontmatter includes those tags before upload.
54
+
55
+ The excludes matter: `packages.find` would otherwise pick up a stray `build/lib/`
56
+ tree as a second copy of the package, and `.pyc` files built by a different
57
+ Python end up installed into the daemon's 3.12 venv.
esp32_motion_controller/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """ESP32 Motion Controller Reachy Mini app."""
2
+
3
+ __version__ = "1.0.0"
esp32_motion_controller/behavior.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Antenna idle animation and threshold body-follow yaw.
3
+
4
+ Body holds until |head_yaw - body_yaw| exceeds BODY_FOLLOW_THRESHOLD, then
5
+ catches up on the excess only. IMU / clutch rotation always drives the head;
6
+ body is irrelevant below that threshold. MAX_HEAD_YAW remains the hard neck
7
+ limit used by the movement clamp.
8
+
9
+ Antenna dualSine ported from lens-studio RobotDriver.ts.
10
+ Owns body_yaw and antennas only — never head pose axes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import time
17
+
18
+ MAX_HEAD_YAW = 65.0 * math.pi / 180.0
19
+ # Start body catch-up before the hard neck limit so torso rotates sooner.
20
+ BODY_FOLLOW_THRESHOLD = 40.0 * math.pi / 180.0
21
+ MAX_BODY_YAW = 160.0 * math.pi / 180.0
22
+ MAX_HEAD_YAW_ABSOLUTE = math.pi
23
+
24
+ # Puppeteer-like liveliness defaults
25
+ DEFAULT_LIVELINESS = 1.25
26
+ DEFAULT_GAZE_RESPONSIVENESS = 1.2
27
+ DEFAULT_ANTENNA_ACTIVITY = 0.8
28
+ HEAD_MOVE_SPEED = 0.06
29
+ MAX_HEAD_DELTA_DEG = 2.0
30
+ ANTENNA_AMPLITUDE_DEG = 15.0
31
+
32
+
33
+ def dual_sine(t: float, freq_a: float, freq_b: float) -> float:
34
+ return math.sin(t * freq_a) * 0.6 + math.sin(t * freq_b) * 0.4
35
+
36
+
37
+ def _clamp(value: float, lo: float, hi: float) -> float:
38
+ return max(lo, min(hi, value))
39
+
40
+
41
+ def _dampen(delta: float, max_delta: float) -> float:
42
+ return _clamp(delta, -max_delta, max_delta)
43
+
44
+
45
+ class Behavior:
46
+ """Derives body_yaw and antennas from head yaw + time."""
47
+
48
+ def __init__(
49
+ self,
50
+ liveliness: float = DEFAULT_LIVELINESS,
51
+ gaze_responsiveness: float = DEFAULT_GAZE_RESPONSIVENESS,
52
+ antenna_activity: float = DEFAULT_ANTENNA_ACTIVITY,
53
+ ) -> None:
54
+ self.liveliness = liveliness
55
+ self.gaze_responsiveness = gaze_responsiveness
56
+ self.antenna_activity = antenna_activity
57
+ self.body_yaw = 0.0
58
+ self.antenna_left = 0.0
59
+ self.antenna_right = 0.0
60
+ self._t0 = time.monotonic()
61
+
62
+ def reset(self) -> None:
63
+ self.body_yaw = 0.0
64
+ self.antenna_left = 0.0
65
+ self.antenna_right = 0.0
66
+ self._t0 = time.monotonic()
67
+
68
+ def update(self, head_yaw: float) -> tuple[float, list[float]]:
69
+ """Advance one tick. Returns (body_yaw, [left, right] antennas)."""
70
+ now = time.monotonic() - self._t0
71
+ deg = math.pi / 180.0
72
+
73
+ yaw_smoothing = HEAD_MOVE_SPEED * self.gaze_responsiveness
74
+ max_yaw_delta = MAX_HEAD_DELTA_DEG * self.gaze_responsiveness * deg
75
+ body_smoothing = yaw_smoothing * 0.7 * (0.3 + self.liveliness * 0.4)
76
+ antenna_smoothing = yaw_smoothing * 1.5
77
+ effective_ant_amp = ANTENNA_AMPLITUDE_DEG * self.antenna_activity * deg
78
+ ant_speed = 0.5 + self.antenna_activity * 0.5
79
+
80
+ # Body follows only when head exceeds the follow threshold
81
+ rel_yaw = head_yaw - self.body_yaw
82
+ if abs(rel_yaw) > BODY_FOLLOW_THRESHOLD:
83
+ excess = abs(rel_yaw) - BODY_FOLLOW_THRESHOLD
84
+ step = math.copysign(excess * body_smoothing * 8, rel_yaw)
85
+ self.body_yaw += _dampen(step, max_yaw_delta)
86
+ self.body_yaw = _clamp(self.body_yaw, -MAX_BODY_YAW, MAX_BODY_YAW)
87
+
88
+ # Antennas
89
+ desired_l = dual_sine(now * ant_speed, 1.3, 3.11) * effective_ant_amp
90
+ desired_r = dual_sine(now * ant_speed, 1.7, 2.73) * effective_ant_amp
91
+ self.antenna_left += (desired_l - self.antenna_left) * antenna_smoothing
92
+ self.antenna_right += (desired_r - self.antenna_right) * antenna_smoothing
93
+
94
+ return self.body_yaw, [self.antenna_left, self.antenna_right]
95
+
96
+
97
+ def clamp_head_yaw_for_body(head_yaw: float, body_yaw: float) -> float:
98
+ """Optional helper: clamp absolute head yaw range after body follow."""
99
+ max_range = min(MAX_BODY_YAW + MAX_HEAD_YAW, MAX_HEAD_YAW_ABSOLUTE)
100
+ return _clamp(head_yaw, -max_range, max_range)
esp32_motion_controller/controller_state.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Clutch mapping: relative orientation + displacement from the ESP32 controller.
3
+
4
+ Owns the engage rising-edge reference and the desired head pose (x/y/z/rpy).
5
+ Does not write body_yaw or antennas.
6
+
7
+ Frames
8
+ ------
9
+ Device (after IMU_MAP_*): X right, Y up, Z out of screen (screen = Reachy face).
10
+ Head: x forward, y left, z up.
11
+
12
+ DEV_TO_HEAD maps device vectors onto head vectors (screen↔face, up↔up):
13
+ head_x = device_z, head_y = device_x, head_z = device_y
14
+
15
+ `p` arrives in the gravity-aligned world frame. On engage we rotate the
16
+ world-frame delta into the clutch reference (device frame at engage), then
17
+ apply DEV_TO_HEAD.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Sequence
24
+
25
+ import numpy as np
26
+ from scipy.spatial.transform import Rotation as R
27
+
28
+ POSE_AXES = ("x", "y", "z", "roll", "pitch", "yaw")
29
+
30
+ # Device → head: face with face, up with up (det = +1).
31
+ # head_x = dev_z (face), head_y = dev_x (screen's left), head_z = dev_y (up)
32
+ DEV_TO_HEAD = np.array(
33
+ [
34
+ [0.0, 0.0, 1.0],
35
+ [1.0, 0.0, 0.0],
36
+ [0.0, 1.0, 0.0],
37
+ ],
38
+ dtype=np.float64,
39
+ )
40
+
41
+ # Hand travel (metres) → head travel: ~50 mm of hand maps onto ~15 mm of head
42
+ # at translation_gain = 1.0, so a comfortable push fills most of the workspace.
43
+ TRANSLATION_SCALE = 0.30 # base m_head / m_hand before the UI gain
44
+ TRANSLATION_GAIN_DEFAULT = 1.0
45
+
46
+
47
+ def _finite_quat(q: Sequence[float]) -> np.ndarray:
48
+ arr = np.asarray(q, dtype=np.float64).reshape(4)
49
+ if not np.all(np.isfinite(arr)) or np.linalg.norm(arr) < 1e-9:
50
+ return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
51
+ return arr / np.linalg.norm(arr)
52
+
53
+
54
+ def _finite_vec3(v: Sequence[float]) -> np.ndarray:
55
+ arr = np.asarray(v, dtype=np.float64).reshape(3)
56
+ if not np.all(np.isfinite(arr)):
57
+ return np.zeros(3, dtype=np.float64)
58
+ return arr
59
+
60
+
61
+ def _wxyz_to_rotation(q: np.ndarray) -> R:
62
+ # scipy uses [x, y, z, w]; wire format is [w, x, y, z]
63
+ return R.from_quat([q[1], q[2], q[3], q[0]])
64
+
65
+
66
+ def quat_relative_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, float, float]:
67
+ """Return head (roll, pitch, yaw) of the relative device rotation.
68
+
69
+ Applies the similarity transform M * R_rel_dev * M^{-1} so that a
70
+ physical tip/turn/roll of the board becomes the matching head RPY.
71
+ Euler order is extrinsic xyz, matching create_head_pose.
72
+ """
73
+ r_ref = _wxyz_to_rotation(q_ref)
74
+ r_dev = _wxyz_to_rotation(q_device)
75
+ r_rel_dev = r_ref.inv() * r_dev
76
+ m = R.from_matrix(DEV_TO_HEAD)
77
+ r_head = m * r_rel_dev * m.inv()
78
+ roll, pitch, yaw = r_head.as_euler("xyz", degrees=False)
79
+ return float(roll), float(pitch), float(yaw)
80
+
81
+
82
+ def remap_displacement(
83
+ p_world_delta: np.ndarray,
84
+ q_ref: np.ndarray,
85
+ *,
86
+ translation_gain: float = TRANSLATION_GAIN_DEFAULT,
87
+ ) -> np.ndarray:
88
+ """Map world-frame displacement delta to head-frame metres.
89
+
90
+ Rotates into the engage reference (device frame at clutch), then applies
91
+ DEV_TO_HEAD, then scales by TRANSLATION_SCALE * translation_gain.
92
+ """
93
+ r_ref = _wxyz_to_rotation(q_ref)
94
+ disp_ref = r_ref.inv().apply(p_world_delta)
95
+ disp_head = DEV_TO_HEAD @ disp_ref
96
+ return disp_head * TRANSLATION_SCALE * float(translation_gain)
97
+
98
+
99
+ class ControllerState:
100
+ """Clutch state machine for one ESP32 controller."""
101
+
102
+ def __init__(
103
+ self,
104
+ *,
105
+ translation_gain: float = TRANSLATION_GAIN_DEFAULT,
106
+ ) -> None:
107
+ self.engaged = False
108
+ self.gain = 1.0
109
+ self.translation_gain = float(translation_gain)
110
+ self.ready = False
111
+ self._q_ref = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
112
+ self._p_ref = np.zeros(3, dtype=np.float64)
113
+ self._base_pose = {k: 0.0 for k in POSE_AXES}
114
+ self._desired = {k: 0.0 for k in POSE_AXES}
115
+ self._was_engaged = False
116
+
117
+ @property
118
+ def desired_pose(self) -> dict[str, float]:
119
+ return dict(self._desired)
120
+
121
+ @property
122
+ def base_pose(self) -> dict[str, float]:
123
+ return dict(self._base_pose)
124
+
125
+ def set_base_pose(self, pose: dict[str, float]) -> None:
126
+ self._base_pose = {k: float(pose.get(k, 0.0)) for k in POSE_AXES}
127
+ if not self.engaged:
128
+ self._desired = dict(self._base_pose)
129
+
130
+ def rebase_neutral(self) -> None:
131
+ self._base_pose = {k: 0.0 for k in POSE_AXES}
132
+ self._desired = {k: 0.0 for k in POSE_AXES}
133
+ self.engaged = False
134
+ self._was_engaged = False
135
+ self._q_ref = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
136
+ self._p_ref = np.zeros(3, dtype=np.float64)
137
+
138
+ def update(
139
+ self,
140
+ *,
141
+ q: Sequence[float],
142
+ p: Sequence[float],
143
+ engaged: bool,
144
+ gain: float,
145
+ ready: bool,
146
+ allow_engage: bool = True,
147
+ ) -> dict[str, float]:
148
+ """Ingest one controller_state packet. Returns desired head pose."""
149
+ self.ready = bool(ready)
150
+ self.gain = float(gain) if math.isfinite(float(gain)) else self.gain
151
+ self.gain = max(0.1, min(3.0, self.gain))
152
+
153
+ q_dev = _finite_quat(q)
154
+ p_dev = _finite_vec3(p)
155
+
156
+ want_engage = bool(engaged) and self.ready and allow_engage
157
+ rising = want_engage and not self._was_engaged
158
+ falling = (not want_engage) and self._was_engaged
159
+
160
+ if rising:
161
+ self._q_ref = q_dev.copy()
162
+ self._p_ref = p_dev.copy()
163
+ # base already holds the committed pose from last release / reset
164
+
165
+ self.engaged = want_engage
166
+ self._was_engaged = want_engage
167
+
168
+ if self.engaged:
169
+ roll, pitch, yaw = quat_relative_rpy(self._q_ref, q_dev)
170
+ # Translation uses its own gain (and TRANSLATION_SCALE); rotation
171
+ # uses the UI gain. Both multiply the UI gain so the slider still
172
+ # scales the whole motion feel.
173
+ disp = remap_displacement(
174
+ p_dev - self._p_ref,
175
+ self._q_ref,
176
+ translation_gain=self.translation_gain * self.gain,
177
+ )
178
+ self._desired = {
179
+ "x": self._base_pose["x"] + float(disp[0]),
180
+ "y": self._base_pose["y"] + float(disp[1]),
181
+ "z": self._base_pose["z"] + float(disp[2]),
182
+ "roll": self._base_pose["roll"] + self.gain * roll,
183
+ "pitch": self._base_pose["pitch"] + self.gain * pitch,
184
+ "yaw": self._base_pose["yaw"] + self.gain * yaw,
185
+ }
186
+ elif falling:
187
+ # Commit both rotation and translation into the base on release
188
+ self._base_pose = dict(self._desired)
189
+ # else idle: hold last desired / base
190
+
191
+ return dict(self._desired)
192
+
193
+ def force_disengage(self) -> None:
194
+ if self.engaged:
195
+ self._base_pose = dict(self._desired)
196
+ self.engaged = False
197
+ self._was_engaged = False
esp32_motion_controller/main.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Motion Controller Reachy Mini app entry point.
3
+
4
+ FastAPI/uvicorn on port 8766, mDNS advertise _reachyctl._tcp, clutch + behavior bridge.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import os
12
+ import socket
13
+ import sys
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import uvicorn
19
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
20
+ from fastapi.responses import FileResponse, JSONResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+
23
+ from esp32_motion_controller.behavior import Behavior
24
+ from esp32_motion_controller.controller_state import ControllerState
25
+ from esp32_motion_controller.movement_handler import MovementHandler
26
+ from esp32_motion_controller.ws_handler import WebSocketHandler
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ WS_PORT = 8766
31
+ STATIC_DIR = Path(__file__).parent / "static"
32
+ MDNS_SERVICE_TYPE = "_reachyctl._tcp.local."
33
+ ENV_SEND_RATE_HZ = "REACHY_MOTION_SEND_RATE_HZ"
34
+ DEFAULT_SEND_RATE_HZ = 20.0
35
+ SERVER_START_TIMEOUT_S = 10.0
36
+
37
+
38
+ def _get_send_rate_hz() -> float:
39
+ raw = os.environ.get(ENV_SEND_RATE_HZ)
40
+ if raw is None or raw.strip() == "":
41
+ return DEFAULT_SEND_RATE_HZ
42
+ try:
43
+ return max(5.0, min(50.0, float(raw.strip())))
44
+ except ValueError:
45
+ return DEFAULT_SEND_RATE_HZ
46
+
47
+
48
+ def get_local_ips() -> list[str]:
49
+ ips: list[str] = []
50
+ try:
51
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
52
+ s.connect(("8.8.8.8", 80))
53
+ ips.append(s.getsockname()[0])
54
+ s.close()
55
+ except OSError:
56
+ pass
57
+ try:
58
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
59
+ addr = info[4][0]
60
+ if not addr.startswith("127."):
61
+ ips.append(addr)
62
+ except (socket.gaierror, OSError):
63
+ pass
64
+ return list(dict.fromkeys(ips))
65
+
66
+
67
+ class MdnsAdvertiser:
68
+ def __init__(self, port: int = WS_PORT) -> None:
69
+ self.port = port
70
+ self._zc = None
71
+ self._info = None
72
+
73
+ def start(self) -> None:
74
+ """Advertise over mDNS. Never raises — discovery is a convenience."""
75
+ try:
76
+ from zeroconf import ServiceInfo, Zeroconf
77
+ except ImportError:
78
+ logger.warning("zeroconf not installed; mDNS advertise skipped")
79
+ return
80
+
81
+ zc = None
82
+ try:
83
+ ips = get_local_ips()
84
+ if not ips:
85
+ logger.warning("No local IP for mDNS advertise")
86
+ return
87
+ info = ServiceInfo(
88
+ MDNS_SERVICE_TYPE,
89
+ f"esp32-motion-controller.{MDNS_SERVICE_TYPE}",
90
+ addresses=[socket.inet_aton(ip) for ip in ips],
91
+ port=self.port,
92
+ properties={"path": b"/ws"},
93
+ server="esp32-motion.local.",
94
+ )
95
+ zc = Zeroconf()
96
+ # An advertisement from a previous run lingers in the mDNS cache for
97
+ # the record TTL (75 min), so a restart collides with itself.
98
+ # Renaming is safe: the controller browses the service type, never
99
+ # the instance name.
100
+ zc.register_service(info, allow_name_change=True)
101
+ except Exception:
102
+ logger.warning(
103
+ "mDNS advertise failed; controller must use a configured host",
104
+ exc_info=True,
105
+ )
106
+ if zc is not None:
107
+ try:
108
+ zc.close()
109
+ except Exception:
110
+ pass
111
+ return
112
+
113
+ self._zc = zc
114
+ self._info = info
115
+ logger.info("mDNS advertised %s on %s:%d", info.name, ips[0], self.port)
116
+
117
+ def stop(self) -> None:
118
+ if self._zc is not None:
119
+ try:
120
+ if self._info is not None:
121
+ self._zc.unregister_service(self._info)
122
+ except Exception:
123
+ pass
124
+ try:
125
+ self._zc.close()
126
+ except Exception:
127
+ pass
128
+ self._zc = None
129
+ self._info = None
130
+
131
+
132
+ def create_app(
133
+ reachy_mini,
134
+ stop_event: threading.Event,
135
+ *,
136
+ log_only: bool = False,
137
+ ) -> FastAPI:
138
+ app = FastAPI(title="ESP32 Motion Controller")
139
+ send_rate = _get_send_rate_hz()
140
+ robot_available = reachy_mini is not None and not log_only
141
+ movement = MovementHandler(reachy_mini, send_rate_hz=send_rate)
142
+ controller = ControllerState()
143
+ behavior = Behavior()
144
+ ws_handler = WebSocketHandler(
145
+ movement,
146
+ controller,
147
+ behavior,
148
+ robot_available=robot_available or log_only,
149
+ log_only=log_only,
150
+ )
151
+ app.state.ws_handler = ws_handler
152
+
153
+ @app.websocket("/ws")
154
+ async def websocket_endpoint(websocket: WebSocket) -> None:
155
+ admitted = await ws_handler.on_connect(websocket)
156
+ if not admitted:
157
+ return
158
+ try:
159
+ while not stop_event.is_set():
160
+ raw = await websocket.receive_text()
161
+ await ws_handler.handle_message(websocket, raw)
162
+ except WebSocketDisconnect:
163
+ logger.info("WebSocket disconnect")
164
+ except Exception as exc:
165
+ logger.error("WebSocket error: %s", exc)
166
+ finally:
167
+ ws_handler.cleanup(websocket)
168
+
169
+ @app.get("/api/info")
170
+ async def info() -> JSONResponse:
171
+ ips = get_local_ips()
172
+ return JSONResponse(
173
+ {
174
+ "ips": ips,
175
+ "port": WS_PORT,
176
+ "ws_url": f"ws://{ips[0]}:{WS_PORT}/ws" if ips else None,
177
+ "mdns": MDNS_SERVICE_TYPE,
178
+ }
179
+ )
180
+
181
+ @app.get("/api/status")
182
+ async def status() -> JSONResponse:
183
+ return JSONResponse(
184
+ {
185
+ "status": "ok",
186
+ "robot": robot_available or log_only,
187
+ "busy": ws_handler.busy,
188
+ "log_only": log_only,
189
+ }
190
+ )
191
+
192
+ @app.get("/")
193
+ async def root() -> FileResponse:
194
+ return FileResponse(STATIC_DIR / "index.html")
195
+
196
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
197
+ return app
198
+
199
+
200
+ def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
201
+ ips = get_local_ips()
202
+ logger.info("=" * 50)
203
+ logger.info("ESP32 Motion Controller")
204
+ logger.info("=" * 50)
205
+ for ip in ips:
206
+ logger.info(" WebSocket: ws://%s:%d/ws", ip, WS_PORT)
207
+ if log_only:
208
+ logger.info(" Mode: --log-only (no robot SDK calls)")
209
+ logger.info("=" * 50)
210
+
211
+ app = create_app(reachy_mini, stop_event, log_only=log_only)
212
+ # Stash for shutdown — create_app closes over the handler.
213
+ ws_handler: WebSocketHandler = app.state.ws_handler # type: ignore[attr-defined]
214
+ config = uvicorn.Config(app, host="0.0.0.0", port=WS_PORT, log_level="info")
215
+ server = uvicorn.Server(config)
216
+ thread = threading.Thread(target=server.run, daemon=True)
217
+ thread.start()
218
+
219
+ # A failed bind only kills the server thread, so without this the app would
220
+ # sit here advertising a port nothing listens on.
221
+ deadline = time.monotonic() + SERVER_START_TIMEOUT_S
222
+ while not server.started and thread.is_alive() and time.monotonic() < deadline:
223
+ time.sleep(0.05)
224
+ if not server.started:
225
+ raise RuntimeError(
226
+ f"Could not serve on port {WS_PORT} — another Motion Controller "
227
+ f"instance is probably already running"
228
+ )
229
+
230
+ # Advertise only once we are actually listening.
231
+ mdns = MdnsAdvertiser(WS_PORT)
232
+ mdns.start()
233
+
234
+ stop_event.wait()
235
+ ws_handler.shutdown()
236
+ server.should_exit = True
237
+ thread.join(timeout=5)
238
+ mdns.stop()
239
+ logger.info("Motion Controller stopped")
240
+
241
+
242
+ try:
243
+ from reachy_mini import ReachyMini, ReachyMiniApp
244
+
245
+ class Esp32MotionController(ReachyMiniApp):
246
+ """Reachy Mini App that bridges an ESP32 motion controller."""
247
+
248
+ name = "ESP32 Motion Controller"
249
+ emoji = "🕹️"
250
+ custom_app_url: str | None = None
251
+
252
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
253
+ _run_server(reachy_mini, stop_event, log_only=False)
254
+
255
+ except ImportError:
256
+ class Esp32MotionController: # type: ignore[no-redef]
257
+ name = "ESP32 Motion Controller"
258
+ emoji = "🕹️"
259
+ custom_app_url = None
260
+
261
+ def run(self, reachy_mini, stop_event: threading.Event) -> None:
262
+ _run_server(reachy_mini, stop_event, log_only=False)
263
+
264
+ def wrapped_run(self) -> None:
265
+ raise RuntimeError("reachy-mini SDK is required unless using --log-only")
266
+
267
+ def stop(self) -> None:
268
+ pass
269
+
270
+
271
+ def main(argv: list[str] | None = None) -> int:
272
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
273
+ parser = argparse.ArgumentParser(description="ESP32 Motion Controller for Reachy Mini")
274
+ parser.add_argument(
275
+ "--log-only",
276
+ action="store_true",
277
+ help="Run without a ReachyMini SDK instance; log decoded controller state",
278
+ )
279
+ args = parser.parse_args(argv)
280
+ stop = threading.Event()
281
+
282
+ if args.log_only:
283
+ try:
284
+ _run_server(None, stop, log_only=True)
285
+ except KeyboardInterrupt:
286
+ stop.set()
287
+ return 0
288
+
289
+ app = Esp32MotionController()
290
+ try:
291
+ app.wrapped_run()
292
+ except KeyboardInterrupt:
293
+ app.stop()
294
+ return 0
295
+
296
+
297
+ if __name__ == "__main__":
298
+ sys.exit(main())
esp32_motion_controller/movement_handler.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Movement state and robot commands: target/current LERP and set_target rate limiting.
3
+
4
+ Ported from spectacles_reachy_mini.movement_handler with a five-axis Stewart
5
+ ellipsoid that couples x/y translation into the workspace check.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import math
13
+ import time
14
+ import uuid as uuid_mod
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ from reachy_mini import ReachyMini
19
+ from reachy_mini.utils import create_head_pose
20
+ from scipy.spatial.transform import Rotation
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ POSE_AXES = ("x", "y", "z", "roll", "pitch", "yaw")
25
+ ANGULAR_AXES = ("roll", "pitch", "yaw")
26
+ POSITIONAL_AXES = ("x", "y", "z")
27
+
28
+ POSE_ALPHA = 0.12
29
+ ANTENNA_ALPHA = 0.08
30
+ # Hard safety gate (same numbers as spectacles_reachy_mini). set_target on the
31
+ # daemon is instantaneous — these caps on consecutive sends are what keep the
32
+ # head from whipping when bookkeeping and the robot disagree.
33
+ MAX_ANGULAR_VEL = 1.5 # rad/s
34
+ MAX_POS_VEL = 0.05 # m/s
35
+ LOOP_INTERVAL = 0.033
36
+ DEFAULT_SEND_RATE_HZ = 20.0
37
+ SEND_RATE_HZ_MIN = 5.0
38
+ SEND_RATE_HZ_MAX = 50.0
39
+ MAX_DT_FOR_VEL_CLAMP = 0.05 # never allow a single step larger than 50 ms worth
40
+ RESYNC_FROM_ROBOT_SEC = 2.0
41
+ # Measured vs bookkeeping: within this, soft-refresh _prev_sent; beyond, hard seed.
42
+ RESYNC_EPS_POS_M = 0.003
43
+ RESYNC_EPS_ANG_RAD = 0.05
44
+
45
+ LIMIT_BODY_YAW_RAD = 160.0 * math.pi / 180.0
46
+ LIMIT_HEAD_YAW_RAD = math.pi
47
+ LIMIT_HEAD_BODY_YAW_DELTA_RAD = 65.0 * math.pi / 180.0
48
+
49
+ # Opened conservatively for controller translation (±20 mm).
50
+ LIMIT_HEAD_X_MIN = -0.020
51
+ LIMIT_HEAD_X_MAX = 0.020
52
+ LIMIT_HEAD_Y_MIN = -0.020
53
+ LIMIT_HEAD_Y_MAX = 0.020
54
+ LIMIT_HEAD_Z_MIN = 0.0
55
+ LIMIT_HEAD_Z_MAX = 0.025
56
+
57
+ # Stewart workspace: motor_arm≈0.04 m, rod≈0.085 m → pitch/roll ≈ ±25°, Z ≈ ±0.03 m.
58
+ # Rotation is clamped to its own radii first; translation then fits the remainder
59
+ # so junk/large translation cannot scale a real tilt down.
60
+ ELLIPSOID_X_MAX = 0.015
61
+ ELLIPSOID_Y_MAX = 0.015
62
+ ELLIPSOID_Z_MAX = 0.018
63
+ ELLIPSOID_ROLL_MAX_RAD = 25.0 * math.pi / 180.0
64
+ ELLIPSOID_PITCH_MAX_RAD = 25.0 * math.pi / 180.0
65
+
66
+ IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
67
+ IK_FAIL_CONSECUTIVE_THRESHOLD = 3
68
+
69
+
70
+ def _zero_pose() -> dict[str, float]:
71
+ return {k: 0.0 for k in POSE_AXES}
72
+
73
+
74
+ def _clamp(value: float, lo: float, hi: float) -> float:
75
+ return max(lo, min(hi, value))
76
+
77
+
78
+ def _parse_send_rate_hz(value: float | None) -> float:
79
+ if value is None:
80
+ return 1.0 / DEFAULT_SEND_RATE_HZ
81
+ rate = max(SEND_RATE_HZ_MIN, min(SEND_RATE_HZ_MAX, value))
82
+ return 1.0 / rate
83
+
84
+
85
+ def _clamp_stewart_ellipsoid(
86
+ x: float, y: float, z: float, roll: float, pitch: float,
87
+ ) -> tuple[float, float, float, float, float]:
88
+ """Clamp rotation first, then fit translation into the remaining budget.
89
+
90
+ Budget: (x/X)^2 + (y/Y)^2 + (z/Z)^2 + (roll/R)^2 + (pitch/P)^2 <= 1.
91
+ Roll/pitch are hard-clamped to their radii (never scaled by translation).
92
+ Translation is then projected onto whatever budget remains.
93
+ """
94
+ z_clamped = _clamp(z, LIMIT_HEAD_Z_MIN, LIMIT_HEAD_Z_MAX)
95
+ x_clamped = _clamp(x, LIMIT_HEAD_X_MIN, LIMIT_HEAD_X_MAX)
96
+ y_clamped = _clamp(y, LIMIT_HEAD_Y_MIN, LIMIT_HEAD_Y_MAX)
97
+
98
+ roll_c = _clamp(roll, -ELLIPSOID_ROLL_MAX_RAD, ELLIPSOID_ROLL_MAX_RAD)
99
+ pitch_c = _clamp(pitch, -ELLIPSOID_PITCH_MAX_RAD, ELLIPSOID_PITCH_MAX_RAD)
100
+
101
+ nr = roll_c / ELLIPSOID_ROLL_MAX_RAD if ELLIPSOID_ROLL_MAX_RAD > 0 else 0.0
102
+ np_ = pitch_c / ELLIPSOID_PITCH_MAX_RAD if ELLIPSOID_PITCH_MAX_RAD > 0 else 0.0
103
+ rot_budget = nr * nr + np_ * np_
104
+ # Tiny epsilon so a full-scale tilt leaves a sliver for translation=0.
105
+ remaining = max(0.0, 1.0 - rot_budget)
106
+
107
+ nx = x_clamped / ELLIPSOID_X_MAX if ELLIPSOID_X_MAX > 0 else 0.0
108
+ ny = y_clamped / ELLIPSOID_Y_MAX if ELLIPSOID_Y_MAX > 0 else 0.0
109
+ nz = z_clamped / ELLIPSOID_Z_MAX if ELLIPSOID_Z_MAX > 0 else 0.0
110
+ trans_sq = nx * nx + ny * ny + nz * nz
111
+
112
+ if trans_sq <= remaining or trans_sq <= 1e-12:
113
+ return (x_clamped, y_clamped, z_clamped, roll_c, pitch_c)
114
+
115
+ scale = math.sqrt(remaining / trans_sq)
116
+ return (
117
+ x_clamped * scale,
118
+ y_clamped * scale,
119
+ z_clamped * scale,
120
+ roll_c,
121
+ pitch_c,
122
+ )
123
+
124
+
125
+ def _clamp_pose_to_daemon_limits(
126
+ pose: dict[str, float], body_yaw: float
127
+ ) -> tuple[dict[str, float], float]:
128
+ out_pose = dict(pose)
129
+ cx, cy, cz, cr, cp = _clamp_stewart_ellipsoid(
130
+ pose["x"], pose["y"], pose["z"], pose["roll"], pose["pitch"],
131
+ )
132
+ out_pose["x"] = cx
133
+ out_pose["y"] = cy
134
+ out_pose["z"] = cz
135
+ out_pose["roll"] = cr
136
+ out_pose["pitch"] = cp
137
+
138
+ body_yaw_clamped = _clamp(body_yaw, -LIMIT_BODY_YAW_RAD, LIMIT_BODY_YAW_RAD)
139
+ out_pose["yaw"] = _clamp(pose["yaw"], -LIMIT_HEAD_YAW_RAD, LIMIT_HEAD_YAW_RAD)
140
+ delta = out_pose["yaw"] - body_yaw_clamped
141
+ if delta > LIMIT_HEAD_BODY_YAW_DELTA_RAD:
142
+ out_pose["yaw"] = body_yaw_clamped + LIMIT_HEAD_BODY_YAW_DELTA_RAD
143
+ elif delta < -LIMIT_HEAD_BODY_YAW_DELTA_RAD:
144
+ out_pose["yaw"] = body_yaw_clamped - LIMIT_HEAD_BODY_YAW_DELTA_RAD
145
+ return (out_pose, body_yaw_clamped)
146
+
147
+
148
+ def _lerp(a: float, b: float, t: float) -> float:
149
+ return a + (b - a) * t
150
+
151
+
152
+ class MovementHandler:
153
+ """Owns all movement state and SDK interaction."""
154
+
155
+ def __init__(
156
+ self,
157
+ reachy_mini: ReachyMini | None,
158
+ send_rate_hz: float | None = None,
159
+ ) -> None:
160
+ self.mini = reachy_mini
161
+ self._send_min_interval = _parse_send_rate_hz(send_rate_hz)
162
+ logger.info(
163
+ "MovementHandler send rate: %.1f Hz (interval %.3f s)",
164
+ 1.0 / self._send_min_interval,
165
+ self._send_min_interval,
166
+ )
167
+
168
+ self._target_pose: dict[str, float] = _zero_pose()
169
+ self._target_body_yaw: float = 0.0
170
+ self._target_antennas: list[float] = [0.0, 0.0]
171
+
172
+ self._current_pose: dict[str, float] = _zero_pose()
173
+ self._current_body_yaw: float = 0.0
174
+ self._current_antennas: list[float] = [0.0, 0.0]
175
+
176
+ self._prev_sent_pose: dict[str, float] = _zero_pose()
177
+ self._prev_sent_body_yaw: float = 0.0
178
+
179
+ self._active_gotos: dict[str, bool] = {}
180
+ self._apply_task: asyncio.Task[None] | None = None
181
+ self._send_future: asyncio.Future[Any] | None = None
182
+ self._last_send_time: float = 0.0
183
+ self._send_count: int = 0
184
+ self._send_seq: int = 0
185
+ self._last_applied_seq: int = 0
186
+ self._consecutive_ik_failures: int = 0
187
+ self._last_resync_time: float = 0.0
188
+ self._seeded: bool = False
189
+ self._sends_frozen: bool = False
190
+
191
+ @property
192
+ def current_pose(self) -> dict[str, float]:
193
+ return dict(self._current_pose)
194
+
195
+ @property
196
+ def target_pose(self) -> dict[str, float]:
197
+ return dict(self._target_pose)
198
+
199
+ def set_target(
200
+ self,
201
+ pose: dict[str, float],
202
+ body_yaw: float | None = None,
203
+ antennas: list[float] | None = None,
204
+ ) -> None:
205
+ merged = {}
206
+ for k in POSE_AXES:
207
+ v = pose.get(k, self._target_pose[k])
208
+ merged[k] = (
209
+ v
210
+ if isinstance(v, (int, float)) and math.isfinite(v)
211
+ else self._target_pose[k]
212
+ )
213
+ by = (
214
+ body_yaw
215
+ if body_yaw is not None
216
+ and isinstance(body_yaw, (int, float))
217
+ and math.isfinite(body_yaw)
218
+ else self._target_body_yaw
219
+ )
220
+ self._target_pose, self._target_body_yaw = _clamp_pose_to_daemon_limits(
221
+ merged, by
222
+ )
223
+ if antennas is not None:
224
+ self._target_antennas = [
225
+ a
226
+ if isinstance(a, (int, float)) and math.isfinite(a)
227
+ else (self._target_antennas[i] if i < len(self._target_antennas) else 0.0)
228
+ for i, a in enumerate(antennas)
229
+ ]
230
+ self._target_antennas = (self._target_antennas + [0.0, 0.0])[:2]
231
+
232
+ def goto(
233
+ self,
234
+ pose: dict[str, float],
235
+ body_yaw: float = 0.0,
236
+ antennas: list[float] | None = None,
237
+ duration: float = 0.5,
238
+ interpolation: str = "minjerk",
239
+ ) -> str:
240
+ move_uuid = str(uuid_mod.uuid4())
241
+ self._active_gotos[move_uuid] = True
242
+
243
+ start_pose = dict(self._current_pose)
244
+ start_body_yaw = self._current_body_yaw
245
+ start_antennas = list(self._current_antennas)
246
+ end_pose = {k: pose.get(k, 0.0) for k in POSE_AXES}
247
+ end_antennas = list(antennas) if antennas else [0.0, 0.0]
248
+
249
+ asyncio.create_task(
250
+ self._run_goto(
251
+ move_uuid,
252
+ start_pose, end_pose,
253
+ start_body_yaw, body_yaw,
254
+ start_antennas, end_antennas,
255
+ duration, interpolation,
256
+ )
257
+ )
258
+ return move_uuid
259
+
260
+ def stop_move(self, move_uuid: str) -> bool:
261
+ if move_uuid in self._active_gotos:
262
+ self._active_gotos[move_uuid] = False
263
+ return True
264
+ return False
265
+
266
+ def rebase_to_neutral(self) -> None:
267
+ """Re-sync clamp reference after a reset goto; keep targets at neutral.
268
+
269
+ Refresh `_prev_sent` from the robot so the velocity gate is honest, but
270
+ do not copy the measured pose into targets — that would undo the goto.
271
+ On read failure freeze sends; never invent a zero clamp reference.
272
+ """
273
+ if not self._seed_from_robot(update_target=False):
274
+ logger.error("rebase_to_neutral: robot pose unread — freezing sends")
275
+ self._sends_frozen = True
276
+ return
277
+ for axis in POSE_AXES:
278
+ self._target_pose[axis] = 0.0
279
+ self._current_pose[axis] = 0.0
280
+ self._target_body_yaw = 0.0
281
+ self._current_body_yaw = 0.0
282
+ self._target_antennas = [0.0, 0.0]
283
+ self._current_antennas = [0.0, 0.0]
284
+
285
+ def _read_robot_pose(self) -> tuple[dict[str, float], float] | None:
286
+ if self.mini is None:
287
+ return None
288
+ try:
289
+ head = np.asarray(self.mini.get_current_head_pose(), dtype=np.float64)
290
+ joints, _ = self.mini.get_current_joint_positions()
291
+ body_yaw = float(joints[0])
292
+ except Exception as exc:
293
+ logger.warning("Could not read robot pose: %s", exc)
294
+ return None
295
+ rpy = Rotation.from_matrix(head[:3, :3]).as_euler("xyz")
296
+ pose = {
297
+ "x": float(head[0, 3]),
298
+ "y": float(head[1, 3]),
299
+ "z": float(head[2, 3]),
300
+ "roll": float(rpy[0]),
301
+ "pitch": float(rpy[1]),
302
+ "yaw": float(rpy[2]),
303
+ }
304
+ return pose, body_yaw
305
+
306
+ @staticmethod
307
+ def _pose_gap(
308
+ a: dict[str, float], a_yaw: float, b: dict[str, float], b_yaw: float
309
+ ) -> tuple[float, float]:
310
+ pos = math.sqrt(
311
+ (a["x"] - b["x"]) ** 2 + (a["y"] - b["y"]) ** 2 + (a["z"] - b["z"]) ** 2
312
+ )
313
+ ang = max(
314
+ abs(a["roll"] - b["roll"]),
315
+ abs(a["pitch"] - b["pitch"]),
316
+ abs(a["yaw"] - b["yaw"]),
317
+ abs(a_yaw - b_yaw),
318
+ )
319
+ return pos, ang
320
+
321
+ def _apply_seed_read(
322
+ self,
323
+ read: tuple[dict[str, float], float] | None,
324
+ *,
325
+ update_target: bool = True,
326
+ ) -> bool:
327
+ """Apply a pose read to bookkeeping. Returns False when read is None."""
328
+ if read is None:
329
+ self._sends_frozen = True
330
+ return False
331
+ pose, body_yaw = read
332
+ self._prev_sent_pose = dict(pose)
333
+ self._prev_sent_body_yaw = body_yaw
334
+ if update_target:
335
+ self._target_pose = dict(pose)
336
+ self._current_pose = dict(pose)
337
+ self._target_body_yaw = body_yaw
338
+ self._current_body_yaw = body_yaw
339
+ logger.info(
340
+ "Seeded movement state from robot: pose=%s body_yaw=%.3f",
341
+ {k: round(v, 4) for k, v in pose.items()},
342
+ body_yaw,
343
+ )
344
+ self._last_resync_time = time.monotonic()
345
+ self._seeded = True
346
+ self._sends_frozen = False
347
+ return True
348
+
349
+ def _seed_from_robot(self, *, update_target: bool = True) -> bool:
350
+ """Initialize pose bookkeeping from the robot's measured pose.
351
+
352
+ Returns False when no robot is attached or the readback failed.
353
+ On failure, bookkeeping is left untouched and sends freeze.
354
+ """
355
+ if self.mini is None:
356
+ # log-only / no robot: treat as seeded at current bookkeeping
357
+ self._seeded = True
358
+ self._sends_frozen = False
359
+ return True
360
+ return self._apply_seed_read(self._read_robot_pose(), update_target=update_target)
361
+
362
+ async def _periodic_resync(self) -> None:
363
+ """Refresh clamp reference only when the robot is near bookkeeping.
364
+
365
+ Pose reads go through the default executor so a USB round trip cannot
366
+ block the asyncio event loop (and therefore WebSocket pongs).
367
+ """
368
+ if self.mini is None:
369
+ self._last_resync_time = time.monotonic()
370
+ return
371
+ loop = asyncio.get_running_loop()
372
+ read = await loop.run_in_executor(None, self._read_robot_pose)
373
+ if read is None:
374
+ self._sends_frozen = True
375
+ return
376
+ pose, body_yaw = read
377
+ pos_gap, ang_gap = self._pose_gap(
378
+ pose, body_yaw, self._prev_sent_pose, self._prev_sent_body_yaw
379
+ )
380
+ self._last_resync_time = time.monotonic()
381
+ if pos_gap <= RESYNC_EPS_POS_M and ang_gap <= RESYNC_EPS_ANG_RAD:
382
+ self._prev_sent_pose = dict(pose)
383
+ self._prev_sent_body_yaw = body_yaw
384
+ self._sends_frozen = False
385
+ return
386
+ # Hard desync: full seed, then resume under the velocity gate.
387
+ logger.warning(
388
+ "Pose desync pos=%.4f m ang=%.3f rad — full resync",
389
+ pos_gap,
390
+ ang_gap,
391
+ )
392
+ self._prev_sent_pose = dict(pose)
393
+ self._prev_sent_body_yaw = body_yaw
394
+ self._target_pose = dict(pose)
395
+ self._current_pose = dict(pose)
396
+ self._target_body_yaw = body_yaw
397
+ self._current_body_yaw = body_yaw
398
+ self._sends_frozen = False
399
+
400
+ def resync_from_robot(self) -> bool:
401
+ """Public: re-align velocity clamp with the robot after a reconnect."""
402
+ return self._seed_from_robot(update_target=True)
403
+
404
+ async def resync_from_robot_async(self, *, update_target: bool = True) -> bool:
405
+ """Async resync — pose read runs in the executor so the event loop stays free."""
406
+ if self.mini is None:
407
+ self._seeded = True
408
+ self._sends_frozen = False
409
+ return True
410
+ loop = asyncio.get_running_loop()
411
+ read = await loop.run_in_executor(None, self._read_robot_pose)
412
+ return self._apply_seed_read(read, update_target=update_target)
413
+
414
+ def start(self) -> None:
415
+ if self._apply_task is None or self._apply_task.done():
416
+ if not self._seed_from_robot():
417
+ logger.error("start: robot pose unread — sends frozen until resync")
418
+ self._apply_task = asyncio.ensure_future(self._apply_loop())
419
+
420
+ def stop(self) -> None:
421
+ if self._apply_task is not None and not self._apply_task.done():
422
+ self._apply_task.cancel()
423
+ self._apply_task = None
424
+ for uid in list(self._active_gotos):
425
+ self._active_gotos[uid] = False
426
+ self._active_gotos.clear()
427
+
428
+ async def _apply_loop(self) -> None:
429
+ loop = asyncio.get_running_loop()
430
+ try:
431
+ while True:
432
+ now = time.monotonic()
433
+
434
+ for axis in POSE_AXES:
435
+ self._current_pose[axis] += POSE_ALPHA * (
436
+ self._target_pose[axis] - self._current_pose[axis]
437
+ )
438
+ self._current_body_yaw += POSE_ALPHA * (
439
+ self._target_body_yaw - self._current_body_yaw
440
+ )
441
+ for i in range(min(len(self._current_antennas), len(self._target_antennas))):
442
+ self._current_antennas[i] += ANTENNA_ALPHA * (
443
+ self._target_antennas[i] - self._current_antennas[i]
444
+ )
445
+
446
+ interval_ok = (
447
+ self._last_send_time == 0
448
+ or (now - self._last_send_time) >= self._send_min_interval
449
+ )
450
+ # Serialize set_target: never dual in-flight (out-of-order
451
+ # done-callbacks corrupt the velocity-clamp reference).
452
+ previous_done = self._send_future is None or self._send_future.done()
453
+ can_send = (
454
+ interval_ok
455
+ and previous_done
456
+ and not self._sends_frozen
457
+ and (self._seeded or self.mini is None)
458
+ )
459
+
460
+ if can_send and self.mini is not None:
461
+ # Hard resync overwrites targets — never fight an active goto
462
+ # (reset minjerk owns the trajectory exclusively).
463
+ if not self._active_gotos and (
464
+ self._last_resync_time == 0.0
465
+ or (now - self._last_resync_time) >= RESYNC_FROM_ROBOT_SEC
466
+ ):
467
+ await self._periodic_resync()
468
+ if self._sends_frozen:
469
+ await asyncio.sleep(LOOP_INTERVAL)
470
+ continue
471
+
472
+ dt_since_send = (
473
+ now - self._last_send_time
474
+ if self._last_send_time > 0
475
+ else LOOP_INTERVAL
476
+ )
477
+ dt_clamped = min(dt_since_send, MAX_DT_FOR_VEL_CLAMP)
478
+ max_d_ang = MAX_ANGULAR_VEL * dt_clamped
479
+ max_d_pos = MAX_POS_VEL * dt_clamped
480
+
481
+ send_pose: dict[str, float] = {}
482
+ for axis in ANGULAR_AXES:
483
+ delta = self._current_pose[axis] - self._prev_sent_pose[axis]
484
+ send_pose[axis] = self._prev_sent_pose[axis] + _clamp(
485
+ delta, -max_d_ang, max_d_ang
486
+ )
487
+ for axis in POSITIONAL_AXES:
488
+ delta = self._current_pose[axis] - self._prev_sent_pose[axis]
489
+ send_pose[axis] = self._prev_sent_pose[axis] + _clamp(
490
+ delta, -max_d_pos, max_d_pos
491
+ )
492
+ body_yaw_delta = self._current_body_yaw - self._prev_sent_body_yaw
493
+ send_body_yaw = self._prev_sent_body_yaw + _clamp(
494
+ body_yaw_delta, -max_d_ang, max_d_ang
495
+ )
496
+
497
+ head = create_head_pose(
498
+ x=send_pose["x"],
499
+ y=send_pose["y"],
500
+ z=send_pose["z"],
501
+ roll=send_pose["roll"],
502
+ pitch=send_pose["pitch"],
503
+ yaw=send_pose["yaw"],
504
+ degrees=False,
505
+ )
506
+ antennas_arr = np.array(self._current_antennas, dtype=np.float64)
507
+ self._send_seq += 1
508
+ send_seq = self._send_seq
509
+ sent_pose = dict(send_pose)
510
+ sent_body_yaw = send_body_yaw
511
+
512
+ def _do_set_target(
513
+ h=head, b=send_body_yaw, a=antennas_arr.copy()
514
+ ) -> None:
515
+ try:
516
+ self.mini.set_target(head=h, body_yaw=b, antennas=a)
517
+ except Exception as exc:
518
+ logger.warning("set_target failed: %s", exc)
519
+ raise
520
+
521
+ self._send_future = loop.run_in_executor(None, _do_set_target)
522
+
523
+ def _on_send_done(fut: asyncio.Future[Any]) -> None:
524
+ if send_seq < self._last_applied_seq:
525
+ return
526
+ if fut.exception() is None:
527
+ self._prev_sent_pose = sent_pose
528
+ self._prev_sent_body_yaw = sent_body_yaw
529
+ self._last_applied_seq = send_seq
530
+ self._consecutive_ik_failures = 0
531
+ else:
532
+ self._consecutive_ik_failures += 1
533
+ if self._consecutive_ik_failures >= IK_FAIL_CONSECUTIVE_THRESHOLD:
534
+ # Pull only the target back toward neutral. Never
535
+ # move _prev_sent — that is the clamp reference.
536
+ alpha_t = IK_FAIL_RETRACT_TARGET_ALPHA
537
+ for ax in POSE_AXES:
538
+ self._target_pose[ax] *= 1.0 - alpha_t
539
+ self._target_body_yaw *= 1.0 - alpha_t
540
+
541
+ self._send_future.add_done_callback(_on_send_done)
542
+ self._last_send_time = now
543
+ self._send_count += 1
544
+ elif can_send and self.mini is None:
545
+ self._prev_sent_pose = dict(self._current_pose)
546
+ self._prev_sent_body_yaw = self._current_body_yaw
547
+ self._last_send_time = now
548
+
549
+ await asyncio.sleep(LOOP_INTERVAL)
550
+ except asyncio.CancelledError:
551
+ pass
552
+
553
+ async def _run_goto(
554
+ self,
555
+ move_uuid: str,
556
+ start_pose: dict[str, float],
557
+ end_pose: dict[str, float],
558
+ start_body_yaw: float,
559
+ end_body_yaw: float,
560
+ start_antennas: list[float],
561
+ end_antennas: list[float],
562
+ duration: float,
563
+ interpolation: str,
564
+ ) -> None:
565
+ t0 = time.monotonic()
566
+ while self._active_gotos.get(move_uuid, False):
567
+ elapsed = time.monotonic() - t0
568
+ t = min(elapsed / max(duration, 0.001), 1.0)
569
+ s = self._ease(t, interpolation)
570
+ lerped = {k: _lerp(start_pose[k], end_pose[k], s) for k in POSE_AXES}
571
+ by = _lerp(start_body_yaw, end_body_yaw, s)
572
+ self._target_pose, self._target_body_yaw = _clamp_pose_to_daemon_limits(
573
+ lerped, by
574
+ )
575
+ self._target_antennas = [
576
+ _lerp(start_antennas[i], end_antennas[i], s)
577
+ for i in range(min(len(start_antennas), len(end_antennas)))
578
+ ]
579
+ if t >= 1.0:
580
+ break
581
+ await asyncio.sleep(LOOP_INTERVAL)
582
+ self._active_gotos.pop(move_uuid, None)
583
+
584
+ @staticmethod
585
+ def _ease(t: float, mode: str) -> float:
586
+ if mode == "minjerk":
587
+ return t * t * t * (10 + t * (-15 + t * 6))
588
+ if mode == "ease":
589
+ if t < 0.5:
590
+ return 4 * t * t * t
591
+ return 1 - ((-2 * t + 2) ** 3) / 2
592
+ if mode == "cartoon":
593
+ c = 1.70158
594
+ c3 = c + 1
595
+ return 1 + c3 * ((t - 1) ** 3) + c * ((t - 1) ** 2)
596
+ return t
esp32_motion_controller/static/index.html ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ESP32 Motion Controller — Reachy Mini</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; background: #111; color: #eee; margin: 2rem; }
9
+ a { color: #8bd3ff; }
10
+ code { background: #222; padding: 0.15rem 0.4rem; border-radius: 4px; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <h1>🕹️ ESP32 Motion Controller</h1>
15
+ <p>ESP32 handheld IMU controller bridge for Reachy Mini.</p>
16
+ <p>WebSocket: <code id="ws">ws://…:8766/ws</code></p>
17
+ <p><a href="/api/info">/api/info</a> · <a href="/api/status">/api/status</a></p>
18
+ <script>
19
+ fetch('/api/info').then(r => r.json()).then(j => {
20
+ if (j.ws_url) document.getElementById('ws').textContent = j.ws_url;
21
+ }).catch(() => {});
22
+ </script>
23
+ </body>
24
+ </html>
esp32_motion_controller/ws_handler.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket router for the ESP32 motion controller.
3
+
4
+ Handles controller_state / reset / status, the 300 ms stale-packet watchdog,
5
+ reset interlock (busy ignores engage; rebases clutch on goto completion),
6
+ and single-controller admission.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import time
15
+ from typing import Any
16
+
17
+ from fastapi import WebSocket
18
+ from starlette.websockets import WebSocketState
19
+
20
+ from esp32_motion_controller.behavior import Behavior
21
+ from esp32_motion_controller.controller_state import ControllerState
22
+ from esp32_motion_controller.movement_handler import MovementHandler
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ STALE_PACKET_SEC = 0.300
27
+ BEHAVIOR_TICK_SEC = 0.033
28
+ RESET_DURATION_SEC = 1.5
29
+ # Keep the apply loop across short reconnect blips; stop after this idle.
30
+ IDLE_STOP_SEC = 5.0
31
+
32
+
33
+ class WebSocketHandler:
34
+ def __init__(
35
+ self,
36
+ movement: MovementHandler,
37
+ controller: ControllerState,
38
+ behavior: Behavior,
39
+ *,
40
+ robot_available: bool = True,
41
+ log_only: bool = False,
42
+ ) -> None:
43
+ self.movement = movement
44
+ self.controller = controller
45
+ self.behavior = behavior
46
+ self.robot_available = robot_available
47
+ self.log_only = log_only
48
+ self.busy = False
49
+ self._last_packet_time: float = 0.0
50
+ self._watchdog_task: asyncio.Task[None] | None = None
51
+ self._behavior_task: asyncio.Task[None] | None = None
52
+ self._idle_stop_task: asyncio.Task[None] | None = None
53
+ self._active_ws: WebSocket | None = None
54
+
55
+ async def on_connect(self, websocket: WebSocket) -> bool:
56
+ """Admit a single controller. Stale sockets are replaced, not rejected.
57
+
58
+ The ESP client auto-reconnects after WiFi blips. If we still hold the
59
+ previous WebSocket object, a hard reject (1008) makes the board flap
60
+ forever and the face stays closed.
61
+
62
+ Movement keeps running across reconnects — stop/start would re-zero
63
+ velocity-clamp bookkeeping and let the next set_target snap the head.
64
+ """
65
+ if self._idle_stop_task is not None and not self._idle_stop_task.done():
66
+ self._idle_stop_task.cancel()
67
+ self._idle_stop_task = None
68
+
69
+ if self._active_ws is not None:
70
+ old = self._active_ws
71
+ alive = old.client_state == WebSocketState.CONNECTED
72
+ if alive:
73
+ logger.warning("Replacing active controller connection")
74
+ self._detach_controller(stop_movement=False)
75
+ if alive:
76
+ try:
77
+ await old.close(code=1000)
78
+ except Exception:
79
+ pass
80
+
81
+ await websocket.accept()
82
+ self._active_ws = websocket
83
+ self.movement.start()
84
+ if self.movement.resync_from_robot():
85
+ # Clutch base matches the physical head so idle/engage cannot yank home.
86
+ self.controller.set_base_pose(self.movement.current_pose)
87
+ self._watchdog_task = asyncio.create_task(self._watchdog_loop())
88
+ self._behavior_task = asyncio.create_task(self._behavior_loop())
89
+ logger.info("Controller connected")
90
+ return True
91
+
92
+ def _detach_controller(self, *, stop_movement: bool) -> None:
93
+ self.controller.force_disengage()
94
+ if stop_movement:
95
+ self.movement.stop()
96
+ for task in (self._watchdog_task, self._behavior_task):
97
+ if task is not None and not task.done():
98
+ task.cancel()
99
+ self._watchdog_task = None
100
+ self._behavior_task = None
101
+ self._active_ws = None
102
+
103
+ def cleanup(self, websocket: WebSocket | None = None) -> None:
104
+ """Drop the active controller. Ignore stale sockets that already lost the slot."""
105
+ if websocket is not None and self._active_ws is not websocket:
106
+ return
107
+ if self._active_ws is None and websocket is None:
108
+ return
109
+ # Keep the apply loop alive on a blip so velocity clamping stays
110
+ # continuous; schedule a stop if nothing reconnects.
111
+ self._detach_controller(stop_movement=False)
112
+ if self._idle_stop_task is None or self._idle_stop_task.done():
113
+ self._idle_stop_task = asyncio.create_task(self._idle_stop_after_grace())
114
+ logger.info("Controller disconnected")
115
+
116
+ async def _idle_stop_after_grace(self) -> None:
117
+ try:
118
+ await asyncio.sleep(IDLE_STOP_SEC)
119
+ if self._active_ws is None:
120
+ logger.info("No controller for %.1fs — stopping movement loop", IDLE_STOP_SEC)
121
+ self.movement.stop()
122
+ except asyncio.CancelledError:
123
+ pass
124
+
125
+ def shutdown(self) -> None:
126
+ """App teardown: always stop movement."""
127
+ if self._idle_stop_task is not None and not self._idle_stop_task.done():
128
+ self._idle_stop_task.cancel()
129
+ self._idle_stop_task = None
130
+ self._detach_controller(stop_movement=True)
131
+
132
+ async def handle_message(self, websocket: WebSocket, raw: str) -> None:
133
+ try:
134
+ msg: dict[str, Any] = json.loads(raw)
135
+ except json.JSONDecodeError as exc:
136
+ await self._send_error(websocket, "parse", f"Invalid JSON: {exc}")
137
+ return
138
+
139
+ msg_type = msg.get("type")
140
+ request_id = msg.get("_id")
141
+ handler = {
142
+ "controller_state": self._handle_controller_state,
143
+ "reset": self._handle_reset,
144
+ "status": self._handle_status,
145
+ }.get(msg_type)
146
+
147
+ if handler is None:
148
+ await self._send_error(
149
+ websocket,
150
+ msg_type or "unknown",
151
+ f"Unknown message type: {msg_type}",
152
+ request_id,
153
+ )
154
+ return
155
+
156
+ try:
157
+ response = await handler(msg)
158
+ except Exception as exc:
159
+ logger.error("Handler %s failed: %s", msg_type, exc)
160
+ await self._send_error(websocket, msg_type, str(exc), request_id)
161
+ return
162
+
163
+ if request_id is None or response is None:
164
+ return
165
+ response["_id"] = request_id
166
+ await websocket.send_json(response)
167
+
168
+ async def _handle_controller_state(self, msg: dict[str, Any]) -> dict[str, Any] | None:
169
+ self._last_packet_time = time.monotonic()
170
+ q = msg.get("q", [1, 0, 0, 0])
171
+ p = msg.get("p", [0, 0, 0])
172
+ engaged = bool(msg.get("engaged", False))
173
+ gain = float(msg.get("gain", 1.0))
174
+ ready = bool(msg.get("ready", False))
175
+
176
+ desired = self.controller.update(
177
+ q=q,
178
+ p=p,
179
+ engaged=engaged,
180
+ gain=gain,
181
+ ready=ready,
182
+ allow_engage=not self.busy,
183
+ )
184
+
185
+ # Reset goto owns the robot target exclusively — streaming set_target
186
+ # here fights the minjerk path and looks like a whip when rebase snaps.
187
+ if self.busy:
188
+ return None
189
+
190
+ # Behavior owns body_yaw + antennas from head yaw
191
+ body_yaw, antennas = self.behavior.update(desired["yaw"])
192
+ self.movement.set_target(desired, body_yaw=body_yaw, antennas=antennas)
193
+
194
+ if self.log_only:
195
+ logger.info(
196
+ "controller engaged=%s gain=%.2f ready=%s pose=%s body_yaw=%.3f",
197
+ self.controller.engaged,
198
+ self.controller.gain,
199
+ ready,
200
+ {k: round(desired[k], 4) for k in desired},
201
+ body_yaw,
202
+ )
203
+ return None
204
+
205
+ async def _handle_reset(self, _msg: dict[str, Any]) -> dict[str, Any]:
206
+ if self.busy:
207
+ return {"type": "reset_result", "success": False, "message": "Reset already in progress"}
208
+ if not self.robot_available and not self.log_only:
209
+ return {"type": "reset_result", "success": False, "message": "Robot not available"}
210
+
211
+ self.busy = True
212
+ self.controller.force_disengage()
213
+ # Zero clutch immediately so idle/behavior cannot re-target the old pose
214
+ # while the goto is running.
215
+ self.controller.rebase_neutral()
216
+ self.behavior.reset()
217
+
218
+ # Seed from the measured pose so goto starts where the head actually is
219
+ # (and clears a prior send-freeze). Fail closed when the robot is real.
220
+ if not await self.movement.resync_from_robot_async(update_target=True):
221
+ if not self.log_only:
222
+ self.busy = False
223
+ return {
224
+ "type": "reset_result",
225
+ "success": False,
226
+ "message": "Robot pose unread",
227
+ }
228
+
229
+ neutral = {k: 0.0 for k in ("x", "y", "z", "roll", "pitch", "yaw")}
230
+ move_uuid = self.movement.goto(
231
+ neutral, body_yaw=0.0, antennas=[0.0, 0.0],
232
+ duration=RESET_DURATION_SEC, interpolation="minjerk",
233
+ )
234
+ asyncio.create_task(self._finish_reset(move_uuid))
235
+ return {"type": "reset_result", "success": True, "uuid": move_uuid}
236
+
237
+ async def _finish_reset(self, move_uuid: str) -> None:
238
+ try:
239
+ await asyncio.sleep(RESET_DURATION_SEC + 0.05)
240
+ finally:
241
+ self.movement.rebase_to_neutral()
242
+ self.controller.rebase_neutral()
243
+ self.behavior.reset()
244
+ self.busy = False
245
+ logger.info("Reset complete; clutch rebased to neutral (uuid=%s)", move_uuid)
246
+
247
+ async def _handle_status(self, _msg: dict[str, Any]) -> dict[str, Any]:
248
+ return {
249
+ "type": "status_result",
250
+ "connected": True,
251
+ "robot": self.robot_available,
252
+ "busy": self.busy,
253
+ }
254
+
255
+ async def _watchdog_loop(self) -> None:
256
+ try:
257
+ while True:
258
+ await asyncio.sleep(0.05)
259
+ if self._last_packet_time <= 0:
260
+ continue
261
+ if time.monotonic() - self._last_packet_time > STALE_PACKET_SEC:
262
+ if self.controller.engaged:
263
+ logger.warning("Stale controller_state; forcing disengage")
264
+ self.controller.force_disengage()
265
+ # freeze: keep last desired as movement target, no further updates
266
+ except asyncio.CancelledError:
267
+ pass
268
+
269
+ async def _behavior_loop(self) -> None:
270
+ """Keep antennas / body-follow alive while idle (not engaged)."""
271
+ try:
272
+ while True:
273
+ await asyncio.sleep(BEHAVIOR_TICK_SEC)
274
+ if self.controller.engaged or self.busy:
275
+ continue
276
+ pose = self.controller.desired_pose
277
+ body_yaw, antennas = self.behavior.update(pose["yaw"])
278
+ self.movement.set_target(pose, body_yaw=body_yaw, antennas=antennas)
279
+ except asyncio.CancelledError:
280
+ pass
281
+
282
+ async def _send_error(
283
+ self,
284
+ websocket: WebSocket,
285
+ request_type: str,
286
+ message: str,
287
+ request_id: Any = None,
288
+ ) -> None:
289
+ response: dict[str, Any] = {
290
+ "type": "error",
291
+ "request_type": request_type,
292
+ "message": message,
293
+ }
294
+ if request_id is not None:
295
+ response["_id"] = request_id
296
+ await websocket.send_json(response)
index.html ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ESP32 Motion Controller — Reachy Mini</title>
7
+ <link rel="stylesheet" href="style.css" />
8
+ </head>
9
+ <body>
10
+ <main class="card">
11
+ <h1>ESP32 Motion Controller</h1>
12
+ <p>
13
+ Install this Space from the Reachy Mini Control desktop app, then hold
14
+ the ESP32 display to clutch-drive Reachy Mini's head.
15
+ </p>
16
+ <p>WebSocket once running: <code>ws://&lt;host&gt;:8766/ws</code></p>
17
+ </main>
18
+ </body>
19
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "esp32_motion_controller"
7
+ version = "1.0.0"
8
+ description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "reachy-mini",
13
+ "fastapi",
14
+ "uvicorn[standard]",
15
+ "numpy",
16
+ "scipy",
17
+ "zeroconf",
18
+ ]
19
+ keywords = ["reachy-mini-app"]
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["pytest", "pytest-asyncio", "httpx", "websockets"]
23
+
24
+ [project.entry-points."reachy_mini_apps"]
25
+ esp32_motion_controller = "esp32_motion_controller.main:Esp32MotionController"
26
+
27
+ [tool.setuptools]
28
+ package-dir = { "" = "." }
29
+ include-package-data = true
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["esp32_motion_controller*"]
34
+ exclude = ["build*", "dist*", "tools*", "tests*"]
35
+
36
+ [tool.setuptools.package-data]
37
+ esp32_motion_controller = ["static/*"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["../tools/tests"]
41
+ pythonpath = ["."]
42
+ asyncio_mode = "auto"
43
+ asyncio_default_fixture_loop_scope = "function"
style.css ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ padding: 2rem;
3
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
4
+ background: linear-gradient(160deg, #edebe6 0%, #d8d4cc 100%);
5
+ color: #1c1b19;
6
+ }
7
+
8
+ h1 {
9
+ font-size: 1.5rem;
10
+ margin-top: 0;
11
+ }
12
+
13
+ p {
14
+ color: #3f3c38;
15
+ font-size: 1rem;
16
+ line-height: 1.45;
17
+ }
18
+
19
+ code {
20
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
21
+ font-size: 0.9em;
22
+ }
23
+
24
+ .card {
25
+ max-width: 620px;
26
+ margin: 0 auto;
27
+ padding: 1.25rem 1.5rem;
28
+ }