v2: protocol rewrite with single host control owner
Browse files- README.md +2 -2
- esp32_motion_controller/__init__.py +2 -2
- esp32_motion_controller/control.py +610 -0
- esp32_motion_controller/main.py +34 -47
- esp32_motion_controller/protocol.py +229 -0
- esp32_motion_controller/robot_control.py +372 -0
- esp32_motion_controller/session.py +333 -0
- esp32_motion_controller/static/index.html +1 -1
- pyproject.toml +1 -1
README.md
CHANGED
|
@@ -16,9 +16,9 @@ tags:
|
|
| 16 |
|
| 17 |
# ESP32 Motion Controller
|
| 18 |
|
| 19 |
-
WebSocket bridge for an ESP32 handheld IMU controller that drives Reachy Mini's head with clutch-style relative motion, IK-safe clamping, antenna idle animation, and body follow only when head exceeds the neck yaw threshold.
|
| 20 |
|
| 21 |
-
See the [repository README](https://github.com/V4C38/esp32-reachy-mini-controller) and [`PROTOCOL.md`](../PROTOCOL.md).
|
| 22 |
|
| 23 |
## Local install
|
| 24 |
|
|
|
|
| 16 |
|
| 17 |
# ESP32 Motion Controller
|
| 18 |
|
| 19 |
+
WebSocket bridge (protocol v2) for an ESP32 handheld IMU controller that drives Reachy Mini's head with clutch-style relative motion, IK-safe clamping, antenna idle animation, and body follow only when head exceeds the neck yaw threshold.
|
| 20 |
|
| 21 |
+
See the [repository README](https://github.com/V4C38/esp32-reachy-mini-controller) and [`PROTOCOL.md`](../PROTOCOL.md). Firmware and app must both speak protocol v2.
|
| 22 |
|
| 23 |
## Local install
|
| 24 |
|
esp32_motion_controller/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
"""ESP32 Motion Controller Reachy Mini app."""
|
| 2 |
|
| 3 |
-
__version__ = "
|
|
|
|
| 1 |
+
"""ESP32 Motion Controller Reachy Mini app (protocol v2)."""
|
| 2 |
|
| 3 |
+
__version__ = "2.0.0"
|
esp32_motion_controller/control.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pure control reducer for protocol v2.
|
| 3 |
+
|
| 4 |
+
Owns clutch mapping, body follow, antenna phase, workspace projection, smoothing,
|
| 5 |
+
stale release, and slew limiting. No I/O.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from dataclasses import dataclass, field, replace
|
| 12 |
+
from typing import Sequence
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
from scipy.spatial.transform import Rotation as R
|
| 16 |
+
|
| 17 |
+
from esp32_motion_controller.protocol import Sample
|
| 18 |
+
|
| 19 |
+
POSE_AXES = ("x", "y", "z", "roll", "pitch", "yaw")
|
| 20 |
+
ANGULAR_AXES = ("roll", "pitch", "yaw")
|
| 21 |
+
POSITIONAL_AXES = ("x", "y", "z")
|
| 22 |
+
|
| 23 |
+
DEV_TO_HEAD = np.array(
|
| 24 |
+
[
|
| 25 |
+
[0.0, 0.0, 1.0],
|
| 26 |
+
[1.0, 0.0, 0.0],
|
| 27 |
+
[0.0, 1.0, 0.0],
|
| 28 |
+
],
|
| 29 |
+
dtype=np.float64,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
TRANSLATION_SCALE = 0.30
|
| 33 |
+
TRANSLATION_GAIN_DEFAULT = 1.0
|
| 34 |
+
|
| 35 |
+
STALE_PACKET_SEC = 0.300
|
| 36 |
+
CONTROL_HZ = 20.0
|
| 37 |
+
CONTROL_DT = 1.0 / CONTROL_HZ
|
| 38 |
+
|
| 39 |
+
# Match v1 feel: POSE_ALPHA=0.12 at 33 ms ≈ tau ~0.26 s
|
| 40 |
+
POSE_TAU_SEC = 0.255
|
| 41 |
+
ANTENNA_TAU_SEC = 0.39
|
| 42 |
+
|
| 43 |
+
MAX_ANGULAR_VEL = 1.5 # rad/s
|
| 44 |
+
MAX_POS_VEL = 0.05 # m/s
|
| 45 |
+
MAX_DT_FOR_VEL_CLAMP = 0.05
|
| 46 |
+
|
| 47 |
+
LIMIT_BODY_YAW_RAD = 160.0 * math.pi / 180.0
|
| 48 |
+
LIMIT_HEAD_YAW_RAD = math.pi
|
| 49 |
+
LIMIT_HEAD_BODY_YAW_DELTA_RAD = 65.0 * math.pi / 180.0
|
| 50 |
+
|
| 51 |
+
LIMIT_HEAD_X_MIN = -0.020
|
| 52 |
+
LIMIT_HEAD_X_MAX = 0.020
|
| 53 |
+
LIMIT_HEAD_Y_MIN = -0.020
|
| 54 |
+
LIMIT_HEAD_Y_MAX = 0.020
|
| 55 |
+
LIMIT_HEAD_Z_MIN = 0.0
|
| 56 |
+
LIMIT_HEAD_Z_MAX = 0.025
|
| 57 |
+
|
| 58 |
+
ELLIPSOID_X_MAX = 0.015
|
| 59 |
+
ELLIPSOID_Y_MAX = 0.015
|
| 60 |
+
ELLIPSOID_Z_MAX = 0.018
|
| 61 |
+
ELLIPSOID_ROLL_MAX_RAD = 25.0 * math.pi / 180.0
|
| 62 |
+
ELLIPSOID_PITCH_MAX_RAD = 25.0 * math.pi / 180.0
|
| 63 |
+
|
| 64 |
+
MAX_HEAD_YAW = 65.0 * math.pi / 180.0
|
| 65 |
+
BODY_FOLLOW_THRESHOLD = 40.0 * math.pi / 180.0
|
| 66 |
+
MAX_BODY_YAW = 160.0 * math.pi / 180.0
|
| 67 |
+
|
| 68 |
+
DEFAULT_LIVELINESS = 1.25
|
| 69 |
+
DEFAULT_GAZE_RESPONSIVENESS = 1.2
|
| 70 |
+
DEFAULT_ANTENNA_ACTIVITY = 0.8
|
| 71 |
+
HEAD_MOVE_SPEED = 0.06
|
| 72 |
+
MAX_HEAD_DELTA_DEG = 2.0
|
| 73 |
+
ANTENNA_AMPLITUDE_DEG = 15.0
|
| 74 |
+
|
| 75 |
+
IK_FAIL_RETRACT_TARGET_ALPHA = 0.06
|
| 76 |
+
IK_FAIL_CONSECUTIVE_THRESHOLD = 3
|
| 77 |
+
|
| 78 |
+
Mode = str # idle | engaged | resetting | fault
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def zero_pose() -> dict[str, float]:
|
| 82 |
+
return {k: 0.0 for k in POSE_AXES}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
| 86 |
+
return max(lo, min(hi, value))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _finite_quat(q: Sequence[float]) -> np.ndarray:
|
| 90 |
+
arr = np.asarray(q, dtype=np.float64).reshape(4)
|
| 91 |
+
if not np.all(np.isfinite(arr)) or np.linalg.norm(arr) < 1e-9:
|
| 92 |
+
return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
|
| 93 |
+
return arr / np.linalg.norm(arr)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _finite_vec3(v: Sequence[float]) -> np.ndarray:
|
| 97 |
+
arr = np.asarray(v, dtype=np.float64).reshape(3)
|
| 98 |
+
if not np.all(np.isfinite(arr)):
|
| 99 |
+
return np.zeros(3, dtype=np.float64)
|
| 100 |
+
return arr
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _wxyz_to_rotation(q: np.ndarray) -> R:
|
| 104 |
+
return R.from_quat([q[1], q[2], q[3], q[0]])
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def quat_relative_rpy(q_ref: np.ndarray, q_device: np.ndarray) -> tuple[float, float, float]:
|
| 108 |
+
r_ref = _wxyz_to_rotation(q_ref)
|
| 109 |
+
r_dev = _wxyz_to_rotation(q_device)
|
| 110 |
+
r_rel_dev = r_ref.inv() * r_dev
|
| 111 |
+
m = R.from_matrix(DEV_TO_HEAD)
|
| 112 |
+
r_head = m * r_rel_dev * m.inv()
|
| 113 |
+
roll, pitch, yaw = r_head.as_euler("xyz", degrees=False)
|
| 114 |
+
return float(roll), float(pitch), float(yaw)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def remap_displacement(
|
| 118 |
+
p_world_delta: np.ndarray,
|
| 119 |
+
q_ref: np.ndarray,
|
| 120 |
+
*,
|
| 121 |
+
translation_gain: float = TRANSLATION_GAIN_DEFAULT,
|
| 122 |
+
) -> np.ndarray:
|
| 123 |
+
r_ref = _wxyz_to_rotation(q_ref)
|
| 124 |
+
disp_ref = r_ref.inv().apply(p_world_delta)
|
| 125 |
+
disp_head = DEV_TO_HEAD @ disp_ref
|
| 126 |
+
return disp_head * TRANSLATION_SCALE * float(translation_gain)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def dual_sine(t: float, freq_a: float, freq_b: float) -> float:
|
| 130 |
+
return math.sin(t * freq_a) * 0.6 + math.sin(t * freq_b) * 0.4
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def clamp_stewart_ellipsoid(
|
| 134 |
+
x: float, y: float, z: float, roll: float, pitch: float,
|
| 135 |
+
) -> tuple[float, float, float, float, float]:
|
| 136 |
+
z_clamped = _clamp(z, LIMIT_HEAD_Z_MIN, LIMIT_HEAD_Z_MAX)
|
| 137 |
+
x_clamped = _clamp(x, LIMIT_HEAD_X_MIN, LIMIT_HEAD_X_MAX)
|
| 138 |
+
y_clamped = _clamp(y, LIMIT_HEAD_Y_MIN, LIMIT_HEAD_Y_MAX)
|
| 139 |
+
|
| 140 |
+
roll_c = _clamp(roll, -ELLIPSOID_ROLL_MAX_RAD, ELLIPSOID_ROLL_MAX_RAD)
|
| 141 |
+
pitch_c = _clamp(pitch, -ELLIPSOID_PITCH_MAX_RAD, ELLIPSOID_PITCH_MAX_RAD)
|
| 142 |
+
|
| 143 |
+
nr = roll_c / ELLIPSOID_ROLL_MAX_RAD if ELLIPSOID_ROLL_MAX_RAD > 0 else 0.0
|
| 144 |
+
np_ = pitch_c / ELLIPSOID_PITCH_MAX_RAD if ELLIPSOID_PITCH_MAX_RAD > 0 else 0.0
|
| 145 |
+
remaining = max(0.0, 1.0 - (nr * nr + np_ * np_))
|
| 146 |
+
|
| 147 |
+
nx = x_clamped / ELLIPSOID_X_MAX if ELLIPSOID_X_MAX > 0 else 0.0
|
| 148 |
+
ny = y_clamped / ELLIPSOID_Y_MAX if ELLIPSOID_Y_MAX > 0 else 0.0
|
| 149 |
+
nz = z_clamped / ELLIPSOID_Z_MAX if ELLIPSOID_Z_MAX > 0 else 0.0
|
| 150 |
+
trans_sq = nx * nx + ny * ny + nz * nz
|
| 151 |
+
|
| 152 |
+
if trans_sq <= remaining or trans_sq <= 1e-12:
|
| 153 |
+
return (x_clamped, y_clamped, z_clamped, roll_c, pitch_c)
|
| 154 |
+
|
| 155 |
+
scale = math.sqrt(remaining / trans_sq)
|
| 156 |
+
return (
|
| 157 |
+
x_clamped * scale,
|
| 158 |
+
y_clamped * scale,
|
| 159 |
+
z_clamped * scale,
|
| 160 |
+
roll_c,
|
| 161 |
+
pitch_c,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def clamp_pose_to_daemon_limits(
|
| 166 |
+
pose: dict[str, float], body_yaw: float
|
| 167 |
+
) -> tuple[dict[str, float], float]:
|
| 168 |
+
out_pose = dict(pose)
|
| 169 |
+
cx, cy, cz, cr, cp = clamp_stewart_ellipsoid(
|
| 170 |
+
pose["x"], pose["y"], pose["z"], pose["roll"], pose["pitch"],
|
| 171 |
+
)
|
| 172 |
+
out_pose["x"] = cx
|
| 173 |
+
out_pose["y"] = cy
|
| 174 |
+
out_pose["z"] = cz
|
| 175 |
+
out_pose["roll"] = cr
|
| 176 |
+
out_pose["pitch"] = cp
|
| 177 |
+
|
| 178 |
+
body_yaw_clamped = _clamp(body_yaw, -LIMIT_BODY_YAW_RAD, LIMIT_BODY_YAW_RAD)
|
| 179 |
+
out_pose["yaw"] = _clamp(pose["yaw"], -LIMIT_HEAD_YAW_RAD, LIMIT_HEAD_YAW_RAD)
|
| 180 |
+
delta = out_pose["yaw"] - body_yaw_clamped
|
| 181 |
+
if delta > LIMIT_HEAD_BODY_YAW_DELTA_RAD:
|
| 182 |
+
out_pose["yaw"] = body_yaw_clamped + LIMIT_HEAD_BODY_YAW_DELTA_RAD
|
| 183 |
+
elif delta < -LIMIT_HEAD_BODY_YAW_DELTA_RAD:
|
| 184 |
+
out_pose["yaw"] = body_yaw_clamped - LIMIT_HEAD_BODY_YAW_DELTA_RAD
|
| 185 |
+
return out_pose, body_yaw_clamped
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _alpha(dt: float, tau: float) -> float:
|
| 189 |
+
if tau <= 0.0:
|
| 190 |
+
return 1.0
|
| 191 |
+
return 1.0 - math.exp(-max(dt, 0.0) / tau)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def slew_limit(
|
| 195 |
+
baseline: dict[str, float],
|
| 196 |
+
baseline_body: float,
|
| 197 |
+
desired: dict[str, float],
|
| 198 |
+
desired_body: float,
|
| 199 |
+
dt: float,
|
| 200 |
+
) -> tuple[dict[str, float], float]:
|
| 201 |
+
dt_c = min(max(dt, 0.0), MAX_DT_FOR_VEL_CLAMP)
|
| 202 |
+
max_d_ang = MAX_ANGULAR_VEL * dt_c
|
| 203 |
+
max_d_pos = MAX_POS_VEL * dt_c
|
| 204 |
+
send = {}
|
| 205 |
+
for axis in ANGULAR_AXES:
|
| 206 |
+
delta = desired[axis] - baseline[axis]
|
| 207 |
+
send[axis] = baseline[axis] + _clamp(delta, -max_d_ang, max_d_ang)
|
| 208 |
+
for axis in POSITIONAL_AXES:
|
| 209 |
+
delta = desired[axis] - baseline[axis]
|
| 210 |
+
send[axis] = baseline[axis] + _clamp(delta, -max_d_pos, max_d_pos)
|
| 211 |
+
body_delta = desired_body - baseline_body
|
| 212 |
+
send_body = baseline_body + _clamp(body_delta, -max_d_ang, max_d_ang)
|
| 213 |
+
return clamp_pose_to_daemon_limits(send, send_body)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@dataclass
|
| 217 |
+
class ControlState:
|
| 218 |
+
mode: Mode = "idle"
|
| 219 |
+
robot_available: bool = True
|
| 220 |
+
error: str | None = None
|
| 221 |
+
|
| 222 |
+
engaged: bool = False
|
| 223 |
+
was_engaged: bool = False
|
| 224 |
+
gain: float = 1.0
|
| 225 |
+
ready: bool = False
|
| 226 |
+
translation_gain: float = TRANSLATION_GAIN_DEFAULT
|
| 227 |
+
|
| 228 |
+
q_ref: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0]))
|
| 229 |
+
p_ref: np.ndarray = field(default_factory=lambda: np.zeros(3))
|
| 230 |
+
base_pose: dict[str, float] = field(default_factory=zero_pose)
|
| 231 |
+
desired_pose: dict[str, float] = field(default_factory=zero_pose)
|
| 232 |
+
|
| 233 |
+
body_yaw: float = 0.0
|
| 234 |
+
antenna_left: float = 0.0
|
| 235 |
+
antenna_right: float = 0.0
|
| 236 |
+
behavior_t0: float = 0.0
|
| 237 |
+
|
| 238 |
+
smooth_pose: dict[str, float] = field(default_factory=zero_pose)
|
| 239 |
+
smooth_body_yaw: float = 0.0
|
| 240 |
+
smooth_antennas: list[float] = field(default_factory=lambda: [0.0, 0.0])
|
| 241 |
+
|
| 242 |
+
baseline_pose: dict[str, float] = field(default_factory=zero_pose)
|
| 243 |
+
baseline_body_yaw: float = 0.0
|
| 244 |
+
baseline_antennas: list[float] = field(default_factory=lambda: [0.0, 0.0])
|
| 245 |
+
|
| 246 |
+
last_sample_time: float = 0.0
|
| 247 |
+
have_sample: bool = False
|
| 248 |
+
sends_frozen: bool = True
|
| 249 |
+
seeded: bool = False
|
| 250 |
+
consecutive_ik_failures: int = 0
|
| 251 |
+
|
| 252 |
+
liveliness: float = DEFAULT_LIVELINESS
|
| 253 |
+
gaze_responsiveness: float = DEFAULT_GAZE_RESPONSIVENESS
|
| 254 |
+
antenna_activity: float = DEFAULT_ANTENNA_ACTIVITY
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@dataclass(frozen=True, slots=True)
|
| 258 |
+
class Command:
|
| 259 |
+
pose: dict[str, float]
|
| 260 |
+
body_yaw: float
|
| 261 |
+
antennas: tuple[float, float]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@dataclass(frozen=True, slots=True)
|
| 265 |
+
class StepResult:
|
| 266 |
+
state: ControlState
|
| 267 |
+
command: Command | None
|
| 268 |
+
mode_changed: bool = False
|
| 269 |
+
host_error: str | None = None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def initial_state(*, robot_available: bool, now: float) -> ControlState:
|
| 273 |
+
return ControlState(
|
| 274 |
+
robot_available=robot_available,
|
| 275 |
+
behavior_t0=now,
|
| 276 |
+
sends_frozen=not robot_available,
|
| 277 |
+
seeded=not robot_available,
|
| 278 |
+
mode="idle",
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def seed_from_pose(
|
| 283 |
+
state: ControlState,
|
| 284 |
+
pose: dict[str, float],
|
| 285 |
+
body_yaw: float,
|
| 286 |
+
*,
|
| 287 |
+
antennas: Sequence[float] | None = None,
|
| 288 |
+
) -> ControlState:
|
| 289 |
+
ants = list(antennas) if antennas is not None else [0.0, 0.0]
|
| 290 |
+
ants = (ants + [0.0, 0.0])[:2]
|
| 291 |
+
pose_c, body_c = clamp_pose_to_daemon_limits(
|
| 292 |
+
{k: float(pose.get(k, 0.0)) for k in POSE_AXES}, float(body_yaw)
|
| 293 |
+
)
|
| 294 |
+
return replace(
|
| 295 |
+
state,
|
| 296 |
+
base_pose=dict(pose_c),
|
| 297 |
+
desired_pose=dict(pose_c),
|
| 298 |
+
smooth_pose=dict(pose_c),
|
| 299 |
+
baseline_pose=dict(pose_c),
|
| 300 |
+
body_yaw=body_c,
|
| 301 |
+
smooth_body_yaw=body_c,
|
| 302 |
+
baseline_body_yaw=body_c,
|
| 303 |
+
antenna_left=float(ants[0]),
|
| 304 |
+
antenna_right=float(ants[1]),
|
| 305 |
+
smooth_antennas=[float(ants[0]), float(ants[1])],
|
| 306 |
+
baseline_antennas=[float(ants[0]), float(ants[1])],
|
| 307 |
+
seeded=True,
|
| 308 |
+
sends_frozen=False,
|
| 309 |
+
consecutive_ik_failures=0,
|
| 310 |
+
engaged=False,
|
| 311 |
+
was_engaged=False,
|
| 312 |
+
mode="idle" if state.mode != "resetting" else state.mode,
|
| 313 |
+
error=None,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def rebase_neutral(state: ControlState, *, measured_baseline: dict[str, float] | None,
|
| 318 |
+
measured_body: float | None) -> ControlState:
|
| 319 |
+
"""After reset completion: targets at neutral; baseline from measured pose if provided."""
|
| 320 |
+
neutral = zero_pose()
|
| 321 |
+
if measured_baseline is None or measured_body is None:
|
| 322 |
+
return replace(
|
| 323 |
+
state,
|
| 324 |
+
base_pose=dict(neutral),
|
| 325 |
+
desired_pose=dict(neutral),
|
| 326 |
+
smooth_pose=dict(neutral),
|
| 327 |
+
body_yaw=0.0,
|
| 328 |
+
smooth_body_yaw=0.0,
|
| 329 |
+
antenna_left=0.0,
|
| 330 |
+
antenna_right=0.0,
|
| 331 |
+
smooth_antennas=[0.0, 0.0],
|
| 332 |
+
engaged=False,
|
| 333 |
+
was_engaged=False,
|
| 334 |
+
q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
|
| 335 |
+
p_ref=np.zeros(3),
|
| 336 |
+
sends_frozen=True,
|
| 337 |
+
mode="fault",
|
| 338 |
+
error="robot pose unread after reset",
|
| 339 |
+
behavior_t0=state.behavior_t0,
|
| 340 |
+
)
|
| 341 |
+
pose_c, body_c = clamp_pose_to_daemon_limits(measured_baseline, measured_body)
|
| 342 |
+
return replace(
|
| 343 |
+
state,
|
| 344 |
+
base_pose=dict(neutral),
|
| 345 |
+
desired_pose=dict(neutral),
|
| 346 |
+
smooth_pose=dict(neutral),
|
| 347 |
+
baseline_pose=dict(pose_c),
|
| 348 |
+
body_yaw=0.0,
|
| 349 |
+
smooth_body_yaw=0.0,
|
| 350 |
+
baseline_body_yaw=body_c,
|
| 351 |
+
antenna_left=0.0,
|
| 352 |
+
antenna_right=0.0,
|
| 353 |
+
smooth_antennas=[0.0, 0.0],
|
| 354 |
+
baseline_antennas=[0.0, 0.0],
|
| 355 |
+
engaged=False,
|
| 356 |
+
was_engaged=False,
|
| 357 |
+
q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
|
| 358 |
+
p_ref=np.zeros(3),
|
| 359 |
+
sends_frozen=False,
|
| 360 |
+
seeded=True,
|
| 361 |
+
mode="idle",
|
| 362 |
+
error=None,
|
| 363 |
+
consecutive_ik_failures=0,
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def begin_reset(state: ControlState, now: float) -> ControlState:
|
| 368 |
+
st = force_disengage(state)
|
| 369 |
+
return replace(
|
| 370 |
+
st,
|
| 371 |
+
mode="resetting",
|
| 372 |
+
error=None,
|
| 373 |
+
base_pose=zero_pose(),
|
| 374 |
+
desired_pose=zero_pose(),
|
| 375 |
+
body_yaw=0.0,
|
| 376 |
+
antenna_left=0.0,
|
| 377 |
+
antenna_right=0.0,
|
| 378 |
+
was_engaged=False,
|
| 379 |
+
engaged=False,
|
| 380 |
+
q_ref=np.array([1.0, 0.0, 0.0, 0.0]),
|
| 381 |
+
p_ref=np.zeros(3),
|
| 382 |
+
behavior_t0=now,
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def force_disengage(state: ControlState) -> ControlState:
|
| 387 |
+
if state.engaged:
|
| 388 |
+
return replace(
|
| 389 |
+
state,
|
| 390 |
+
base_pose=dict(state.desired_pose),
|
| 391 |
+
engaged=False,
|
| 392 |
+
was_engaged=False,
|
| 393 |
+
mode="idle" if state.mode == "engaged" else state.mode,
|
| 394 |
+
)
|
| 395 |
+
return replace(state, engaged=False, was_engaged=False)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def mark_sdk_success(state: ControlState, command: Command) -> ControlState:
|
| 399 |
+
return replace(
|
| 400 |
+
state,
|
| 401 |
+
baseline_pose=dict(command.pose),
|
| 402 |
+
baseline_body_yaw=command.body_yaw,
|
| 403 |
+
baseline_antennas=list(command.antennas),
|
| 404 |
+
consecutive_ik_failures=0,
|
| 405 |
+
sends_frozen=False,
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def mark_sdk_failure(state: ControlState) -> ControlState:
|
| 410 |
+
fails = state.consecutive_ik_failures + 1
|
| 411 |
+
desired = dict(state.desired_pose)
|
| 412 |
+
body = state.body_yaw
|
| 413 |
+
if fails >= IK_FAIL_CONSECUTIVE_THRESHOLD:
|
| 414 |
+
alpha = IK_FAIL_RETRACT_TARGET_ALPHA
|
| 415 |
+
for ax in POSE_AXES:
|
| 416 |
+
desired[ax] *= 1.0 - alpha
|
| 417 |
+
body *= 1.0 - alpha
|
| 418 |
+
return replace(
|
| 419 |
+
state,
|
| 420 |
+
consecutive_ik_failures=fails,
|
| 421 |
+
desired_pose=desired,
|
| 422 |
+
body_yaw=body,
|
| 423 |
+
sends_frozen=True,
|
| 424 |
+
mode="fault",
|
| 425 |
+
error="set_target failed",
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def mark_pose_unread(state: ControlState) -> ControlState:
|
| 430 |
+
return replace(
|
| 431 |
+
state,
|
| 432 |
+
sends_frozen=True,
|
| 433 |
+
mode="fault",
|
| 434 |
+
error="robot pose unread",
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _update_clutch(state: ControlState, sample: Sample, *, allow_engage: bool) -> ControlState:
|
| 439 |
+
gain = _clamp(float(sample.gain), 0.1, 3.0)
|
| 440 |
+
q_dev = _finite_quat(sample.q)
|
| 441 |
+
p_dev = _finite_vec3(sample.p)
|
| 442 |
+
want = bool(sample.engaged) and bool(sample.ready) and allow_engage
|
| 443 |
+
rising = want and not state.was_engaged
|
| 444 |
+
falling = (not want) and state.was_engaged
|
| 445 |
+
|
| 446 |
+
q_ref = state.q_ref
|
| 447 |
+
p_ref = state.p_ref
|
| 448 |
+
base = dict(state.base_pose)
|
| 449 |
+
desired = dict(state.desired_pose)
|
| 450 |
+
|
| 451 |
+
if rising:
|
| 452 |
+
q_ref = q_dev.copy()
|
| 453 |
+
p_ref = p_dev.copy()
|
| 454 |
+
|
| 455 |
+
if want:
|
| 456 |
+
roll, pitch, yaw = quat_relative_rpy(q_ref, q_dev)
|
| 457 |
+
disp = remap_displacement(
|
| 458 |
+
p_dev - p_ref,
|
| 459 |
+
q_ref,
|
| 460 |
+
translation_gain=state.translation_gain * gain,
|
| 461 |
+
)
|
| 462 |
+
desired = {
|
| 463 |
+
"x": base["x"] + float(disp[0]),
|
| 464 |
+
"y": base["y"] + float(disp[1]),
|
| 465 |
+
"z": base["z"] + float(disp[2]),
|
| 466 |
+
"roll": base["roll"] + gain * roll,
|
| 467 |
+
"pitch": base["pitch"] + gain * pitch,
|
| 468 |
+
"yaw": base["yaw"] + gain * yaw,
|
| 469 |
+
}
|
| 470 |
+
elif falling:
|
| 471 |
+
base = dict(desired)
|
| 472 |
+
|
| 473 |
+
return replace(
|
| 474 |
+
state,
|
| 475 |
+
ready=bool(sample.ready),
|
| 476 |
+
gain=gain,
|
| 477 |
+
q_ref=q_ref,
|
| 478 |
+
p_ref=p_ref,
|
| 479 |
+
base_pose=base,
|
| 480 |
+
desired_pose=desired,
|
| 481 |
+
engaged=want,
|
| 482 |
+
was_engaged=want,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _advance_behavior(state: ControlState, head_yaw: float, now: float, dt: float) -> ControlState:
|
| 487 |
+
# Normalize behavior update against the legacy ~33 ms tick using dt scaling.
|
| 488 |
+
tick_scale = dt / 0.033 if dt > 0 else 1.0
|
| 489 |
+
t = now - state.behavior_t0
|
| 490 |
+
deg = math.pi / 180.0
|
| 491 |
+
|
| 492 |
+
yaw_smoothing = HEAD_MOVE_SPEED * state.gaze_responsiveness * tick_scale
|
| 493 |
+
max_yaw_delta = MAX_HEAD_DELTA_DEG * state.gaze_responsiveness * deg * tick_scale
|
| 494 |
+
body_smoothing = yaw_smoothing * 0.7 * (0.3 + state.liveliness * 0.4)
|
| 495 |
+
antenna_smoothing = yaw_smoothing * 1.5
|
| 496 |
+
effective_ant_amp = ANTENNA_AMPLITUDE_DEG * state.antenna_activity * deg
|
| 497 |
+
ant_speed = 0.5 + state.antenna_activity * 0.5
|
| 498 |
+
|
| 499 |
+
body_yaw = state.body_yaw
|
| 500 |
+
rel_yaw = head_yaw - body_yaw
|
| 501 |
+
if abs(rel_yaw) > BODY_FOLLOW_THRESHOLD:
|
| 502 |
+
excess = abs(rel_yaw) - BODY_FOLLOW_THRESHOLD
|
| 503 |
+
step = math.copysign(excess * body_smoothing * 8, rel_yaw)
|
| 504 |
+
body_yaw += _clamp(step, -max_yaw_delta, max_yaw_delta)
|
| 505 |
+
body_yaw = _clamp(body_yaw, -MAX_BODY_YAW, MAX_BODY_YAW)
|
| 506 |
+
|
| 507 |
+
desired_l = dual_sine(t * ant_speed, 1.3, 3.11) * effective_ant_amp
|
| 508 |
+
desired_r = dual_sine(t * ant_speed, 1.7, 2.73) * effective_ant_amp
|
| 509 |
+
ant_l = state.antenna_left + (desired_l - state.antenna_left) * antenna_smoothing
|
| 510 |
+
ant_r = state.antenna_right + (desired_r - state.antenna_right) * antenna_smoothing
|
| 511 |
+
|
| 512 |
+
return replace(
|
| 513 |
+
state,
|
| 514 |
+
body_yaw=body_yaw,
|
| 515 |
+
antenna_left=ant_l,
|
| 516 |
+
antenna_right=ant_r,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def step(
|
| 521 |
+
state: ControlState,
|
| 522 |
+
*,
|
| 523 |
+
now: float,
|
| 524 |
+
dt: float,
|
| 525 |
+
sample: Sample | None,
|
| 526 |
+
sample_is_fresh: bool,
|
| 527 |
+
) -> StepResult:
|
| 528 |
+
"""Advance one control tick.
|
| 529 |
+
|
| 530 |
+
`sample` is the latest validated sample (may be None before first packet).
|
| 531 |
+
`sample_is_fresh` is True only when a new sample arrived since the previous tick.
|
| 532 |
+
"""
|
| 533 |
+
prev_mode = state.mode
|
| 534 |
+
st = state
|
| 535 |
+
|
| 536 |
+
if st.mode == "resetting":
|
| 537 |
+
# No streaming commands while reset owns the robot.
|
| 538 |
+
return StepResult(state=st, command=None, mode_changed=False)
|
| 539 |
+
|
| 540 |
+
# Stale detection uses host receipt time stamped on the sample mailbox.
|
| 541 |
+
if st.have_sample and st.last_sample_time > 0:
|
| 542 |
+
if now - st.last_sample_time > STALE_PACKET_SEC and st.engaged:
|
| 543 |
+
st = force_disengage(st)
|
| 544 |
+
|
| 545 |
+
if sample is not None and sample_is_fresh and st.mode != "resetting":
|
| 546 |
+
allow_engage = st.mode != "fault" and not st.sends_frozen
|
| 547 |
+
st = _update_clutch(st, sample, allow_engage=allow_engage)
|
| 548 |
+
st = replace(st, have_sample=True)
|
| 549 |
+
|
| 550 |
+
if st.mode not in {"resetting"}:
|
| 551 |
+
st = _advance_behavior(st, st.desired_pose["yaw"], now, dt)
|
| 552 |
+
|
| 553 |
+
target_pose, target_body = clamp_pose_to_daemon_limits(st.desired_pose, st.body_yaw)
|
| 554 |
+
target_ants = [st.antenna_left, st.antenna_right]
|
| 555 |
+
|
| 556 |
+
# Elapsed-time-normalized smoothing (replaces fixed 30 Hz POSE_ALPHA).
|
| 557 |
+
a_pose = _alpha(dt, POSE_TAU_SEC)
|
| 558 |
+
a_ant = _alpha(dt, ANTENNA_TAU_SEC)
|
| 559 |
+
smooth = {
|
| 560 |
+
k: st.smooth_pose[k] + a_pose * (target_pose[k] - st.smooth_pose[k])
|
| 561 |
+
for k in POSE_AXES
|
| 562 |
+
}
|
| 563 |
+
smooth_body = st.smooth_body_yaw + a_pose * (target_body - st.smooth_body_yaw)
|
| 564 |
+
smooth_ants = [
|
| 565 |
+
st.smooth_antennas[i] + a_ant * (target_ants[i] - st.smooth_antennas[i])
|
| 566 |
+
for i in range(2)
|
| 567 |
+
]
|
| 568 |
+
|
| 569 |
+
new_mode: Mode = st.mode
|
| 570 |
+
if st.mode not in {"resetting", "fault"}:
|
| 571 |
+
new_mode = "engaged" if st.engaged else "idle"
|
| 572 |
+
|
| 573 |
+
st = replace(
|
| 574 |
+
st,
|
| 575 |
+
desired_pose=dict(target_pose),
|
| 576 |
+
body_yaw=target_body,
|
| 577 |
+
smooth_pose=smooth,
|
| 578 |
+
smooth_body_yaw=smooth_body,
|
| 579 |
+
smooth_antennas=smooth_ants,
|
| 580 |
+
mode=new_mode,
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
if st.sends_frozen or not st.seeded:
|
| 584 |
+
return StepResult(
|
| 585 |
+
state=st,
|
| 586 |
+
command=None,
|
| 587 |
+
mode_changed=(st.mode != prev_mode),
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
send_pose, send_body = slew_limit(
|
| 591 |
+
st.baseline_pose,
|
| 592 |
+
st.baseline_body_yaw,
|
| 593 |
+
smooth,
|
| 594 |
+
smooth_body,
|
| 595 |
+
dt,
|
| 596 |
+
)
|
| 597 |
+
command = Command(
|
| 598 |
+
pose=send_pose,
|
| 599 |
+
body_yaw=send_body,
|
| 600 |
+
antennas=(smooth_ants[0], smooth_ants[1]),
|
| 601 |
+
)
|
| 602 |
+
return StepResult(
|
| 603 |
+
state=st,
|
| 604 |
+
command=command,
|
| 605 |
+
mode_changed=(st.mode != prev_mode),
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def note_sample_receipt(state: ControlState, receipt_time: float) -> ControlState:
|
| 610 |
+
return replace(state, last_sample_time=receipt_time, have_sample=True)
|
esp32_motion_controller/main.py
CHANGED
|
@@ -1,14 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
-
Motion Controller Reachy Mini app entry point.
|
| 3 |
|
| 4 |
-
FastAPI/uvicorn on port 8766, mDNS advertise _reachyctl._tcp, clutch +
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import argparse
|
| 10 |
import logging
|
| 11 |
-
import os
|
| 12 |
import socket
|
| 13 |
import sys
|
| 14 |
import threading
|
|
@@ -20,31 +19,17 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
| 20 |
from fastapi.responses import FileResponse, JSONResponse
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
|
| 23 |
-
from esp32_motion_controller.
|
| 24 |
-
from esp32_motion_controller.
|
| 25 |
-
from esp32_motion_controller.movement_handler import MovementHandler
|
| 26 |
-
from esp32_motion_controller.ws_handler import WebSocketHandler
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
| 30 |
WS_PORT = 8766
|
| 31 |
STATIC_DIR = Path(__file__).parent / "static"
|
| 32 |
MDNS_SERVICE_TYPE = "_reachyctl._tcp.local."
|
| 33 |
-
ENV_SEND_RATE_HZ = "REACHY_MOTION_SEND_RATE_HZ"
|
| 34 |
-
DEFAULT_SEND_RATE_HZ = 20.0
|
| 35 |
SERVER_START_TIMEOUT_S = 10.0
|
| 36 |
|
| 37 |
|
| 38 |
-
def _get_send_rate_hz() -> float:
|
| 39 |
-
raw = os.environ.get(ENV_SEND_RATE_HZ)
|
| 40 |
-
if raw is None or raw.strip() == "":
|
| 41 |
-
return DEFAULT_SEND_RATE_HZ
|
| 42 |
-
try:
|
| 43 |
-
return max(5.0, min(50.0, float(raw.strip())))
|
| 44 |
-
except ValueError:
|
| 45 |
-
return DEFAULT_SEND_RATE_HZ
|
| 46 |
-
|
| 47 |
-
|
| 48 |
def get_local_ips() -> list[str]:
|
| 49 |
ips: list[str] = []
|
| 50 |
try:
|
|
@@ -89,14 +74,10 @@ class MdnsAdvertiser:
|
|
| 89 |
f"esp32-motion-controller.{MDNS_SERVICE_TYPE}",
|
| 90 |
addresses=[socket.inet_aton(ip) for ip in ips],
|
| 91 |
port=self.port,
|
| 92 |
-
properties={"path": b"/ws"},
|
| 93 |
server="esp32-motion.local.",
|
| 94 |
)
|
| 95 |
zc = Zeroconf()
|
| 96 |
-
# An advertisement from a previous run lingers in the mDNS cache for
|
| 97 |
-
# the record TTL (75 min), so a restart collides with itself.
|
| 98 |
-
# Renaming is safe: the controller browses the service type, never
|
| 99 |
-
# the instance name.
|
| 100 |
zc.register_service(info, allow_name_change=True)
|
| 101 |
except Exception:
|
| 102 |
logger.warning(
|
|
@@ -136,35 +117,41 @@ def create_app(
|
|
| 136 |
log_only: bool = False,
|
| 137 |
) -> FastAPI:
|
| 138 |
app = FastAPI(title="ESP32 Motion Controller")
|
| 139 |
-
send_rate = _get_send_rate_hz()
|
| 140 |
robot_available = reachy_mini is not None and not log_only
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
behavior,
|
| 148 |
-
robot_available=robot_available or log_only,
|
| 149 |
log_only=log_only,
|
| 150 |
)
|
| 151 |
-
app.state.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
@app.websocket("/ws")
|
| 154 |
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
return
|
| 158 |
try:
|
| 159 |
while not stop_event.is_set():
|
| 160 |
raw = await websocket.receive_text()
|
| 161 |
-
await
|
| 162 |
except WebSocketDisconnect:
|
| 163 |
logger.info("WebSocket disconnect")
|
| 164 |
except Exception as exc:
|
| 165 |
logger.error("WebSocket error: %s", exc)
|
| 166 |
finally:
|
| 167 |
-
|
|
|
|
| 168 |
|
| 169 |
@app.get("/api/info")
|
| 170 |
async def info() -> JSONResponse:
|
|
@@ -175,17 +162,22 @@ def create_app(
|
|
| 175 |
"port": WS_PORT,
|
| 176 |
"ws_url": f"ws://{ips[0]}:{WS_PORT}/ws" if ips else None,
|
| 177 |
"mdns": MDNS_SERVICE_TYPE,
|
|
|
|
| 178 |
}
|
| 179 |
)
|
| 180 |
|
| 181 |
@app.get("/api/status")
|
| 182 |
async def status() -> JSONResponse:
|
|
|
|
| 183 |
return JSONResponse(
|
| 184 |
{
|
| 185 |
"status": "ok",
|
| 186 |
"robot": robot_available or log_only,
|
| 187 |
-
"busy":
|
|
|
|
|
|
|
| 188 |
"log_only": log_only,
|
|
|
|
| 189 |
}
|
| 190 |
)
|
| 191 |
|
|
@@ -200,7 +192,7 @@ def create_app(
|
|
| 200 |
def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
|
| 201 |
ips = get_local_ips()
|
| 202 |
logger.info("=" * 50)
|
| 203 |
-
logger.info("ESP32 Motion Controller")
|
| 204 |
logger.info("=" * 50)
|
| 205 |
for ip in ips:
|
| 206 |
logger.info(" WebSocket: ws://%s:%d/ws", ip, WS_PORT)
|
|
@@ -209,15 +201,11 @@ def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) ->
|
|
| 209 |
logger.info("=" * 50)
|
| 210 |
|
| 211 |
app = create_app(reachy_mini, stop_event, log_only=log_only)
|
| 212 |
-
# Stash for shutdown — create_app closes over the handler.
|
| 213 |
-
ws_handler: WebSocketHandler = app.state.ws_handler # type: ignore[attr-defined]
|
| 214 |
config = uvicorn.Config(app, host="0.0.0.0", port=WS_PORT, log_level="info")
|
| 215 |
server = uvicorn.Server(config)
|
| 216 |
thread = threading.Thread(target=server.run, daemon=True)
|
| 217 |
thread.start()
|
| 218 |
|
| 219 |
-
# A failed bind only kills the server thread, so without this the app would
|
| 220 |
-
# sit here advertising a port nothing listens on.
|
| 221 |
deadline = time.monotonic() + SERVER_START_TIMEOUT_S
|
| 222 |
while not server.started and thread.is_alive() and time.monotonic() < deadline:
|
| 223 |
time.sleep(0.05)
|
|
@@ -227,12 +215,11 @@ def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) ->
|
|
| 227 |
f"instance is probably already running"
|
| 228 |
)
|
| 229 |
|
| 230 |
-
# Advertise only once we are actually listening.
|
| 231 |
mdns = MdnsAdvertiser(WS_PORT)
|
| 232 |
mdns.start()
|
| 233 |
|
| 234 |
stop_event.wait()
|
| 235 |
-
|
| 236 |
server.should_exit = True
|
| 237 |
thread.join(timeout=5)
|
| 238 |
mdns.stop()
|
|
|
|
| 1 |
"""
|
| 2 |
+
Motion Controller Reachy Mini app entry point (protocol v2).
|
| 3 |
|
| 4 |
+
FastAPI/uvicorn on port 8766, mDNS advertise _reachyctl._tcp, clutch + safety bridge.
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import argparse
|
| 10 |
import logging
|
|
|
|
| 11 |
import socket
|
| 12 |
import sys
|
| 13 |
import threading
|
|
|
|
| 19 |
from fastapi.responses import FileResponse, JSONResponse
|
| 20 |
from fastapi.staticfiles import StaticFiles
|
| 21 |
|
| 22 |
+
from esp32_motion_controller.robot_control import RobotControl, RobotGateway
|
| 23 |
+
from esp32_motion_controller.session import SessionHub
|
|
|
|
|
|
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
WS_PORT = 8766
|
| 28 |
STATIC_DIR = Path(__file__).parent / "static"
|
| 29 |
MDNS_SERVICE_TYPE = "_reachyctl._tcp.local."
|
|
|
|
|
|
|
| 30 |
SERVER_START_TIMEOUT_S = 10.0
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def get_local_ips() -> list[str]:
|
| 34 |
ips: list[str] = []
|
| 35 |
try:
|
|
|
|
| 74 |
f"esp32-motion-controller.{MDNS_SERVICE_TYPE}",
|
| 75 |
addresses=[socket.inet_aton(ip) for ip in ips],
|
| 76 |
port=self.port,
|
| 77 |
+
properties={"path": b"/ws", "protocol": b"2"},
|
| 78 |
server="esp32-motion.local.",
|
| 79 |
)
|
| 80 |
zc = Zeroconf()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
zc.register_service(info, allow_name_change=True)
|
| 82 |
except Exception:
|
| 83 |
logger.warning(
|
|
|
|
| 117 |
log_only: bool = False,
|
| 118 |
) -> FastAPI:
|
| 119 |
app = FastAPI(title="ESP32 Motion Controller")
|
|
|
|
| 120 |
robot_available = reachy_mini is not None and not log_only
|
| 121 |
+
session = SessionHub(robot_available=robot_available or log_only)
|
| 122 |
+
gateway = RobotGateway(reachy_mini, log_only=log_only)
|
| 123 |
+
control = RobotControl(
|
| 124 |
+
session,
|
| 125 |
+
gateway,
|
| 126 |
+
robot_available=robot_available,
|
|
|
|
|
|
|
| 127 |
log_only=log_only,
|
| 128 |
)
|
| 129 |
+
app.state.session = session
|
| 130 |
+
app.state.control = control
|
| 131 |
+
|
| 132 |
+
@app.on_event("startup")
|
| 133 |
+
async def _startup() -> None:
|
| 134 |
+
control.start()
|
| 135 |
+
|
| 136 |
+
@app.on_event("shutdown")
|
| 137 |
+
async def _shutdown() -> None:
|
| 138 |
+
await control.stop()
|
| 139 |
|
| 140 |
@app.websocket("/ws")
|
| 141 |
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 142 |
+
generation = await session.on_connect(websocket)
|
| 143 |
+
await control.on_controller_connected()
|
|
|
|
| 144 |
try:
|
| 145 |
while not stop_event.is_set():
|
| 146 |
raw = await websocket.receive_text()
|
| 147 |
+
await session.handle_message(websocket, generation, raw)
|
| 148 |
except WebSocketDisconnect:
|
| 149 |
logger.info("WebSocket disconnect")
|
| 150 |
except Exception as exc:
|
| 151 |
logger.error("WebSocket error: %s", exc)
|
| 152 |
finally:
|
| 153 |
+
await session.cleanup(websocket, generation)
|
| 154 |
+
await control.on_controller_disconnected()
|
| 155 |
|
| 156 |
@app.get("/api/info")
|
| 157 |
async def info() -> JSONResponse:
|
|
|
|
| 162 |
"port": WS_PORT,
|
| 163 |
"ws_url": f"ws://{ips[0]}:{WS_PORT}/ws" if ips else None,
|
| 164 |
"mdns": MDNS_SERVICE_TYPE,
|
| 165 |
+
"protocol_version": 2,
|
| 166 |
}
|
| 167 |
)
|
| 168 |
|
| 169 |
@app.get("/api/status")
|
| 170 |
async def status() -> JSONResponse:
|
| 171 |
+
snap = await session.snapshot_status()
|
| 172 |
return JSONResponse(
|
| 173 |
{
|
| 174 |
"status": "ok",
|
| 175 |
"robot": robot_available or log_only,
|
| 176 |
+
"busy": snap["busy"],
|
| 177 |
+
"mode": snap["mode"],
|
| 178 |
+
"connected": snap["connected"],
|
| 179 |
"log_only": log_only,
|
| 180 |
+
"protocol_version": 2,
|
| 181 |
}
|
| 182 |
)
|
| 183 |
|
|
|
|
| 192 |
def _run_server(reachy_mini, stop_event: threading.Event, *, log_only: bool) -> None:
|
| 193 |
ips = get_local_ips()
|
| 194 |
logger.info("=" * 50)
|
| 195 |
+
logger.info("ESP32 Motion Controller (protocol v2)")
|
| 196 |
logger.info("=" * 50)
|
| 197 |
for ip in ips:
|
| 198 |
logger.info(" WebSocket: ws://%s:%d/ws", ip, WS_PORT)
|
|
|
|
| 201 |
logger.info("=" * 50)
|
| 202 |
|
| 203 |
app = create_app(reachy_mini, stop_event, log_only=log_only)
|
|
|
|
|
|
|
| 204 |
config = uvicorn.Config(app, host="0.0.0.0", port=WS_PORT, log_level="info")
|
| 205 |
server = uvicorn.Server(config)
|
| 206 |
thread = threading.Thread(target=server.run, daemon=True)
|
| 207 |
thread.start()
|
| 208 |
|
|
|
|
|
|
|
| 209 |
deadline = time.monotonic() + SERVER_START_TIMEOUT_S
|
| 210 |
while not server.started and thread.is_alive() and time.monotonic() < deadline:
|
| 211 |
time.sleep(0.05)
|
|
|
|
| 215 |
f"instance is probably already running"
|
| 216 |
)
|
| 217 |
|
|
|
|
| 218 |
mdns = MdnsAdvertiser(WS_PORT)
|
| 219 |
mdns.start()
|
| 220 |
|
| 221 |
stop_event.wait()
|
| 222 |
+
# Shutdown path: uvicorn will fire FastAPI shutdown hooks.
|
| 223 |
server.should_exit = True
|
| 224 |
thread.join(timeout=5)
|
| 225 |
mdns.stop()
|
esp32_motion_controller/protocol.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Protocol v2 parsing — dependency-light, no SDK imports.
|
| 3 |
+
|
| 4 |
+
Rejects oversize frames, wrong types, non-finite numbers, and unsupported versions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
PROTOCOL_VERSION = 2
|
| 15 |
+
MAX_FRAME_BYTES = 512
|
| 16 |
+
GAIN_MIN = 0.1
|
| 17 |
+
GAIN_MAX = 3.0
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ProtocolError(ValueError):
|
| 21 |
+
def __init__(self, message: str, *, request_type: str = "unknown") -> None:
|
| 22 |
+
super().__init__(message)
|
| 23 |
+
self.request_type = request_type
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True, slots=True)
|
| 27 |
+
class Hello:
|
| 28 |
+
protocol_version: int
|
| 29 |
+
boot_id: str
|
| 30 |
+
device: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True, slots=True)
|
| 34 |
+
class Sample:
|
| 35 |
+
boot_id: str
|
| 36 |
+
seq: int
|
| 37 |
+
q: tuple[float, float, float, float]
|
| 38 |
+
p: tuple[float, float, float]
|
| 39 |
+
engaged: bool
|
| 40 |
+
gain: float
|
| 41 |
+
ready: bool
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass(frozen=True, slots=True)
|
| 45 |
+
class Reset:
|
| 46 |
+
boot_id: str
|
| 47 |
+
op_id: int
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _require_dict(msg: Any, request_type: str) -> dict[str, Any]:
|
| 51 |
+
if not isinstance(msg, dict):
|
| 52 |
+
raise ProtocolError("JSON root must be an object", request_type=request_type)
|
| 53 |
+
return msg
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _require_str(msg: dict[str, Any], key: str, request_type: str) -> str:
|
| 57 |
+
val = msg.get(key)
|
| 58 |
+
if not isinstance(val, str) or not val:
|
| 59 |
+
raise ProtocolError(f"{key} must be a non-empty string", request_type=request_type)
|
| 60 |
+
return val
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _require_int(msg: dict[str, Any], key: str, request_type: str) -> int:
|
| 64 |
+
val = msg.get(key)
|
| 65 |
+
if isinstance(val, bool) or not isinstance(val, int):
|
| 66 |
+
raise ProtocolError(f"{key} must be an integer", request_type=request_type)
|
| 67 |
+
return int(val)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _require_bool(msg: dict[str, Any], key: str, request_type: str) -> bool:
|
| 71 |
+
val = msg.get(key)
|
| 72 |
+
if not isinstance(val, bool):
|
| 73 |
+
raise ProtocolError(f"{key} must be a boolean", request_type=request_type)
|
| 74 |
+
return val
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _require_finite_float(msg: dict[str, Any], key: str, request_type: str) -> float:
|
| 78 |
+
val = msg.get(key)
|
| 79 |
+
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
| 80 |
+
raise ProtocolError(f"{key} must be a number", request_type=request_type)
|
| 81 |
+
out = float(val)
|
| 82 |
+
if not math.isfinite(out):
|
| 83 |
+
raise ProtocolError(f"{key} must be finite", request_type=request_type)
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _require_vec(msg: dict[str, Any], key: str, n: int, request_type: str) -> tuple[float, ...]:
|
| 88 |
+
val = msg.get(key)
|
| 89 |
+
if not isinstance(val, list) or len(val) != n:
|
| 90 |
+
raise ProtocolError(f"{key} must be an array of length {n}", request_type=request_type)
|
| 91 |
+
out: list[float] = []
|
| 92 |
+
for i, item in enumerate(val):
|
| 93 |
+
if isinstance(item, bool) or not isinstance(item, (int, float)):
|
| 94 |
+
raise ProtocolError(f"{key}[{i}] must be a number", request_type=request_type)
|
| 95 |
+
f = float(item)
|
| 96 |
+
if not math.isfinite(f):
|
| 97 |
+
raise ProtocolError(f"{key}[{i}] must be finite", request_type=request_type)
|
| 98 |
+
out.append(f)
|
| 99 |
+
return tuple(out)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def parse_frame(raw: str | bytes) -> Hello | Sample | Reset:
|
| 103 |
+
if isinstance(raw, bytes):
|
| 104 |
+
if len(raw) > MAX_FRAME_BYTES:
|
| 105 |
+
raise ProtocolError("frame exceeds size limit", request_type="parse")
|
| 106 |
+
try:
|
| 107 |
+
text = raw.decode("utf-8")
|
| 108 |
+
except UnicodeDecodeError as exc:
|
| 109 |
+
raise ProtocolError(f"invalid UTF-8: {exc}", request_type="parse") from exc
|
| 110 |
+
else:
|
| 111 |
+
if len(raw.encode("utf-8")) > MAX_FRAME_BYTES:
|
| 112 |
+
raise ProtocolError("frame exceeds size limit", request_type="parse")
|
| 113 |
+
text = raw
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
msg = json.loads(text)
|
| 117 |
+
except json.JSONDecodeError as exc:
|
| 118 |
+
raise ProtocolError(f"invalid JSON: {exc}", request_type="parse") from exc
|
| 119 |
+
|
| 120 |
+
msg = _require_dict(msg, "parse")
|
| 121 |
+
msg_type = msg.get("type")
|
| 122 |
+
if not isinstance(msg_type, str):
|
| 123 |
+
raise ProtocolError("missing type", request_type="parse")
|
| 124 |
+
|
| 125 |
+
if msg_type == "hello":
|
| 126 |
+
return parse_hello(msg)
|
| 127 |
+
if msg_type == "sample":
|
| 128 |
+
return parse_sample(msg)
|
| 129 |
+
if msg_type == "reset":
|
| 130 |
+
return parse_reset(msg)
|
| 131 |
+
raise ProtocolError(f"unknown message type: {msg_type}", request_type=msg_type)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def parse_hello(msg: dict[str, Any]) -> Hello:
|
| 135 |
+
request_type = "hello"
|
| 136 |
+
version = _require_int(msg, "protocol_version", request_type)
|
| 137 |
+
if version != PROTOCOL_VERSION:
|
| 138 |
+
raise ProtocolError(
|
| 139 |
+
f"unsupported protocol_version: {version}",
|
| 140 |
+
request_type=request_type,
|
| 141 |
+
)
|
| 142 |
+
return Hello(
|
| 143 |
+
protocol_version=version,
|
| 144 |
+
boot_id=_require_str(msg, "boot_id", request_type),
|
| 145 |
+
device=_require_str(msg, "device", request_type) if "device" in msg else "esp32",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def parse_sample(msg: dict[str, Any]) -> Sample:
|
| 150 |
+
request_type = "sample"
|
| 151 |
+
q = _require_vec(msg, "q", 4, request_type)
|
| 152 |
+
p = _require_vec(msg, "p", 3, request_type)
|
| 153 |
+
gain = _require_finite_float(msg, "gain", request_type)
|
| 154 |
+
if gain < GAIN_MIN or gain > GAIN_MAX:
|
| 155 |
+
raise ProtocolError(
|
| 156 |
+
f"gain out of range [{GAIN_MIN}, {GAIN_MAX}]",
|
| 157 |
+
request_type=request_type,
|
| 158 |
+
)
|
| 159 |
+
seq = _require_int(msg, "seq", request_type)
|
| 160 |
+
if seq < 0:
|
| 161 |
+
raise ProtocolError("seq must be non-negative", request_type=request_type)
|
| 162 |
+
return Sample(
|
| 163 |
+
boot_id=_require_str(msg, "boot_id", request_type),
|
| 164 |
+
seq=seq,
|
| 165 |
+
q=(q[0], q[1], q[2], q[3]),
|
| 166 |
+
p=(p[0], p[1], p[2]),
|
| 167 |
+
engaged=_require_bool(msg, "engaged", request_type),
|
| 168 |
+
gain=gain,
|
| 169 |
+
ready=_require_bool(msg, "ready", request_type),
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def parse_reset(msg: dict[str, Any]) -> Reset:
|
| 174 |
+
request_type = "reset"
|
| 175 |
+
op_id = _require_int(msg, "op_id", request_type)
|
| 176 |
+
if op_id < 0:
|
| 177 |
+
raise ProtocolError("op_id must be non-negative", request_type=request_type)
|
| 178 |
+
return Reset(
|
| 179 |
+
boot_id=_require_str(msg, "boot_id", request_type),
|
| 180 |
+
op_id=op_id,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def encode_hello_response(session_id: int) -> dict[str, Any]:
|
| 185 |
+
return {
|
| 186 |
+
"type": "hello",
|
| 187 |
+
"protocol_version": PROTOCOL_VERSION,
|
| 188 |
+
"session_id": int(session_id),
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def encode_host_state(
|
| 193 |
+
*,
|
| 194 |
+
robot: bool,
|
| 195 |
+
mode: str,
|
| 196 |
+
error: str | None = None,
|
| 197 |
+
) -> dict[str, Any]:
|
| 198 |
+
return {
|
| 199 |
+
"type": "host_state",
|
| 200 |
+
"robot": bool(robot),
|
| 201 |
+
"mode": mode,
|
| 202 |
+
"error": error,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def encode_reset_result(
|
| 207 |
+
*,
|
| 208 |
+
boot_id: str,
|
| 209 |
+
op_id: int,
|
| 210 |
+
status: str,
|
| 211 |
+
message: str | None = None,
|
| 212 |
+
) -> dict[str, Any]:
|
| 213 |
+
out: dict[str, Any] = {
|
| 214 |
+
"type": "reset_result",
|
| 215 |
+
"boot_id": boot_id,
|
| 216 |
+
"op_id": int(op_id),
|
| 217 |
+
"status": status,
|
| 218 |
+
}
|
| 219 |
+
if message is not None:
|
| 220 |
+
out["message"] = message
|
| 221 |
+
return out
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def encode_error(request_type: str, message: str) -> dict[str, Any]:
|
| 225 |
+
return {
|
| 226 |
+
"type": "error",
|
| 227 |
+
"request_type": request_type,
|
| 228 |
+
"message": message,
|
| 229 |
+
}
|
esp32_motion_controller/robot_control.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Single fixed-rate robot command owner for protocol v2.
|
| 3 |
+
|
| 4 |
+
Consumes the latest sample from SessionHub, runs the pure reducer, and performs
|
| 5 |
+
at most one SDK call in flight. Owns reset goto completion and pose seeding.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
from scipy.spatial.transform import Rotation
|
| 17 |
+
|
| 18 |
+
from esp32_motion_controller.control import (
|
| 19 |
+
CONTROL_DT,
|
| 20 |
+
CONTROL_HZ,
|
| 21 |
+
Command,
|
| 22 |
+
ControlState,
|
| 23 |
+
begin_reset,
|
| 24 |
+
force_disengage,
|
| 25 |
+
initial_state,
|
| 26 |
+
mark_pose_unread,
|
| 27 |
+
mark_sdk_failure,
|
| 28 |
+
mark_sdk_success,
|
| 29 |
+
note_sample_receipt,
|
| 30 |
+
rebase_neutral,
|
| 31 |
+
seed_from_pose,
|
| 32 |
+
step,
|
| 33 |
+
zero_pose,
|
| 34 |
+
)
|
| 35 |
+
from esp32_motion_controller.session import SessionHub
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
RESET_DURATION_SEC = 1.5
|
| 40 |
+
IDLE_RECONCILE_SEC = 2.0
|
| 41 |
+
RESYNC_EPS_POS_M = 0.003
|
| 42 |
+
RESYNC_EPS_ANG_RAD = 0.05
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RobotGateway:
|
| 46 |
+
"""Thin SDK adapter — all blocking calls run in an executor by RobotControl."""
|
| 47 |
+
|
| 48 |
+
def __init__(self, reachy_mini: Any | None, *, log_only: bool = False) -> None:
|
| 49 |
+
self.mini = reachy_mini
|
| 50 |
+
self.log_only = log_only
|
| 51 |
+
|
| 52 |
+
def read_pose(self) -> tuple[dict[str, float], float] | None:
|
| 53 |
+
if self.mini is None:
|
| 54 |
+
return zero_pose(), 0.0
|
| 55 |
+
try:
|
| 56 |
+
head = np.asarray(self.mini.get_current_head_pose(), dtype=np.float64)
|
| 57 |
+
joints, _ = self.mini.get_current_joint_positions()
|
| 58 |
+
body_yaw = float(joints[0])
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
logger.warning("Could not read robot pose: %s", exc)
|
| 61 |
+
return None
|
| 62 |
+
rpy = Rotation.from_matrix(head[:3, :3]).as_euler("xyz")
|
| 63 |
+
pose = {
|
| 64 |
+
"x": float(head[0, 3]),
|
| 65 |
+
"y": float(head[1, 3]),
|
| 66 |
+
"z": float(head[2, 3]),
|
| 67 |
+
"roll": float(rpy[0]),
|
| 68 |
+
"pitch": float(rpy[1]),
|
| 69 |
+
"yaw": float(rpy[2]),
|
| 70 |
+
}
|
| 71 |
+
return pose, body_yaw
|
| 72 |
+
|
| 73 |
+
def set_target(self, command: Command) -> None:
|
| 74 |
+
if self.mini is None or self.log_only:
|
| 75 |
+
return
|
| 76 |
+
from reachy_mini.utils import create_head_pose
|
| 77 |
+
|
| 78 |
+
head = create_head_pose(
|
| 79 |
+
x=command.pose["x"],
|
| 80 |
+
y=command.pose["y"],
|
| 81 |
+
z=command.pose["z"],
|
| 82 |
+
roll=command.pose["roll"],
|
| 83 |
+
pitch=command.pose["pitch"],
|
| 84 |
+
yaw=command.pose["yaw"],
|
| 85 |
+
degrees=False,
|
| 86 |
+
)
|
| 87 |
+
antennas = np.array(command.antennas, dtype=np.float64)
|
| 88 |
+
self.mini.set_target(head=head, body_yaw=command.body_yaw, antennas=antennas)
|
| 89 |
+
|
| 90 |
+
def goto_neutral(self, duration: float = RESET_DURATION_SEC) -> None:
|
| 91 |
+
if self.mini is None or self.log_only:
|
| 92 |
+
time.sleep(min(duration, 0.05))
|
| 93 |
+
return
|
| 94 |
+
from reachy_mini.utils import create_head_pose
|
| 95 |
+
|
| 96 |
+
head = create_head_pose(
|
| 97 |
+
x=0.0, y=0.0, z=0.0, roll=0.0, pitch=0.0, yaw=0.0, degrees=False
|
| 98 |
+
)
|
| 99 |
+
self.mini.goto_target(
|
| 100 |
+
head=head,
|
| 101 |
+
body_yaw=0.0,
|
| 102 |
+
antennas=[0.0, 0.0],
|
| 103 |
+
duration=duration,
|
| 104 |
+
method="minjerk",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class RobotControl:
|
| 109 |
+
def __init__(
|
| 110 |
+
self,
|
| 111 |
+
session: SessionHub,
|
| 112 |
+
gateway: RobotGateway,
|
| 113 |
+
*,
|
| 114 |
+
robot_available: bool,
|
| 115 |
+
log_only: bool = False,
|
| 116 |
+
hz: float = CONTROL_HZ,
|
| 117 |
+
) -> None:
|
| 118 |
+
self.session = session
|
| 119 |
+
self.gateway = gateway
|
| 120 |
+
self.robot_available = robot_available
|
| 121 |
+
self.log_only = log_only
|
| 122 |
+
self.dt = 1.0 / hz
|
| 123 |
+
self._task: asyncio.Task[None] | None = None
|
| 124 |
+
self._state = initial_state(robot_available=robot_available or log_only, now=time.monotonic())
|
| 125 |
+
self._last_seen_seq: int | None = None
|
| 126 |
+
self._last_reconcile = 0.0
|
| 127 |
+
self._sdk_lock = asyncio.Lock()
|
| 128 |
+
self._started = False
|
| 129 |
+
|
| 130 |
+
@property
|
| 131 |
+
def state(self) -> ControlState:
|
| 132 |
+
return self._state
|
| 133 |
+
|
| 134 |
+
def start(self) -> None:
|
| 135 |
+
if self._task is None or self._task.done():
|
| 136 |
+
self._task = asyncio.create_task(self._loop(), name="robot_control")
|
| 137 |
+
self._started = True
|
| 138 |
+
|
| 139 |
+
async def stop(self) -> None:
|
| 140 |
+
if self._task is not None and not self._task.done():
|
| 141 |
+
self._task.cancel()
|
| 142 |
+
try:
|
| 143 |
+
await self._task
|
| 144 |
+
except asyncio.CancelledError:
|
| 145 |
+
pass
|
| 146 |
+
self._task = None
|
| 147 |
+
self._started = False
|
| 148 |
+
|
| 149 |
+
async def _loop(self) -> None:
|
| 150 |
+
loop = asyncio.get_running_loop()
|
| 151 |
+
last = time.monotonic()
|
| 152 |
+
try:
|
| 153 |
+
# Initial seed attempt.
|
| 154 |
+
await self._seed(update_host=True)
|
| 155 |
+
while True:
|
| 156 |
+
t0 = time.monotonic()
|
| 157 |
+
dt = min(t0 - last, 0.05)
|
| 158 |
+
if dt <= 0:
|
| 159 |
+
dt = self.dt
|
| 160 |
+
last = t0
|
| 161 |
+
|
| 162 |
+
await self._tick(loop, t0, dt)
|
| 163 |
+
|
| 164 |
+
elapsed = time.monotonic() - t0
|
| 165 |
+
await asyncio.sleep(max(0.0, self.dt - elapsed))
|
| 166 |
+
except asyncio.CancelledError:
|
| 167 |
+
pass
|
| 168 |
+
|
| 169 |
+
async def _tick(self, loop: asyncio.AbstractEventLoop, now: float, dt: float) -> None:
|
| 170 |
+
# Handle pending reset first — exclusive robot ownership.
|
| 171 |
+
pending = await self.session.take_pending_reset()
|
| 172 |
+
if pending is not None:
|
| 173 |
+
await self._run_reset(loop, pending, now)
|
| 174 |
+
return
|
| 175 |
+
|
| 176 |
+
latest = await self.session.take_latest_sample()
|
| 177 |
+
sample = None
|
| 178 |
+
sample_is_fresh = False
|
| 179 |
+
if latest is not None:
|
| 180 |
+
sample = latest.sample
|
| 181 |
+
if self._last_seen_seq != sample.seq:
|
| 182 |
+
sample_is_fresh = True
|
| 183 |
+
self._last_seen_seq = sample.seq
|
| 184 |
+
self._state = note_sample_receipt(self._state, latest.receipt_time)
|
| 185 |
+
|
| 186 |
+
# Idle reconciliation when disengaged and not frozen.
|
| 187 |
+
if (
|
| 188 |
+
self._state.mode == "idle"
|
| 189 |
+
and not self._state.engaged
|
| 190 |
+
and not self._state.sends_frozen
|
| 191 |
+
and (now - self._last_reconcile) >= IDLE_RECONCILE_SEC
|
| 192 |
+
):
|
| 193 |
+
await self._reconcile_idle(loop, now)
|
| 194 |
+
|
| 195 |
+
prev_mode = self._state.mode
|
| 196 |
+
result = step(
|
| 197 |
+
self._state,
|
| 198 |
+
now=now,
|
| 199 |
+
dt=dt,
|
| 200 |
+
sample=sample,
|
| 201 |
+
sample_is_fresh=sample_is_fresh,
|
| 202 |
+
)
|
| 203 |
+
self._state = result.state
|
| 204 |
+
|
| 205 |
+
if result.mode_changed or self._state.mode != prev_mode:
|
| 206 |
+
await self.session.push_host_state(
|
| 207 |
+
mode=self._state.mode,
|
| 208 |
+
robot=self.robot_available or self.log_only,
|
| 209 |
+
error=self._state.error,
|
| 210 |
+
clear_error=self._state.error is None,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
if result.command is None:
|
| 214 |
+
return
|
| 215 |
+
|
| 216 |
+
if self.log_only:
|
| 217 |
+
if sample_is_fresh:
|
| 218 |
+
logger.info(
|
| 219 |
+
"command engaged=%s pose=%s body=%.3f",
|
| 220 |
+
self._state.engaged,
|
| 221 |
+
{k: round(result.command.pose[k], 4) for k in result.command.pose},
|
| 222 |
+
result.command.body_yaw,
|
| 223 |
+
)
|
| 224 |
+
self._state = mark_sdk_success(self._state, result.command)
|
| 225 |
+
return
|
| 226 |
+
|
| 227 |
+
async with self._sdk_lock:
|
| 228 |
+
try:
|
| 229 |
+
await loop.run_in_executor(None, self.gateway.set_target, result.command)
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
logger.warning("set_target failed: %s", exc)
|
| 232 |
+
self._state = mark_sdk_failure(self._state)
|
| 233 |
+
await self.session.push_host_state(
|
| 234 |
+
mode=self._state.mode,
|
| 235 |
+
error=self._state.error,
|
| 236 |
+
)
|
| 237 |
+
return
|
| 238 |
+
self._state = mark_sdk_success(self._state, result.command)
|
| 239 |
+
|
| 240 |
+
async def _seed(self, *, update_host: bool) -> bool:
|
| 241 |
+
loop = asyncio.get_running_loop()
|
| 242 |
+
read = await loop.run_in_executor(None, self.gateway.read_pose)
|
| 243 |
+
if read is None:
|
| 244 |
+
self._state = mark_pose_unread(self._state)
|
| 245 |
+
if update_host:
|
| 246 |
+
await self.session.push_host_state(mode="fault", error=self._state.error)
|
| 247 |
+
return False
|
| 248 |
+
pose, body = read
|
| 249 |
+
self._state = seed_from_pose(self._state, pose, body)
|
| 250 |
+
self._last_reconcile = time.monotonic()
|
| 251 |
+
if update_host:
|
| 252 |
+
await self.session.push_host_state(mode="idle", clear_error=True)
|
| 253 |
+
return True
|
| 254 |
+
|
| 255 |
+
async def _reconcile_idle(
|
| 256 |
+
self, loop: asyncio.AbstractEventLoop, now: float
|
| 257 |
+
) -> None:
|
| 258 |
+
self._last_reconcile = now
|
| 259 |
+
if self.gateway.mini is None:
|
| 260 |
+
return
|
| 261 |
+
read = await loop.run_in_executor(None, self.gateway.read_pose)
|
| 262 |
+
if read is None:
|
| 263 |
+
self._state = mark_pose_unread(self._state)
|
| 264 |
+
await self.session.push_host_state(mode="fault", error=self._state.error)
|
| 265 |
+
return
|
| 266 |
+
pose, body = read
|
| 267 |
+
prev = self._state.baseline_pose
|
| 268 |
+
pos = (
|
| 269 |
+
(pose["x"] - prev["x"]) ** 2
|
| 270 |
+
+ (pose["y"] - prev["y"]) ** 2
|
| 271 |
+
+ (pose["z"] - prev["z"]) ** 2
|
| 272 |
+
) ** 0.5
|
| 273 |
+
ang = max(
|
| 274 |
+
abs(pose["roll"] - prev["roll"]),
|
| 275 |
+
abs(pose["pitch"] - prev["pitch"]),
|
| 276 |
+
abs(pose["yaw"] - prev["yaw"]),
|
| 277 |
+
abs(body - self._state.baseline_body_yaw),
|
| 278 |
+
)
|
| 279 |
+
if pos <= RESYNC_EPS_POS_M and ang <= RESYNC_EPS_ANG_RAD:
|
| 280 |
+
self._state = mark_sdk_success(
|
| 281 |
+
self._state,
|
| 282 |
+
Command(pose=pose, body_yaw=body, antennas=tuple(self._state.baseline_antennas[:2])),
|
| 283 |
+
)
|
| 284 |
+
return
|
| 285 |
+
logger.warning("Pose desync pos=%.4f m ang=%.3f rad — idle resync", pos, ang)
|
| 286 |
+
self._state = seed_from_pose(self._state, pose, body)
|
| 287 |
+
|
| 288 |
+
async def _run_reset(
|
| 289 |
+
self, loop: asyncio.AbstractEventLoop, reset, now: float
|
| 290 |
+
) -> None:
|
| 291 |
+
if not self.robot_available and not self.log_only:
|
| 292 |
+
await self.session.complete_reset(
|
| 293 |
+
boot_id=reset.boot_id,
|
| 294 |
+
op_id=reset.op_id,
|
| 295 |
+
status="failed",
|
| 296 |
+
message="Robot not available",
|
| 297 |
+
)
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
self._state = begin_reset(self._state, now)
|
| 301 |
+
await self.session.push_host_state(mode="resetting", clear_error=True)
|
| 302 |
+
|
| 303 |
+
# Seed from measured pose before goto.
|
| 304 |
+
read = await loop.run_in_executor(None, self.gateway.read_pose)
|
| 305 |
+
if read is None and not self.log_only:
|
| 306 |
+
self._state = mark_pose_unread(self._state)
|
| 307 |
+
await self.session.complete_reset(
|
| 308 |
+
boot_id=reset.boot_id,
|
| 309 |
+
op_id=reset.op_id,
|
| 310 |
+
status="failed",
|
| 311 |
+
message="Robot pose unread",
|
| 312 |
+
)
|
| 313 |
+
return
|
| 314 |
+
if read is not None:
|
| 315 |
+
pose, body = read
|
| 316 |
+
self._state = seed_from_pose(self._state, pose, body)
|
| 317 |
+
self._state = begin_reset(self._state, now)
|
| 318 |
+
|
| 319 |
+
async with self._sdk_lock:
|
| 320 |
+
try:
|
| 321 |
+
await loop.run_in_executor(None, self.gateway.goto_neutral, RESET_DURATION_SEC)
|
| 322 |
+
except Exception as exc:
|
| 323 |
+
logger.error("goto_target failed: %s", exc)
|
| 324 |
+
self._state = mark_pose_unread(self._state)
|
| 325 |
+
await self.session.complete_reset(
|
| 326 |
+
boot_id=reset.boot_id,
|
| 327 |
+
op_id=reset.op_id,
|
| 328 |
+
status="failed",
|
| 329 |
+
message=str(exc),
|
| 330 |
+
)
|
| 331 |
+
return
|
| 332 |
+
|
| 333 |
+
measured = await loop.run_in_executor(None, self.gateway.read_pose)
|
| 334 |
+
if measured is None and not self.log_only:
|
| 335 |
+
self._state = rebase_neutral(self._state, measured_baseline=None, measured_body=None)
|
| 336 |
+
await self.session.complete_reset(
|
| 337 |
+
boot_id=reset.boot_id,
|
| 338 |
+
op_id=reset.op_id,
|
| 339 |
+
status="failed",
|
| 340 |
+
message="Robot pose unread after reset",
|
| 341 |
+
)
|
| 342 |
+
return
|
| 343 |
+
|
| 344 |
+
if measured is None:
|
| 345 |
+
measured = (zero_pose(), 0.0)
|
| 346 |
+
pose, body = measured
|
| 347 |
+
self._state = rebase_neutral(
|
| 348 |
+
self._state, measured_baseline=pose, measured_body=body
|
| 349 |
+
)
|
| 350 |
+
self._last_reconcile = time.monotonic()
|
| 351 |
+
await self.session.complete_reset(
|
| 352 |
+
boot_id=reset.boot_id,
|
| 353 |
+
op_id=reset.op_id,
|
| 354 |
+
status="completed",
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
async def on_controller_connected(self) -> None:
|
| 358 |
+
"""Reseed when a new controller session is admitted.
|
| 359 |
+
|
| 360 |
+
Do not push host_state here — the hello handler sends the first snapshot
|
| 361 |
+
so the device always sees hello before host_state.
|
| 362 |
+
"""
|
| 363 |
+
self._state = force_disengage(self._state)
|
| 364 |
+
self._last_seen_seq = None
|
| 365 |
+
ok = await self._seed(update_host=False)
|
| 366 |
+
if not ok and self.log_only:
|
| 367 |
+
self._state = seed_from_pose(self._state, zero_pose(), 0.0)
|
| 368 |
+
|
| 369 |
+
async def on_controller_disconnected(self) -> None:
|
| 370 |
+
self._state = force_disengage(self._state)
|
| 371 |
+
if self._state.mode not in {"resetting"}:
|
| 372 |
+
await self.session.push_host_state(mode="idle")
|
esp32_motion_controller/session.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket session ingress for protocol v2.
|
| 3 |
+
|
| 4 |
+
Validates frames, keeps only the latest sample, manages session generation and
|
| 5 |
+
reset operation mailbox. Never calls the robot SDK.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, Awaitable, Callable
|
| 15 |
+
|
| 16 |
+
from fastapi import WebSocket
|
| 17 |
+
from starlette.websockets import WebSocketState
|
| 18 |
+
|
| 19 |
+
from esp32_motion_controller.protocol import (
|
| 20 |
+
Hello,
|
| 21 |
+
ProtocolError,
|
| 22 |
+
Reset,
|
| 23 |
+
Sample,
|
| 24 |
+
encode_error,
|
| 25 |
+
encode_hello_response,
|
| 26 |
+
encode_host_state,
|
| 27 |
+
encode_reset_result,
|
| 28 |
+
parse_frame,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
SendFn = Callable[[dict[str, Any]], Awaitable[None]]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class LatestSample:
|
| 38 |
+
sample: Sample
|
| 39 |
+
receipt_time: float
|
| 40 |
+
generation: int
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class ResetRecord:
|
| 45 |
+
boot_id: str
|
| 46 |
+
op_id: int
|
| 47 |
+
status: str # accepted | completed | failed
|
| 48 |
+
message: str | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class SessionMailbox:
|
| 53 |
+
"""Shared state between the WS receiver and the control loop."""
|
| 54 |
+
|
| 55 |
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
| 56 |
+
generation: int = 0
|
| 57 |
+
boot_id: str | None = None
|
| 58 |
+
active_ws: WebSocket | None = None
|
| 59 |
+
latest: LatestSample | None = None
|
| 60 |
+
last_seq: int | None = None
|
| 61 |
+
pending_reset: Reset | None = None
|
| 62 |
+
reset_cache: dict[tuple[str, int], ResetRecord] = field(default_factory=dict)
|
| 63 |
+
host_mode: str = "idle"
|
| 64 |
+
host_robot: bool = True
|
| 65 |
+
host_error: str | None = None
|
| 66 |
+
controller_present: bool = False
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class SessionHub:
|
| 70 |
+
def __init__(self, *, robot_available: bool) -> None:
|
| 71 |
+
self.mailbox = SessionMailbox(host_robot=robot_available)
|
| 72 |
+
self._send_lock = asyncio.Lock()
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def generation(self) -> int:
|
| 76 |
+
return self.mailbox.generation
|
| 77 |
+
|
| 78 |
+
async def on_connect(self, websocket: WebSocket) -> int:
|
| 79 |
+
mb = self.mailbox
|
| 80 |
+
async with mb.lock:
|
| 81 |
+
old = mb.active_ws
|
| 82 |
+
mb.generation += 1
|
| 83 |
+
gen = mb.generation
|
| 84 |
+
mb.active_ws = websocket
|
| 85 |
+
mb.latest = None
|
| 86 |
+
mb.last_seq = None
|
| 87 |
+
mb.pending_reset = None
|
| 88 |
+
mb.boot_id = None
|
| 89 |
+
mb.controller_present = True
|
| 90 |
+
mb.host_mode = "idle"
|
| 91 |
+
mb.host_error = None
|
| 92 |
+
|
| 93 |
+
if old is not None and old is not websocket:
|
| 94 |
+
alive = old.client_state == WebSocketState.CONNECTED
|
| 95 |
+
if alive:
|
| 96 |
+
logger.warning("Replacing active controller connection")
|
| 97 |
+
try:
|
| 98 |
+
await old.close(code=1000)
|
| 99 |
+
except Exception:
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
await websocket.accept()
|
| 103 |
+
logger.info("Controller socket accepted generation=%d", gen)
|
| 104 |
+
return gen
|
| 105 |
+
|
| 106 |
+
async def cleanup(self, websocket: WebSocket, generation: int) -> None:
|
| 107 |
+
mb = self.mailbox
|
| 108 |
+
async with mb.lock:
|
| 109 |
+
if mb.active_ws is not websocket or mb.generation != generation:
|
| 110 |
+
return
|
| 111 |
+
mb.active_ws = None
|
| 112 |
+
mb.controller_present = False
|
| 113 |
+
mb.latest = None
|
| 114 |
+
# Keep reset_cache and boot_id so reconnect can retrieve outcomes.
|
| 115 |
+
logger.info("Controller disconnected generation=%d", generation)
|
| 116 |
+
|
| 117 |
+
async def handle_message(self, websocket: WebSocket, generation: int, raw: str) -> None:
|
| 118 |
+
try:
|
| 119 |
+
msg = parse_frame(raw)
|
| 120 |
+
except ProtocolError as exc:
|
| 121 |
+
await self._send(websocket, generation, encode_error(exc.request_type, str(exc)))
|
| 122 |
+
if exc.request_type == "hello":
|
| 123 |
+
try:
|
| 124 |
+
await websocket.close(code=1002)
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
return
|
| 128 |
+
|
| 129 |
+
if isinstance(msg, Hello):
|
| 130 |
+
await self._handle_hello(websocket, generation, msg)
|
| 131 |
+
return
|
| 132 |
+
if isinstance(msg, Sample):
|
| 133 |
+
await self._handle_sample(websocket, generation, msg)
|
| 134 |
+
return
|
| 135 |
+
if isinstance(msg, Reset):
|
| 136 |
+
await self._handle_reset(websocket, generation, msg)
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
async def _handle_hello(self, websocket: WebSocket, generation: int, hello: Hello) -> None:
|
| 140 |
+
mb = self.mailbox
|
| 141 |
+
async with mb.lock:
|
| 142 |
+
if mb.generation != generation or mb.active_ws is not websocket:
|
| 143 |
+
return
|
| 144 |
+
mb.boot_id = hello.boot_id
|
| 145 |
+
robot = mb.host_robot
|
| 146 |
+
mode = mb.host_mode
|
| 147 |
+
err = mb.host_error
|
| 148 |
+
await self._send(websocket, generation, encode_hello_response(generation))
|
| 149 |
+
await self._send(
|
| 150 |
+
websocket,
|
| 151 |
+
generation,
|
| 152 |
+
encode_host_state(robot=robot, mode=mode, error=err),
|
| 153 |
+
)
|
| 154 |
+
logger.info("Hello ok boot_id=%s generation=%d", hello.boot_id, generation)
|
| 155 |
+
|
| 156 |
+
async def _handle_sample(self, websocket: WebSocket, generation: int, sample: Sample) -> None:
|
| 157 |
+
mb = self.mailbox
|
| 158 |
+
now = time.monotonic()
|
| 159 |
+
async with mb.lock:
|
| 160 |
+
if mb.generation != generation or mb.active_ws is not websocket:
|
| 161 |
+
return
|
| 162 |
+
if mb.boot_id is None:
|
| 163 |
+
# Implicit bind if device skipped hello (should not happen).
|
| 164 |
+
mb.boot_id = sample.boot_id
|
| 165 |
+
if sample.boot_id != mb.boot_id:
|
| 166 |
+
logger.warning("sample boot_id mismatch; dropping")
|
| 167 |
+
return
|
| 168 |
+
if mb.last_seq is not None:
|
| 169 |
+
# Duplicate
|
| 170 |
+
if sample.seq == mb.last_seq:
|
| 171 |
+
return
|
| 172 |
+
# Out-of-order (small backward jump) — drop unless wrap.
|
| 173 |
+
if sample.seq < mb.last_seq:
|
| 174 |
+
# uint32 wrap: large backward jump
|
| 175 |
+
if mb.last_seq - sample.seq < 2**31:
|
| 176 |
+
return
|
| 177 |
+
mb.last_seq = sample.seq
|
| 178 |
+
mb.latest = LatestSample(sample=sample, receipt_time=now, generation=generation)
|
| 179 |
+
|
| 180 |
+
async def _handle_reset(self, websocket: WebSocket, generation: int, reset: Reset) -> None:
|
| 181 |
+
mb = self.mailbox
|
| 182 |
+
async with mb.lock:
|
| 183 |
+
if mb.generation != generation or mb.active_ws is not websocket:
|
| 184 |
+
return
|
| 185 |
+
if mb.boot_id is None:
|
| 186 |
+
mb.boot_id = reset.boot_id
|
| 187 |
+
if reset.boot_id != mb.boot_id:
|
| 188 |
+
await self._send(
|
| 189 |
+
websocket,
|
| 190 |
+
generation,
|
| 191 |
+
encode_reset_result(
|
| 192 |
+
boot_id=reset.boot_id,
|
| 193 |
+
op_id=reset.op_id,
|
| 194 |
+
status="failed",
|
| 195 |
+
message="boot_id mismatch",
|
| 196 |
+
),
|
| 197 |
+
)
|
| 198 |
+
return
|
| 199 |
+
cached = mb.reset_cache.get((reset.boot_id, reset.op_id))
|
| 200 |
+
if cached is not None:
|
| 201 |
+
result = encode_reset_result(
|
| 202 |
+
boot_id=cached.boot_id,
|
| 203 |
+
op_id=cached.op_id,
|
| 204 |
+
status=cached.status,
|
| 205 |
+
message=cached.message,
|
| 206 |
+
)
|
| 207 |
+
pending = None
|
| 208 |
+
elif mb.host_mode == "resetting":
|
| 209 |
+
# Another reset already running with a different op_id.
|
| 210 |
+
result = encode_reset_result(
|
| 211 |
+
boot_id=reset.boot_id,
|
| 212 |
+
op_id=reset.op_id,
|
| 213 |
+
status="failed",
|
| 214 |
+
message="Reset already in progress",
|
| 215 |
+
)
|
| 216 |
+
pending = None
|
| 217 |
+
else:
|
| 218 |
+
mb.pending_reset = reset
|
| 219 |
+
mb.reset_cache[(reset.boot_id, reset.op_id)] = ResetRecord(
|
| 220 |
+
boot_id=reset.boot_id,
|
| 221 |
+
op_id=reset.op_id,
|
| 222 |
+
status="accepted",
|
| 223 |
+
)
|
| 224 |
+
mb.host_mode = "resetting"
|
| 225 |
+
result = encode_reset_result(
|
| 226 |
+
boot_id=reset.boot_id,
|
| 227 |
+
op_id=reset.op_id,
|
| 228 |
+
status="accepted",
|
| 229 |
+
)
|
| 230 |
+
pending = reset
|
| 231 |
+
|
| 232 |
+
await self._send(websocket, generation, result)
|
| 233 |
+
if pending is not None:
|
| 234 |
+
await self.push_host_state(mode="resetting")
|
| 235 |
+
|
| 236 |
+
async def take_latest_sample(self) -> LatestSample | None:
|
| 237 |
+
mb = self.mailbox
|
| 238 |
+
async with mb.lock:
|
| 239 |
+
return mb.latest
|
| 240 |
+
|
| 241 |
+
async def take_pending_reset(self) -> Reset | None:
|
| 242 |
+
mb = self.mailbox
|
| 243 |
+
async with mb.lock:
|
| 244 |
+
reset = mb.pending_reset
|
| 245 |
+
mb.pending_reset = None
|
| 246 |
+
return reset
|
| 247 |
+
|
| 248 |
+
async def complete_reset(
|
| 249 |
+
self,
|
| 250 |
+
*,
|
| 251 |
+
boot_id: str,
|
| 252 |
+
op_id: int,
|
| 253 |
+
status: str,
|
| 254 |
+
message: str | None = None,
|
| 255 |
+
) -> None:
|
| 256 |
+
mb = self.mailbox
|
| 257 |
+
async with mb.lock:
|
| 258 |
+
mb.reset_cache[(boot_id, op_id)] = ResetRecord(
|
| 259 |
+
boot_id=boot_id, op_id=op_id, status=status, message=message
|
| 260 |
+
)
|
| 261 |
+
if status in {"completed", "failed"} and mb.host_mode == "resetting":
|
| 262 |
+
mb.host_mode = "idle" if status == "completed" else "fault"
|
| 263 |
+
mb.host_error = message if status == "failed" else None
|
| 264 |
+
ws = mb.active_ws
|
| 265 |
+
gen = mb.generation
|
| 266 |
+
mode = mb.host_mode
|
| 267 |
+
robot = mb.host_robot
|
| 268 |
+
err = mb.host_error
|
| 269 |
+
if ws is not None:
|
| 270 |
+
await self._send(
|
| 271 |
+
ws,
|
| 272 |
+
gen,
|
| 273 |
+
encode_reset_result(
|
| 274 |
+
boot_id=boot_id, op_id=op_id, status=status, message=message
|
| 275 |
+
),
|
| 276 |
+
)
|
| 277 |
+
await self._send(
|
| 278 |
+
ws,
|
| 279 |
+
gen,
|
| 280 |
+
encode_host_state(robot=robot, mode=mode, error=err),
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
async def push_host_state(
|
| 284 |
+
self,
|
| 285 |
+
*,
|
| 286 |
+
mode: str | None = None,
|
| 287 |
+
robot: bool | None = None,
|
| 288 |
+
error: str | None = None,
|
| 289 |
+
clear_error: bool = False,
|
| 290 |
+
) -> None:
|
| 291 |
+
mb = self.mailbox
|
| 292 |
+
async with mb.lock:
|
| 293 |
+
if mode is not None:
|
| 294 |
+
mb.host_mode = mode
|
| 295 |
+
if robot is not None:
|
| 296 |
+
mb.host_robot = robot
|
| 297 |
+
if clear_error:
|
| 298 |
+
mb.host_error = None
|
| 299 |
+
elif error is not None:
|
| 300 |
+
mb.host_error = error
|
| 301 |
+
ws = mb.active_ws
|
| 302 |
+
gen = mb.generation
|
| 303 |
+
payload = encode_host_state(
|
| 304 |
+
robot=mb.host_robot, mode=mb.host_mode, error=mb.host_error
|
| 305 |
+
)
|
| 306 |
+
if ws is not None:
|
| 307 |
+
await self._send(ws, gen, payload)
|
| 308 |
+
|
| 309 |
+
async def snapshot_status(self) -> dict[str, Any]:
|
| 310 |
+
mb = self.mailbox
|
| 311 |
+
async with mb.lock:
|
| 312 |
+
return {
|
| 313 |
+
"robot": mb.host_robot,
|
| 314 |
+
"busy": mb.host_mode == "resetting",
|
| 315 |
+
"mode": mb.host_mode,
|
| 316 |
+
"connected": mb.controller_present,
|
| 317 |
+
"error": mb.host_error,
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
async def _send(
|
| 321 |
+
self, websocket: WebSocket, generation: int, payload: dict[str, Any]
|
| 322 |
+
) -> None:
|
| 323 |
+
async with self._send_lock:
|
| 324 |
+
mb = self.mailbox
|
| 325 |
+
async with mb.lock:
|
| 326 |
+
if mb.generation != generation or mb.active_ws is not websocket:
|
| 327 |
+
return
|
| 328 |
+
if websocket.client_state != WebSocketState.CONNECTED:
|
| 329 |
+
return
|
| 330 |
+
try:
|
| 331 |
+
await websocket.send_json(payload)
|
| 332 |
+
except Exception as exc:
|
| 333 |
+
logger.warning("WS send failed: %s", exc)
|
esp32_motion_controller/static/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<h1>🕹️ ESP32 Motion Controller</h1>
|
| 15 |
-
<p>ESP32 handheld IMU controller bridge for Reachy Mini.</p>
|
| 16 |
<p>WebSocket: <code id="ws">ws://…:8766/ws</code></p>
|
| 17 |
<p><a href="/api/info">/api/info</a> · <a href="/api/status">/api/status</a></p>
|
| 18 |
<script>
|
|
|
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<h1>🕹️ ESP32 Motion Controller</h1>
|
| 15 |
+
<p>ESP32 handheld IMU controller bridge for Reachy Mini (protocol v2).</p>
|
| 16 |
<p>WebSocket: <code id="ws">ws://…:8766/ws</code></p>
|
| 17 |
<p><a href="/api/info">/api/info</a> · <a href="/api/status">/api/status</a></p>
|
| 18 |
<script>
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "esp32_motion_controller"
|
| 7 |
-
version = "
|
| 8 |
description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "esp32_motion_controller"
|
| 7 |
+
version = "2.0.0"
|
| 8 |
description = "ESP32 Motion Controller — handheld IMU controller for Reachy Mini"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|