Upload folder using huggingface_hub
Browse files- README.md +4 -5
- esp32_motion_controller/__init__.py +2 -2
- esp32_motion_controller/behavior.py +0 -100
- esp32_motion_controller/control.py +222 -48
- esp32_motion_controller/controller_state.py +0 -197
- esp32_motion_controller/main.py +80 -32
- esp32_motion_controller/movement_handler.py +0 -596
- esp32_motion_controller/protocol.py +86 -67
- esp32_motion_controller/robot_control.py +275 -124
- esp32_motion_controller/session.py +171 -189
- esp32_motion_controller/static/index.html +3 -3
- esp32_motion_controller/ws_handler.py +0 -296
- index.html +1 -1
- pyproject.toml +2 -2
README.md
CHANGED
|
@@ -10,15 +10,15 @@ tags:
|
|
| 10 |
- reachy_mini
|
| 11 |
- reachy_mini_python_app
|
| 12 |
- esp32
|
| 13 |
-
-
|
| 14 |
- imu
|
| 15 |
---
|
| 16 |
|
| 17 |
# ESP32 Motion Controller
|
| 18 |
|
| 19 |
-
|
| 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
|
| 22 |
|
| 23 |
## Local install
|
| 24 |
|
|
@@ -27,8 +27,7 @@ pip install -e .
|
|
| 27 |
python -m esp32_motion_controller.main
|
| 28 |
```
|
| 29 |
|
| 30 |
-
|
| 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
|
| 2 |
|
| 3 |
-
__version__ = "
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 44 |
-
|
|
|
|
| 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
|
| 108 |
r_ref = _wxyz_to_rotation(q_ref)
|
| 109 |
r_dev = _wxyz_to_rotation(q_device)
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 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],
|
|
|
|
|
|
|
|
|
|
| 167 |
) -> tuple[dict[str, float], float]:
|
| 168 |
out_pose = dict(pose)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
delta = desired[axis] - baseline[axis]
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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},
|
|
|
|
|
|
|
| 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
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
if measured_baseline is None or measured_body is None:
|
| 322 |
return replace(
|
| 323 |
state,
|
| 324 |
-
base_pose=dict(
|
| 325 |
-
desired_pose=dict(
|
| 326 |
-
smooth_pose=dict(
|
| 327 |
-
body_yaw=
|
| 328 |
-
smooth_body_yaw=
|
| 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,
|
|
|
|
|
|
|
| 342 |
return replace(
|
| 343 |
state,
|
| 344 |
-
base_pose=dict(
|
| 345 |
-
desired_pose=dict(
|
| 346 |
-
smooth_pose=dict(
|
| 347 |
baseline_pose=dict(pose_c),
|
| 348 |
-
body_yaw=
|
| 349 |
-
smooth_body_yaw=
|
| 350 |
-
baseline_body_yaw=
|
| 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 =
|
| 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 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 554 |
target_ants = [st.antenna_left, st.antenna_right]
|
| 555 |
|
| 556 |
# Elapsed-time-normalized smoothing (replaces fixed 30 Hz POSE_ALPHA).
|
| 557 |
-
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 3 |
|
| 4 |
-
FastAPI/uvicorn on
|
|
|
|
| 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
|
| 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 |
-
|
| 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 =
|
| 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={"
|
| 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 |
-
|
| 141 |
-
|
| 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":
|
| 163 |
-
"
|
| 164 |
"mdns": MDNS_SERVICE_TYPE,
|
| 165 |
-
"protocol_version":
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 196 |
logger.info("=" * 50)
|
| 197 |
for ip in ips:
|
| 198 |
-
logger.info("
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 215 |
f"instance is probably already running"
|
| 216 |
)
|
| 217 |
|
| 218 |
-
mdns = MdnsAdvertiser(
|
| 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
|
| 3 |
|
| 4 |
-
Rejects oversize frames, wrong types, non-finite numbers,
|
|
|
|
| 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 =
|
| 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
|
| 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 |
-
|
| 122 |
-
if not isinstance(
|
| 123 |
-
raise ProtocolError("missing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
if
|
| 126 |
return parse_hello(msg)
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
| 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 |
-
|
| 199 |
-
"
|
| 200 |
"robot": bool(robot),
|
| 201 |
"mode": mode,
|
| 202 |
"error": error,
|
| 203 |
}
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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
|
| 3 |
|
| 4 |
Consumes the latest sample from SessionHub, runs the pure reducer, and performs
|
| 5 |
-
at most one SDK call in flight.
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
self
|
| 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 |
-
|
| 301 |
-
|
|
|
|
| 302 |
|
| 303 |
-
|
| 304 |
-
|
| 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 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
message="Robot pose unread after reset",
|
| 341 |
)
|
| 342 |
return
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
self._state, measured_baseline=pose, measured_body=body
|
| 349 |
)
|
| 350 |
-
self.
|
| 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 |
-
|
| 361 |
-
|
| 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
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
|
| 4 |
-
Validates
|
| 5 |
-
|
| 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 |
-
|
| 25 |
-
encode_hello_response,
|
| 26 |
-
encode_host_state,
|
| 27 |
-
encode_reset_result,
|
| 28 |
parse_frame,
|
| 29 |
)
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
| 33 |
-
|
| 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
|
| 54 |
|
| 55 |
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
| 56 |
-
generation: int = 0
|
| 57 |
boot_id: str | None = None
|
| 58 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
class SessionHub:
|
| 70 |
def __init__(self, *, robot_available: bool) -> None:
|
| 71 |
self.mailbox = SessionMailbox(host_robot=robot_available)
|
| 72 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
return self.mailbox.generation
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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(
|
| 120 |
except ProtocolError as exc:
|
| 121 |
-
|
| 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(
|
| 131 |
return
|
| 132 |
if isinstance(msg, Sample):
|
| 133 |
-
await self._handle_sample(
|
| 134 |
return
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
return
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
mb = self.mailbox
|
| 141 |
async with mb.lock:
|
| 142 |
-
|
| 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 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
)
|
| 154 |
-
|
|
|
|
| 155 |
|
| 156 |
-
async def _handle_sample(self,
|
| 157 |
mb = self.mailbox
|
| 158 |
now = time.monotonic()
|
| 159 |
async with mb.lock:
|
| 160 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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":
|
| 317 |
"error": mb.host_error,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 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
|
| 16 |
-
<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.
|
| 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>
|
| 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><host>: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 = "
|
| 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"
|
| 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"
|