Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
6622d12
Stiction compensation, 0xA4 wire mode, and firmware-loop tuning for t…
shawnpatel Sep 19, 2026
cfaf588
Firmware-loop tab: show the motor's live loop gains as each field's b…
shawnpatel Sep 19, 2026
e02ea6f
Workbench: fix duplicate fmtGain, expose stiction/dither in the overr…
shawnpatel Sep 19, 2026
e8691cc
tune.a4: read the joint before holding it after an abort; current lim…
shawnpatel Sep 19, 2026
f2c589d
Firmware-loop tab: show the motor's stored planner acceleration as th…
shawnpatel Sep 19, 2026
5fa5a72
tune.friction --raw-csv / --bins, and scripts/cogging_map.py for posi…
shawnpatel Sep 19, 2026
f4cba84
tune.friction raw CSV carries the pass index; cogging_map correlates …
shawnpatel Sep 19, 2026
51fe29e
Stribeck cancellation on measured velocity; load-proportional Coulomb…
shawnpatel Sep 19, 2026
5d2ebd9
stribeck_pole: per-joint measured-velocity pole for the Stribeck term…
shawnpatel Sep 19, 2026
962c489
axol-rt: drop the unread stribeck_pole copy on ReadyMotor
shawnpatel Sep 19, 2026
69fcb37
Tuners never torque off a joint that is not at rest; tune.a4 homes on…
shawnpatel Sep 19, 2026
fea7a51
fw_gains.py --accel: set the position planner alongside the loop gains
shawnpatel Sep 19, 2026
76c5e0f
Firmware loop gains in the joint config, written to ROM at enable
shawnpatel Sep 22, 2026
62b1708
Dashboard: per-joint controller picker on the Recorded-motion tab
shawnpatel Sep 22, 2026
0c2bdd1
tune.a4: write the planner acceleration before the mode-switch reset
shawnpatel Sep 22, 2026
362d6fd
Firmware loop gains for the X6-P20 elbow: position_kp 0.2, speed 0.1 …
shawnpatel Sep 22, 2026
de1fcf1
tune.motion: verify the start pose before playback; name joints left …
shawnpatel Sep 22, 2026
b79695b
Reset a motor after writing its firmware gains so the loop loads them
shawnpatel Sep 22, 2026
f01201e
axol-rt: keep a4 joints on the 0xA4 frame through holds
shawnpatel Sep 22, 2026
bb3d50b
Dashboard: firmware-loop runs re-arm the Firmware-loop tab with their…
shawnpatel Sep 22, 2026
3c2e3e7
tune.a4: --cap-track makes the 0xA4 speed cap follow the commanded speed
shawnpatel Sep 22, 2026
4cdc57e
tune.a4: ignore --cap-track under direct tracking (planner 0)
shawnpatel Sep 22, 2026
c26dd3d
Elbow firmware position_kp 0.2 -> 0.5
shawnpatel Sep 22, 2026
1416248
Elbow firmware position_kp 0.5: test and docs follow-up
shawnpatel Sep 22, 2026
af7fdde
tune.a4: score the current spread and the 3-8 Hz mode current
shawnpatel Sep 22, 2026
961e668
Firmware loop gains tuned for the 400 Hz a4 stream: shoulders 1.0/0.0…
shawnpatel Sep 22, 2026
518207b
axol-rt: report per-tick bus occupancy in the 5 s stats line
shawnpatel Sep 22, 2026
d5b2653
Sweep safety: mirror the shoulder_3 sweep's shoulder_1 raise per arm
shawnpatel Sep 22, 2026
45c62f8
tune.a4: report held joints that moved during the wave
shawnpatel Sep 22, 2026
c6ecc47
tune.a4: --pose holds, and the held joints are sampled and scored dur…
shawnpatel Sep 22, 2026
159b61f
Firmware loop gains for shoulder_3 (X6-P20): position_kp 1.0, speed_k…
shawnpatel Sep 22, 2026
34d157e
Firmware loop gains for wrist_1: the X6 roll set (position_kp 1.0, sp…
shawnpatel Sep 22, 2026
414d231
tune.a4: tune the Damiao wrists' position-velocity loop too
shawnpatel Sep 22, 2026
9709ae5
Tuners: correct the ±360° boot wrap on every read; never ramp from an…
shawnpatel Sep 22, 2026
be81f42
Tuners: park a joint whose rest pose is a hard stop 2 deg inside it
shawnpatel Sep 22, 2026
ccc424e
Damiao wrists: KP_APR 400 in config, provisioned at enable like the M…
shawnpatel Sep 22, 2026
9e88783
Controller option: impedance (MIT, 240 Hz) or the firmware position l…
shawnpatel Sep 22, 2026
4ab2167
rt_proto_check: speak the current config protocol (was pinned at prot…
shawnpatel Sep 22, 2026
8431942
tune.motion --record: the teleop flight recorder on a replay (PREFIX_…
shawnpatel Sep 22, 2026
eb12ea0
Position controller diagnostics: wrist profiler ramp in config (ACC/D…
shawnpatel Sep 22, 2026
7c065e4
tune.motion: re-time cache reads onto the command clock before scoring
shawnpatel Sep 22, 2026
15e2b6a
Position controller: MyActuator joints on 0xA4, Damiao wrists stay on…
shawnpatel Sep 22, 2026
01e9458
core: a thinned joint's latest sample stays fresh across its off-ticks
shawnpatel Sep 22, 2026
c1003bb
Merge origin/main into chrome-helmet
shawnpatel Sep 22, 2026
0ed0d00
tests: controller_option config tests stub find_binary so CI needs no…
shawnpatel Sep 22, 2026
54c6122
Position controller: every joint on its firmware loop, Damiao wrists …
shawnpatel Sep 22, 2026
b1e431b
CAN purge: USB-reset the arm hub, whose firmware replays frames a fla…
shawnpatel Sep 22, 2026
653d7b8
Firmware-loop tab: gain boxes follow the joint's motor, no sliders
shawnpatel Sep 22, 2026
831dce7
tune.a4: record the held joints' velocity and torque, and name the jo…
shawnpatel Sep 22, 2026
b1bc2d1
tune.a4 --held-gain: set the held joints' firmware gains for a run
shawnpatel Sep 22, 2026
f1bec71
Impedance runs at 240 Hz only: refuse any other core rate with an MIT…
shawnpatel Sep 22, 2026
1cd01c7
core: impedance joints at exactly 240 Hz inside a 480 Hz loop for mix…
shawnpatel Sep 22, 2026
2a4e564
Wrists kp 130 -> 100: the intermittent field wrist buzz is kp-driven
shawnpatel Sep 23, 2026
9cf4ad9
tune.motion --repeat N: replay the motion back to back in one session
shawnpatel Sep 23, 2026
654caaf
tune.motion --hold SIDE.JOINT[=DEG]: hold joints steady during a replay
shawnpatel Sep 23, 2026
bed571b
Dashboard: chart held and parked joints; expose tune.motion's arms, r…
shawnpatel Sep 23, 2026
d0ab76e
0xA4 planner: firmware.planner_accel + per-tick speed-cap tracking (f…
shawnpatel Sep 23, 2026
1eccdf7
Revert wrists kp 130 -> 100 (2a4e564)
shawnpatel Sep 23, 2026
c60f936
tune.motion --gain: override one joint only, not every joint sharing …
shawnpatel Sep 23, 2026
f90fb43
tune.motion: stop the replay when a firmware-loop joint leaves its ta…
shawnpatel Sep 23, 2026
ca81b82
tune.a4 --lead-ms: send the 0xA4 target ahead along the commanded vel…
shawnpatel Sep 23, 2026
a625dba
core: 0xA4 planner joints ride the half-rate lane (240 Hz in a 480 Hz…
shawnpatel Sep 23, 2026
b41d57f
0xA4 planner target lead (firmware.planner_lead_ms); per-joint config…
shawnpatel Sep 23, 2026
14c27fe
Refuse a bring-up whose held joints run other firmware gains than con…
shawnpatel Sep 23, 2026
938f8bd
Impedance joints back on their motors' stock firmware loops
shawnpatel Sep 23, 2026
5acdbec
Tuning workbench: switching arm or joint resets the per-joint fields
shawnpatel Sep 23, 2026
bbce853
Planner write: require the acceleration only - the X8 keeps a decel f…
shawnpatel Sep 23, 2026
554bc80
Tuning workbench: a select's default that is one of its options shows…
shawnpatel Sep 23, 2026
635a905
Tuning workbench: gain and controller tables scroll inside the card i…
shawnpatel Sep 23, 2026
242c951
480 Hz impedance, cogging cancellation by feedforward, 0x73 torque fe…
shawnpatel Sep 23, 2026
e9a1f42
Wrist IMU worker: poll get_sensors_data at 0.25 ms, no grab() fallbac…
shawnpatel Sep 23, 2026
e30828c
Wrist IMU worker: drop the stale sample the SDK returns right after open
shawnpatel Sep 23, 2026
eea5481
Per-joint impedance rate: single joints at 480 Hz, the rest on the 24…
shawnpatel Sep 23, 2026
871e7d7
Wrist IMU score: 1-15 Hz over 2 s windows, split into 1-3 Hz and 3-15 Hz
shawnpatel Sep 23, 2026
8b75739
tune.motion --gain: the gravity model's link mass and com.x/y/z
shawnpatel Sep 24, 2026
4bf490d
Bring-up writes planner_accel only to joints on the 0xA4 loop
shawnpatel Sep 24, 2026
f5b9cf9
Every MyActuator joint back on its stock firmware loop in the config
shawnpatel Sep 24, 2026
355bc8c
Reference motion s1_creep: right shoulder_1 alone at 3 and 6 deg/s
shawnpatel Sep 24, 2026
a109db6
Reference motion el_creep: right elbow alone at 3 and 6 deg/s
shawnpatel Sep 24, 2026
a1fa970
Core: a runaway on the firmware position loop takes the session limp
shawnpatel Sep 24, 2026
9eb7cd9
Reference motion hold, and tune.motion scores a pass with no motion
shawnpatel Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ Each operation can be driven from the web control panel or the CLI:
- [`tracker.*`](https://docs.almond.bot/cli/tracker) — Mantis tracker setup: bridge, identify, pair, install, and base-station / Ultimate checks
- [`tune.pid`](https://docs.almond.bot/cli/tune-pid)
- [`tune.friction`](https://docs.almond.bot/cli/tune-friction)
- [`tune.breakaway`](https://docs.almond.bot/cli/tune-breakaway)
- [`tune.a4`](https://docs.almond.bot/cli/tune-a4)
- [`tune.gravity`](https://docs.almond.bot/cli/tune-gravity)
- [`tune.factory`](https://docs.almond.bot/cli/tune-factory)
- [`calibration.pull`](https://docs.almond.bot/cli/tune-factory#calibration-pull)
Expand Down
5 changes: 4 additions & 1 deletion almond_axol/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
from .motor import set_config as motor_set_config
from .tune import factory as tune_factory
from .tune import filter as tune_filter
from .tune import friction, pid, repeatability
from .tune import a4 as tune_a4
from .tune import breakaway, friction, pid, repeatability
from .tune import gravity as tune_gravity
from .tune import motion as tune_motion
from .zed import driver as zed_driver
Expand Down Expand Up @@ -165,6 +166,8 @@ def build_parser() -> argparse.ArgumentParser:
jetson_setup.add_parser(subparsers)
pid.add_parser(subparsers)
friction.add_parser(subparsers)
breakaway.add_parser(subparsers)
tune_a4.add_parser(subparsers)
tune_gravity.add_parser(subparsers)
tune_factory.add_parser(subparsers)
calibration_cmd.add_parser(subparsers)
Expand Down
126 changes: 120 additions & 6 deletions almond_axol/cli/can/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
CAN_LEFT,
CAN_MANTIS_LEFT,
CAN_MANTIS_RIGHT,
CAN_RESET_SCRIPT,
CAN_RIGHT,
Joint,
)
Expand Down Expand Up @@ -1400,10 +1401,10 @@ def _identify_dual_adapter(serial: str, *, reset: bool = False) -> DualHubIdenti
# a healthy first pass, but if either half is down recover the adapter
# pair together before probing. Pair-wide ordering matters for this
# dual-channel firmware: both channels go down before either comes up.
bring_up_interfaces(
ifaces,
force_cycle=reset or not all(iface_up(iface) for iface in ifaces),
)
if reset or not all(iface_up(iface) for iface in ifaces):
_recover_hub_pair(ifaces)
else:
bring_up_interfaces(ifaces, force_cycle=False)
except RuntimeError:
return "silent"

Expand All @@ -1429,7 +1430,7 @@ def _identify_dual_adapter(serial: str, *, reset: bool = False) -> DualHubIdenti
" No identity response; recovering both CAN channels and retrying..."
)
try:
bring_up_interfaces(ifaces, force_cycle=True)
_recover_hub_pair(ifaces)
except RuntimeError:
return "silent"
return "silent"
Expand Down Expand Up @@ -1940,6 +1941,109 @@ def _write_cron_script(profile: _Profile = _AXOL_PROFILE) -> None:
print(" Done.")


def _reset_script_text() -> str:
"""The arm hub's USB reset: clears what a link flap cannot, then brings up.

``gs_can_close()`` kills the host's in-flight URBs and sends the adapter a
mode reset, but the hub firmware keeps the frames it had already accepted
(up to ``GS_MAX_TX_URBS``, 10 per channel) and transmits them on the next
open: the kernel logs one "Unexpected unused echo id" per replayed frame.
Behind a stalled bus those are the session's last position commands, so a
flap only defers the jump to whatever brings the bus back. A USB port
reset re-enumerates the hub; the bring-up script then configures the
fresh interfaces. Only the arm hub is reset — the single-channel wheel
and chest adapters keep the plain flap the bring-up script gives them.
"""
left, right = _AXOL_PROFILE.left, _AXOL_PROFILE.right
return (
f"#!/bin/bash\n"
f"# USB-reset the Almond Axol arm hub, then bring the CAN interfaces up.\n"
f"#\n"
f"# The hub firmware keeps frames it already accepted through a link\n"
f"# down/up and transmits them on the next open, so the purge after a\n"
f"# stalled bus resets the device instead of flapping its channels.\n"
f"# Written by `axol can.setup`; run by the realtime core's purge and by\n"
f"# can.setup's recovery (granted by `axol provision`).\n"
f"set -euo pipefail\n\n"
f'if [ "${{{_GLOBAL_LOCK_ENV}:-}}" != "1" ]; then\n'
f' exec 8<"{_GLOBAL_LOCK_FILE}"\n'
f" flock 8\n"
f"fi\n\n"
f"HUB=\n"
f"for IFACE in {left} {right}; do\n"
f' if [ -e "/sys/class/net/${{IFACE}}/device" ]; then\n'
f' HUB=$(readlink -f "/sys/class/net/${{IFACE}}/device/..")\n'
f" break\n"
f" fi\n"
f"done\n\n"
f'if [ -n "${{HUB}}" ] && [ -f "${{HUB}}/busnum" ]; then\n'
f' BUS=$(cat "${{HUB}}/busnum")\n'
f' DEV=$(cat "${{HUB}}/devnum")\n'
f" if command -v usbreset >/dev/null 2>&1 && "
f'usbreset "$(printf "%03d/%03d" "${{BUS}}" "${{DEV}}")"; then\n'
f' echo "arm hub ${{HUB##*/}}: USB reset"\n'
f" else\n"
f" # No usbreset (or it failed): deauthorize and reauthorize, which\n"
f" # unbinds gs_usb and re-enumerates the hub the same way.\n"
f' echo 0 > "${{HUB}}/authorized"\n'
f" sleep 0.5\n"
f' echo 1 > "${{HUB}}/authorized"\n'
f' echo "arm hub ${{HUB##*/}}: reauthorized"\n'
f" fi\n"
f" # gs_usb rebinds inside the reset; udev renames the new netdevs.\n"
f" for _ in $(seq 1 50); do\n"
f" if ip link show {left} >/dev/null 2>&1 "
f"&& ip link show {right} >/dev/null 2>&1; then\n"
f" break\n"
f" fi\n"
f" sleep 0.1\n"
f" done\n"
f"else\n"
f' echo "{left}/{right} not present — no hub to reset"\n'
f"fi\n\n"
f"# fd 8 (the global lock) survives the exec; the flag stops the bring-up\n"
f"# script from reopening it and deadlocking on itself.\n"
f'exec env {_GLOBAL_LOCK_ENV}=1 bash "{_AXOL_PROFILE.cron_script}"\n'
)


def _write_reset_script() -> None:
"""Install :func:`_reset_script_text` at :data:`CAN_RESET_SCRIPT`."""
print(f"Writing CAN adapter reset script to {CAN_RESET_SCRIPT}...")
_ensure_root_lock_file(_GLOBAL_LOCK_FILE)
_install_privileged_script(CAN_RESET_SCRIPT, _reset_script_text())
print(" Done.")


def _root_script_command(script: Path) -> list[str]:
"""``bash script``, flagged when this thread already holds the global lock.

The generated scripts take the global lock themselves; reopening it while
this thread owns it would deadlock. The unflagged form is the one
``axol provision`` grants for non-interactive callers.
"""
command = ["bash", str(script)]
if int(getattr(_LOCK_LOCAL, "depth", 0)):
command = ["env", f"{_GLOBAL_LOCK_ENV}=1", *command]
return command


def _recover_hub_pair(channels: list[str]) -> None:
"""Recover a silent dual hub: USB reset where installed, else a pair cycle.

Every recovery cycle of the arm hub is also a moment its firmware can
release frames queued behind an earlier stall (seen 2026-09-22: the right
arm drove to the stalled session's last pose during ``can.setup``). The
reset script clears them first; other hubs, and hosts that have not been
set up with it yet, keep the paired down/up cycle.
"""
if CAN_RESET_SCRIPT.exists() and set(channels) == {_CAN_L, _CAN_R}:
print(" Arm hub: USB reset (drops frames the adapter still holds)...")
run_root(_root_script_command(CAN_RESET_SCRIPT), check=True)
return
bring_up_interfaces(channels, force_cycle=True)


def _read_root_crontab() -> str:
"""Read root's crontab, distinguishing an empty table from inspection errors."""
result = run_root(["env", "LC_ALL=C", "crontab", "-l"])
Expand Down Expand Up @@ -2226,7 +2330,7 @@ def _bring_up_can_locked(profile: _Profile = _AXOL_PROFILE) -> None:
run_root(script_command, check=True)
for attempt in range(3):
if attempt:
bring_up_interfaces([profile.left, profile.right], force_cycle=True)
_recover_hub_pair([profile.left, profile.right])
left, right = rx_alive_per_arm(profile)
if left and right:
print(f" Done — motors responding on both {noun}s.")
Expand Down Expand Up @@ -2367,6 +2471,15 @@ def _flap_for_purge(channels: list[str]) -> None:
``can0``, a host that has never run ``can.setup`` — falls back to
:func:`bring_up_interfaces`, which configures each channel explicitly.
"""
if (
CAN_RESET_SCRIPT.exists()
and set(channels) <= _SCRIPT_MANAGED_CHANNELS
and set(channels) & {_CAN_L, _CAN_R}
):
# Frames on the arm hub may also be parked inside the adapter, where
# only a USB reset reaches them; the script then runs the bring-up.
run_root(["bash", str(CAN_RESET_SCRIPT)], check=True)
return
if CAN_BRINGUP_SCRIPT.exists() and set(channels) <= _SCRIPT_MANAGED_CHANNELS:
# The script takes its own locks, so this deliberately does not hold
# the global setup lock: a caller that did would deadlock it.
Expand Down Expand Up @@ -2459,6 +2572,7 @@ def _apply_setup(
_validate_adapter_assignments(hub_serial, wheels_serial, chest_serial)
_write_udev_rules(hub_serial, wheels_serial, chest_serial)
_write_cron_script()
_write_reset_script()
_write_hotplug_unit()
_reload_udev()
_rename_interfaces(hub_serial, wheels_serial, chest_serial)
Expand Down
Loading
Loading