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

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -10,15 +10,15 @@ 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 (protocol v2) 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 v2.
22
 
23
  ## Local install
24
 
@@ -27,8 +27,7 @@ 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
 
 
10
  - reachy_mini
11
  - reachy_mini_python_app
12
  - esp32
13
+ - udp
14
  - imu
15
  ---
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
 
 
27
  python -m esp32_motion_controller.main
28
  ```
29
 
30
+ UDP: `<host>:8766`
 
31
 
32
  Live Space: https://huggingface.co/spaces/V4C38/esp32_motion_controller
33
 
esp32_motion_controller/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- """ESP32 Motion Controller Reachy Mini app (protocol v2)."""
2
 
3
- __version__ = "2.0.0"
 
1
+ """ESP32 Motion Controller Reachy Mini app (protocol v3)."""
2
 
3
+ __version__ = "3.0.0"
esp32_motion_controller/behavior.py DELETED
@@ -1,100 +0,0 @@
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/control.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Pure control reducer for protocol v2.
3
 
4
  Owns clutch mapping, body follow, antenna phase, workspace projection, smoothing,
5
  stale release, and slew limiting. No I/O.
@@ -32,7 +32,16 @@ DEV_TO_HEAD = np.array(
32
  TRANSLATION_SCALE = 0.30
33
  TRANSLATION_GAIN_DEFAULT = 1.0
34
 
35
- STALE_PACKET_SEC = 0.300
 
 
 
 
 
 
 
 
 
36
  CONTROL_HZ = 20.0
37
  CONTROL_DT = 1.0 / CONTROL_HZ
38
 
@@ -40,8 +49,9 @@ CONTROL_DT = 1.0 / CONTROL_HZ
40
  POSE_TAU_SEC = 0.255
41
  ANTENNA_TAU_SEC = 0.39
42
 
43
- MAX_ANGULAR_VEL = 1.5 # rad/s
44
- MAX_POS_VEL = 0.05 # m/s
 
45
  MAX_DT_FOR_VEL_CLAMP = 0.05
46
 
47
  LIMIT_BODY_YAW_RAD = 160.0 * math.pi / 180.0
@@ -75,6 +85,13 @@ ANTENNA_AMPLITUDE_DEG = 15.0
75
  IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
76
  IK_FAIL_CONSECUTIVE_THRESHOLD = 3
77
 
 
 
 
 
 
 
 
78
  Mode = str # idle | engaged | resetting | fault
79
 
80
 
@@ -82,6 +99,13 @@ def zero_pose() -> dict[str, float]:
82
  return {k: 0.0 for k in POSE_AXES}
83
 
84
 
 
 
 
 
 
 
 
85
  def _clamp(value: float, lo: float, hi: float) -> float:
86
  return max(lo, min(hi, value))
87
 
@@ -104,16 +128,39 @@ def _wxyz_to_rotation(q: np.ndarray) -> R:
104
  return R.from_quat([q[1], q[2], q[3], q[0]])
105
 
106
 
107
- def quat_relative_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, float, float]:
108
  r_ref = _wxyz_to_rotation(q_ref)
109
  r_dev = _wxyz_to_rotation(q_device)
110
- r_rel_dev = r_ref.inv() * r_dev
 
 
 
111
  m = R.from_matrix(DEV_TO_HEAD)
112
  r_head = m * r_rel_dev * m.inv()
113
  roll, pitch, yaw = r_head.as_euler("xyz", degrees=False)
114
  return float(roll), float(pitch), float(yaw)
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def remap_displacement(
118
  p_world_delta: np.ndarray,
119
  q_ref: np.ndarray,
@@ -163,17 +210,21 @@ def clamp_stewart_ellipsoid(
163
 
164
 
165
  def clamp_pose_to_daemon_limits(
166
- pose: dict[str, float], body_yaw: float
 
 
 
167
  ) -> tuple[dict[str, float], float]:
168
  out_pose = dict(pose)
169
- cx, cy, cz, cr, cp = clamp_stewart_ellipsoid(
170
- pose["x"], pose["y"], pose["z"], pose["roll"], pose["pitch"],
171
- )
172
- out_pose["x"] = cx
173
- out_pose["y"] = cy
174
- out_pose["z"] = cz
175
- out_pose["roll"] = cr
176
- out_pose["pitch"] = cp
 
177
 
178
  body_yaw_clamped = _clamp(body_yaw, -LIMIT_BODY_YAW_RAD, LIMIT_BODY_YAW_RAD)
179
  out_pose["yaw"] = _clamp(pose["yaw"], -LIMIT_HEAD_YAW_RAD, LIMIT_HEAD_YAW_RAD)
@@ -191,26 +242,110 @@ def _alpha(dt: float, tau: float) -> float:
191
  return 1.0 - math.exp(-max(dt, 0.0) / tau)
192
 
193
 
194
- def slew_limit(
 
 
 
 
 
 
 
 
 
 
 
 
195
  baseline: dict[str, float],
196
  baseline_body: float,
197
  desired: dict[str, float],
198
  desired_body: float,
199
  dt: float,
 
 
200
  ) -> tuple[dict[str, float], float]:
 
 
 
 
 
 
 
201
  dt_c = min(max(dt, 0.0), MAX_DT_FOR_VEL_CLAMP)
202
  max_d_ang = MAX_ANGULAR_VEL * dt_c
203
  max_d_pos = MAX_POS_VEL * dt_c
204
- send = {}
205
- for axis in ANGULAR_AXES:
206
- delta = desired[axis] - baseline[axis]
207
- send[axis] = baseline[axis] + _clamp(delta, -max_d_ang, max_d_ang)
208
- for axis in POSITIONAL_AXES:
 
 
 
 
 
 
 
 
209
  delta = desired[axis] - baseline[axis]
210
- send[axis] = baseline[axis] + _clamp(delta, -max_d_pos, max_d_pos)
211
- body_delta = desired_body - baseline_body
212
- send_body = baseline_body + _clamp(body_delta, -max_d_ang, max_d_ang)
213
- return clamp_pose_to_daemon_limits(send, send_body)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
 
216
  @dataclass
@@ -285,11 +420,14 @@ def seed_from_pose(
285
  body_yaw: float,
286
  *,
287
  antennas: Sequence[float] | None = None,
 
288
  ) -> ControlState:
289
  ants = list(antennas) if antennas is not None else [0.0, 0.0]
290
  ants = (ants + [0.0, 0.0])[:2]
291
  pose_c, body_c = clamp_pose_to_daemon_limits(
292
- {k: float(pose.get(k, 0.0)) for k in POSE_AXES}, float(body_yaw)
 
 
293
  )
294
  return replace(
295
  state,
@@ -316,16 +454,38 @@ def seed_from_pose(
316
 
317
  def rebase_neutral(state: ControlState, *, measured_baseline: dict[str, float] | None,
318
  measured_body: float | None) -> ControlState:
319
- """After reset completion: targets at neutral; baseline from measured pose if provided."""
320
- neutral = zero_pose()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  if measured_baseline is None or measured_body is None:
322
  return replace(
323
  state,
324
- base_pose=dict(neutral),
325
- desired_pose=dict(neutral),
326
- smooth_pose=dict(neutral),
327
- body_yaw=0.0,
328
- smooth_body_yaw=0.0,
329
  antenna_left=0.0,
330
  antenna_right=0.0,
331
  smooth_antennas=[0.0, 0.0],
@@ -338,16 +498,18 @@ def rebase_neutral(state: ControlState, *, measured_baseline: dict[str, float] |
338
  error="robot pose unread after reset",
339
  behavior_t0=state.behavior_t0,
340
  )
341
- pose_c, body_c = clamp_pose_to_daemon_limits(measured_baseline, measured_body)
 
 
342
  return replace(
343
  state,
344
- base_pose=dict(neutral),
345
- desired_pose=dict(neutral),
346
- smooth_pose=dict(neutral),
347
  baseline_pose=dict(pose_c),
348
- body_yaw=0.0,
349
- smooth_body_yaw=0.0,
350
- baseline_body_yaw=body_c,
351
  antenna_left=0.0,
352
  antenna_right=0.0,
353
  smooth_antennas=[0.0, 0.0],
@@ -453,7 +615,7 @@ def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -
453
  p_ref = p_dev.copy()
454
 
455
  if want:
456
- roll, pitch, yaw = quat_relative_rpy(q_ref, q_dev)
457
  disp = remap_displacement(
458
  p_dev - p_ref,
459
  q_ref,
@@ -524,11 +686,13 @@ def step(
524
  dt: float,
525
  sample: Sample | None,
526
  sample_is_fresh: bool,
 
527
  ) -> StepResult:
528
  """Advance one control tick.
529
 
530
  `sample` is the latest validated sample (may be None before first packet).
531
  `sample_is_fresh` is True only when a new sample arrived since the previous tick.
 
532
  """
533
  prev_mode = state.mode
534
  st = state
@@ -537,10 +701,14 @@ def step(
537
  # No streaming commands while reset owns the robot.
538
  return StepResult(state=st, command=None, mode_changed=False)
539
 
540
- # Stale detection uses host receipt time stamped on the sample mailbox.
541
- if st.have_sample and st.last_sample_time > 0:
542
- if now - st.last_sample_time > STALE_PACKET_SEC and st.engaged:
543
- st = force_disengage(st)
 
 
 
 
544
 
545
  if sample is not None and sample_is_fresh and st.mode != "resetting":
546
  allow_engage = st.mode != "fault" and not st.sends_frozen
@@ -550,11 +718,16 @@ def step(
550
  if st.mode not in {"resetting"}:
551
  st = _advance_behavior(st, st.desired_pose["yaw"], now, dt)
552
 
553
- target_pose, target_body = clamp_pose_to_daemon_limits(st.desired_pose, st.body_yaw)
 
 
 
554
  target_ants = [st.antenna_left, st.antenna_right]
555
 
556
  # Elapsed-time-normalized smoothing (replaces fixed 30 Hz POSE_ALPHA).
557
- a_pose = _alpha(dt, POSE_TAU_SEC)
 
 
558
  a_ant = _alpha(dt, ANTENNA_TAU_SEC)
559
  smooth = {
560
  k: st.smooth_pose[k] + a_pose * (target_pose[k] - st.smooth_pose[k])
@@ -587,12 +760,13 @@ def step(
587
  mode_changed=(st.mode != prev_mode),
588
  )
589
 
590
- send_pose, send_body = slew_limit(
591
  st.baseline_pose,
592
  st.baseline_body_yaw,
593
  smooth,
594
  smooth_body,
595
  dt,
 
596
  )
597
  command = Command(
598
  pose=send_pose,
 
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.
 
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
47
 
 
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
 
85
  IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
86
  IK_FAIL_CONSECUTIVE_THRESHOLD = 3
87
 
88
+ # Disengaged rest: 5 cm down from reset, slight nod.
89
+ DISENGAGED_Z = -0.050
90
+ DISENGAGED_PITCH = math.radians(5.0)
91
+
92
+ NEAR_POSE_EPS_M = 0.005
93
+ NEAR_POSE_EPS_RAD = math.radians(3.0)
94
+
95
  Mode = str # idle | engaged | resetting | fault
96
 
97
 
 
99
  return {k: 0.0 for k in POSE_AXES}
100
 
101
 
102
+ def disengaged_rest_pose() -> dict[str, float]:
103
+ pose = zero_pose()
104
+ pose["z"] = DISENGAGED_Z
105
+ pose["pitch"] = DISENGAGED_PITCH
106
+ return pose
107
+
108
+
109
  def _clamp(value: float, lo: float, hi: float) -> float:
110
  return max(lo, min(hi, value))
111
 
 
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)
134
+ return r_ref.inv() * r_dev
135
+
136
+
137
+ def _device_rotation_to_head_rpy(r_rel_dev: R) -> tuple[float, float, float]:
138
  m = R.from_matrix(DEV_TO_HEAD)
139
  r_head = m * r_rel_dev * m.inv()
140
  roll, pitch, yaw = r_head.as_euler("xyz", degrees=False)
141
  return float(roll), float(pitch), float(yaw)
142
 
143
 
144
+ def quat_relative_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, float, float]:
145
+ r_rel_dev = _relative_device_rotation(q_ref, q_device)
146
+ return _device_rotation_to_head_rpy(r_rel_dev)
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
+
157
+
158
+ def relative_head_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, float, float]:
159
+ """Device-to-head rpy with per-axis physical-motion gains."""
160
+ r_rel_dev = scale_device_rotation(_relative_device_rotation(q_ref, q_device))
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,
 
210
 
211
 
212
  def clamp_pose_to_daemon_limits(
213
+ pose: dict[str, float],
214
+ body_yaw: float,
215
+ *,
216
+ apply_ellipsoid: bool = True,
217
  ) -> tuple[dict[str, float], float]:
218
  out_pose = dict(pose)
219
+ if apply_ellipsoid:
220
+ cx, cy, cz, cr, cp = clamp_stewart_ellipsoid(
221
+ pose["x"], pose["y"], pose["z"], pose["roll"], pose["pitch"],
222
+ )
223
+ out_pose["x"] = cx
224
+ out_pose["y"] = cy
225
+ out_pose["z"] = cz
226
+ out_pose["roll"] = cr
227
+ out_pose["pitch"] = cp
228
 
229
  body_yaw_clamped = _clamp(body_yaw, -LIMIT_BODY_YAW_RAD, LIMIT_BODY_YAW_RAD)
230
  out_pose["yaw"] = _clamp(pose["yaw"], -LIMIT_HEAD_YAW_RAD, LIMIT_HEAD_YAW_RAD)
 
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
254
+ )
255
+
256
+
257
+ def speed_lock(
258
  baseline: dict[str, float],
259
  baseline_body: float,
260
  desired: dict[str, float],
261
  desired_body: float,
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)
279
+ dist = float(np.linalg.norm(dp))
280
+ if dist > max_d_pos and dist > 0.0:
281
+ scale = max_d_pos / dist
282
+ for i, axis in enumerate(POSITIONAL_AXES):
283
+ send[axis] = baseline[axis] + float(dp[i]) * scale
284
+ else:
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(
307
+ baseline: dict[str, float],
308
+ baseline_body: float,
309
+ desired: dict[str, float],
310
+ desired_body: float,
311
+ dt: float,
312
+ *,
313
+ apply_ellipsoid: bool = True,
314
+ ) -> tuple[dict[str, float], float]:
315
+ return speed_lock(
316
+ baseline,
317
+ baseline_body,
318
+ desired,
319
+ desired_body,
320
+ dt,
321
+ apply_ellipsoid=apply_ellipsoid,
322
+ )
323
+
324
+
325
+ def pose_travel(
326
+ from_pose: dict[str, float],
327
+ to_pose: dict[str, float],
328
+ from_body: float,
329
+ to_body: float,
330
+ ) -> tuple[float, float]:
331
+ """Return (euclidean_m, geodesic_plus_body_rad) between two poses."""
332
+ dp = np.array(
333
+ [to_pose[k] - from_pose[k] for k in POSITIONAL_AXES], dtype=np.float64
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
+
341
+ def pose_near(
342
+ from_pose: dict[str, float],
343
+ to_pose: dict[str, float],
344
+ from_body: float = 0.0,
345
+ to_body: float = 0.0,
346
+ ) -> bool:
347
+ dist, ang = pose_travel(from_pose, to_pose, from_body, to_body)
348
+ return dist < NEAR_POSE_EPS_M and ang < NEAR_POSE_EPS_RAD
349
 
350
 
351
  @dataclass
 
420
  body_yaw: float,
421
  *,
422
  antennas: Sequence[float] | None = None,
423
+ apply_ellipsoid: bool = True,
424
  ) -> ControlState:
425
  ants = list(antennas) if antennas is not None else [0.0, 0.0]
426
  ants = (ants + [0.0, 0.0])[:2]
427
  pose_c, body_c = clamp_pose_to_daemon_limits(
428
+ {k: float(pose.get(k, 0.0)) for k in POSE_AXES},
429
+ float(body_yaw),
430
+ apply_ellipsoid=apply_ellipsoid,
431
  )
432
  return replace(
433
  state,
 
454
 
455
  def rebase_neutral(state: ControlState, *, measured_baseline: dict[str, float] | None,
456
  measured_body: float | None) -> ControlState:
457
+ """After appear / settings reset: targets at neutral; baseline from measured pose."""
458
+ return rebase_to_pose(
459
+ state,
460
+ target=zero_pose(),
461
+ target_body=0.0,
462
+ measured_baseline=measured_baseline,
463
+ measured_body=measured_body,
464
+ )
465
+
466
+
467
+ def rebase_to_pose(
468
+ state: ControlState,
469
+ *,
470
+ target: dict[str, float],
471
+ target_body: float,
472
+ measured_baseline: dict[str, float] | None,
473
+ measured_body: float | None,
474
+ ) -> ControlState:
475
+ """After exclusive goto: hold `target`; baseline from measured pose if provided."""
476
+ target_c, body_c = clamp_pose_to_daemon_limits(
477
+ {k: float(target.get(k, 0.0)) for k in POSE_AXES},
478
+ float(target_body),
479
+ apply_ellipsoid=False,
480
+ )
481
  if measured_baseline is None or measured_body is None:
482
  return replace(
483
  state,
484
+ base_pose=dict(target_c),
485
+ desired_pose=dict(target_c),
486
+ smooth_pose=dict(target_c),
487
+ body_yaw=body_c,
488
+ smooth_body_yaw=body_c,
489
  antenna_left=0.0,
490
  antenna_right=0.0,
491
  smooth_antennas=[0.0, 0.0],
 
498
  error="robot pose unread after reset",
499
  behavior_t0=state.behavior_t0,
500
  )
501
+ pose_c, meas_body = clamp_pose_to_daemon_limits(
502
+ measured_baseline, measured_body, apply_ellipsoid=False
503
+ )
504
  return replace(
505
  state,
506
+ base_pose=dict(target_c),
507
+ desired_pose=dict(target_c),
508
+ smooth_pose=dict(target_c),
509
  baseline_pose=dict(pose_c),
510
+ body_yaw=body_c,
511
+ smooth_body_yaw=body_c,
512
+ baseline_body_yaw=meas_body,
513
  antenna_left=0.0,
514
  antenna_right=0.0,
515
  smooth_antennas=[0.0, 0.0],
 
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,
 
686
  dt: float,
687
  sample: Sample | None,
688
  sample_is_fresh: bool,
689
+ controller_present: bool = True,
690
  ) -> StepResult:
691
  """Advance one control tick.
692
 
693
  `sample` is the latest validated sample (may be None before first packet).
694
  `sample_is_fresh` is True only when a new sample arrived since the previous tick.
695
+ Age-based stale release runs only when the controller socket is down.
696
  """
697
  prev_mode = state.mode
698
  st = state
 
701
  # No streaming commands while reset owns the robot.
702
  return StepResult(state=st, command=None, mode_changed=False)
703
 
704
+ if (
705
+ not controller_present
706
+ and st.have_sample
707
+ and st.last_sample_time > 0
708
+ and now - st.last_sample_time > STALE_PACKET_SEC
709
+ and st.engaged
710
+ ):
711
+ st = force_disengage(st)
712
 
713
  if sample is not None and sample_is_fresh and st.mode != "resetting":
714
  allow_engage = st.mode != "fault" and not st.sends_frozen
 
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
+ )
725
  target_ants = [st.antenna_left, st.antenna_right]
726
 
727
  # Elapsed-time-normalized smoothing (replaces fixed 30 Hz POSE_ALPHA).
728
+ # Engaged streaming skips pose/body EMA so a new tilt is applied this tick;
729
+ # the per-axis speed lock is the snap guard.
730
+ a_pose = 1.0 if st.engaged else _alpha(dt, POSE_TAU_SEC)
731
  a_ant = _alpha(dt, ANTENNA_TAU_SEC)
732
  smooth = {
733
  k: st.smooth_pose[k] + a_pose * (target_pose[k] - st.smooth_pose[k])
 
760
  mode_changed=(st.mode != prev_mode),
761
  )
762
 
763
+ send_pose, send_body = speed_lock(
764
  st.baseline_pose,
765
  st.baseline_body_yaw,
766
  smooth,
767
  smooth_body,
768
  dt,
769
+ apply_ellipsoid=apply_ellipsoid,
770
  )
771
  command = Command(
772
  pose=send_pose,
esp32_motion_controller/controller_state.py DELETED
@@ -1,197 +0,0 @@
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 CHANGED
@@ -1,12 +1,14 @@
1
  """
2
- Motion Controller Reachy Mini app entry point (protocol v2).
3
 
4
- FastAPI/uvicorn on port 8766, mDNS advertise _reachyctl._tcp, clutch + safety bridge.
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import argparse
 
10
  import logging
11
  import socket
12
  import sys
@@ -15,19 +17,22 @@ import time
15
  from pathlib import Path
16
 
17
  import uvicorn
18
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
19
  from fastapi.responses import FileResponse, JSONResponse
20
  from fastapi.staticfiles import StaticFiles
21
 
 
22
  from esp32_motion_controller.robot_control import RobotControl, RobotGateway
23
  from esp32_motion_controller.session import SessionHub
 
24
 
25
  logger = logging.getLogger(__name__)
26
 
27
- WS_PORT = 8766
28
  STATIC_DIR = Path(__file__).parent / "static"
29
  MDNS_SERVICE_TYPE = "_reachyctl._tcp.local."
30
  SERVER_START_TIMEOUT_S = 10.0
 
31
 
32
 
33
  def get_local_ips() -> list[str]:
@@ -49,8 +54,40 @@ def get_local_ips() -> list[str]:
49
  return list(dict.fromkeys(ips))
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  class MdnsAdvertiser:
53
- def __init__(self, port: int = WS_PORT) -> None:
54
  self.port = port
55
  self._zc = None
56
  self._info = None
@@ -74,7 +111,7 @@ class MdnsAdvertiser:
74
  f"esp32-motion-controller.{MDNS_SERVICE_TYPE}",
75
  addresses=[socket.inet_aton(ip) for ip in ips],
76
  port=self.port,
77
- properties={"path": b"/ws", "protocol": b"2"},
78
  server="esp32-motion.local.",
79
  )
80
  zc = Zeroconf()
@@ -126,32 +163,27 @@ def create_app(
126
  robot_available=robot_available,
127
  log_only=log_only,
128
  )
 
129
  app.state.session = session
130
  app.state.control = control
131
 
132
  @app.on_event("startup")
133
  async def _startup() -> None:
 
 
 
 
 
 
 
134
  control.start()
135
 
136
  @app.on_event("shutdown")
137
  async def _shutdown() -> None:
138
  await control.stop()
139
-
140
- @app.websocket("/ws")
141
- async def websocket_endpoint(websocket: WebSocket) -> None:
142
- generation = await session.on_connect(websocket)
143
- await control.on_controller_connected()
144
- try:
145
- while not stop_event.is_set():
146
- raw = await websocket.receive_text()
147
- await session.handle_message(websocket, generation, raw)
148
- except WebSocketDisconnect:
149
- logger.info("WebSocket disconnect")
150
- except Exception as exc:
151
- logger.error("WebSocket error: %s", exc)
152
- finally:
153
- await session.cleanup(websocket, generation)
154
- await control.on_controller_disconnected()
155
 
156
  @app.get("/api/info")
157
  async def info() -> JSONResponse:
@@ -159,10 +191,10 @@ def create_app(
159
  return JSONResponse(
160
  {
161
  "ips": ips,
162
- "port": WS_PORT,
163
- "ws_url": f"ws://{ips[0]}:{WS_PORT}/ws" if ips else None,
164
  "mdns": MDNS_SERVICE_TYPE,
165
- "protocol_version": 2,
166
  }
167
  )
168
 
@@ -177,7 +209,19 @@ def create_app(
177
  "mode": snap["mode"],
178
  "connected": snap["connected"],
179
  "log_only": log_only,
180
- "protocol_version": 2,
 
 
 
 
 
 
 
 
 
 
 
 
181
  }
182
  )
183
 
@@ -192,16 +236,21 @@ def create_app(
192
  def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
193
  ips = get_local_ips()
194
  logger.info("=" * 50)
195
- logger.info("ESP32 Motion Controller (protocol v2)")
196
  logger.info("=" * 50)
197
  for ip in ips:
198
- logger.info(" WebSocket: ws://%s:%d/ws", ip, WS_PORT)
199
  if log_only:
200
  logger.info(" Mode: --log-only (no robot SDK calls)")
201
  logger.info("=" * 50)
202
 
203
  app = create_app(reachy_mini, stop_event, log_only=log_only)
204
- config = uvicorn.Config(app, host="0.0.0.0", port=WS_PORT, log_level="info")
 
 
 
 
 
205
  server = uvicorn.Server(config)
206
  thread = threading.Thread(target=server.run, daemon=True)
207
  thread.start()
@@ -211,15 +260,14 @@ def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) ->
211
  time.sleep(0.05)
212
  if not server.started:
213
  raise RuntimeError(
214
- f"Could not serve on port {WS_PORT} — another Motion Controller "
215
  f"instance is probably already running"
216
  )
217
 
218
- mdns = MdnsAdvertiser(WS_PORT)
219
  mdns.start()
220
 
221
  stop_event.wait()
222
- # Shutdown path: uvicorn will fire FastAPI shutdown hooks.
223
  server.should_exit = True
224
  thread.join(timeout=5)
225
  mdns.stop()
 
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.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  import argparse
11
+ import asyncio
12
  import logging
13
  import socket
14
  import sys
 
17
  from pathlib import Path
18
 
19
  import uvicorn
20
+ from fastapi import FastAPI
21
  from fastapi.responses import FileResponse, JSONResponse
22
  from fastapi.staticfiles import StaticFiles
23
 
24
+ from esp32_motion_controller.protocol import PROTOCOL_VERSION
25
  from esp32_motion_controller.robot_control import RobotControl, RobotGateway
26
  from esp32_motion_controller.session import SessionHub
27
+ from esp32_motion_controller import __version__
28
 
29
  logger = logging.getLogger(__name__)
30
 
31
+ LINK_PORT = 8766
32
  STATIC_DIR = Path(__file__).parent / "static"
33
  MDNS_SERVICE_TYPE = "_reachyctl._tcp.local."
34
  SERVER_START_TIMEOUT_S = 10.0
35
+ UDP_PROBE = b"RMC2?"
36
 
37
 
38
  def get_local_ips() -> list[str]:
 
54
  return list(dict.fromkeys(ips))
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
62
+ self.transport: asyncio.DatagramTransport | None = None
63
+
64
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
65
+ self.transport = transport # type: ignore[assignment]
66
+ self.session.bind_transport(transport)
67
+
68
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
69
+ if data.startswith(UDP_PROBE):
70
+ self._reply_probe(addr)
71
+ return
72
+ asyncio.create_task(self.session.handle_datagram(data, addr))
73
+
74
+ def error_received(self, exc: Exception) -> None:
75
+ logger.warning("UDP error: %s", exc)
76
+
77
+ def _reply_probe(self, addr: tuple[str, int]) -> None:
78
+ if self.transport is None:
79
+ return
80
+ ips = get_local_ips()
81
+ if not ips:
82
+ return
83
+ try:
84
+ self.transport.sendto(f"RMC2 {ips[0]} {LINK_PORT}".encode(), addr)
85
+ except OSError:
86
+ pass
87
+
88
+
89
  class MdnsAdvertiser:
90
+ def __init__(self, port: int = LINK_PORT) -> None:
91
  self.port = port
92
  self._zc = None
93
  self._info = None
 
111
  f"esp32-motion-controller.{MDNS_SERVICE_TYPE}",
112
  addresses=[socket.inet_aton(ip) for ip in ips],
113
  port=self.port,
114
+ properties={"protocol": b"3", "transport": b"udp"},
115
  server="esp32-motion.local.",
116
  )
117
  zc = Zeroconf()
 
163
  robot_available=robot_available,
164
  log_only=log_only,
165
  )
166
+ session.on_hello = control.on_controller_hello
167
  app.state.session = session
168
  app.state.control = control
169
 
170
  @app.on_event("startup")
171
  async def _startup() -> None:
172
+ loop = asyncio.get_running_loop()
173
+ transport, _protocol = await loop.create_datagram_endpoint(
174
+ lambda: ControllerProtocol(session),
175
+ local_addr=("0.0.0.0", LINK_PORT),
176
+ )
177
+ app.state.udp_transport = transport
178
+ logger.info("UDP link listening on 0.0.0.0:%d", LINK_PORT)
179
  control.start()
180
 
181
  @app.on_event("shutdown")
182
  async def _shutdown() -> None:
183
  await control.stop()
184
+ transport = getattr(app.state, "udp_transport", None)
185
+ if transport is not None:
186
+ transport.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  @app.get("/api/info")
189
  async def info() -> JSONResponse:
 
191
  return JSONResponse(
192
  {
193
  "ips": ips,
194
+ "port": LINK_PORT,
195
+ "udp": f"{ips[0]}:{LINK_PORT}" if ips else None,
196
  "mdns": MDNS_SERVICE_TYPE,
197
+ "protocol_version": PROTOCOL_VERSION,
198
  }
199
  )
200
 
 
209
  "mode": snap["mode"],
210
  "connected": snap["connected"],
211
  "log_only": log_only,
212
+ "protocol_version": PROTOCOL_VERSION,
213
+ "app_version": __version__,
214
+ "boot_id": snap["boot_id"],
215
+ "peer": snap["peer"],
216
+ "presents": snap["presents"],
217
+ "absents": snap["absents"],
218
+ "last_seq": snap["last_seq"],
219
+ "seq_skips": snap["seq_skips"],
220
+ "sample_gaps": snap["sample_gaps"],
221
+ "last_rx_age_ms": snap["last_rx_age_ms"],
222
+ "last_diag": snap["last_diag"],
223
+ "max_tick_lag_ms": snap["max_tick_lag_ms"],
224
+ "last_sdk_ms": snap["last_sdk_ms"],
225
  }
226
  )
227
 
 
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)
243
  if log_only:
244
  logger.info(" Mode: --log-only (no robot SDK calls)")
245
  logger.info("=" * 50)
246
 
247
  app = create_app(reachy_mini, stop_event, log_only=log_only)
248
+ config = uvicorn.Config(
249
+ app,
250
+ host="0.0.0.0",
251
+ port=LINK_PORT,
252
+ log_level="info",
253
+ )
254
  server = uvicorn.Server(config)
255
  thread = threading.Thread(target=server.run, daemon=True)
256
  thread.start()
 
260
  time.sleep(0.05)
261
  if not server.started:
262
  raise RuntimeError(
263
+ f"Could not serve on port {LINK_PORT} — another Motion Controller "
264
  f"instance is probably already running"
265
  )
266
 
267
+ mdns = MdnsAdvertiser(LINK_PORT)
268
  mdns.start()
269
 
270
  stop_event.wait()
 
271
  server.should_exit = True
272
  thread.join(timeout=5)
273
  mdns.stop()
esp32_motion_controller/movement_handler.py DELETED
@@ -1,596 +0,0 @@
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/protocol.py CHANGED
@@ -1,7 +1,8 @@
1
  """
2
- Protocol v2 parsing — dependency-light, no SDK imports.
3
 
4
- Rejects oversize frames, wrong types, non-finite numbers, and unsupported versions.
 
5
  """
6
 
7
  from __future__ import annotations
@@ -11,10 +12,12 @@ import math
11
  from dataclasses import dataclass
12
  from typing import Any
13
 
14
- PROTOCOL_VERSION = 2
15
  MAX_FRAME_BYTES = 512
16
  GAIN_MIN = 0.1
17
  GAIN_MAX = 3.0
 
 
18
 
19
 
20
  class ProtocolError(ValueError):
@@ -23,11 +26,38 @@ class ProtocolError(ValueError):
23
  self.request_type = request_type
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  @dataclass(frozen=True, slots=True)
27
  class Hello:
28
  protocol_version: int
29
  boot_id: str
30
  device: str
 
31
 
32
 
33
  @dataclass(frozen=True, slots=True)
@@ -39,6 +69,7 @@ class Sample:
39
  engaged: bool
40
  gain: float
41
  ready: bool
 
42
 
43
 
44
  @dataclass(frozen=True, slots=True)
@@ -99,7 +130,7 @@ def _require_vec(msg: dict[str, Any], key: str, n: int, request_type: str) -> tu
99
  return tuple(out)
100
 
101
 
102
- def parse_frame(raw: str | bytes) -> Hello | Sample | Reset:
103
  if isinstance(raw, bytes):
104
  if len(raw) > MAX_FRAME_BYTES:
105
  raise ProtocolError("frame exceeds size limit", request_type="parse")
@@ -118,31 +149,50 @@ def parse_frame(raw: str | bytes) -> Hello | Sample | Reset:
118
  raise ProtocolError(f"invalid JSON: {exc}", request_type="parse") from exc
119
 
120
  msg = _require_dict(msg, "parse")
121
- msg_type = msg.get("type")
122
- if not isinstance(msg_type, str):
123
- raise ProtocolError("missing type", request_type="parse")
 
 
 
 
 
124
 
125
- if msg_type == "hello":
126
  return parse_hello(msg)
127
- if msg_type == "sample":
128
- return parse_sample(msg)
129
- if msg_type == "reset":
130
- return parse_reset(msg)
131
- raise ProtocolError(f"unknown message type: {msg_type}", request_type=msg_type)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
 
134
  def parse_hello(msg: dict[str, Any]) -> Hello:
135
  request_type = "hello"
136
- version = _require_int(msg, "protocol_version", request_type)
137
- if version != PROTOCOL_VERSION:
138
- raise ProtocolError(
139
- f"unsupported protocol_version: {version}",
140
- request_type=request_type,
141
- )
142
  return Hello(
143
- protocol_version=version,
144
  boot_id=_require_str(msg, "boot_id", request_type),
145
  device=_require_str(msg, "device", request_type) if "device" in msg else "esp32",
 
146
  )
147
 
148
 
@@ -159,6 +209,13 @@ def parse_sample(msg: dict[str, Any]) -> Sample:
159
  seq = _require_int(msg, "seq", request_type)
160
  if seq < 0:
161
  raise ProtocolError("seq must be non-negative", request_type=request_type)
 
 
 
 
 
 
 
162
  return Sample(
163
  boot_id=_require_str(msg, "boot_id", request_type),
164
  seq=seq,
@@ -167,63 +224,25 @@ def parse_sample(msg: dict[str, Any]) -> Sample:
167
  engaged=_require_bool(msg, "engaged", request_type),
168
  gain=gain,
169
  ready=_require_bool(msg, "ready", request_type),
 
170
  )
171
 
172
 
173
- def parse_reset(msg: dict[str, Any]) -> Reset:
174
- request_type = "reset"
175
- op_id = _require_int(msg, "op_id", request_type)
176
- if op_id < 0:
177
- raise ProtocolError("op_id must be non-negative", request_type=request_type)
178
- return Reset(
179
- boot_id=_require_str(msg, "boot_id", request_type),
180
- op_id=op_id,
181
- )
182
-
183
-
184
- def encode_hello_response(session_id: int) -> dict[str, Any]:
185
- return {
186
- "type": "hello",
187
- "protocol_version": PROTOCOL_VERSION,
188
- "session_id": int(session_id),
189
- }
190
-
191
-
192
- def encode_host_state(
193
  *,
194
  robot: bool,
195
  mode: str,
196
  error: str | None = None,
 
 
197
  ) -> dict[str, Any]:
198
- return {
199
- "type": "host_state",
200
  "robot": bool(robot),
201
  "mode": mode,
202
  "error": error,
203
  }
204
-
205
-
206
- def encode_reset_result(
207
- *,
208
- boot_id: str,
209
- op_id: int,
210
- status: str,
211
- message: str | None = None,
212
- ) -> dict[str, Any]:
213
- out: dict[str, Any] = {
214
- "type": "reset_result",
215
- "boot_id": boot_id,
216
- "op_id": int(op_id),
217
- "status": status,
218
- }
219
- if message is not None:
220
- out["message"] = message
221
  return out
222
-
223
-
224
- def encode_error(request_type: str, message: str) -> dict[str, Any]:
225
- return {
226
- "type": "error",
227
- "request_type": request_type,
228
- "message": message,
229
- }
 
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.
6
  """
7
 
8
  from __future__ import annotations
 
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
 
22
 
23
  class ProtocolError(ValueError):
 
26
  self.request_type = request_type
27
 
28
 
29
+ @dataclass(frozen=True, slots=True)
30
+ class LinkDiag:
31
+ rst: int = 0
32
+ wifi_n: int = 0
33
+ wifi_r: int = 0
34
+ rssi: int = 0
35
+ wifi_up: int = 0
36
+ down_ms: int = 0
37
+ send_ok: int = 0
38
+ send_fail: int = 0
39
+ send_ms: int = 0
40
+
41
+ def as_dict(self) -> dict[str, int]:
42
+ return {
43
+ "rst": self.rst,
44
+ "wifi_n": self.wifi_n,
45
+ "wifi_r": self.wifi_r,
46
+ "rssi": self.rssi,
47
+ "wifi_up": self.wifi_up,
48
+ "down_ms": self.down_ms,
49
+ "send_ok": self.send_ok,
50
+ "send_fail": self.send_fail,
51
+ "send_ms": self.send_ms,
52
+ }
53
+
54
+
55
  @dataclass(frozen=True, slots=True)
56
  class Hello:
57
  protocol_version: int
58
  boot_id: str
59
  device: str
60
+ diag: LinkDiag | None = None
61
 
62
 
63
  @dataclass(frozen=True, slots=True)
 
69
  engaged: bool
70
  gain: float
71
  ready: bool
72
+ op: int | None = None
73
 
74
 
75
  @dataclass(frozen=True, slots=True)
 
130
  return tuple(out)
131
 
132
 
133
+ def parse_frame(raw: str | bytes) -> Hello | Sample:
134
  if isinstance(raw, bytes):
135
  if len(raw) > MAX_FRAME_BYTES:
136
  raise ProtocolError("frame exceeds size limit", request_type="parse")
 
149
  raise ProtocolError(f"invalid JSON: {exc}", request_type="parse") from exc
150
 
151
  msg = _require_dict(msg, "parse")
152
+ version = msg.get("pv")
153
+ if not isinstance(version, int) or isinstance(version, bool):
154
+ raise ProtocolError("missing pv", request_type="parse")
155
+ if version != PROTOCOL_VERSION:
156
+ raise ProtocolError(
157
+ f"unsupported protocol_version: {version}",
158
+ request_type="hello" if msg.get("type") == "hello" else "parse",
159
+ )
160
 
161
+ if msg.get("type") == "hello":
162
  return parse_hello(msg)
163
+ return parse_sample(msg)
164
+
165
+
166
+ def _optional_int(msg: dict[str, Any], key: str) -> int:
167
+ val = msg.get(key)
168
+ if isinstance(val, bool) or not isinstance(val, int):
169
+ return 0
170
+ return int(val)
171
+
172
+
173
+ def parse_link_diag(raw: Any) -> LinkDiag | None:
174
+ if not isinstance(raw, dict):
175
+ return None
176
+ return LinkDiag(
177
+ rst=_optional_int(raw, "rst"),
178
+ wifi_n=_optional_int(raw, "wifi_n"),
179
+ wifi_r=_optional_int(raw, "wifi_r"),
180
+ rssi=_optional_int(raw, "rssi"),
181
+ wifi_up=_optional_int(raw, "wifi_up"),
182
+ down_ms=_optional_int(raw, "down_ms"),
183
+ send_ok=_optional_int(raw, "send_ok"),
184
+ send_fail=_optional_int(raw, "send_fail"),
185
+ send_ms=_optional_int(raw, "send_ms"),
186
+ )
187
 
188
 
189
  def parse_hello(msg: dict[str, Any]) -> Hello:
190
  request_type = "hello"
 
 
 
 
 
 
191
  return Hello(
192
+ protocol_version=PROTOCOL_VERSION,
193
  boot_id=_require_str(msg, "boot_id", request_type),
194
  device=_require_str(msg, "device", request_type) if "device" in msg else "esp32",
195
+ diag=parse_link_diag(msg.get("diag")),
196
  )
197
 
198
 
 
209
  seq = _require_int(msg, "seq", request_type)
210
  if seq < 0:
211
  raise ProtocolError("seq must be non-negative", request_type=request_type)
212
+ op = None
213
+ if "op" in msg:
214
+ op_val = _require_int(msg, "op", request_type)
215
+ if op_val < 0:
216
+ raise ProtocolError("op must be non-negative", request_type=request_type)
217
+ if op_val > 0:
218
+ op = op_val
219
  return Sample(
220
  boot_id=_require_str(msg, "boot_id", request_type),
221
  seq=seq,
 
224
  engaged=_require_bool(msg, "engaged", request_type),
225
  gain=gain,
226
  ready=_require_bool(msg, "ready", request_type),
227
+ op=op,
228
  )
229
 
230
 
231
+ def encode_state_reply(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  *,
233
  robot: bool,
234
  mode: str,
235
  error: str | None = None,
236
+ op_ack: int | None = None,
237
+ op_status: str | None = None,
238
  ) -> dict[str, Any]:
239
+ out: dict[str, Any] = {
240
+ "pv": PROTOCOL_VERSION,
241
  "robot": bool(robot),
242
  "mode": mode,
243
  "error": error,
244
  }
245
+ if op_ack is not None:
246
+ out["op_ack"] = int(op_ack)
247
+ out["op_status"] = op_status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  return out
 
 
 
 
 
 
 
 
esp32_motion_controller/robot_control.py CHANGED
@@ -1,34 +1,39 @@
1
  """
2
- Single fixed-rate robot command owner for protocol v2.
3
 
4
  Consumes the latest sample from SessionHub, runs the pure reducer, and performs
5
- at most one SDK call in flight. Owns reset goto completion and pose seeding.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  import asyncio
11
  import logging
 
12
  import time
 
13
  from typing import Any
14
 
15
  import numpy as np
16
  from scipy.spatial.transform import Rotation
17
 
18
  from esp32_motion_controller.control import (
19
- CONTROL_DT,
20
  CONTROL_HZ,
 
21
  Command,
22
  ControlState,
23
  begin_reset,
 
24
  force_disengage,
25
  initial_state,
26
  mark_pose_unread,
27
  mark_sdk_failure,
28
  mark_sdk_success,
29
  note_sample_receipt,
30
- rebase_neutral,
 
31
  seed_from_pose,
 
32
  step,
33
  zero_pose,
34
  )
@@ -36,10 +41,13 @@ from esp32_motion_controller.session import SessionHub
36
 
37
  logger = logging.getLogger(__name__)
38
 
39
- RESET_DURATION_SEC = 1.5
40
  IDLE_RECONCILE_SEC = 2.0
41
  RESYNC_EPS_POS_M = 0.003
42
  RESYNC_EPS_ANG_RAD = 0.05
 
 
 
 
43
 
44
 
45
  class RobotGateway:
@@ -87,23 +95,6 @@ class RobotGateway:
87
  antennas = np.array(command.antennas, dtype=np.float64)
88
  self.mini.set_target(head=head, body_yaw=command.body_yaw, antennas=antennas)
89
 
90
- def goto_neutral(self, duration: float = RESET_DURATION_SEC) -> None:
91
- if self.mini is None or self.log_only:
92
- time.sleep(min(duration, 0.05))
93
- return
94
- from reachy_mini.utils import create_head_pose
95
-
96
- head = create_head_pose(
97
- x=0.0, y=0.0, z=0.0, roll=0.0, pitch=0.0, yaw=0.0, degrees=False
98
- )
99
- self.mini.goto_target(
100
- head=head,
101
- body_yaw=0.0,
102
- antennas=[0.0, 0.0],
103
- duration=duration,
104
- method="minjerk",
105
- )
106
-
107
 
108
  class RobotControl:
109
  def __init__(
@@ -126,6 +117,159 @@ class RobotControl:
126
  self._last_reconcile = 0.0
127
  self._sdk_lock = asyncio.Lock()
128
  self._started = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  @property
131
  def state(self) -> ControlState:
@@ -152,11 +296,16 @@ class RobotControl:
152
  try:
153
  # Initial seed attempt.
154
  await self._seed(update_host=True)
 
155
  while True:
156
  t0 = time.monotonic()
157
  dt = min(t0 - last, 0.05)
158
  if dt <= 0:
159
  dt = self.dt
 
 
 
 
160
  last = t0
161
 
162
  await self._tick(loop, t0, dt)
@@ -166,12 +315,24 @@ class RobotControl:
166
  except asyncio.CancelledError:
167
  pass
168
 
 
 
 
 
 
 
 
169
  async def _tick(self, loop: asyncio.AbstractEventLoop, now: float, dt: float) -> None:
170
- # Handle pending reset first — exclusive robot ownership.
171
  pending = await self.session.take_pending_reset()
172
  if pending is not None:
173
- await self._run_reset(loop, pending, now)
174
- return
 
 
 
 
 
 
175
 
176
  latest = await self.session.take_latest_sample()
177
  sample = None
@@ -183,10 +344,46 @@ class RobotControl:
183
  self._last_seen_seq = sample.seq
184
  self._state = note_sample_receipt(self._state, latest.receipt_time)
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  # Idle reconciliation when disengaged and not frozen.
187
  if (
188
  self._state.mode == "idle"
189
  and not self._state.engaged
 
190
  and not self._state.sends_frozen
191
  and (now - self._last_reconcile) >= IDLE_RECONCILE_SEC
192
  ):
@@ -199,6 +396,7 @@ class RobotControl:
199
  dt=dt,
200
  sample=sample,
201
  sample_is_fresh=sample_is_fresh,
 
202
  )
203
  self._state = result.state
204
 
@@ -212,30 +410,7 @@ class RobotControl:
212
 
213
  if result.command is None:
214
  return
215
-
216
- if self.log_only:
217
- if sample_is_fresh:
218
- logger.info(
219
- "command engaged=%s pose=%s body=%.3f",
220
- self._state.engaged,
221
- {k: round(result.command.pose[k], 4) for k in result.command.pose},
222
- result.command.body_yaw,
223
- )
224
- self._state = mark_sdk_success(self._state, result.command)
225
- return
226
-
227
- async with self._sdk_lock:
228
- try:
229
- await loop.run_in_executor(None, self.gateway.set_target, result.command)
230
- except Exception as exc:
231
- logger.warning("set_target failed: %s", exc)
232
- self._state = mark_sdk_failure(self._state)
233
- await self.session.push_host_state(
234
- mode=self._state.mode,
235
- error=self._state.error,
236
- )
237
- return
238
- self._state = mark_sdk_success(self._state, result.command)
239
 
240
  async def _seed(self, *, update_host: bool) -> bool:
241
  loop = asyncio.get_running_loop()
@@ -246,7 +421,8 @@ class RobotControl:
246
  await self.session.push_host_state(mode="fault", error=self._state.error)
247
  return False
248
  pose, body = read
249
- self._state = seed_from_pose(self._state, pose, body)
 
250
  self._last_reconcile = time.monotonic()
251
  if update_host:
252
  await self.session.push_host_state(mode="idle", clear_error=True)
@@ -281,92 +457,67 @@ class RobotControl:
281
  self._state,
282
  Command(pose=pose, body_yaw=body, antennas=tuple(self._state.baseline_antennas[:2])),
283
  )
 
284
  return
285
  logger.warning("Pose desync pos=%.4f m ang=%.3f rad — idle resync", pos, ang)
286
- self._state = seed_from_pose(self._state, pose, body)
287
-
288
- async def _run_reset(
289
- self, loop: asyncio.AbstractEventLoop, reset, now: float
290
- ) -> None:
291
- if not self.robot_available and not self.log_only:
292
- await self.session.complete_reset(
293
- boot_id=reset.boot_id,
294
- op_id=reset.op_id,
295
- status="failed",
296
- message="Robot not available",
297
- )
298
- return
299
 
300
- self._state = begin_reset(self._state, now)
301
- await self.session.push_host_state(mode="resetting", clear_error=True)
 
302
 
303
- # Seed from measured pose before goto.
304
- read = await loop.run_in_executor(None, self.gateway.read_pose)
305
- if read is None and not self.log_only:
306
- self._state = mark_pose_unread(self._state)
307
- await self.session.complete_reset(
308
- boot_id=reset.boot_id,
309
- op_id=reset.op_id,
310
- status="failed",
311
- message="Robot pose unread",
312
- )
313
- return
314
- if read is not None:
315
- pose, body = read
316
- self._state = seed_from_pose(self._state, pose, body)
317
- self._state = begin_reset(self._state, now)
318
 
319
- async with self._sdk_lock:
320
- try:
321
- await loop.run_in_executor(None, self.gateway.goto_neutral, RESET_DURATION_SEC)
322
- except Exception as exc:
323
- logger.error("goto_target failed: %s", exc)
324
- self._state = mark_pose_unread(self._state)
325
- await self.session.complete_reset(
326
- boot_id=reset.boot_id,
327
- op_id=reset.op_id,
328
- status="failed",
329
- message=str(exc),
330
- )
331
- return
332
-
333
- measured = await loop.run_in_executor(None, self.gateway.read_pose)
334
- if measured is None and not self.log_only:
335
- self._state = rebase_neutral(self._state, measured_baseline=None, measured_body=None)
336
- await self.session.complete_reset(
337
- boot_id=reset.boot_id,
338
- op_id=reset.op_id,
339
- status="failed",
340
- message="Robot pose unread after reset",
341
  )
342
  return
343
-
344
- if measured is None:
345
- measured = (zero_pose(), 0.0)
346
- pose, body = measured
347
- self._state = rebase_neutral(
348
- self._state, measured_baseline=pose, measured_body=body
349
  )
350
- self._last_reconcile = time.monotonic()
351
- await self.session.complete_reset(
352
- boot_id=reset.boot_id,
353
- op_id=reset.op_id,
354
- status="completed",
355
- )
356
-
357
- async def on_controller_connected(self) -> None:
358
- """Reseed when a new controller session is admitted.
359
 
360
- Do not push host_state here — the hello handler sends the first snapshot
361
- so the device always sees hello before host_state.
362
- """
363
  self._state = force_disengage(self._state)
364
  self._last_seen_seq = None
 
 
 
365
  ok = await self._seed(update_host=False)
366
  if not ok and self.log_only:
367
  self._state = seed_from_pose(self._state, zero_pose(), 0.0)
 
368
 
369
- async def on_controller_disconnected(self) -> None:
370
- self._state = force_disengage(self._state)
371
- if self._state.mode not in {"resetting"}:
372
- await self.session.push_host_state(mode="idle")
 
 
 
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.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  import asyncio
11
  import logging
12
+ import math
13
  import time
14
+ from dataclasses import replace
15
  from typing import Any
16
 
17
  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,
28
  initial_state,
29
  mark_pose_unread,
30
  mark_sdk_failure,
31
  mark_sdk_success,
32
  note_sample_receipt,
33
+ pose_near,
34
+ pose_travel,
35
  seed_from_pose,
36
+ speed_lock,
37
  step,
38
  zero_pose,
39
  )
 
41
 
42
  logger = logging.getLogger(__name__)
43
 
 
44
  IDLE_RECONCILE_SEC = 2.0
45
  RESYNC_EPS_POS_M = 0.003
46
  RESYNC_EPS_ANG_RAD = 0.05
47
+ # Incoming jumps larger than this are dropped (hold last sent). Smaller
48
+ # deltas are speed-locked to one tick of MAX_*_VEL.
49
+ CULL_POS_M = 0.020
50
+ CULL_ANG_RAD = math.radians(20.0)
51
 
52
 
53
  class RobotGateway:
 
95
  antennas = np.array(command.antennas, dtype=np.float64)
96
  self.mini.set_target(head=head, body_yaw=command.body_yaw, antennas=antennas)
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  class RobotControl:
100
  def __init__(
 
117
  self._last_reconcile = 0.0
118
  self._sdk_lock = asyncio.Lock()
119
  self._started = False
120
+ self._sent_pose: dict[str, float] | None = None
121
+ self._sent_body: float = 0.0
122
+ self._sent_at: float = 0.0
123
+ self._posture: str = "unknown"
124
+ self._boot_id: str | None = None
125
+ self._button_engaged: bool = False
126
+ self._suppress_button_edge: bool = False
127
+ self._anim: str | None = None
128
+ self._anim_pose = zero_pose()
129
+ self._anim_body = 0.0
130
+ self._pending_reset = None
131
+
132
+ def _remember_sent(self, pose: dict[str, float], body: float, now: float) -> None:
133
+ self._sent_pose = dict(pose)
134
+ self._sent_body = float(body)
135
+ self._sent_at = now
136
+
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 = (
145
+ dict(self._sent_pose)
146
+ if self._sent_pose is not None
147
+ else dict(self._state.baseline_pose)
148
+ )
149
+ ref_body = (
150
+ float(self._sent_body)
151
+ if self._sent_pose is not None
152
+ else float(self._state.baseline_body_yaw)
153
+ )
154
+ if self._anim is None:
155
+ dist, ang = pose_travel(
156
+ ref_pose, command.pose, ref_body, command.body_yaw
157
+ )
158
+ if dist > CULL_POS_M or ang > CULL_ANG_RAD:
159
+ logger.warning(
160
+ "cull jump pos=%.3f m ang=%.1f deg (hold last)",
161
+ dist,
162
+ math.degrees(ang),
163
+ )
164
+ return Command(
165
+ pose=ref_pose,
166
+ body_yaw=ref_body,
167
+ antennas=command.antennas,
168
+ )
169
+ pose, body = speed_lock(
170
+ ref_pose,
171
+ ref_body,
172
+ command.pose,
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
+
179
+ def _begin_anim(self, kind: str, pose: dict[str, float], body: float) -> None:
180
+ self._anim = kind
181
+ self._anim_pose = dict(pose)
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:
196
+ return
197
+ if not pose_near(sent.pose, self._anim_pose, sent.body_yaw, self._anim_body):
198
+ return
199
+ kind = self._anim
200
+ self._anim = None
201
+ if kind == "appear":
202
+ self._posture = "neutral"
203
+ self._state = replace(
204
+ self._state,
205
+ desired_pose=zero_pose(),
206
+ base_pose=zero_pose(),
207
+ body_yaw=0.0,
208
+ )
209
+ elif kind == "disappear":
210
+ self._posture = "ducked"
211
+ rest = disengaged_rest_pose()
212
+ self._state = replace(
213
+ self._state,
214
+ desired_pose=dict(rest),
215
+ base_pose=dict(rest),
216
+ body_yaw=0.0,
217
+ engaged=False,
218
+ )
219
+ elif kind == "reset":
220
+ self._posture = "neutral"
221
+ self._state = replace(self._state, mode="idle")
222
+ reset = self._pending_reset
223
+ self._pending_reset = None
224
+ if reset is not None:
225
+ await self.session.complete_reset(
226
+ boot_id=reset.boot_id,
227
+ op_id=reset.op_id,
228
+ status="completed",
229
+ )
230
+ self._begin_anim("disappear", disengaged_rest_pose(), 0.0)
231
+ await self.session.push_host_state(mode="idle", clear_error=True)
232
+
233
+ async def _send_command(
234
+ self,
235
+ loop: asyncio.AbstractEventLoop,
236
+ now: float,
237
+ command: Command,
238
+ *,
239
+ log_sample: bool,
240
+ ) -> Command | None:
241
+ command = self._guard_command(command, now)
242
+ if self.log_only:
243
+ if log_sample:
244
+ logger.info(
245
+ "command engaged=%s pose=%s body=%.3f",
246
+ self._state.engaged,
247
+ {k: round(command.pose[k], 4) for k in command.pose},
248
+ command.body_yaw,
249
+ )
250
+ self._state = mark_sdk_success(self._state, command)
251
+ self._remember_sent(command.pose, command.body_yaw, now)
252
+ return command
253
+
254
+ async with self._sdk_lock:
255
+ t_sdk = time.monotonic()
256
+ try:
257
+ await loop.run_in_executor(None, self.gateway.set_target, command)
258
+ except Exception as exc:
259
+ logger.warning("set_target failed: %s", exc)
260
+ self._state = mark_sdk_failure(self._state)
261
+ await self.session.push_host_state(
262
+ mode=self._state.mode,
263
+ error=self._state.error,
264
+ )
265
+ return None
266
+ sdk_ms = (time.monotonic() - t_sdk) * 1000.0
267
+ self.session.note_sdk_duration(sdk_ms)
268
+ if sdk_ms > 50.0:
269
+ logger.warning("set_target slow %.0f ms", sdk_ms)
270
+ self._state = mark_sdk_success(self._state, command)
271
+ self._remember_sent(command.pose, command.body_yaw, now)
272
+ return command
273
 
274
  @property
275
  def state(self) -> ControlState:
 
296
  try:
297
  # Initial seed attempt.
298
  await self._seed(update_host=True)
299
+ last = time.monotonic()
300
  while True:
301
  t0 = time.monotonic()
302
  dt = min(t0 - last, 0.05)
303
  if dt <= 0:
304
  dt = self.dt
305
+ lag_ms = (t0 - last) * 1000.0
306
+ if last > 0.0 and lag_ms > 80.0:
307
+ logger.warning("control tick lag %.0f ms", lag_ms)
308
+ self.session.note_tick_lag(lag_ms)
309
  last = t0
310
 
311
  await self._tick(loop, t0, dt)
 
315
  except asyncio.CancelledError:
316
  pass
317
 
318
+ def _want_engaged(self, sample) -> bool:
319
+ if sample is None:
320
+ return False
321
+ if self._state.mode == "fault" or self._state.sends_frozen:
322
+ return False
323
+ return bool(sample.engaged) and bool(sample.ready)
324
+
325
  async def _tick(self, loop: asyncio.AbstractEventLoop, now: float, dt: float) -> None:
 
326
  pending = await self.session.take_pending_reset()
327
  if pending is not None:
328
+ self._pending_reset = pending
329
+ self._state = begin_reset(self._state, now)
330
+ self._begin_anim("reset", zero_pose(), 0.0)
331
+ await self.session.push_host_state(mode="resetting", clear_error=True)
332
+
333
+ edge = self.session.poll_presence_edge()
334
+ if edge == "absent":
335
+ await self.on_controller_absent()
336
 
337
  latest = await self.session.take_latest_sample()
338
  sample = None
 
344
  self._last_seen_seq = sample.seq
345
  self._state = note_sample_receipt(self._state, latest.receipt_time)
346
 
347
+ currently = self._state.engaged
348
+ stale = (
349
+ currently
350
+ and not self.session.controller_present
351
+ and self._state.have_sample
352
+ and self._state.last_sample_time > 0.0
353
+ and (now - self._state.last_sample_time) > STALE_PACKET_SEC
354
+ )
355
+ if stale:
356
+ logger.warning(
357
+ "stale disengage controller_absent age=%.0f ms (limit=%.0f ms)",
358
+ (now - self._state.last_sample_time) * 1000.0,
359
+ STALE_PACKET_SEC * 1000.0,
360
+ )
361
+ self._state = force_disengage(self._state)
362
+
363
+ if sample_is_fresh:
364
+ want = self._want_engaged(sample)
365
+ if self._state.mode == "resetting" or self._anim == "reset":
366
+ self._button_engaged = False
367
+ elif self._suppress_button_edge:
368
+ self._button_engaged = want
369
+ self._suppress_button_edge = False
370
+ elif want and not self._button_engaged:
371
+ self._button_engaged = True
372
+ if self._posture == "ducked":
373
+ self._begin_anim("appear", zero_pose(), 0.0)
374
+ elif not want and self._button_engaged:
375
+ self._button_engaged = False
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.
383
  if (
384
  self._state.mode == "idle"
385
  and not self._state.engaged
386
+ and not self._button_engaged
387
  and not self._state.sends_frozen
388
  and (now - self._last_reconcile) >= IDLE_RECONCILE_SEC
389
  ):
 
396
  dt=dt,
397
  sample=sample,
398
  sample_is_fresh=sample_is_fresh,
399
+ controller_present=self.session.controller_present,
400
  )
401
  self._state = result.state
402
 
 
410
 
411
  if result.command is None:
412
  return
413
+ await self._send_command(loop, now, result.command, log_sample=sample_is_fresh)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
  async def _seed(self, *, update_host: bool) -> bool:
416
  loop = asyncio.get_running_loop()
 
421
  await self.session.push_host_state(mode="fault", error=self._state.error)
422
  return False
423
  pose, body = read
424
+ self._state = seed_from_pose(self._state, pose, body, apply_ellipsoid=False)
425
+ self._remember_sent(self._state.baseline_pose, self._state.baseline_body_yaw, time.monotonic())
426
  self._last_reconcile = time.monotonic()
427
  if update_host:
428
  await self.session.push_host_state(mode="idle", clear_error=True)
 
457
  self._state,
458
  Command(pose=pose, body_yaw=body, antennas=tuple(self._state.baseline_antennas[:2])),
459
  )
460
+ self._remember_sent(pose, body, now)
461
  return
462
  logger.warning("Pose desync pos=%.4f m ang=%.3f rad — idle resync", pos, ang)
463
+ self._state = seed_from_pose(
464
+ self._state, pose, body, apply_ellipsoid=False
465
+ )
466
+ self._remember_sent(pose, body, now)
 
 
 
 
 
 
 
 
 
467
 
468
+ async def on_controller_connected(self) -> None:
469
+ """Reseed clutch from the measured pose. Tests and first-hello use this."""
470
+ await self._reseed_controller()
471
 
472
+ async def on_controller_hello(self, hello) -> None:
473
+ """Keep clutch on first hello and any same-boot reconnect.
 
 
 
 
 
 
 
 
 
 
 
 
 
474
 
475
+ Appear/disappear are button edges only — a socket gap must not duck
476
+ or rise. Reseed only when the device actually rebooted.
477
+ """
478
+ prev = self._boot_id
479
+ first = prev is None
480
+ same = prev is not None and hello.boot_id == prev
481
+ self._boot_id = hello.boot_id
482
+ keep = first or (
483
+ same
484
+ and not self._state.sends_frozen
485
+ and self._state.mode not in {"fault"}
486
+ )
487
+ if keep:
488
+ logger.info(
489
+ "hello keep clutch boot_id=%s first=%s same_boot=%s engaged=%s button=%s posture=%s",
490
+ hello.boot_id,
491
+ first,
492
+ same,
493
+ self._state.engaged,
494
+ self._button_engaged,
495
+ self._posture,
 
496
  )
497
  return
498
+ logger.info(
499
+ "hello reseed boot_id=%s prev=%s mode=%s",
500
+ hello.boot_id,
501
+ prev,
502
+ self._state.mode,
 
503
  )
504
+ await self._reseed_controller()
 
 
 
 
 
 
 
 
505
 
506
+ async def _reseed_controller(self) -> None:
507
+ """Do not push host_state here hello already sent the snapshot."""
 
508
  self._state = force_disengage(self._state)
509
  self._last_seen_seq = None
510
+ self._posture = "unknown"
511
+ self._button_engaged = False
512
+ self._suppress_button_edge = False
513
  ok = await self._seed(update_host=False)
514
  if not ok and self.log_only:
515
  self._state = seed_from_pose(self._state, zero_pose(), 0.0)
516
+ self._remember_sent(zero_pose(), 0.0, time.monotonic())
517
 
518
+ async def on_controller_absent(self) -> None:
519
+ logger.info(
520
+ "controller absent engaged=%s mode=%s",
521
+ self._state.engaged,
522
+ self._state.mode,
523
+ )
esp32_motion_controller/session.py CHANGED
@@ -1,43 +1,39 @@
1
  """
2
- WebSocket session ingress for protocol v2.
3
 
4
- Validates frames, keeps only the latest sample, manages session generation and
5
- reset operation mailbox. Never calls the robot SDK.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  import asyncio
 
11
  import logging
12
  import time
13
  from dataclasses import dataclass, field
14
  from typing import Any, Awaitable, Callable
15
 
16
- from fastapi import WebSocket
17
- from starlette.websockets import WebSocketState
18
-
19
  from esp32_motion_controller.protocol import (
 
20
  Hello,
 
21
  ProtocolError,
22
  Reset,
23
  Sample,
24
- encode_error,
25
- encode_hello_response,
26
- encode_host_state,
27
- encode_reset_result,
28
  parse_frame,
29
  )
30
 
31
  logger = logging.getLogger(__name__)
32
 
33
- SendFn = Callable[[dict[str, Any]], Awaitable[None]]
34
 
35
 
36
  @dataclass
37
  class LatestSample:
38
  sample: Sample
39
  receipt_time: float
40
- generation: int
41
 
42
 
43
  @dataclass
@@ -50,12 +46,12 @@ class ResetRecord:
50
 
51
  @dataclass
52
  class SessionMailbox:
53
- """Shared state between the WS receiver and the control loop."""
54
 
55
  lock: asyncio.Lock = field(default_factory=asyncio.Lock)
56
- generation: int = 0
57
  boot_id: str | None = None
58
- active_ws: WebSocket | None = None
 
59
  latest: LatestSample | None = None
60
  last_seq: int | None = None
61
  pending_reset: Reset | None = None
@@ -63,175 +59,178 @@ class SessionMailbox:
63
  host_mode: str = "idle"
64
  host_robot: bool = True
65
  host_error: str | None = None
66
- controller_present: bool = False
 
 
 
 
67
 
68
 
69
  class SessionHub:
70
  def __init__(self, *, robot_available: bool) -> None:
71
  self.mailbox = SessionMailbox(host_robot=robot_available)
72
- self._send_lock = asyncio.Lock()
 
 
 
 
 
73
 
74
- @property
75
- def generation(self) -> int:
76
- return self.mailbox.generation
77
 
78
- async def on_connect(self, websocket: WebSocket) -> int:
79
- mb = self.mailbox
80
- async with mb.lock:
81
- old = mb.active_ws
82
- mb.generation += 1
83
- gen = mb.generation
84
- mb.active_ws = websocket
85
- mb.latest = None
86
- mb.last_seq = None
87
- mb.pending_reset = None
88
- mb.boot_id = None
89
- mb.controller_present = True
90
- mb.host_mode = "idle"
91
- mb.host_error = None
92
-
93
- if old is not None and old is not websocket:
94
- alive = old.client_state == WebSocketState.CONNECTED
95
- if alive:
96
- logger.warning("Replacing active controller connection")
97
- try:
98
- await old.close(code=1000)
99
- except Exception:
100
- pass
101
-
102
- await websocket.accept()
103
- logger.info("Controller socket accepted generation=%d", gen)
104
- return gen
105
-
106
- async def cleanup(self, websocket: WebSocket, generation: int) -> None:
107
- mb = self.mailbox
108
- async with mb.lock:
109
- if mb.active_ws is not websocket or mb.generation != generation:
110
- return
111
- mb.active_ws = None
112
- mb.controller_present = False
113
- mb.latest = None
114
- # Keep reset_cache and boot_id so reconnect can retrieve outcomes.
115
- logger.info("Controller disconnected generation=%d", generation)
116
-
117
- async def handle_message(self, websocket: WebSocket, generation: int, raw: str) -> None:
118
  try:
119
- msg = parse_frame(raw)
120
  except ProtocolError as exc:
121
- await self._send(websocket, generation, encode_error(exc.request_type, str(exc)))
122
- if exc.request_type == "hello":
123
- try:
124
- await websocket.close(code=1002)
125
- except Exception:
126
- pass
127
  return
128
 
129
  if isinstance(msg, Hello):
130
- await self._handle_hello(websocket, generation, msg)
131
  return
132
  if isinstance(msg, Sample):
133
- await self._handle_sample(websocket, generation, msg)
134
  return
135
- if isinstance(msg, Reset):
136
- await self._handle_reset(websocket, generation, msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  return
 
 
138
 
139
- async def _handle_hello(self, websocket: WebSocket, generation: int, hello: Hello) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  mb = self.mailbox
141
  async with mb.lock:
142
- if mb.generation != generation or mb.active_ws is not websocket:
143
- return
144
- mb.boot_id = hello.boot_id
145
  robot = mb.host_robot
146
  mode = mb.host_mode
147
  err = mb.host_error
148
- await self._send(websocket, generation, encode_hello_response(generation))
149
- await self._send(
150
- websocket,
151
- generation,
152
- encode_host_state(robot=robot, mode=mode, error=err),
153
  )
154
- logger.info("Hello ok boot_id=%s generation=%d", hello.boot_id, generation)
 
155
 
156
- async def _handle_sample(self, websocket: WebSocket, generation: int, sample: Sample) -> None:
157
  mb = self.mailbox
158
  now = time.monotonic()
159
  async with mb.lock:
160
- if mb.generation != generation or mb.active_ws is not websocket:
161
- return
162
- if mb.boot_id is None:
163
- # Implicit bind if device skipped hello (should not happen).
164
- mb.boot_id = sample.boot_id
165
- if sample.boot_id != mb.boot_id:
166
- logger.warning("sample boot_id mismatch; dropping")
167
- return
168
  if mb.last_seq is not None:
169
- # Duplicate
170
  if sample.seq == mb.last_seq:
 
 
 
 
 
 
 
 
 
171
  return
172
- # Out-of-order (small backward jump) — drop unless wrap.
173
  if sample.seq < mb.last_seq:
174
- # uint32 wrap: large backward jump
175
  if mb.last_seq - sample.seq < 2**31:
176
  return
 
 
 
 
 
 
 
 
177
  mb.last_seq = sample.seq
178
- mb.latest = LatestSample(sample=sample, receipt_time=now, generation=generation)
 
 
 
 
 
 
 
 
179
 
180
- async def _handle_reset(self, websocket: WebSocket, generation: int, reset: Reset) -> None:
181
- mb = self.mailbox
182
- async with mb.lock:
183
- if mb.generation != generation or mb.active_ws is not websocket:
184
- return
185
- if mb.boot_id is None:
186
- mb.boot_id = reset.boot_id
187
- if reset.boot_id != mb.boot_id:
188
- await self._send(
189
- websocket,
190
- generation,
191
- encode_reset_result(
192
- boot_id=reset.boot_id,
193
- op_id=reset.op_id,
194
- status="failed",
195
- message="boot_id mismatch",
196
- ),
197
- )
198
- return
199
- cached = mb.reset_cache.get((reset.boot_id, reset.op_id))
200
- if cached is not None:
201
- result = encode_reset_result(
202
- boot_id=cached.boot_id,
203
- op_id=cached.op_id,
204
- status=cached.status,
205
- message=cached.message,
206
- )
207
- pending = None
208
- elif mb.host_mode == "resetting":
209
- # Another reset already running with a different op_id.
210
- result = encode_reset_result(
211
- boot_id=reset.boot_id,
212
- op_id=reset.op_id,
213
- status="failed",
214
- message="Reset already in progress",
215
- )
216
- pending = None
217
- else:
218
- mb.pending_reset = reset
219
- mb.reset_cache[(reset.boot_id, reset.op_id)] = ResetRecord(
220
- boot_id=reset.boot_id,
221
- op_id=reset.op_id,
222
- status="accepted",
223
- )
224
- mb.host_mode = "resetting"
225
- result = encode_reset_result(
226
- boot_id=reset.boot_id,
227
- op_id=reset.op_id,
228
- status="accepted",
229
- )
230
- pending = reset
231
-
232
- await self._send(websocket, generation, result)
233
- if pending is not None:
234
- await self.push_host_state(mode="resetting")
235
 
236
  async def take_latest_sample(self) -> LatestSample | None:
237
  mb = self.mailbox
@@ -261,24 +260,6 @@ class SessionHub:
261
  if status in {"completed", "failed"} and mb.host_mode == "resetting":
262
  mb.host_mode = "idle" if status == "completed" else "fault"
263
  mb.host_error = message if status == "failed" else None
264
- ws = mb.active_ws
265
- gen = mb.generation
266
- mode = mb.host_mode
267
- robot = mb.host_robot
268
- err = mb.host_error
269
- if ws is not None:
270
- await self._send(
271
- ws,
272
- gen,
273
- encode_reset_result(
274
- boot_id=boot_id, op_id=op_id, status=status, message=message
275
- ),
276
- )
277
- await self._send(
278
- ws,
279
- gen,
280
- encode_host_state(robot=robot, mode=mode, error=err),
281
- )
282
 
283
  async def push_host_state(
284
  self,
@@ -298,36 +279,37 @@ class SessionHub:
298
  mb.host_error = None
299
  elif error is not None:
300
  mb.host_error = error
301
- ws = mb.active_ws
302
- gen = mb.generation
303
- payload = encode_host_state(
304
- robot=mb.host_robot, mode=mb.host_mode, error=mb.host_error
305
- )
306
- if ws is not None:
307
- await self._send(ws, gen, payload)
308
 
309
  async def snapshot_status(self) -> dict[str, Any]:
310
  mb = self.mailbox
311
  async with mb.lock:
 
 
 
312
  return {
313
  "robot": mb.host_robot,
314
  "busy": mb.host_mode == "resetting",
315
  "mode": mb.host_mode,
316
- "connected": mb.controller_present,
317
  "error": mb.host_error,
 
 
 
 
 
 
 
 
 
 
 
318
  }
319
 
320
- async def _send(
321
- self, websocket: WebSocket, generation: int, payload: dict[str, Any]
322
- ) -> None:
323
- async with self._send_lock:
324
- mb = self.mailbox
325
- async with mb.lock:
326
- if mb.generation != generation or mb.active_ws is not websocket:
327
- return
328
- if websocket.client_state != WebSocketState.CONNECTED:
329
- return
330
- try:
331
- await websocket.send_json(payload)
332
- except Exception as exc:
333
- logger.warning("WS send failed: %s", exc)
 
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.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  import asyncio
11
+ import json
12
  import logging
13
  import time
14
  from dataclasses import dataclass, field
15
  from typing import Any, Awaitable, Callable
16
 
 
 
 
17
  from esp32_motion_controller.protocol import (
18
+ LINK_STALE_SEC,
19
  Hello,
20
+ LinkDiag,
21
  ProtocolError,
22
  Reset,
23
  Sample,
24
+ encode_state_reply,
 
 
 
25
  parse_frame,
26
  )
27
 
28
  logger = logging.getLogger(__name__)
29
 
30
+ OnBootFn = Callable[[Hello], Awaitable[None]]
31
 
32
 
33
  @dataclass
34
  class LatestSample:
35
  sample: Sample
36
  receipt_time: float
 
37
 
38
 
39
  @dataclass
 
46
 
47
  @dataclass
48
  class SessionMailbox:
49
+ """Shared state between the UDP receiver and the control loop."""
50
 
51
  lock: asyncio.Lock = field(default_factory=asyncio.Lock)
 
52
  boot_id: str | None = None
53
+ peer: tuple[str, int] | None = None
54
+ last_rx: float = 0.0
55
  latest: LatestSample | None = None
56
  last_seq: int | None = None
57
  pending_reset: Reset | None = None
 
59
  host_mode: str = "idle"
60
  host_robot: bool = True
61
  host_error: str | None = None
62
+ last_diag: LinkDiag | None = None
63
+ seq_skips: int = 0
64
+ sample_gaps: int = 0
65
+ presents: int = 0
66
+ absents: int = 0
67
 
68
 
69
  class SessionHub:
70
  def __init__(self, *, robot_available: bool) -> None:
71
  self.mailbox = SessionMailbox(host_robot=robot_available)
72
+ self._transport: Any = None
73
+ self._last_sample_receipt: float = 0.0
74
+ self._present: bool = False
75
+ self.max_tick_lag_ms: float = 0.0
76
+ self.last_sdk_ms: float = 0.0
77
+ self.on_hello: OnBootFn | None = None
78
 
79
+ def bind_transport(self, transport: Any) -> None:
80
+ self._transport = transport
 
81
 
82
+ @property
83
+ def controller_present(self) -> bool:
84
+ last = self.mailbox.last_rx
85
+ if last <= 0.0:
86
+ return False
87
+ return (time.monotonic() - last) < LINK_STALE_SEC
88
+
89
+ def note_tick_lag(self, lag_ms: float) -> None:
90
+ if lag_ms > self.max_tick_lag_ms:
91
+ self.max_tick_lag_ms = lag_ms
92
+
93
+ def note_sdk_duration(self, duration_ms: float) -> None:
94
+ self.last_sdk_ms = duration_ms
95
+
96
+ def poll_presence_edge(self) -> str | None:
97
+ """Return 'present' / 'absent' on a liveness edge, else None."""
98
+ now = self.controller_present
99
+ prev = self._present
100
+ if now == prev:
101
+ return None
102
+ self._present = now
103
+ if now:
104
+ self.mailbox.presents += 1
105
+ return "present"
106
+ self.mailbox.absents += 1
107
+ return "absent"
108
+
109
+ async def handle_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
110
  try:
111
+ msg = parse_frame(data)
112
  except ProtocolError as exc:
113
+ logger.warning("drop datagram from %s: %s", addr, exc)
 
 
 
 
 
114
  return
115
 
116
  if isinstance(msg, Hello):
117
+ await self._handle_hello(msg, addr)
118
  return
119
  if isinstance(msg, Sample):
120
+ await self._handle_sample(msg, addr)
121
  return
122
+
123
+ def _touch_peer(
124
+ self, boot_id: str, addr: tuple[str, int], *, hello: Hello | None
125
+ ) -> bool:
126
+ """Update peer/boot under lock. Returns True when boot_id changed."""
127
+ mb = self.mailbox
128
+ prev_boot = mb.boot_id
129
+ mb.peer = addr
130
+ mb.last_rx = time.monotonic()
131
+ if prev_boot is not None and boot_id != prev_boot:
132
+ mb.last_seq = None
133
+ mb.latest = None
134
+ mb.boot_id = boot_id
135
+ if hello is not None and hello.diag is not None:
136
+ mb.last_diag = hello.diag
137
+ return prev_boot != boot_id
138
+
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]:
146
+ mb = self.mailbox
147
+ if op is None:
148
+ return None, None
149
+ cached = mb.reset_cache.get((boot_id, op))
150
+ if cached is not None:
151
+ return cached.op_id, cached.status
152
+ if mb.host_mode == "resetting":
153
+ pending = mb.pending_reset
154
+ if pending is not None and pending.op_id == op and pending.boot_id == boot_id:
155
+ return op, "accepted"
156
+ mb.reset_cache[(boot_id, op)] = ResetRecord(
157
+ boot_id=boot_id,
158
+ op_id=op,
159
+ status="failed",
160
+ message="Reset already in progress",
161
+ )
162
+ return op, "failed"
163
+ mb.pending_reset = Reset(boot_id=boot_id, op_id=op)
164
+ mb.reset_cache[(boot_id, op)] = ResetRecord(
165
+ boot_id=boot_id, op_id=op, status="accepted"
166
+ )
167
+ mb.host_mode = "resetting"
168
+ return op, "accepted"
169
+
170
+ async def _handle_hello(self, hello: Hello, addr: tuple[str, int]) -> None:
171
  mb = self.mailbox
172
  async with mb.lock:
173
+ changed = self._touch_peer(hello.boot_id, addr, hello=hello)
 
 
174
  robot = mb.host_robot
175
  mode = mb.host_mode
176
  err = mb.host_error
177
+ diag = hello.diag.as_dict() if hello.diag is not None else None
178
+ logger.info("hello boot_id=%s diag=%s", hello.boot_id, diag)
179
+ self._send(
180
+ addr,
181
+ encode_state_reply(robot=robot, mode=mode, error=err),
182
  )
183
+ if changed:
184
+ await self._maybe_reseed(hello.boot_id, hello)
185
 
186
+ async def _handle_sample(self, sample: Sample, addr: tuple[str, int]) -> None:
187
  mb = self.mailbox
188
  now = time.monotonic()
189
  async with mb.lock:
190
+ changed = self._touch_peer(sample.boot_id, addr, hello=None)
 
 
 
 
 
 
 
191
  if mb.last_seq is not None:
 
192
  if sample.seq == mb.last_seq:
193
+ op_ack, op_status = self._reset_fields(sample.boot_id, sample.op)
194
+ payload = encode_state_reply(
195
+ robot=mb.host_robot,
196
+ mode=mb.host_mode,
197
+ error=mb.host_error,
198
+ op_ack=op_ack,
199
+ op_status=op_status,
200
+ )
201
+ self._send(addr, payload)
202
  return
 
203
  if sample.seq < mb.last_seq:
 
204
  if mb.last_seq - sample.seq < 2**31:
205
  return
206
+ elif sample.seq > mb.last_seq + 1:
207
+ mb.seq_skips += 1
208
+ logger.warning(
209
+ "seq skip last=%s now=%s gap=%s",
210
+ mb.last_seq,
211
+ sample.seq,
212
+ sample.seq - mb.last_seq,
213
+ )
214
  mb.last_seq = sample.seq
215
+ mb.latest = LatestSample(sample=sample, receipt_time=now)
216
+ op_ack, op_status = self._reset_fields(sample.boot_id, sample.op)
217
+ payload = encode_state_reply(
218
+ robot=mb.host_robot,
219
+ mode=mb.host_mode,
220
+ error=mb.host_error,
221
+ op_ack=op_ack,
222
+ op_status=op_status,
223
+ )
224
 
225
+ if self._last_sample_receipt > 0.0:
226
+ gap = now - self._last_sample_receipt
227
+ if gap > 0.250:
228
+ mb.sample_gaps += 1
229
+ logger.warning("sample gap %.0f ms seq=%s", gap * 1000.0, sample.seq)
230
+ self._last_sample_receipt = now
231
+ self._send(addr, payload)
232
+ if changed:
233
+ await self._maybe_reseed(sample.boot_id, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
  async def take_latest_sample(self) -> LatestSample | None:
236
  mb = self.mailbox
 
260
  if status in {"completed", "failed"} and mb.host_mode == "resetting":
261
  mb.host_mode = "idle" if status == "completed" else "fault"
262
  mb.host_error = message if status == "failed" else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
  async def push_host_state(
265
  self,
 
279
  mb.host_error = None
280
  elif error is not None:
281
  mb.host_error = error
 
 
 
 
 
 
 
282
 
283
  async def snapshot_status(self) -> dict[str, Any]:
284
  mb = self.mailbox
285
  async with mb.lock:
286
+ last_rx = mb.last_rx
287
+ present = last_rx > 0.0 and (time.monotonic() - last_rx) < LINK_STALE_SEC
288
+ age_ms = (time.monotonic() - last_rx) * 1000.0 if last_rx > 0.0 else None
289
  return {
290
  "robot": mb.host_robot,
291
  "busy": mb.host_mode == "resetting",
292
  "mode": mb.host_mode,
293
+ "connected": present,
294
  "error": mb.host_error,
295
+ "boot_id": mb.boot_id,
296
+ "peer": f"{mb.peer[0]}:{mb.peer[1]}" if mb.peer else None,
297
+ "presents": mb.presents,
298
+ "absents": mb.absents,
299
+ "last_seq": mb.last_seq,
300
+ "seq_skips": mb.seq_skips,
301
+ "sample_gaps": mb.sample_gaps,
302
+ "last_rx_age_ms": None if age_ms is None else round(age_ms, 1),
303
+ "last_diag": mb.last_diag.as_dict() if mb.last_diag is not None else None,
304
+ "max_tick_lag_ms": round(self.max_tick_lag_ms, 1),
305
+ "last_sdk_ms": round(self.last_sdk_ms, 1),
306
  }
307
 
308
+ def _send(self, addr: tuple[str, int], payload: dict[str, Any]) -> None:
309
+ transport = self._transport
310
+ if transport is None:
311
+ return
312
+ try:
313
+ transport.sendto(json.dumps(payload, separators=(",", ":")).encode(), addr)
314
+ except Exception as exc:
315
+ logger.warning("UDP send failed: %s", exc)
 
 
 
 
 
 
esp32_motion_controller/static/index.html CHANGED
@@ -12,12 +12,12 @@
12
  </head>
13
  <body>
14
  <h1>🕹️ ESP32 Motion Controller</h1>
15
- <p>ESP32 handheld IMU controller bridge for Reachy Mini (protocol v2).</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>
 
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>
19
  fetch('/api/info').then(r => r.json()).then(j => {
20
+ if (j.udp) document.getElementById('udp').textContent = j.udp;
21
  }).catch(() => {});
22
  </script>
23
  </body>
esp32_motion_controller/ws_handler.py DELETED
@@ -1,296 +0,0 @@
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 CHANGED
@@ -13,7 +13,7 @@
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>
 
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>UDP once running: <code>&lt;host&gt;:8766</code></p>
17
  </main>
18
  </body>
19
  </html>
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "esp32_motion_controller"
7
- version = "2.0.0"
8
  description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
@@ -19,7 +19,7 @@ dependencies = [
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"
 
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"
 
19
  keywords = ["reachy-mini-app"]
20
 
21
  [project.optional-dependencies]
22
+ dev = ["pytest", "pytest-asyncio", "httpx"]
23
 
24
  [project.entry-points."reachy_mini_apps"]
25
  esp32_motion_controller = "esp32_motion_controller.main:Esp32MotionController"