Skip to content

Geometric motion model, LED direction indicator, and firmware fixes - #55

Open
iplayfast wants to merge 13 commits into
sb-ocr:mainfrom
iplayfast:geometric-motion-model
Open

iplayfast wants to merge 13 commits into
sb-ocr:mainfrom
iplayfast:geometric-motion-model

Conversation

@iplayfast

Copy link
Copy Markdown

Summary

This started as a standby-timeout fix and grew into a larger rework of how motion is computed and displayed. Opening it here in case any of it is useful upstream — happy to split into smaller PRs if that's easier to review, or if parts don't fit your direction just take what's useful.

  • Fix standby: motionActive_ now checks fabs(filt_[i]) >= dead instead of out[i] != 0, restoring the LED-off timeout without reverting the start-of-motion responsiveness fix from an earlier commit.
  • Add EMA smoothing to raw sensor readings — every caller (motion, baseline calibration, HID feature report) was previously seeing a single unsmoothed I2C sample.
  • Add a UF2 bootloader gesture (hold both buttons 10s) and idle LED color cycling (hold right button 3s, persists across power cycles).
  • Replace the linear 6x9 calibration matrix with a geometric position+orientation model for live motion output: motion is now computed as a 3D point + orientation from the 3 sensors' real PCB mounting positions, decoupled by a ridge-regularized 6x6 matrix fit via a new standalone host tool (interactive_calibrate.py). The old linear matrix is still fit/pushed/persisted as a compiled-in fallback but no longer drives live output.
  • Rewrite the idle-state LED direction indicator (see lightpattern.md for the exact spec): idle cyan, push/pull full-ring red/green, tilt shows 2 green LEDs on a cyan ring, slide shows a green/red half-split, twist shows a blue dot circling a cyan ring — all active indicators blink/animate at a rate proportional to motion magnitude.
  • Add diagnostic HID output reports for host-driven LED color/pixel control, useful for bring-up/debugging without a serial connection.
  • Fix board_build.filesystem_size (was unset, silently giving LittleFS a 0-byte partition — calibration and LED-color persistence never actually worked).
  • Add interactive_calibrate.py, a standalone host tool for live-tuning/verifying the geometric model, plus a fitted geo_matrix.json as a starting point for similarly-built units (sensor mounting geometry is fixed by the PCB design, so it should be a reasonable starting fit for other units of this board).

Caveats

  • Some constants (GEO_OUTPUT_SCALE in particular) were tuned live against one physical unit and will likely need retuning on others.
  • This diverges fairly heavily from the calibration approach in the spacemouse-emulation work I saw open here — different philosophy (geometric model vs. per-unit linear fit), not a drop-in replacement for that.

Test plan

  • Builds clean (pio run -e seeed_xiao_rp2350)
  • Flashed and live-tested on hardware: all 6 DOF (push/pull, slide left/right/fwd/back, tilt front/back/left/right, twist cw/ccw) verified correct in both CAD view motion and LED indicator

iplayfast and others added 13 commits June 25, 2026 19:59
The hand-coded translation/rotation formulas caused large spurious outputs
on Z, Rx, Ry during pure XY translation (confirmed via log analysis).

Replaces the formulas with a 6x9 calibration matrix (Config::DECOUPLING_M)
fitted from per-DOF calibration movements using least squares, so each axis
responds only to its intended physical input.

Changes:
- MotionController: matrix multiply replaces hand-coded formulas
- Calibration.h: generated file holding the matrix (included by Config.h)
- TelemetryController: now emits 9 baseline-subtracted sensor deltas
  (s0x..s2z) alongside motion values, for use by calibration tooling
- monitor.py: logs sensor deltas + motion in tab-separated format with header
- calibrate.py: fits the 6x9 matrix from 6 labelled log files, writes Calibration.h
- run_calibration.sh: walks builder through each DOF movement end-to-end

Other builders run ./run_calibration.sh then reflash to generate their own
Calibration.h matched to their specific hardware assembly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the purpose of the sensor decoupling matrix, step-by-step
calibration instructions, and how the least-squares fit works in README.md.
Includes the updated run_calibration.sh script and regenerated Calibration.h.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…el bugs

- Remove double dead-zone: hardZero() on output was zeroing motion while
  the lowpass filter ramped up from 0, causing a sticky delay at the start
  of every movement. Output is now just clampf(filt_[i], ±AXIS_LIMIT).
- Delete unused GAIN_T, GAIN_R, SIGN_AXIS from Config.h (pre-decoupling-matrix
  leftovers with no callers).
- Default ENABLE_TELEMETRY to false so production builds don't ship with
  serial output and a 200ms startup delay.
- Make readRaw() return bool and check TLx493D read success; IdleState and
  updateCalibration now skip the frame/sample on I2C failure.
- Replace millis()==0 sentinels with explicit bool flags (calibrationSampleStarted_,
  spinnerStarted_, bothHeldActive_) in SensorController, LEDController,
  and InputController.
- Move baseline subtraction for telemetry into TelemetryController::publish()
  to eliminate the redundant delta loop in IdleState.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds spacemouse-buttons.py, a hidraw-based listener that fires xdotool
key sequences when a button is pressed, matched against the active window
title. Reads from /dev/hidrawN directly (non-exclusive) so it coexists with
spacenavd which holds an exclusive grab on the evdev node.

Key design decisions:
- buffering=0 on hidraw open — preserves one-report-per-read kernel guarantee
- SIGHUP reloads config without restarting the service
- Restart=always in the service so it recovers automatically after device replug
- button-map.conf ships with common CAD app mappings (FreeCAD, SolidWorks,
  Inventor, Rhino, Fusion, Blender) with a * fallback; user config at
  ~/.config/spacemouse/button-map.conf is never overwritten on reinstall

linux-setup.sh updated to install xdotool, write udev rules for both
input and hidraw subsystems, and deploy the button mapper as a systemd
user service alongside the existing spacenavd setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… needed

- HID descriptor gains report ID 4 (feature: 9 sensor-delta floats) and
  report ID 5 (output: 6×9 calibration matrix). TinyUSB callbacks route
  GET/SET report to HIDController.
- MotionController.setDecouplingMatrix() lets the matrix be updated at
  runtime without reflashing. Falls back to compiled-in Calibration.h.
- CalibrationStore reads/writes the matrix to LittleFS flash so it
  survives power cycles. Loaded on boot in main.cpp setup().
- IdleState exposes baseline-subtracted sensor deltas each loop cycle
  (for monitor.py to poll) and applies any incoming matrix from the host.
- monitor.py rewritten: polls HID feature report via HIDIOCGFEATURE ioctl
  instead of reading serial. Reads motion axes from HID input report ID 1.
  Same log format, so calibrate.py is unchanged.
- calibrate.py sends the fitted matrix to the device via output report ID 5
  (write on hidraw) after fitting, giving immediate effect without reflash.
- run_calibration.sh no longer mentions serial ports or firmware builds.
- platformio.ini collapsed back to one build environment.
- mouse_monitor.log untracked (was accidentally committed earlier).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…kflow

- Add Linux Setup section covering linux-setup.sh, spacenavd, and the
  button mapper (config, reload, debug commands).
- Update Calibration section: remove pyserial requirement and reflash
  step; explain that the matrix is sent to the device over USB and
  persisted to LittleFS flash.
- Expand "How it works" to describe HID feature report polling, matrix
  fitting, and the output report + LittleFS persistence path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When hardZero() was removed in bb5b9c8 to eliminate the start-of-motion
lag, it inadvertently broke standby. The old code only set motionActive_
when filt_[i] had ramped above the dead zone; sensor noise spikes could
briefly push y[i] above dead, but filt_[i] would snap back to 0 on the
next frame and never accumulate, so motionActive_ stayed false at rest.

With out[i] != 0 as the check, any noise spike that briefly moved filt_[i]
off zero would set motionActive_ = true, keeping lastActivityMs_ fresh and
preventing the 2-minute sleep timeout from ever firing.

Fix: check fabs(filt_[i]) >= dead instead. Since filt_[i] snaps to 0
whenever y[i] drops below dead, noise cannot accumulate in the filter —
only sustained motion builds filt_[i] above the threshold. The HID output
(out[i] = clampf(filt_[i], ...)) is unchanged, so there is no regression
to the start-of-motion responsiveness fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every caller (real-time motion, baseline calibration, and the HID
feature report calibrate.py reads) was seeing a single unsmoothed I2C
sample, which fed noise straight into the dead-zone comparison and
into calibration data collection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…itives

Hold both buttons 10s to reboot into the UF2 bootloader for drag-and-drop
reflashing without opening the case. Hold the right button alone for 3s
to cycle the idle LED color, persisted in flash across power cycles.

Also adds setPixel/setPixelGroup/setTwoGroups/setPixelGroupOnBackground
to LEDController, used by the live direction indicator and diagnostic
HID LED commands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MotionController now computes a 3D point and orientation from the 3
sensors' real PCB mounting positions instead of mapping raw sensor
deltas straight through a single fitted linear matrix. A second,
ridge-regularized 6x6 matrix (GEO_M, GeoCalibration.h) then decouples
the crosstalk this introduces (the short sensor baseline amplifies any
Z-asymmetry into a large apparent tilt angle), and a per-axis gain lets
translation axes compete fairly against the rotation crosstalk they
induce. Ported from interactive_calibrate.py, where this was developed
and verified live.

Also:
- Adds diagnostic HID output reports (6/7) for host-driven LED color/
  pixel control, and wakes SleepState to apply them or a pushed
  calibration matrix instead of silently dropping them.
- Rewrites the idle-state LED direction indicator to match
  lightpattern.md exactly: idle cyan, push/pull full-ring red/green,
  tilt shows 2 green LEDs on a cyan ring, slide shows a green/red
  half-split, twist shows a blue dot circling a cyan ring. All active
  indicators blink/animate at a rate proportional to magnitude instead
  of a fixed interval.
- Fixes board_build.filesystem_size (was unset, silently giving
  LittleFS a 0-byte partition so calibration/LED-color persistence
  never actually worked).
- Swaps which physical gesture drives HID Y vs Z (slide fwd/back vs
  push/pull) — spacenavd/FreeCAD treats HID Y as dolly/zoom, which
  made slide fwd/back zoom instead of pan.
- GEO_AXIS_GAIN[TX] and GEO_AXIS_GAIN... TZ (existing AXIS_GAIN) are
  negative to fix inverted CAD view motion found in live testing; the
  LED direction logic compensates so indicators still match the
  physical motion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Self-paced host tool (run directly in a terminal, not through an AI
assistant) for tuning and verifying the geometric motion model live:
menu 1-6 captures 5s of motion per axis, 'g' fits the GEO_M decoupling
matrix from those captures, 'c' runs the existing linear-matrix
calibrate.py, +/- tune the 3D-point scale factor live. Shows raw
per-sensor deltas, the fitted 3D point/orientation, and a plain-English
"THE MOUSE THINKS: ..." line reflecting the same logic as the firmware's
LED indicator.

geo_matrix.json is fit from one physical unit but included as a
starting point for similarly-built units, since the sensor mounting
geometry is fixed by the PCB design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Geometric motion model, LED direction indicator, and misc firmware fixes
@Mictronics

Copy link
Copy Markdown

I would like to test your code on my cad mouse build. What to pull in, this PR#55 or PR#44? Is #55 an evolution of #44?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants