V4C38 commited on
Commit
836f6f0
·
verified ·
1 Parent(s): 9de9b10

v2: protocol rewrite with single host control owner

Browse files
README.md CHANGED
@@ -16,9 +16,9 @@ tags:
16
 
17
  # ESP32 Motion Controller
18
 
19
- WebSocket bridge for an ESP32 handheld IMU controller that drives Reachy Mini's head with clutch-style relative motion, IK-safe clamping, antenna idle animation, and body follow only when head exceeds the neck yaw threshold.
20
 
21
- See the [repository README](https://github.com/V4C38/esp32-reachy-mini-controller) and [`PROTOCOL.md`](../PROTOCOL.md).
22
 
23
  ## Local install
24
 
 
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
 
esp32_motion_controller/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- """ESP32 Motion Controller Reachy Mini app."""
2
 
3
- __version__ = "1.0.0"
 
1
+ """ESP32 Motion Controller Reachy Mini app (protocol v2)."""
2
 
3
+ __version__ = "2.0.0"
esp32_motion_controller/control.py ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, field, replace
12
+ from typing import Sequence
13
+
14
+ import numpy as np
15
+ from scipy.spatial.transform import Rotation as R
16
+
17
+ from esp32_motion_controller.protocol import Sample
18
+
19
+ POSE_AXES = ("x", "y", "z", "roll", "pitch", "yaw")
20
+ ANGULAR_AXES = ("roll", "pitch", "yaw")
21
+ POSITIONAL_AXES = ("x", "y", "z")
22
+
23
+ DEV_TO_HEAD = np.array(
24
+ [
25
+ [0.0, 0.0, 1.0],
26
+ [1.0, 0.0, 0.0],
27
+ [0.0, 1.0, 0.0],
28
+ ],
29
+ dtype=np.float64,
30
+ )
31
+
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
+
39
+ # Match v1 feel: POSE_ALPHA=0.12 at 33 ms ≈ tau ~0.26 s
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
48
+ LIMIT_HEAD_YAW_RAD = math.pi
49
+ LIMIT_HEAD_BODY_YAW_DELTA_RAD = 65.0 * math.pi / 180.0
50
+
51
+ LIMIT_HEAD_X_MIN = -0.020
52
+ LIMIT_HEAD_X_MAX = 0.020
53
+ LIMIT_HEAD_Y_MIN = -0.020
54
+ LIMIT_HEAD_Y_MAX = 0.020
55
+ LIMIT_HEAD_Z_MIN = 0.0
56
+ LIMIT_HEAD_Z_MAX = 0.025
57
+
58
+ ELLIPSOID_X_MAX = 0.015
59
+ ELLIPSOID_Y_MAX = 0.015
60
+ ELLIPSOID_Z_MAX = 0.018
61
+ ELLIPSOID_ROLL_MAX_RAD = 25.0 * math.pi / 180.0
62
+ ELLIPSOID_PITCH_MAX_RAD = 25.0 * math.pi / 180.0
63
+
64
+ MAX_HEAD_YAW = 65.0 * math.pi / 180.0
65
+ BODY_FOLLOW_THRESHOLD = 40.0 * math.pi / 180.0
66
+ MAX_BODY_YAW = 160.0 * math.pi / 180.0
67
+
68
+ DEFAULT_LIVELINESS = 1.25
69
+ DEFAULT_GAZE_RESPONSIVENESS = 1.2
70
+ DEFAULT_ANTENNA_ACTIVITY = 0.8
71
+ HEAD_MOVE_SPEED = 0.06
72
+ MAX_HEAD_DELTA_DEG = 2.0
73
+ ANTENNA_AMPLITUDE_DEG = 15.0
74
+
75
+ IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
76
+ IK_FAIL_CONSECUTIVE_THRESHOLD = 3
77
+
78
+ Mode = str # idle | engaged | resetting | fault
79
+
80
+
81
+ 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
+
88
+
89
+ def _finite_quat(q: Sequence[float]) -> np.ndarray:
90
+ arr = np.asarray(q, dtype=np.float64).reshape(4)
91
+ if not np.all(np.isfinite(arr)) or np.linalg.norm(arr) < 1e-9:
92
+ return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
93
+ return arr / np.linalg.norm(arr)
94
+
95
+
96
+ def _finite_vec3(v: Sequence[float]) -> np.ndarray:
97
+ arr = np.asarray(v, dtype=np.float64).reshape(3)
98
+ if not np.all(np.isfinite(arr)):
99
+ return np.zeros(3, dtype=np.float64)
100
+ return arr
101
+
102
+
103
+ 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,
120
+ *,
121
+ translation_gain: float = TRANSLATION_GAIN_DEFAULT,
122
+ ) -> np.ndarray:
123
+ r_ref = _wxyz_to_rotation(q_ref)
124
+ disp_ref = r_ref.inv().apply(p_world_delta)
125
+ disp_head = DEV_TO_HEAD @ disp_ref
126
+ return disp_head * TRANSLATION_SCALE * float(translation_gain)
127
+
128
+
129
+ def dual_sine(t: float, freq_a: float, freq_b: float) -> float:
130
+ return math.sin(t * freq_a) * 0.6 + math.sin(t * freq_b) * 0.4
131
+
132
+
133
+ def clamp_stewart_ellipsoid(
134
+ x: float, y: float, z: float, roll: float, pitch: float,
135
+ ) -> tuple[float, float, float, float, float]:
136
+ z_clamped = _clamp(z, LIMIT_HEAD_Z_MIN, LIMIT_HEAD_Z_MAX)
137
+ x_clamped = _clamp(x, LIMIT_HEAD_X_MIN, LIMIT_HEAD_X_MAX)
138
+ y_clamped = _clamp(y, LIMIT_HEAD_Y_MIN, LIMIT_HEAD_Y_MAX)
139
+
140
+ roll_c = _clamp(roll, -ELLIPSOID_ROLL_MAX_RAD, ELLIPSOID_ROLL_MAX_RAD)
141
+ pitch_c = _clamp(pitch, -ELLIPSOID_PITCH_MAX_RAD, ELLIPSOID_PITCH_MAX_RAD)
142
+
143
+ nr = roll_c / ELLIPSOID_ROLL_MAX_RAD if ELLIPSOID_ROLL_MAX_RAD > 0 else 0.0
144
+ np_ = pitch_c / ELLIPSOID_PITCH_MAX_RAD if ELLIPSOID_PITCH_MAX_RAD > 0 else 0.0
145
+ remaining = max(0.0, 1.0 - (nr * nr + np_ * np_))
146
+
147
+ nx = x_clamped / ELLIPSOID_X_MAX if ELLIPSOID_X_MAX > 0 else 0.0
148
+ ny = y_clamped / ELLIPSOID_Y_MAX if ELLIPSOID_Y_MAX > 0 else 0.0
149
+ nz = z_clamped / ELLIPSOID_Z_MAX if ELLIPSOID_Z_MAX > 0 else 0.0
150
+ trans_sq = nx * nx + ny * ny + nz * nz
151
+
152
+ if trans_sq <= remaining or trans_sq <= 1e-12:
153
+ return (x_clamped, y_clamped, z_clamped, roll_c, pitch_c)
154
+
155
+ scale = math.sqrt(remaining / trans_sq)
156
+ return (
157
+ x_clamped * scale,
158
+ y_clamped * scale,
159
+ z_clamped * scale,
160
+ roll_c,
161
+ pitch_c,
162
+ )
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)
180
+ delta = out_pose["yaw"] - body_yaw_clamped
181
+ if delta > LIMIT_HEAD_BODY_YAW_DELTA_RAD:
182
+ out_pose["yaw"] = body_yaw_clamped + LIMIT_HEAD_BODY_YAW_DELTA_RAD
183
+ elif delta < -LIMIT_HEAD_BODY_YAW_DELTA_RAD:
184
+ out_pose["yaw"] = body_yaw_clamped - LIMIT_HEAD_BODY_YAW_DELTA_RAD
185
+ return out_pose, body_yaw_clamped
186
+
187
+
188
+ def _alpha(dt: float, tau: float) -> float:
189
+ if tau <= 0.0:
190
+ return 1.0
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
217
+ class ControlState:
218
+ mode: Mode = "idle"
219
+ robot_available: bool = True
220
+ error: str | None = None
221
+
222
+ engaged: bool = False
223
+ was_engaged: bool = False
224
+ gain: float = 1.0
225
+ ready: bool = False
226
+ translation_gain: float = TRANSLATION_GAIN_DEFAULT
227
+
228
+ q_ref: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0]))
229
+ p_ref: np.ndarray = field(default_factory=lambda: np.zeros(3))
230
+ base_pose: dict[str, float] = field(default_factory=zero_pose)
231
+ desired_pose: dict[str, float] = field(default_factory=zero_pose)
232
+
233
+ body_yaw: float = 0.0
234
+ antenna_left: float = 0.0
235
+ antenna_right: float = 0.0
236
+ behavior_t0: float = 0.0
237
+
238
+ smooth_pose: dict[str, float] = field(default_factory=zero_pose)
239
+ smooth_body_yaw: float = 0.0
240
+ smooth_antennas: list[float] = field(default_factory=lambda: [0.0, 0.0])
241
+
242
+ baseline_pose: dict[str, float] = field(default_factory=zero_pose)
243
+ baseline_body_yaw: float = 0.0
244
+ baseline_antennas: list[float] = field(default_factory=lambda: [0.0, 0.0])
245
+
246
+ last_sample_time: float = 0.0
247
+ have_sample: bool = False
248
+ sends_frozen: bool = True
249
+ seeded: bool = False
250
+ consecutive_ik_failures: int = 0
251
+
252
+ liveliness: float = DEFAULT_LIVELINESS
253
+ gaze_responsiveness: float = DEFAULT_GAZE_RESPONSIVENESS
254
+ antenna_activity: float = DEFAULT_ANTENNA_ACTIVITY
255
+
256
+
257
+ @dataclass(frozen=True, slots=True)
258
+ class Command:
259
+ pose: dict[str, float]
260
+ body_yaw: float
261
+ antennas: tuple[float, float]
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class StepResult:
266
+ state: ControlState
267
+ command: Command | None
268
+ mode_changed: bool = False
269
+ host_error: str | None = None
270
+
271
+
272
+ def initial_state(*, robot_available: bool, now: float) -> ControlState:
273
+ return ControlState(
274
+ robot_available=robot_available,
275
+ behavior_t0=now,
276
+ sends_frozen=not robot_available,
277
+ seeded=not robot_available,
278
+ mode="idle",
279
+ )
280
+
281
+
282
+ def seed_from_pose(
283
+ state: ControlState,
284
+ pose: dict[str, float],
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,
296
+ base_pose=dict(pose_c),
297
+ desired_pose=dict(pose_c),
298
+ smooth_pose=dict(pose_c),
299
+ baseline_pose=dict(pose_c),
300
+ body_yaw=body_c,
301
+ smooth_body_yaw=body_c,
302
+ baseline_body_yaw=body_c,
303
+ antenna_left=float(ants[0]),
304
+ antenna_right=float(ants[1]),
305
+ smooth_antennas=[float(ants[0]), float(ants[1])],
306
+ baseline_antennas=[float(ants[0]), float(ants[1])],
307
+ seeded=True,
308
+ sends_frozen=False,
309
+ consecutive_ik_failures=0,
310
+ engaged=False,
311
+ was_engaged=False,
312
+ mode="idle" if state.mode != "resetting" else state.mode,
313
+ error=None,
314
+ )
315
+
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],
332
+ engaged=False,
333
+ was_engaged=False,
334
+ q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
335
+ p_ref=np.zeros(3),
336
+ sends_frozen=True,
337
+ mode="fault",
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],
354
+ baseline_antennas=[0.0, 0.0],
355
+ engaged=False,
356
+ was_engaged=False,
357
+ q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
358
+ p_ref=np.zeros(3),
359
+ sends_frozen=False,
360
+ seeded=True,
361
+ mode="idle",
362
+ error=None,
363
+ consecutive_ik_failures=0,
364
+ )
365
+
366
+
367
+ def begin_reset(state: ControlState, now: float) -> ControlState:
368
+ st = force_disengage(state)
369
+ return replace(
370
+ st,
371
+ mode="resetting",
372
+ error=None,
373
+ base_pose=zero_pose(),
374
+ desired_pose=zero_pose(),
375
+ body_yaw=0.0,
376
+ antenna_left=0.0,
377
+ antenna_right=0.0,
378
+ was_engaged=False,
379
+ engaged=False,
380
+ q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
381
+ p_ref=np.zeros(3),
382
+ behavior_t0=now,
383
+ )
384
+
385
+
386
+ def force_disengage(state: ControlState) -> ControlState:
387
+ if state.engaged:
388
+ return replace(
389
+ state,
390
+ base_pose=dict(state.desired_pose),
391
+ engaged=False,
392
+ was_engaged=False,
393
+ mode="idle" if state.mode == "engaged" else state.mode,
394
+ )
395
+ return replace(state, engaged=False, was_engaged=False)
396
+
397
+
398
+ def mark_sdk_success(state: ControlState, command: Command) -> ControlState:
399
+ return replace(
400
+ state,
401
+ baseline_pose=dict(command.pose),
402
+ baseline_body_yaw=command.body_yaw,
403
+ baseline_antennas=list(command.antennas),
404
+ consecutive_ik_failures=0,
405
+ sends_frozen=False,
406
+ )
407
+
408
+
409
+ def mark_sdk_failure(state: ControlState) -> ControlState:
410
+ fails = state.consecutive_ik_failures + 1
411
+ desired = dict(state.desired_pose)
412
+ body = state.body_yaw
413
+ if fails >= IK_FAIL_CONSECUTIVE_THRESHOLD:
414
+ alpha = IK_FAIL_RETRACT_TARGET_ALPHA
415
+ for ax in POSE_AXES:
416
+ desired[ax] *= 1.0 - alpha
417
+ body *= 1.0 - alpha
418
+ return replace(
419
+ state,
420
+ consecutive_ik_failures=fails,
421
+ desired_pose=desired,
422
+ body_yaw=body,
423
+ sends_frozen=True,
424
+ mode="fault",
425
+ error="set_target failed",
426
+ )
427
+
428
+
429
+ def mark_pose_unread(state: ControlState) -> ControlState:
430
+ return replace(
431
+ state,
432
+ sends_frozen=True,
433
+ mode="fault",
434
+ error="robot pose unread",
435
+ )
436
+
437
+
438
+ def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -> ControlState:
439
+ gain = _clamp(float(sample.gain), 0.1, 3.0)
440
+ q_dev = _finite_quat(sample.q)
441
+ p_dev = _finite_vec3(sample.p)
442
+ want = bool(sample.engaged) and bool(sample.ready) and allow_engage
443
+ rising = want and not state.was_engaged
444
+ falling = (not want) and state.was_engaged
445
+
446
+ q_ref = state.q_ref
447
+ p_ref = state.p_ref
448
+ base = dict(state.base_pose)
449
+ desired = dict(state.desired_pose)
450
+
451
+ if rising:
452
+ q_ref = q_dev.copy()
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,
460
+ translation_gain=state.translation_gain * gain,
461
+ )
462
+ desired = {
463
+ "x": base["x"] + float(disp[0]),
464
+ "y": base["y"] + float(disp[1]),
465
+ "z": base["z"] + float(disp[2]),
466
+ "roll": base["roll"] + gain * roll,
467
+ "pitch": base["pitch"] + gain * pitch,
468
+ "yaw": base["yaw"] + gain * yaw,
469
+ }
470
+ elif falling:
471
+ base = dict(desired)
472
+
473
+ return replace(
474
+ state,
475
+ ready=bool(sample.ready),
476
+ gain=gain,
477
+ q_ref=q_ref,
478
+ p_ref=p_ref,
479
+ base_pose=base,
480
+ desired_pose=desired,
481
+ engaged=want,
482
+ was_engaged=want,
483
+ )
484
+
485
+
486
+ def _advance_behavior(state: ControlState, head_yaw: float, now: float, dt: float) -> ControlState:
487
+ # Normalize behavior update against the legacy ~33 ms tick using dt scaling.
488
+ tick_scale = dt / 0.033 if dt > 0 else 1.0
489
+ t = now - state.behavior_t0
490
+ deg = math.pi / 180.0
491
+
492
+ yaw_smoothing = HEAD_MOVE_SPEED * state.gaze_responsiveness * tick_scale
493
+ max_yaw_delta = MAX_HEAD_DELTA_DEG * state.gaze_responsiveness * deg * tick_scale
494
+ body_smoothing = yaw_smoothing * 0.7 * (0.3 + state.liveliness * 0.4)
495
+ antenna_smoothing = yaw_smoothing * 1.5
496
+ effective_ant_amp = ANTENNA_AMPLITUDE_DEG * state.antenna_activity * deg
497
+ ant_speed = 0.5 + state.antenna_activity * 0.5
498
+
499
+ body_yaw = state.body_yaw
500
+ rel_yaw = head_yaw - body_yaw
501
+ if abs(rel_yaw) > BODY_FOLLOW_THRESHOLD:
502
+ excess = abs(rel_yaw) - BODY_FOLLOW_THRESHOLD
503
+ step = math.copysign(excess * body_smoothing * 8, rel_yaw)
504
+ body_yaw += _clamp(step, -max_yaw_delta, max_yaw_delta)
505
+ body_yaw = _clamp(body_yaw, -MAX_BODY_YAW, MAX_BODY_YAW)
506
+
507
+ desired_l = dual_sine(t * ant_speed, 1.3, 3.11) * effective_ant_amp
508
+ desired_r = dual_sine(t * ant_speed, 1.7, 2.73) * effective_ant_amp
509
+ ant_l = state.antenna_left + (desired_l - state.antenna_left) * antenna_smoothing
510
+ ant_r = state.antenna_right + (desired_r - state.antenna_right) * antenna_smoothing
511
+
512
+ return replace(
513
+ state,
514
+ body_yaw=body_yaw,
515
+ antenna_left=ant_l,
516
+ antenna_right=ant_r,
517
+ )
518
+
519
+
520
+ def step(
521
+ state: ControlState,
522
+ *,
523
+ now: float,
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
535
+
536
+ if st.mode == "resetting":
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
547
+ st = _update_clutch(st, sample, allow_engage=allow_engage)
548
+ st = replace(st, have_sample=True)
549
+
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])
561
+ for k in POSE_AXES
562
+ }
563
+ smooth_body = st.smooth_body_yaw + a_pose * (target_body - st.smooth_body_yaw)
564
+ smooth_ants = [
565
+ st.smooth_antennas[i] + a_ant * (target_ants[i] - st.smooth_antennas[i])
566
+ for i in range(2)
567
+ ]
568
+
569
+ new_mode: Mode = st.mode
570
+ if st.mode not in {"resetting", "fault"}:
571
+ new_mode = "engaged" if st.engaged else "idle"
572
+
573
+ st = replace(
574
+ st,
575
+ desired_pose=dict(target_pose),
576
+ body_yaw=target_body,
577
+ smooth_pose=smooth,
578
+ smooth_body_yaw=smooth_body,
579
+ smooth_antennas=smooth_ants,
580
+ mode=new_mode,
581
+ )
582
+
583
+ if st.sends_frozen or not st.seeded:
584
+ return StepResult(
585
+ state=st,
586
+ command=None,
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,
599
+ body_yaw=send_body,
600
+ antennas=(smooth_ants[0], smooth_ants[1]),
601
+ )
602
+ return StepResult(
603
+ state=st,
604
+ command=command,
605
+ mode_changed=(st.mode != prev_mode),
606
+ )
607
+
608
+
609
+ def note_sample_receipt(state: ControlState, receipt_time: float) -> ControlState:
610
+ return replace(state, last_sample_time=receipt_time, have_sample=True)
esp32_motion_controller/main.py CHANGED
@@ -1,14 +1,13 @@
1
  """
2
- Motion Controller Reachy Mini app entry point.
3
 
4
- FastAPI/uvicorn on port 8766, mDNS advertise _reachyctl._tcp, clutch + behavior bridge.
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import argparse
10
  import logging
11
- import os
12
  import socket
13
  import sys
14
  import threading
@@ -20,31 +19,17 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
20
  from fastapi.responses import FileResponse, JSONResponse
21
  from fastapi.staticfiles import StaticFiles
22
 
23
- from esp32_motion_controller.behavior import Behavior
24
- from esp32_motion_controller.controller_state import ControllerState
25
- from esp32_motion_controller.movement_handler import MovementHandler
26
- from esp32_motion_controller.ws_handler import WebSocketHandler
27
 
28
  logger = logging.getLogger(__name__)
29
 
30
  WS_PORT = 8766
31
  STATIC_DIR = Path(__file__).parent / "static"
32
  MDNS_SERVICE_TYPE = "_reachyctl._tcp.local."
33
- ENV_SEND_RATE_HZ = "REACHY_MOTION_SEND_RATE_HZ"
34
- DEFAULT_SEND_RATE_HZ = 20.0
35
  SERVER_START_TIMEOUT_S = 10.0
36
 
37
 
38
- def _get_send_rate_hz() -> float:
39
- raw = os.environ.get(ENV_SEND_RATE_HZ)
40
- if raw is None or raw.strip() == "":
41
- return DEFAULT_SEND_RATE_HZ
42
- try:
43
- return max(5.0, min(50.0, float(raw.strip())))
44
- except ValueError:
45
- return DEFAULT_SEND_RATE_HZ
46
-
47
-
48
  def get_local_ips() -> list[str]:
49
  ips: list[str] = []
50
  try:
@@ -89,14 +74,10 @@ class MdnsAdvertiser:
89
  f"esp32-motion-controller.{MDNS_SERVICE_TYPE}",
90
  addresses=[socket.inet_aton(ip) for ip in ips],
91
  port=self.port,
92
- properties={"path": b"/ws"},
93
  server="esp32-motion.local.",
94
  )
95
  zc = Zeroconf()
96
- # An advertisement from a previous run lingers in the mDNS cache for
97
- # the record TTL (75 min), so a restart collides with itself.
98
- # Renaming is safe: the controller browses the service type, never
99
- # the instance name.
100
  zc.register_service(info, allow_name_change=True)
101
  except Exception:
102
  logger.warning(
@@ -136,35 +117,41 @@ def create_app(
136
  log_only: bool = False,
137
  ) -> FastAPI:
138
  app = FastAPI(title="ESP32 Motion Controller")
139
- send_rate = _get_send_rate_hz()
140
  robot_available = reachy_mini is not None and not log_only
141
- movement = MovementHandler(reachy_mini, send_rate_hz=send_rate)
142
- controller = ControllerState()
143
- behavior = Behavior()
144
- ws_handler = WebSocketHandler(
145
- movement,
146
- controller,
147
- behavior,
148
- robot_available=robot_available or log_only,
149
  log_only=log_only,
150
  )
151
- app.state.ws_handler = ws_handler
 
 
 
 
 
 
 
 
 
152
 
153
  @app.websocket("/ws")
154
  async def websocket_endpoint(websocket: WebSocket) -> None:
155
- admitted = await ws_handler.on_connect(websocket)
156
- if not admitted:
157
- return
158
  try:
159
  while not stop_event.is_set():
160
  raw = await websocket.receive_text()
161
- await ws_handler.handle_message(websocket, raw)
162
  except WebSocketDisconnect:
163
  logger.info("WebSocket disconnect")
164
  except Exception as exc:
165
  logger.error("WebSocket error: %s", exc)
166
  finally:
167
- ws_handler.cleanup(websocket)
 
168
 
169
  @app.get("/api/info")
170
  async def info() -> JSONResponse:
@@ -175,17 +162,22 @@ def create_app(
175
  "port": WS_PORT,
176
  "ws_url": f"ws://{ips[0]}:{WS_PORT}/ws" if ips else None,
177
  "mdns": MDNS_SERVICE_TYPE,
 
178
  }
179
  )
180
 
181
  @app.get("/api/status")
182
  async def status() -> JSONResponse:
 
183
  return JSONResponse(
184
  {
185
  "status": "ok",
186
  "robot": robot_available or log_only,
187
- "busy": ws_handler.busy,
 
 
188
  "log_only": log_only,
 
189
  }
190
  )
191
 
@@ -200,7 +192,7 @@ def create_app(
200
  def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
201
  ips = get_local_ips()
202
  logger.info("=" * 50)
203
- logger.info("ESP32 Motion Controller")
204
  logger.info("=" * 50)
205
  for ip in ips:
206
  logger.info(" WebSocket: ws://%s:%d/ws", ip, WS_PORT)
@@ -209,15 +201,11 @@ def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) ->
209
  logger.info("=" * 50)
210
 
211
  app = create_app(reachy_mini, stop_event, log_only=log_only)
212
- # Stash for shutdown — create_app closes over the handler.
213
- ws_handler: WebSocketHandler = app.state.ws_handler # type: ignore[attr-defined]
214
  config = uvicorn.Config(app, host="0.0.0.0", port=WS_PORT, log_level="info")
215
  server = uvicorn.Server(config)
216
  thread = threading.Thread(target=server.run, daemon=True)
217
  thread.start()
218
 
219
- # A failed bind only kills the server thread, so without this the app would
220
- # sit here advertising a port nothing listens on.
221
  deadline = time.monotonic() + SERVER_START_TIMEOUT_S
222
  while not server.started and thread.is_alive() and time.monotonic() < deadline:
223
  time.sleep(0.05)
@@ -227,12 +215,11 @@ def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) ->
227
  f"instance is probably already running"
228
  )
229
 
230
- # Advertise only once we are actually listening.
231
  mdns = MdnsAdvertiser(WS_PORT)
232
  mdns.start()
233
 
234
  stop_event.wait()
235
- ws_handler.shutdown()
236
  server.should_exit = True
237
  thread.join(timeout=5)
238
  mdns.stop()
 
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
13
  import threading
 
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]:
34
  ips: list[str] = []
35
  try:
 
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()
 
 
 
 
81
  zc.register_service(info, allow_name_change=True)
82
  except Exception:
83
  logger.warning(
 
117
  log_only: bool = False,
118
  ) -> FastAPI:
119
  app = FastAPI(title="ESP32 Motion Controller")
 
120
  robot_available = reachy_mini is not None and not log_only
121
+ session = SessionHub(robot_available=robot_available or log_only)
122
+ gateway = RobotGateway(reachy_mini, log_only=log_only)
123
+ control = RobotControl(
124
+ session,
125
+ gateway,
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:
 
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
 
169
  @app.get("/api/status")
170
  async def status() -> JSONResponse:
171
+ snap = await session.snapshot_status()
172
  return JSONResponse(
173
  {
174
  "status": "ok",
175
  "robot": robot_available or log_only,
176
+ "busy": snap["busy"],
177
+ "mode": snap["mode"],
178
+ "connected": snap["connected"],
179
  "log_only": log_only,
180
+ "protocol_version": 2,
181
  }
182
  )
183
 
 
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)
 
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()
208
 
 
 
209
  deadline = time.monotonic() + SERVER_START_TIMEOUT_S
210
  while not server.started and thread.is_alive() and time.monotonic() < deadline:
211
  time.sleep(0.05)
 
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()
esp32_motion_controller/protocol.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
+
9
+ import json
10
+ 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):
21
+ def __init__(self, message: str, *, request_type: str = "unknown") -> None:
22
+ super().__init__(message)
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)
34
+ class Sample:
35
+ boot_id: str
36
+ seq: int
37
+ q: tuple[float, float, float, float]
38
+ p: tuple[float, float, float]
39
+ engaged: bool
40
+ gain: float
41
+ ready: bool
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Reset:
46
+ boot_id: str
47
+ op_id: int
48
+
49
+
50
+ def _require_dict(msg: Any, request_type: str) -> dict[str, Any]:
51
+ if not isinstance(msg, dict):
52
+ raise ProtocolError("JSON root must be an object", request_type=request_type)
53
+ return msg
54
+
55
+
56
+ def _require_str(msg: dict[str, Any], key: str, request_type: str) -> str:
57
+ val = msg.get(key)
58
+ if not isinstance(val, str) or not val:
59
+ raise ProtocolError(f"{key} must be a non-empty string", request_type=request_type)
60
+ return val
61
+
62
+
63
+ def _require_int(msg: dict[str, Any], key: str, request_type: str) -> int:
64
+ val = msg.get(key)
65
+ if isinstance(val, bool) or not isinstance(val, int):
66
+ raise ProtocolError(f"{key} must be an integer", request_type=request_type)
67
+ return int(val)
68
+
69
+
70
+ def _require_bool(msg: dict[str, Any], key: str, request_type: str) -> bool:
71
+ val = msg.get(key)
72
+ if not isinstance(val, bool):
73
+ raise ProtocolError(f"{key} must be a boolean", request_type=request_type)
74
+ return val
75
+
76
+
77
+ def _require_finite_float(msg: dict[str, Any], key: str, request_type: str) -> float:
78
+ val = msg.get(key)
79
+ if isinstance(val, bool) or not isinstance(val, (int, float)):
80
+ raise ProtocolError(f"{key} must be a number", request_type=request_type)
81
+ out = float(val)
82
+ if not math.isfinite(out):
83
+ raise ProtocolError(f"{key} must be finite", request_type=request_type)
84
+ return out
85
+
86
+
87
+ def _require_vec(msg: dict[str, Any], key: str, n: int, request_type: str) -> tuple[float, ...]:
88
+ val = msg.get(key)
89
+ if not isinstance(val, list) or len(val) != n:
90
+ raise ProtocolError(f"{key} must be an array of length {n}", request_type=request_type)
91
+ out: list[float] = []
92
+ for i, item in enumerate(val):
93
+ if isinstance(item, bool) or not isinstance(item, (int, float)):
94
+ raise ProtocolError(f"{key}[{i}] must be a number", request_type=request_type)
95
+ f = float(item)
96
+ if not math.isfinite(f):
97
+ raise ProtocolError(f"{key}[{i}] must be finite", request_type=request_type)
98
+ out.append(f)
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")
106
+ try:
107
+ text = raw.decode("utf-8")
108
+ except UnicodeDecodeError as exc:
109
+ raise ProtocolError(f"invalid UTF-8: {exc}", request_type="parse") from exc
110
+ else:
111
+ if len(raw.encode("utf-8")) > MAX_FRAME_BYTES:
112
+ raise ProtocolError("frame exceeds size limit", request_type="parse")
113
+ text = raw
114
+
115
+ try:
116
+ msg = json.loads(text)
117
+ except json.JSONDecodeError as exc:
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
+
149
+ def parse_sample(msg: dict[str, Any]) -> Sample:
150
+ request_type = "sample"
151
+ q = _require_vec(msg, "q", 4, request_type)
152
+ p = _require_vec(msg, "p", 3, request_type)
153
+ gain = _require_finite_float(msg, "gain", request_type)
154
+ if gain < GAIN_MIN or gain > GAIN_MAX:
155
+ raise ProtocolError(
156
+ f"gain out of range [{GAIN_MIN}, {GAIN_MAX}]",
157
+ request_type=request_type,
158
+ )
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,
165
+ q=(q[0], q[1], q[2], q[3]),
166
+ p=(p[0], p[1], p[2]),
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
+ }
esp32_motion_controller/robot_control.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )
35
+ 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:
46
+ """Thin SDK adapter — all blocking calls run in an executor by RobotControl."""
47
+
48
+ def __init__(self, reachy_mini: Any | None, *, log_only: bool = False) -> None:
49
+ self.mini = reachy_mini
50
+ self.log_only = log_only
51
+
52
+ def read_pose(self) -> tuple[dict[str, float], float] | None:
53
+ if self.mini is None:
54
+ return zero_pose(), 0.0
55
+ try:
56
+ head = np.asarray(self.mini.get_current_head_pose(), dtype=np.float64)
57
+ joints, _ = self.mini.get_current_joint_positions()
58
+ body_yaw = float(joints[0])
59
+ except Exception as exc:
60
+ logger.warning("Could not read robot pose: %s", exc)
61
+ return None
62
+ rpy = Rotation.from_matrix(head[:3, :3]).as_euler("xyz")
63
+ pose = {
64
+ "x": float(head[0, 3]),
65
+ "y": float(head[1, 3]),
66
+ "z": float(head[2, 3]),
67
+ "roll": float(rpy[0]),
68
+ "pitch": float(rpy[1]),
69
+ "yaw": float(rpy[2]),
70
+ }
71
+ return pose, body_yaw
72
+
73
+ def set_target(self, command: Command) -> None:
74
+ if self.mini is None or self.log_only:
75
+ return
76
+ from reachy_mini.utils import create_head_pose
77
+
78
+ head = create_head_pose(
79
+ x=command.pose["x"],
80
+ y=command.pose["y"],
81
+ z=command.pose["z"],
82
+ roll=command.pose["roll"],
83
+ pitch=command.pose["pitch"],
84
+ yaw=command.pose["yaw"],
85
+ degrees=False,
86
+ )
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__(
110
+ self,
111
+ session: SessionHub,
112
+ gateway: RobotGateway,
113
+ *,
114
+ robot_available: bool,
115
+ log_only: bool = False,
116
+ hz: float = CONTROL_HZ,
117
+ ) -> None:
118
+ self.session = session
119
+ self.gateway = gateway
120
+ self.robot_available = robot_available
121
+ self.log_only = log_only
122
+ self.dt = 1.0 / hz
123
+ self._task: asyncio.Task[None] | None = None
124
+ self._state = initial_state(robot_available=robot_available or log_only, now=time.monotonic())
125
+ self._last_seen_seq: int | None = None
126
+ self._last_reconcile = 0.0
127
+ self._sdk_lock = asyncio.Lock()
128
+ self._started = False
129
+
130
+ @property
131
+ def state(self) -> ControlState:
132
+ return self._state
133
+
134
+ def start(self) -> None:
135
+ if self._task is None or self._task.done():
136
+ self._task = asyncio.create_task(self._loop(), name="robot_control")
137
+ self._started = True
138
+
139
+ async def stop(self) -> None:
140
+ if self._task is not None and not self._task.done():
141
+ self._task.cancel()
142
+ try:
143
+ await self._task
144
+ except asyncio.CancelledError:
145
+ pass
146
+ self._task = None
147
+ self._started = False
148
+
149
+ async def _loop(self) -> None:
150
+ loop = asyncio.get_running_loop()
151
+ last = time.monotonic()
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)
163
+
164
+ elapsed = time.monotonic() - t0
165
+ await asyncio.sleep(max(0.0, self.dt - elapsed))
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
178
+ sample_is_fresh = False
179
+ if latest is not None:
180
+ sample = latest.sample
181
+ if self._last_seen_seq != sample.seq:
182
+ sample_is_fresh = True
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
+ ):
193
+ await self._reconcile_idle(loop, now)
194
+
195
+ prev_mode = self._state.mode
196
+ result = step(
197
+ self._state,
198
+ now=now,
199
+ dt=dt,
200
+ sample=sample,
201
+ sample_is_fresh=sample_is_fresh,
202
+ )
203
+ self._state = result.state
204
+
205
+ if result.mode_changed or self._state.mode != prev_mode:
206
+ await self.session.push_host_state(
207
+ mode=self._state.mode,
208
+ robot=self.robot_available or self.log_only,
209
+ error=self._state.error,
210
+ clear_error=self._state.error is None,
211
+ )
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()
242
+ read = await loop.run_in_executor(None, self.gateway.read_pose)
243
+ if read is None:
244
+ self._state = mark_pose_unread(self._state)
245
+ if update_host:
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)
253
+ return True
254
+
255
+ async def _reconcile_idle(
256
+ self, loop: asyncio.AbstractEventLoop, now: float
257
+ ) -> None:
258
+ self._last_reconcile = now
259
+ if self.gateway.mini is None:
260
+ return
261
+ read = await loop.run_in_executor(None, self.gateway.read_pose)
262
+ if read is None:
263
+ self._state = mark_pose_unread(self._state)
264
+ await self.session.push_host_state(mode="fault", error=self._state.error)
265
+ return
266
+ pose, body = read
267
+ prev = self._state.baseline_pose
268
+ pos = (
269
+ (pose["x"] - prev["x"]) ** 2
270
+ + (pose["y"] - prev["y"]) ** 2
271
+ + (pose["z"] - prev["z"]) ** 2
272
+ ) ** 0.5
273
+ ang = max(
274
+ abs(pose["roll"] - prev["roll"]),
275
+ abs(pose["pitch"] - prev["pitch"]),
276
+ abs(pose["yaw"] - prev["yaw"]),
277
+ abs(body - self._state.baseline_body_yaw),
278
+ )
279
+ if pos <= RESYNC_EPS_POS_M and ang <= RESYNC_EPS_ANG_RAD:
280
+ self._state = mark_sdk_success(
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")
esp32_motion_controller/session.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
44
+ class ResetRecord:
45
+ boot_id: str
46
+ op_id: int
47
+ status: str # accepted | completed | failed
48
+ message: str | None = None
49
+
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
62
+ reset_cache: dict[tuple[str, int], ResetRecord] = field(default_factory=dict)
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
238
+ async with mb.lock:
239
+ return mb.latest
240
+
241
+ async def take_pending_reset(self) -> Reset | None:
242
+ mb = self.mailbox
243
+ async with mb.lock:
244
+ reset = mb.pending_reset
245
+ mb.pending_reset = None
246
+ return reset
247
+
248
+ async def complete_reset(
249
+ self,
250
+ *,
251
+ boot_id: str,
252
+ op_id: int,
253
+ status: str,
254
+ message: str | None = None,
255
+ ) -> None:
256
+ mb = self.mailbox
257
+ async with mb.lock:
258
+ mb.reset_cache[(boot_id, op_id)] = ResetRecord(
259
+ boot_id=boot_id, op_id=op_id, status=status, message=message
260
+ )
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,
285
+ *,
286
+ mode: str | None = None,
287
+ robot: bool | None = None,
288
+ error: str | None = None,
289
+ clear_error: bool = False,
290
+ ) -> None:
291
+ mb = self.mailbox
292
+ async with mb.lock:
293
+ if mode is not None:
294
+ mb.host_mode = mode
295
+ if robot is not None:
296
+ mb.host_robot = robot
297
+ if clear_error:
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)
esp32_motion_controller/static/index.html CHANGED
@@ -12,7 +12,7 @@
12
  </head>
13
  <body>
14
  <h1>🕹️ ESP32 Motion Controller</h1>
15
- <p>ESP32 handheld IMU controller bridge for Reachy Mini.</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>
 
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>
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "esp32_motion_controller"
7
- version = "1.0.0"
8
  description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
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"