V4C38 commited on
Commit
a42951e
·
1 Parent(s): 20a0ca6

Ship clutch-relative UDP control (app 3.1.6).

Browse files

Engage snapshots IMU zero; yaw is 1:1 with hard stops; appear/disappear
slews are faster. Protocol v4.

README.md CHANGED
@@ -16,9 +16,9 @@ tags:
16
 
17
  # ESP32 Motion Controller
18
 
19
- UDP bridge (protocol v3) 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). Firmware and app must both speak protocol v3.
22
 
23
  ## Local install
24
 
@@ -49,8 +49,4 @@ hf upload YOUR_USER/esp32_motion_controller . --repo-type space \
49
  --exclude ".DS_Store"
50
  ```
51
 
52
- Ensure README frontmatter includes those tags before upload.
53
-
54
- The excludes matter: `packages.find` would otherwise pick up a stray `build/lib/`
55
- tree as a second copy of the package, and `.pyc` files built by a different
56
- Python end up installed into the daemon's 3.12 venv.
 
16
 
17
  # ESP32 Motion Controller
18
 
19
+ Reachy Mini app that receives protocol v4 UDP samples from the handheld ESP32. Engage snapshots IMU zero; roll/pitch/yaw stay clutch-relative (rotation only). The app owns IK-safe clamping, hard yaw stops, antenna idle, and body follow.
20
 
21
+ See the [repository README](https://github.com/V4C38/esp32-reachy-mini-controller) and [`PROTOCOL.md`](../PROTOCOL.md). Firmware and app must both speak v4.
22
 
23
  ## Local install
24
 
 
49
  --exclude ".DS_Store"
50
  ```
51
 
52
+ Keep those tags in the README frontmatter. The excludes matter: `packages.find` would otherwise pick up a stray `build/lib/` as a second copy of the package, and `.pyc` files from another Python end up in the daemon's 3.12 venv.
 
 
 
 
esp32_motion_controller/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- """ESP32 Motion Controller Reachy Mini app (protocol v3)."""
2
 
3
- __version__ = "3.0.0"
 
1
+ """ESP32 Motion Controller Reachy Mini app (protocol v4)."""
2
 
3
+ __version__ = "3.1.6"
esp32_motion_controller/control.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Pure control reducer for protocol v3.
3
 
4
  Owns clutch mapping, body follow, antenna phase, workspace projection, smoothing,
5
  stale release, and slew limiting. No I/O.
@@ -14,7 +14,7 @@ from typing import Sequence
14
  import numpy as np
15
  from scipy.spatial.transform import Rotation as R
16
 
17
- from esp32_motion_controller.protocol import Sample
18
 
19
  POSE_AXES = ("x", "y", "z", "roll", "pitch", "yaw")
20
  ANGULAR_AXES = ("roll", "pitch", "yaw")
@@ -29,18 +29,6 @@ DEV_TO_HEAD = np.array(
29
  dtype=np.float64,
30
  )
31
 
32
- TRANSLATION_SCALE = 0.30
33
- TRANSLATION_GAIN_DEFAULT = 1.0
34
-
35
- # Device-frame rotation gains, applied to the clutch-relative rotation vector
36
- # *before* DEV_TO_HEAD. Body axes after IMU_MAP / firmware ui.c:
37
- # X = tip top toward user (forward tilt)
38
- # Y = USB-down in-place turn (horizontal pan)
39
- # Z = raise right edge (sideways roll)
40
- FORWARD_GAIN = 1.0 # device X → head pitch
41
- HORIZONTAL_GAIN = 1.75 # device Y → head yaw
42
- SIDEWAYS_GAIN = 0.75 # device Z → head roll
43
-
44
  STALE_PACKET_SEC = 0.600
45
  CONTROL_HZ = 20.0
46
  CONTROL_DT = 1.0 / CONTROL_HZ
@@ -49,13 +37,15 @@ CONTROL_DT = 1.0 / CONTROL_HZ
49
  POSE_TAU_SEC = 0.255
50
  ANTENNA_TAU_SEC = 0.39
51
 
52
- # Hard SDK-boundary speed lock. Per-axis rotation / wrap-safe body yaw / Euclidean xyz.
53
  MAX_ANGULAR_VEL = 1.5 # rad/s (~86 deg/s), matching Spectacles
54
  MAX_POS_VEL = 0.030 # 30 mm/s
55
  MAX_DT_FOR_VEL_CLAMP = 0.05
 
 
56
 
57
  LIMIT_BODY_YAW_RAD = 160.0 * math.pi / 180.0
58
- LIMIT_HEAD_YAW_RAD = math.pi
59
  LIMIT_HEAD_BODY_YAW_DELTA_RAD = 65.0 * math.pi / 180.0
60
 
61
  LIMIT_HEAD_X_MIN = -0.020
@@ -65,12 +55,30 @@ LIMIT_HEAD_Y_MAX = 0.020
65
  LIMIT_HEAD_Z_MIN = 0.0
66
  LIMIT_HEAD_Z_MAX = 0.025
67
 
68
- ELLIPSOID_X_MAX = 0.015
69
- ELLIPSOID_Y_MAX = 0.015
70
- ELLIPSOID_Z_MAX = 0.018
71
  ELLIPSOID_ROLL_MAX_RAD = 25.0 * math.pi / 180.0
72
  ELLIPSOID_PITCH_MAX_RAD = 25.0 * math.pi / 180.0
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  MAX_HEAD_YAW = 65.0 * math.pi / 180.0
75
  BODY_FOLLOW_THRESHOLD = 40.0 * math.pi / 180.0
76
  MAX_BODY_YAW = 160.0 * math.pi / 180.0
@@ -81,6 +89,7 @@ DEFAULT_ANTENNA_ACTIVITY = 0.8
81
  HEAD_MOVE_SPEED = 0.06
82
  MAX_HEAD_DELTA_DEG = 2.0
83
  ANTENNA_AMPLITUDE_DEG = 15.0
 
84
 
85
  IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
86
  IK_FAIL_CONSECUTIVE_THRESHOLD = 3
@@ -110,6 +119,12 @@ def _clamp(value: float, lo: float, hi: float) -> float:
110
  return max(lo, min(hi, value))
111
 
112
 
 
 
 
 
 
 
113
  def _finite_quat(q: Sequence[float]) -> np.ndarray:
114
  arr = np.asarray(q, dtype=np.float64).reshape(4)
115
  if not np.all(np.isfinite(arr)) or np.linalg.norm(arr) < 1e-9:
@@ -117,17 +132,51 @@ def _finite_quat(q: Sequence[float]) -> np.ndarray:
117
  return arr / np.linalg.norm(arr)
118
 
119
 
120
- def _finite_vec3(v: Sequence[float]) -> np.ndarray:
121
- arr = np.asarray(v, dtype=np.float64).reshape(3)
122
- if not np.all(np.isfinite(arr)):
123
- return np.zeros(3, dtype=np.float64)
124
- return arr
125
-
126
-
127
  def _wxyz_to_rotation(q: np.ndarray) -> R:
128
  return R.from_quat([q[1], q[2], q[3], q[0]])
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def _relative_device_rotation(q_ref: np.ndarray, q_device: np.ndarray) -> R:
132
  r_ref = _wxyz_to_rotation(q_ref)
133
  r_dev = _wxyz_to_rotation(q_device)
@@ -147,10 +196,9 @@ def quat_relative_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, f
147
 
148
 
149
  def scale_device_rotation(r_rel_dev: R) -> R:
150
- """Scale clutch-relative rotation about each ESP32 body axis."""
151
  rv = r_rel_dev.as_rotvec()
152
  rv[0] *= FORWARD_GAIN
153
- rv[1] *= HORIZONTAL_GAIN
154
  rv[2] *= SIDEWAYS_GAIN
155
  return R.from_rotvec(rv)
156
 
@@ -161,16 +209,19 @@ def relative_head_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, f
161
  return _device_rotation_to_head_rpy(r_rel_dev)
162
 
163
 
164
- def remap_displacement(
165
- p_world_delta: np.ndarray,
166
  q_ref: np.ndarray,
167
- *,
168
- translation_gain: float = TRANSLATION_GAIN_DEFAULT,
169
- ) -> np.ndarray:
 
170
  r_ref = _wxyz_to_rotation(q_ref)
171
- disp_ref = r_ref.inv().apply(p_world_delta)
172
- disp_head = DEV_TO_HEAD @ disp_ref
173
- return disp_head * TRANSLATION_SCALE * float(translation_gain)
 
 
 
174
 
175
 
176
  def dual_sine(t: float, freq_a: float, freq_b: float) -> float:
@@ -180,32 +231,12 @@ def dual_sine(t: float, freq_a: float, freq_b: float) -> float:
180
  def clamp_stewart_ellipsoid(
181
  x: float, y: float, z: float, roll: float, pitch: float,
182
  ) -> tuple[float, float, float, float, float]:
183
- z_clamped = _clamp(z, LIMIT_HEAD_Z_MIN, LIMIT_HEAD_Z_MAX)
184
- x_clamped = _clamp(x, LIMIT_HEAD_X_MIN, LIMIT_HEAD_X_MAX)
185
- y_clamped = _clamp(y, LIMIT_HEAD_Y_MIN, LIMIT_HEAD_Y_MAX)
186
-
187
- roll_c = _clamp(roll, -ELLIPSOID_ROLL_MAX_RAD, ELLIPSOID_ROLL_MAX_RAD)
188
- pitch_c = _clamp(pitch, -ELLIPSOID_PITCH_MAX_RAD, ELLIPSOID_PITCH_MAX_RAD)
189
-
190
- nr = roll_c / ELLIPSOID_ROLL_MAX_RAD if ELLIPSOID_ROLL_MAX_RAD > 0 else 0.0
191
- np_ = pitch_c / ELLIPSOID_PITCH_MAX_RAD if ELLIPSOID_PITCH_MAX_RAD > 0 else 0.0
192
- remaining = max(0.0, 1.0 - (nr * nr + np_ * np_))
193
-
194
- nx = x_clamped / ELLIPSOID_X_MAX if ELLIPSOID_X_MAX > 0 else 0.0
195
- ny = y_clamped / ELLIPSOID_Y_MAX if ELLIPSOID_Y_MAX > 0 else 0.0
196
- nz = z_clamped / ELLIPSOID_Z_MAX if ELLIPSOID_Z_MAX > 0 else 0.0
197
- trans_sq = nx * nx + ny * ny + nz * nz
198
-
199
- if trans_sq <= remaining or trans_sq <= 1e-12:
200
- return (x_clamped, y_clamped, z_clamped, roll_c, pitch_c)
201
-
202
- scale = math.sqrt(remaining / trans_sq)
203
  return (
204
- x_clamped * scale,
205
- y_clamped * scale,
206
- z_clamped * scale,
207
- roll_c,
208
- pitch_c,
209
  )
210
 
211
 
@@ -236,18 +267,37 @@ def clamp_pose_to_daemon_limits(
236
  return out_pose, body_yaw_clamped
237
 
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  def _alpha(dt: float, tau: float) -> float:
240
  if tau <= 0.0:
241
  return 1.0
242
  return 1.0 - math.exp(-max(dt, 0.0) / tau)
243
 
244
 
245
- def _wrap_delta(from_ang: float, to_ang: float) -> float:
246
- """Shortest signed delta from `from_ang` to `to_ang`, wrapping at ±pi."""
247
- d = (to_ang - from_ang + math.pi) % (2.0 * math.pi) - math.pi
248
- return d
249
-
250
-
251
  def _pose_rotation(pose: dict[str, float]) -> R:
252
  return R.from_euler(
253
  "xyz", [pose["roll"], pose["pitch"], pose["yaw"]], degrees=False
@@ -262,17 +312,19 @@ def speed_lock(
262
  dt: float,
263
  *,
264
  apply_ellipsoid: bool = True,
 
 
265
  ) -> tuple[dict[str, float], float]:
266
  """Cap one streaming command against the last delivered pose.
267
 
268
- Roll, pitch, and yaw each get an independent MAX_ANGULAR_VEL budget so a
269
- new tilt is not starved by leftover pan. Yaw and body yaw use wrap-safe
270
- shortest-arc. Translation uses Euclidean distance. `dt` is capped so
271
  stalls/reconnects cannot accumulate permission for a snap.
272
  """
273
  dt_c = min(max(dt, 0.0), MAX_DT_FOR_VEL_CLAMP)
274
- max_d_ang = MAX_ANGULAR_VEL * dt_c
275
- max_d_pos = MAX_POS_VEL * dt_c
276
  send = {k: float(desired[k]) for k in POSE_AXES}
277
 
278
  dp = np.array([desired[k] - baseline[k] for k in POSITIONAL_AXES], dtype=np.float64)
@@ -285,22 +337,27 @@ def speed_lock(
285
  for axis in POSITIONAL_AXES:
286
  send[axis] = desired[axis]
287
 
 
 
 
288
  for axis in ("roll", "pitch"):
289
  delta = desired[axis] - baseline[axis]
290
  if abs(delta) > max_d_ang:
291
  send[axis] = baseline[axis] + math.copysign(max_d_ang, delta)
292
  else:
293
  send[axis] = desired[axis]
294
- yaw_delta = _wrap_delta(baseline["yaw"], desired["yaw"])
295
  if abs(yaw_delta) > max_d_ang:
296
- yaw_delta = math.copysign(max_d_ang, yaw_delta)
297
- send["yaw"] = baseline["yaw"] + yaw_delta
298
-
299
- body_delta = _wrap_delta(baseline_body, desired_body)
300
  if abs(body_delta) > max_d_ang:
301
  body_delta = math.copysign(max_d_ang, body_delta)
302
  send_body = baseline_body + body_delta
303
- return clamp_pose_to_daemon_limits(send, send_body, apply_ellipsoid=apply_ellipsoid)
 
 
304
 
305
 
306
  def slew_limit(
@@ -334,7 +391,9 @@ def pose_travel(
334
  )
335
  dist = float(np.linalg.norm(dp))
336
  rel = _pose_rotation(from_pose).inv() * _pose_rotation(to_pose)
337
- ang = float(rel.magnitude()) + abs(_wrap_delta(from_body, to_body))
 
 
338
  return dist, ang
339
 
340
 
@@ -358,10 +417,11 @@ class ControlState:
358
  was_engaged: bool = False
359
  gain: float = 1.0
360
  ready: bool = False
361
- translation_gain: float = TRANSLATION_GAIN_DEFAULT
362
 
363
  q_ref: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0]))
364
- p_ref: np.ndarray = field(default_factory=lambda: np.zeros(3))
 
 
365
  base_pose: dict[str, float] = field(default_factory=zero_pose)
366
  desired_pose: dict[str, float] = field(default_factory=zero_pose)
367
 
@@ -447,6 +507,9 @@ def seed_from_pose(
447
  consecutive_ik_failures=0,
448
  engaged=False,
449
  was_engaged=False,
 
 
 
450
  mode="idle" if state.mode != "resetting" else state.mode,
451
  error=None,
452
  )
@@ -491,8 +554,10 @@ def rebase_to_pose(
491
  smooth_antennas=[0.0, 0.0],
492
  engaged=False,
493
  was_engaged=False,
 
 
 
494
  q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
495
- p_ref=np.zeros(3),
496
  sends_frozen=True,
497
  mode="fault",
498
  error="robot pose unread after reset",
@@ -516,8 +581,10 @@ def rebase_to_pose(
516
  baseline_antennas=[0.0, 0.0],
517
  engaged=False,
518
  was_engaged=False,
 
 
 
519
  q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
520
- p_ref=np.zeros(3),
521
  sends_frozen=False,
522
  seeded=True,
523
  mode="idle",
@@ -539,8 +606,10 @@ def begin_reset(state: ControlState, now: float) -> ControlState:
539
  antenna_right=0.0,
540
  was_engaged=False,
541
  engaged=False,
 
 
 
542
  q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
543
- p_ref=np.zeros(3),
544
  behavior_t0=now,
545
  )
546
 
@@ -598,37 +667,53 @@ def mark_pose_unread(state: ControlState) -> ControlState:
598
 
599
 
600
  def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -> ControlState:
601
- gain = _clamp(float(sample.gain), 0.1, 3.0)
602
  q_dev = _finite_quat(sample.q)
603
- p_dev = _finite_vec3(sample.p)
604
  want = bool(sample.engaged) and bool(sample.ready) and allow_engage
605
  rising = want and not state.was_engaged
606
  falling = (not want) and state.was_engaged
607
 
608
  q_ref = state.q_ref
609
- p_ref = state.p_ref
 
 
610
  base = dict(state.base_pose)
611
  desired = dict(state.desired_pose)
612
 
613
  if rising:
 
 
 
614
  q_ref = q_dev.copy()
615
- p_ref = p_dev.copy()
 
 
 
616
 
617
  if want:
618
- roll, pitch, yaw = relative_head_rpy(q_ref, q_dev)
619
- disp = remap_displacement(
620
- p_dev - p_ref,
621
- q_ref,
622
- translation_gain=state.translation_gain * gain,
623
- )
 
624
  desired = {
625
- "x": base["x"] + float(disp[0]),
626
- "y": base["y"] + float(disp[1]),
627
- "z": base["z"] + float(disp[2]),
628
  "roll": base["roll"] + gain * roll,
629
  "pitch": base["pitch"] + gain * pitch,
630
  "yaw": base["yaw"] + gain * yaw,
631
  }
 
 
 
 
 
 
 
 
632
  elif falling:
633
  base = dict(desired)
634
 
@@ -637,7 +722,9 @@ def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -
637
  ready=bool(sample.ready),
638
  gain=gain,
639
  q_ref=q_ref,
640
- p_ref=p_ref,
 
 
641
  base_pose=base,
642
  desired_pose=desired,
643
  engaged=want,
@@ -645,18 +732,39 @@ def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -
645
  )
646
 
647
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  def _advance_behavior(state: ControlState, head_yaw: float, now: float, dt: float) -> ControlState:
649
  # Normalize behavior update against the legacy ~33 ms tick using dt scaling.
650
  tick_scale = dt / 0.033 if dt > 0 else 1.0
651
- t = now - state.behavior_t0
652
  deg = math.pi / 180.0
653
 
654
  yaw_smoothing = HEAD_MOVE_SPEED * state.gaze_responsiveness * tick_scale
655
  max_yaw_delta = MAX_HEAD_DELTA_DEG * state.gaze_responsiveness * deg * tick_scale
656
  body_smoothing = yaw_smoothing * 0.7 * (0.3 + state.liveliness * 0.4)
657
- antenna_smoothing = yaw_smoothing * 1.5
658
- effective_ant_amp = ANTENNA_AMPLITUDE_DEG * state.antenna_activity * deg
659
- ant_speed = 0.5 + state.antenna_activity * 0.5
660
 
661
  body_yaw = state.body_yaw
662
  rel_yaw = head_yaw - body_yaw
@@ -666,17 +774,8 @@ def _advance_behavior(state: ControlState, head_yaw: float, now: float, dt: floa
666
  body_yaw += _clamp(step, -max_yaw_delta, max_yaw_delta)
667
  body_yaw = _clamp(body_yaw, -MAX_BODY_YAW, MAX_BODY_YAW)
668
 
669
- desired_l = dual_sine(t * ant_speed, 1.3, 3.11) * effective_ant_amp
670
- desired_r = dual_sine(t * ant_speed, 1.7, 2.73) * effective_ant_amp
671
- ant_l = state.antenna_left + (desired_l - state.antenna_left) * antenna_smoothing
672
- ant_r = state.antenna_right + (desired_r - state.antenna_right) * antenna_smoothing
673
-
674
- return replace(
675
- state,
676
- body_yaw=body_yaw,
677
- antenna_left=ant_l,
678
- antenna_right=ant_r,
679
- )
680
 
681
 
682
  def step(
@@ -715,10 +814,13 @@ def step(
715
  st = _update_clutch(st, sample, allow_engage=allow_engage)
716
  st = replace(st, have_sample=True)
717
 
718
- if st.mode not in {"resetting"}:
719
- st = _advance_behavior(st, st.desired_pose["yaw"], now, dt)
720
-
721
  apply_ellipsoid = bool(st.engaged)
 
 
 
 
 
 
722
  target_pose, target_body = clamp_pose_to_daemon_limits(
723
  st.desired_pose, st.body_yaw, apply_ellipsoid=apply_ellipsoid
724
  )
 
1
  """
2
+ Pure control reducer for protocol v4.
3
 
4
  Owns clutch mapping, body follow, antenna phase, workspace projection, smoothing,
5
  stale release, and slew limiting. No I/O.
 
14
  import numpy as np
15
  from scipy.spatial.transform import Rotation as R
16
 
17
+ from esp32_motion_controller.protocol import GAIN_MAX, GAIN_MIN, Sample
18
 
19
  POSE_AXES = ("x", "y", "z", "roll", "pitch", "yaw")
20
  ANGULAR_AXES = ("roll", "pitch", "yaw")
 
29
  dtype=np.float64,
30
  )
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  STALE_PACKET_SEC = 0.600
33
  CONTROL_HZ = 20.0
34
  CONTROL_DT = 1.0 / CONTROL_HZ
 
37
  POSE_TAU_SEC = 0.255
38
  ANTENNA_TAU_SEC = 0.39
39
 
40
+ # Hard SDK-boundary speed lock. Per-axis linear rotation / Euclidean xyz.
41
  MAX_ANGULAR_VEL = 1.5 # rad/s (~86 deg/s), matching Spectacles
42
  MAX_POS_VEL = 0.030 # 30 mm/s
43
  MAX_DT_FOR_VEL_CLAMP = 0.05
44
+ # Appear/disappear slews (BOOT engage/clutch) run faster than streaming.
45
+ ANIM_VEL_MULT = 2.5
46
 
47
  LIMIT_BODY_YAW_RAD = 160.0 * math.pi / 180.0
48
+ LIMIT_HEAD_YAW_RAD = math.radians(150.0) # 30° of margin from the ±180° IK wrap
49
  LIMIT_HEAD_BODY_YAW_DELTA_RAD = 65.0 * math.pi / 180.0
50
 
51
  LIMIT_HEAD_X_MIN = -0.020
 
55
  LIMIT_HEAD_Z_MIN = 0.0
56
  LIMIT_HEAD_Z_MAX = 0.025
57
 
 
 
 
58
  ELLIPSOID_ROLL_MAX_RAD = 25.0 * math.pi / 180.0
59
  ELLIPSOID_PITCH_MAX_RAD = 25.0 * math.pi / 180.0
60
 
61
+ # Nominal hold pose: screen toward user, USB + buttons down → device +Y is up.
62
+ # Matches IMU_MAP_* and imu_gravity_sane() in firmware/main/config.h.
63
+ HOLD_TILT = R.from_euler("x", math.pi / 2.0)
64
+
65
+ # Pitch/roll: this much board tilt from the engage snapshot spans the head's ±25°.
66
+ PITCH_ROLL_WINDOW_RAD = math.radians(40.0)
67
+ PITCH_ROLL_SCALE = ELLIPSOID_PITCH_MAX_RAD / PITCH_ROLL_WINDOW_RAD # 25/40 = 0.625
68
+
69
+ # Device-frame rotation gains, applied to the clutch-relative rotation vector
70
+ # *before* DEV_TO_HEAD. Body axes after IMU_MAP / firmware ui.c:
71
+ # X = tip top toward user (forward tilt)
72
+ # Y = USB-down in-place turn (horizontal pan) — 1:1
73
+ # Z = raise right edge (sideways roll)
74
+ FORWARD_GAIN = PITCH_ROLL_SCALE # device X → head pitch (engage-relative)
75
+ SIDEWAYS_GAIN = PITCH_ROLL_SCALE # device Z → head roll (engage-relative)
76
+
77
+ HEADING_MIN_HORIZ = 0.20 # device +X too vertical (board on its side) to give a heading
78
+ YAW_STOP_EPS = 1e-4
79
+ # Sign-cross hold: only when already past the neck stop, not near centre.
80
+ YAW_SIGN_HOLD_ABS = LIMIT_HEAD_BODY_YAW_DELTA_RAD
81
+
82
  MAX_HEAD_YAW = 65.0 * math.pi / 180.0
83
  BODY_FOLLOW_THRESHOLD = 40.0 * math.pi / 180.0
84
  MAX_BODY_YAW = 160.0 * math.pi / 180.0
 
89
  HEAD_MOVE_SPEED = 0.06
90
  MAX_HEAD_DELTA_DEG = 2.0
91
  ANTENNA_AMPLITUDE_DEG = 15.0
92
+ APPEAR_ANTENNA_SPEED_MULT = 2.0
93
 
94
  IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
95
  IK_FAIL_CONSECUTIVE_THRESHOLD = 3
 
119
  return max(lo, min(hi, value))
120
 
121
 
122
+ def _wrap_delta(from_ang: float, to_ang: float) -> float:
123
+ """Shortest signed delta from `from_ang` to `to_ang`, wrapping at ±pi."""
124
+ d = (to_ang - from_ang + math.pi) % (2.0 * math.pi) - math.pi
125
+ return d
126
+
127
+
128
  def _finite_quat(q: Sequence[float]) -> np.ndarray:
129
  arr = np.asarray(q, dtype=np.float64).reshape(4)
130
  if not np.all(np.isfinite(arr)) or np.linalg.norm(arr) < 1e-9:
 
132
  return arr / np.linalg.norm(arr)
133
 
134
 
 
 
 
 
 
 
 
135
  def _wxyz_to_rotation(q: np.ndarray) -> R:
136
  return R.from_quat([q[1], q[2], q[3], q[0]])
137
 
138
 
139
+ def _rotation_to_wxyz(r: R) -> np.ndarray:
140
+ x, y, z, w = r.as_quat()
141
+ return np.array([w, x, y, z], dtype=np.float64)
142
+
143
+
144
+ def _right_edge_xy(r: R) -> tuple[float, float]:
145
+ """World-XY of device +X (right edge). Nod about +X leaves this vector still."""
146
+ v = r.apply(np.array([1.0, 0.0, 0.0]))
147
+ return float(v[0]), float(v[1])
148
+
149
+
150
+ def _heading(r: R) -> float:
151
+ """Rotation about world up from the board's right-edge azimuth.
152
+
153
+ Nod (device +X) does not move this vector, so pitch cannot leak into yaw.
154
+ """
155
+ x, y = _right_edge_xy(r)
156
+ return math.atan2(y, x)
157
+
158
+
159
+ def _heading_stable(r: R, heading_last: float | None) -> float:
160
+ """Heading from the right edge; freeze if the board is rolled onto its side."""
161
+ x, y = _right_edge_xy(r)
162
+ if math.hypot(x, y) < HEADING_MIN_HORIZ:
163
+ if heading_last is not None:
164
+ return float(heading_last)
165
+ return math.atan2(y, x)
166
+ return math.atan2(y, x)
167
+
168
+
169
+ def hold_reference(q_device: np.ndarray) -> np.ndarray:
170
+ """Clutch reference with the nominal hold tilt at the device's heading.
171
+
172
+ Gravity pins tilt absolutely; heading is unobservable without a
173
+ magnetometer, so it is latched from the device at engage.
174
+ """
175
+ r_dev = _wxyz_to_rotation(q_device)
176
+ dh = _wrap_delta(_heading(HOLD_TILT), _heading(r_dev))
177
+ return _rotation_to_wxyz(R.from_euler("z", dh) * HOLD_TILT)
178
+
179
+
180
  def _relative_device_rotation(q_ref: np.ndarray, q_device: np.ndarray) -> R:
181
  r_ref = _wxyz_to_rotation(q_ref)
182
  r_dev = _wxyz_to_rotation(q_device)
 
196
 
197
 
198
  def scale_device_rotation(r_rel_dev: R) -> R:
199
+ """Scale rotation about each ESP32 body axis (tilt window; yaw is 1:1)."""
200
  rv = r_rel_dev.as_rotvec()
201
  rv[0] *= FORWARD_GAIN
 
202
  rv[2] *= SIDEWAYS_GAIN
203
  return R.from_rotvec(rv)
204
 
 
209
  return _device_rotation_to_head_rpy(r_rel_dev)
210
 
211
 
212
+ def tilt_head_rp(
 
213
  q_ref: np.ndarray,
214
+ q_device: np.ndarray,
215
+ heading_dev: float,
216
+ ) -> tuple[float, float]:
217
+ """Roll/pitch from relative rotation after stripping world-up heading."""
218
  r_ref = _wxyz_to_rotation(q_ref)
219
+ r_dev = _wxyz_to_rotation(q_device)
220
+ r_ref_unyaw = R.from_euler("z", -_heading(r_ref)) * r_ref
221
+ r_dev_unyaw = R.from_euler("z", -heading_dev) * r_dev
222
+ r_tilt = r_ref_unyaw.inv() * r_dev_unyaw
223
+ roll, pitch, _ = _device_rotation_to_head_rpy(scale_device_rotation(r_tilt))
224
+ return float(roll), float(pitch)
225
 
226
 
227
  def dual_sine(t: float, freq_a: float, freq_b: float) -> float:
 
231
  def clamp_stewart_ellipsoid(
232
  x: float, y: float, z: float, roll: float, pitch: float,
233
  ) -> tuple[float, float, float, float, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  return (
235
+ _clamp(x, LIMIT_HEAD_X_MIN, LIMIT_HEAD_X_MAX),
236
+ _clamp(y, LIMIT_HEAD_Y_MIN, LIMIT_HEAD_Y_MAX),
237
+ _clamp(z, LIMIT_HEAD_Z_MIN, LIMIT_HEAD_Z_MAX),
238
+ _clamp(roll, -ELLIPSOID_ROLL_MAX_RAD, ELLIPSOID_ROLL_MAX_RAD),
239
+ _clamp(pitch, -ELLIPSOID_PITCH_MAX_RAD, ELLIPSOID_PITCH_MAX_RAD),
240
  )
241
 
242
 
 
267
  return out_pose, body_yaw_clamped
268
 
269
 
270
+ def _yaw_positive_stop(body_yaw: float) -> float:
271
+ body_c = _clamp(body_yaw, -LIMIT_BODY_YAW_RAD, LIMIT_BODY_YAW_RAD)
272
+ return min(LIMIT_HEAD_YAW_RAD, body_c + LIMIT_HEAD_BODY_YAW_DELTA_RAD)
273
+
274
+
275
+ def _yaw_negative_stop(body_yaw: float) -> float:
276
+ body_c = _clamp(body_yaw, -LIMIT_BODY_YAW_RAD, LIMIT_BODY_YAW_RAD)
277
+ return max(-LIMIT_HEAD_YAW_RAD, body_c - LIMIT_HEAD_BODY_YAW_DELTA_RAD)
278
+
279
+
280
+ def _yaw_at_positive_stop(yaw: float, body_yaw: float) -> bool:
281
+ return yaw >= _yaw_positive_stop(body_yaw) - YAW_STOP_EPS
282
+
283
+
284
+ def _yaw_at_negative_stop(yaw: float, body_yaw: float) -> bool:
285
+ return yaw <= _yaw_negative_stop(body_yaw) + YAW_STOP_EPS
286
+
287
+
288
+ def _hold_sign_cross(baseline: float, desired: float, zone: float) -> float:
289
+ """Hold `baseline` if a limit-zone command would chase the opposite sign."""
290
+ if abs(baseline) >= zone - YAW_STOP_EPS and baseline * desired < 0.0:
291
+ return baseline
292
+ return desired
293
+
294
+
295
  def _alpha(dt: float, tau: float) -> float:
296
  if tau <= 0.0:
297
  return 1.0
298
  return 1.0 - math.exp(-max(dt, 0.0) / tau)
299
 
300
 
 
 
 
 
 
 
301
  def _pose_rotation(pose: dict[str, float]) -> R:
302
  return R.from_euler(
303
  "xyz", [pose["roll"], pose["pitch"], pose["yaw"]], degrees=False
 
312
  dt: float,
313
  *,
314
  apply_ellipsoid: bool = True,
315
+ max_ang_vel: float = MAX_ANGULAR_VEL,
316
+ max_pos_vel: float = MAX_POS_VEL,
317
  ) -> tuple[dict[str, float], float]:
318
  """Cap one streaming command against the last delivered pose.
319
 
320
+ Roll, pitch, yaw, and body yaw each get an independent angular-velocity
321
+ budget (linear deltas yaw is a bounded axis, not a wrap). Positional
322
+ slews (appear/disappear/reset) use Euclidean distance. `dt` is capped so
323
  stalls/reconnects cannot accumulate permission for a snap.
324
  """
325
  dt_c = min(max(dt, 0.0), MAX_DT_FOR_VEL_CLAMP)
326
+ max_d_ang = max_ang_vel * dt_c
327
+ max_d_pos = max_pos_vel * dt_c
328
  send = {k: float(desired[k]) for k in POSE_AXES}
329
 
330
  dp = np.array([desired[k] - baseline[k] for k in POSITIONAL_AXES], dtype=np.float64)
 
337
  for axis in POSITIONAL_AXES:
338
  send[axis] = desired[axis]
339
 
340
+ hold_yaw = _hold_sign_cross(baseline["yaw"], desired["yaw"], YAW_SIGN_HOLD_ABS)
341
+ hold_body = _hold_sign_cross(baseline_body, desired_body, LIMIT_BODY_YAW_RAD)
342
+
343
  for axis in ("roll", "pitch"):
344
  delta = desired[axis] - baseline[axis]
345
  if abs(delta) > max_d_ang:
346
  send[axis] = baseline[axis] + math.copysign(max_d_ang, delta)
347
  else:
348
  send[axis] = desired[axis]
349
+ yaw_delta = hold_yaw - baseline["yaw"]
350
  if abs(yaw_delta) > max_d_ang:
351
+ send["yaw"] = baseline["yaw"] + math.copysign(max_d_ang, yaw_delta)
352
+ else:
353
+ send["yaw"] = hold_yaw
354
+ body_delta = hold_body - baseline_body
355
  if abs(body_delta) > max_d_ang:
356
  body_delta = math.copysign(max_d_ang, body_delta)
357
  send_body = baseline_body + body_delta
358
+ return clamp_pose_to_daemon_limits(
359
+ send, send_body, apply_ellipsoid=apply_ellipsoid
360
+ )
361
 
362
 
363
  def slew_limit(
 
391
  )
392
  dist = float(np.linalg.norm(dp))
393
  rel = _pose_rotation(from_pose).inv() * _pose_rotation(to_pose)
394
+ # Geodesic treats +π and −π as identical; linear yaw catches a wrap attempt.
395
+ ang = max(float(rel.magnitude()), abs(to_pose["yaw"] - from_pose["yaw"]))
396
+ ang += abs(_wrap_delta(from_body, to_body))
397
  return dist, ang
398
 
399
 
 
417
  was_engaged: bool = False
418
  gain: float = 1.0
419
  ready: bool = False
 
420
 
421
  q_ref: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0]))
422
+ heading_last: float | None = None
423
+ yaw_unwrapped: float = 0.0
424
+ heading_unwrapped: float = 0.0
425
  base_pose: dict[str, float] = field(default_factory=zero_pose)
426
  desired_pose: dict[str, float] = field(default_factory=zero_pose)
427
 
 
507
  consecutive_ik_failures=0,
508
  engaged=False,
509
  was_engaged=False,
510
+ heading_last=None,
511
+ yaw_unwrapped=0.0,
512
+ heading_unwrapped=0.0,
513
  mode="idle" if state.mode != "resetting" else state.mode,
514
  error=None,
515
  )
 
554
  smooth_antennas=[0.0, 0.0],
555
  engaged=False,
556
  was_engaged=False,
557
+ heading_last=None,
558
+ yaw_unwrapped=0.0,
559
+ heading_unwrapped=0.0,
560
  q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
 
561
  sends_frozen=True,
562
  mode="fault",
563
  error="robot pose unread after reset",
 
581
  baseline_antennas=[0.0, 0.0],
582
  engaged=False,
583
  was_engaged=False,
584
+ heading_last=None,
585
+ yaw_unwrapped=0.0,
586
+ heading_unwrapped=0.0,
587
  q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
 
588
  sends_frozen=False,
589
  seeded=True,
590
  mode="idle",
 
606
  antenna_right=0.0,
607
  was_engaged=False,
608
  engaged=False,
609
+ heading_last=None,
610
+ yaw_unwrapped=0.0,
611
+ heading_unwrapped=0.0,
612
  q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
 
613
  behavior_t0=now,
614
  )
615
 
 
667
 
668
 
669
  def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -> ControlState:
670
+ gain = _clamp(float(sample.gain), GAIN_MIN, GAIN_MAX)
671
  q_dev = _finite_quat(sample.q)
 
672
  want = bool(sample.engaged) and bool(sample.ready) and allow_engage
673
  rising = want and not state.was_engaged
674
  falling = (not want) and state.was_engaged
675
 
676
  q_ref = state.q_ref
677
+ heading_last = state.heading_last
678
+ yaw_unwrapped = state.yaw_unwrapped
679
+ heading_unwrapped = state.heading_unwrapped
680
  base = dict(state.base_pose)
681
  desired = dict(state.desired_pose)
682
 
683
  if rising:
684
+ # Snapshot the device attitude as IMU zero: roll/pitch/yaw are
685
+ # relative to this grab. Prior head pose stays in base_pose; x/y/z
686
+ # hold at that base while engaged.
687
  q_ref = q_dev.copy()
688
+ r_dev = _wxyz_to_rotation(q_dev)
689
+ heading_last = _heading_stable(r_dev, None)
690
+ yaw_unwrapped = 0.0
691
+ heading_unwrapped = 0.0
692
 
693
  if want:
694
+ r_dev = _wxyz_to_rotation(q_dev)
695
+ h = _heading_stable(r_dev, heading_last)
696
+ d = _wrap_delta(heading_last, h) if heading_last is not None else 0.0
697
+ heading_unwrapped = heading_unwrapped + d
698
+ heading_last = h
699
+ yaw = heading_unwrapped
700
+ roll, pitch = tilt_head_rp(q_ref, q_dev, h)
701
  desired = {
702
+ "x": base["x"],
703
+ "y": base["y"],
704
+ "z": base["z"],
705
  "roll": base["roll"] + gain * roll,
706
  "pitch": base["pitch"] + gain * pitch,
707
  "yaw": base["yaw"] + gain * yaw,
708
  }
709
+ prev_at_pos = _yaw_at_positive_stop(state.desired_pose["yaw"], state.body_yaw)
710
+ prev_at_neg = _yaw_at_negative_stop(state.desired_pose["yaw"], state.body_yaw)
711
+ if heading_unwrapped > yaw_unwrapped and prev_at_pos:
712
+ desired["yaw"] = base["yaw"] + gain * yaw_unwrapped
713
+ elif heading_unwrapped < yaw_unwrapped and prev_at_neg:
714
+ desired["yaw"] = base["yaw"] + gain * yaw_unwrapped
715
+ else:
716
+ yaw_unwrapped = heading_unwrapped
717
  elif falling:
718
  base = dict(desired)
719
 
 
722
  ready=bool(sample.ready),
723
  gain=gain,
724
  q_ref=q_ref,
725
+ heading_last=heading_last,
726
+ yaw_unwrapped=yaw_unwrapped,
727
+ heading_unwrapped=heading_unwrapped,
728
  base_pose=base,
729
  desired_pose=desired,
730
  engaged=want,
 
732
  )
733
 
734
 
735
+ def advance_antennas(
736
+ state: ControlState,
737
+ now: float,
738
+ dt: float,
739
+ *,
740
+ speed_mult: float = 1.0,
741
+ ) -> ControlState:
742
+ """Advance the idle antenna wiggle toward its dual-sine target."""
743
+ tick_scale = dt / 0.033 if dt > 0 else 1.0
744
+ t = now - state.behavior_t0
745
+ deg = math.pi / 180.0
746
+
747
+ yaw_smoothing = HEAD_MOVE_SPEED * state.gaze_responsiveness * tick_scale
748
+ antenna_smoothing = yaw_smoothing * 1.5
749
+ effective_ant_amp = ANTENNA_AMPLITUDE_DEG * state.antenna_activity * deg
750
+ ant_speed = (0.5 + state.antenna_activity * 0.5) * speed_mult
751
+
752
+ desired_l = dual_sine(t * ant_speed, 1.3, 3.11) * effective_ant_amp
753
+ desired_r = dual_sine(t * ant_speed, 1.7, 2.73) * effective_ant_amp
754
+ ant_l = state.antenna_left + (desired_l - state.antenna_left) * antenna_smoothing
755
+ ant_r = state.antenna_right + (desired_r - state.antenna_right) * antenna_smoothing
756
+
757
+ return replace(state, antenna_left=ant_l, antenna_right=ant_r)
758
+
759
+
760
  def _advance_behavior(state: ControlState, head_yaw: float, now: float, dt: float) -> ControlState:
761
  # Normalize behavior update against the legacy ~33 ms tick using dt scaling.
762
  tick_scale = dt / 0.033 if dt > 0 else 1.0
 
763
  deg = math.pi / 180.0
764
 
765
  yaw_smoothing = HEAD_MOVE_SPEED * state.gaze_responsiveness * tick_scale
766
  max_yaw_delta = MAX_HEAD_DELTA_DEG * state.gaze_responsiveness * deg * tick_scale
767
  body_smoothing = yaw_smoothing * 0.7 * (0.3 + state.liveliness * 0.4)
 
 
 
768
 
769
  body_yaw = state.body_yaw
770
  rel_yaw = head_yaw - body_yaw
 
774
  body_yaw += _clamp(step, -max_yaw_delta, max_yaw_delta)
775
  body_yaw = _clamp(body_yaw, -MAX_BODY_YAW, MAX_BODY_YAW)
776
 
777
+ st = advance_antennas(state, now, dt)
778
+ return replace(st, body_yaw=body_yaw)
 
 
 
 
 
 
 
 
 
779
 
780
 
781
  def step(
 
814
  st = _update_clutch(st, sample, allow_engage=allow_engage)
815
  st = replace(st, have_sample=True)
816
 
 
 
 
817
  apply_ellipsoid = bool(st.engaged)
818
+ # Clamp before body follow so the neck never chases unclamped clutch yaw.
819
+ target_pose, _ = clamp_pose_to_daemon_limits(
820
+ st.desired_pose, st.body_yaw, apply_ellipsoid=apply_ellipsoid
821
+ )
822
+ if st.mode not in {"resetting"}:
823
+ st = _advance_behavior(st, target_pose["yaw"], now, dt)
824
  target_pose, target_body = clamp_pose_to_daemon_limits(
825
  st.desired_pose, st.body_yaw, apply_ellipsoid=apply_ellipsoid
826
  )
esp32_motion_controller/main.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Motion Controller Reachy Mini app entry point (protocol v3).
3
 
4
  FastAPI/uvicorn HTTP on TCP 8766, UDP datagrams on UDP 8766,
5
  mDNS advertise _reachyctl._tcp, clutch + safety bridge.
@@ -55,7 +55,7 @@ def get_local_ips() -> list[str]:
55
 
56
 
57
  class ControllerProtocol(asyncio.DatagramProtocol):
58
- """One UDP socket: discovery probes + protocol v3 datagrams."""
59
 
60
  def __init__(self, session: SessionHub) -> None:
61
  self.session = session
@@ -236,7 +236,7 @@ def create_app(
236
  def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
237
  ips = get_local_ips()
238
  logger.info("=" * 50)
239
- logger.info("ESP32 Motion Controller (protocol v3) app_version=%s", __version__)
240
  logger.info("=" * 50)
241
  for ip in ips:
242
  logger.info(" UDP: %s:%d", ip, LINK_PORT)
 
1
  """
2
+ Motion Controller Reachy Mini app entry point (protocol v4).
3
 
4
  FastAPI/uvicorn HTTP on TCP 8766, UDP datagrams on UDP 8766,
5
  mDNS advertise _reachyctl._tcp, clutch + safety bridge.
 
55
 
56
 
57
  class ControllerProtocol(asyncio.DatagramProtocol):
58
+ """One UDP socket: discovery probes + protocol v4 datagrams."""
59
 
60
  def __init__(self, session: SessionHub) -> None:
61
  self.session = session
 
236
  def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
237
  ips = get_local_ips()
238
  logger.info("=" * 50)
239
+ logger.info("ESP32 Motion Controller (protocol v4) app_version=%s", __version__)
240
  logger.info("=" * 50)
241
  for ip in ips:
242
  logger.info(" UDP: %s:%d", ip, LINK_PORT)
esp32_motion_controller/protocol.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Protocol v3 parsing — dependency-light, no SDK imports.
3
 
4
  UDP datagrams. Rejects oversize frames, wrong types, non-finite numbers,
5
  and unsupported versions.
@@ -12,10 +12,10 @@ import math
12
  from dataclasses import dataclass
13
  from typing import Any
14
 
15
- PROTOCOL_VERSION = 3
16
  MAX_FRAME_BYTES = 512
17
  GAIN_MIN = 0.1
18
- GAIN_MAX = 3.0
19
  LINK_STALE_SEC = 1.0
20
  HELLO_PERIOD_SEC = 2.0
21
 
@@ -65,7 +65,6 @@ class Sample:
65
  boot_id: str
66
  seq: int
67
  q: tuple[float, float, float, float]
68
- p: tuple[float, float, float]
69
  engaged: bool
70
  gain: float
71
  ready: bool
@@ -199,7 +198,6 @@ def parse_hello(msg: dict[str, Any]) -> Hello:
199
  def parse_sample(msg: dict[str, Any]) -> Sample:
200
  request_type = "sample"
201
  q = _require_vec(msg, "q", 4, request_type)
202
- p = _require_vec(msg, "p", 3, request_type)
203
  gain = _require_finite_float(msg, "gain", request_type)
204
  if gain < GAIN_MIN or gain > GAIN_MAX:
205
  raise ProtocolError(
@@ -220,7 +218,6 @@ def parse_sample(msg: dict[str, Any]) -> Sample:
220
  boot_id=_require_str(msg, "boot_id", request_type),
221
  seq=seq,
222
  q=(q[0], q[1], q[2], q[3]),
223
- p=(p[0], p[1], p[2]),
224
  engaged=_require_bool(msg, "engaged", request_type),
225
  gain=gain,
226
  ready=_require_bool(msg, "ready", request_type),
 
1
  """
2
+ Protocol v4 parsing — dependency-light, no SDK imports.
3
 
4
  UDP datagrams. Rejects oversize frames, wrong types, non-finite numbers,
5
  and unsupported versions.
 
12
  from dataclasses import dataclass
13
  from typing import Any
14
 
15
+ PROTOCOL_VERSION = 4
16
  MAX_FRAME_BYTES = 512
17
  GAIN_MIN = 0.1
18
+ GAIN_MAX = 2.0
19
  LINK_STALE_SEC = 1.0
20
  HELLO_PERIOD_SEC = 2.0
21
 
 
65
  boot_id: str
66
  seq: int
67
  q: tuple[float, float, float, float]
 
68
  engaged: bool
69
  gain: float
70
  ready: bool
 
198
  def parse_sample(msg: dict[str, Any]) -> Sample:
199
  request_type = "sample"
200
  q = _require_vec(msg, "q", 4, request_type)
 
201
  gain = _require_finite_float(msg, "gain", request_type)
202
  if gain < GAIN_MIN or gain > GAIN_MAX:
203
  raise ProtocolError(
 
218
  boot_id=_require_str(msg, "boot_id", request_type),
219
  seq=seq,
220
  q=(q[0], q[1], q[2], q[3]),
 
221
  engaged=_require_bool(msg, "engaged", request_type),
222
  gain=gain,
223
  ready=_require_bool(msg, "ready", request_type),
esp32_motion_controller/robot_control.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Single fixed-rate robot command owner for protocol v3.
3
 
4
  Consumes the latest sample from SessionHub, runs the pure reducer, and performs
5
  at most one SDK call in flight. Every outbound pose is capped per tick.
@@ -18,10 +18,15 @@ import numpy as np
18
  from scipy.spatial.transform import Rotation
19
 
20
  from esp32_motion_controller.control import (
 
 
21
  CONTROL_HZ,
 
 
22
  STALE_PACKET_SEC,
23
  Command,
24
  ControlState,
 
25
  begin_reset,
26
  disengaged_rest_pose,
27
  force_disengage,
@@ -137,8 +142,9 @@ class RobotControl:
137
  def _guard_command(self, command: Command, now: float) -> Command:
138
  """Cap (or drop) an incoming pose before it reaches the robot.
139
 
140
- Appear/disappear slews are speed-locked. Streaming jumps larger than
141
- CULL_* are discarded so a stall cannot accumulate into a snap.
 
142
  """
143
  del now
144
  ref_pose = (
@@ -166,6 +172,7 @@ class RobotControl:
166
  body_yaw=ref_body,
167
  antennas=command.antennas,
168
  )
 
169
  pose, body = speed_lock(
170
  ref_pose,
171
  ref_body,
@@ -173,6 +180,8 @@ class RobotControl:
173
  command.body_yaw,
174
  self.dt,
175
  apply_ellipsoid=self._state.engaged and self._anim is None,
 
 
176
  )
177
  return Command(pose=pose, body_yaw=body, antennas=command.antennas)
178
 
@@ -182,14 +191,30 @@ class RobotControl:
182
  self._anim_body = float(body)
183
  if kind in {"disappear", "reset"}:
184
  self._state = force_disengage(self._state)
 
 
 
 
 
 
 
 
 
185
 
186
  async def _tick_anim(
187
- self, loop: asyncio.AbstractEventLoop, now: float
188
  ) -> None:
 
 
 
 
 
 
 
189
  command = Command(
190
  pose=dict(self._anim_pose),
191
  body_yaw=self._anim_body,
192
- antennas=(0.0, 0.0),
193
  )
194
  sent = await self._send_command(loop, now, command, log_sample=False)
195
  if sent is None:
@@ -205,6 +230,10 @@ class RobotControl:
205
  desired_pose=zero_pose(),
206
  base_pose=zero_pose(),
207
  body_yaw=0.0,
 
 
 
 
208
  )
209
  elif kind == "disappear":
210
  self._posture = "ducked"
@@ -376,7 +405,7 @@ class RobotControl:
376
  self._begin_anim("disappear", disengaged_rest_pose(), 0.0)
377
 
378
  if self._anim is not None:
379
- await self._tick_anim(loop, now)
380
  return
381
 
382
  # Idle reconciliation when disengaged and not frozen.
 
1
  """
2
+ Single fixed-rate robot command owner for protocol v4.
3
 
4
  Consumes the latest sample from SessionHub, runs the pure reducer, and performs
5
  at most one SDK call in flight. Every outbound pose is capped per tick.
 
18
  from scipy.spatial.transform import Rotation
19
 
20
  from esp32_motion_controller.control import (
21
+ ANIM_VEL_MULT,
22
+ APPEAR_ANTENNA_SPEED_MULT,
23
  CONTROL_HZ,
24
+ MAX_ANGULAR_VEL,
25
+ MAX_POS_VEL,
26
  STALE_PACKET_SEC,
27
  Command,
28
  ControlState,
29
+ advance_antennas,
30
  begin_reset,
31
  disengaged_rest_pose,
32
  force_disengage,
 
142
  def _guard_command(self, command: Command, now: float) -> Command:
143
  """Cap (or drop) an incoming pose before it reaches the robot.
144
 
145
+ Appear/disappear slews are speed-locked (faster than streaming).
146
+ Streaming jumps larger than CULL_* are discarded so a stall cannot
147
+ accumulate into a snap.
148
  """
149
  del now
150
  ref_pose = (
 
172
  body_yaw=ref_body,
173
  antennas=command.antennas,
174
  )
175
+ anim_slew = self._anim in {"appear", "disappear"}
176
  pose, body = speed_lock(
177
  ref_pose,
178
  ref_body,
 
180
  command.body_yaw,
181
  self.dt,
182
  apply_ellipsoid=self._state.engaged and self._anim is None,
183
+ max_ang_vel=MAX_ANGULAR_VEL * ANIM_VEL_MULT if anim_slew else MAX_ANGULAR_VEL,
184
+ max_pos_vel=MAX_POS_VEL * ANIM_VEL_MULT if anim_slew else MAX_POS_VEL,
185
  )
186
  return Command(pose=pose, body_yaw=body, antennas=command.antennas)
187
 
 
191
  self._anim_body = float(body)
192
  if kind in {"disappear", "reset"}:
193
  self._state = force_disengage(self._state)
194
+ elif kind == "appear":
195
+ now = time.monotonic()
196
+ self._state = replace(
197
+ self._state,
198
+ behavior_t0=now,
199
+ antenna_left=0.0,
200
+ antenna_right=0.0,
201
+ smooth_antennas=[0.0, 0.0],
202
+ )
203
 
204
  async def _tick_anim(
205
+ self, loop: asyncio.AbstractEventLoop, now: float, dt: float
206
  ) -> None:
207
+ antennas = (0.0, 0.0)
208
+ if self._anim == "appear":
209
+ self._state = advance_antennas(
210
+ self._state, now, dt, speed_mult=APPEAR_ANTENNA_SPEED_MULT
211
+ )
212
+ antennas = (self._state.antenna_left, self._state.antenna_right)
213
+
214
  command = Command(
215
  pose=dict(self._anim_pose),
216
  body_yaw=self._anim_body,
217
+ antennas=antennas,
218
  )
219
  sent = await self._send_command(loop, now, command, log_sample=False)
220
  if sent is None:
 
230
  desired_pose=zero_pose(),
231
  base_pose=zero_pose(),
232
  body_yaw=0.0,
233
+ smooth_antennas=[
234
+ self._state.antenna_left,
235
+ self._state.antenna_right,
236
+ ],
237
  )
238
  elif kind == "disappear":
239
  self._posture = "ducked"
 
405
  self._begin_anim("disappear", disengaged_rest_pose(), 0.0)
406
 
407
  if self._anim is not None:
408
+ await self._tick_anim(loop, now, dt)
409
  return
410
 
411
  # Idle reconciliation when disengaged and not frozen.
esp32_motion_controller/session.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- UDP session ingress for protocol v3.
3
 
4
  Validates datagrams, keeps only the latest sample, tracks the peer by
5
  address + last_rx. Never calls the robot SDK.
@@ -16,6 +16,7 @@ from typing import Any, Awaitable, Callable
16
 
17
  from esp32_motion_controller.protocol import (
18
  LINK_STALE_SEC,
 
19
  Hello,
20
  LinkDiag,
21
  ProtocolError,
@@ -139,7 +140,7 @@ class SessionHub:
139
  async def _maybe_reseed(self, boot_id: str, hello: Hello | None) -> None:
140
  if self.on_hello is None:
141
  return
142
- payload = hello or Hello(protocol_version=3, boot_id=boot_id, device="esp32")
143
  await self.on_hello(payload)
144
 
145
  def _reset_fields(self, boot_id: str, op: int | None) -> tuple[int | None, str | None]:
 
1
  """
2
+ UDP session ingress for protocol v4.
3
 
4
  Validates datagrams, keeps only the latest sample, tracks the peer by
5
  address + last_rx. Never calls the robot SDK.
 
16
 
17
  from esp32_motion_controller.protocol import (
18
  LINK_STALE_SEC,
19
+ PROTOCOL_VERSION,
20
  Hello,
21
  LinkDiag,
22
  ProtocolError,
 
140
  async def _maybe_reseed(self, boot_id: str, hello: Hello | None) -> None:
141
  if self.on_hello is None:
142
  return
143
+ payload = hello or Hello(protocol_version=PROTOCOL_VERSION, boot_id=boot_id, device="esp32")
144
  await self.on_hello(payload)
145
 
146
  def _reset_fields(self, boot_id: str, op: int | None) -> tuple[int | None, str | None]:
esp32_motion_controller/static/index.html CHANGED
@@ -12,7 +12,7 @@
12
  </head>
13
  <body>
14
  <h1>🕹️ ESP32 Motion Controller</h1>
15
- <p>ESP32 handheld IMU controller bridge for Reachy Mini (protocol v3).</p>
16
  <p>UDP: <code id="udp">…:8766</code></p>
17
  <p><a href="/api/info">/api/info</a> · <a href="/api/status">/api/status</a></p>
18
  <script>
 
12
  </head>
13
  <body>
14
  <h1>🕹️ ESP32 Motion Controller</h1>
15
+ <p>ESP32 handheld IMU controller bridge for Reachy Mini (protocol v4).</p>
16
  <p>UDP: <code id="udp">…:8766</code></p>
17
  <p><a href="/api/info">/api/info</a> · <a href="/api/status">/api/status</a></p>
18
  <script>
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "esp32_motion_controller"
7
- version = "3.0.1"
8
  description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
4
 
5
  [project]
6
  name = "esp32_motion_controller"
7
+ version = "3.1.6"
8
  description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"