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