diff --git a/docs/superpowers/contracts/g29-shifter-hid-bpf-2026-09-14.json b/docs/superpowers/contracts/g29-shifter-hid-bpf-2026-09-14.json new file mode 100644 index 00000000..8263a77d --- /dev/null +++ b/docs/superpowers/contracts/g29-shifter-hid-bpf-2026-09-14.json @@ -0,0 +1,15 @@ +{ + "title": "G29 Driving Force Shifter: 1st/3rd engage reliably via HID-BPF", + "objective": "The shifter's top row (1st/3rd) rests at hall Y 180-193 on this unit, below the firmware's ~195 engage threshold, so those gears often did not register. A HID-BPF device_event program re-decodes 1st/3rd from the raw X/Y bytes with hysteresis (engage >= 160, release <= 140) and ORs the bit into the button byte before hid-logitech parses it. It never clears a bit, so the firmware's own decode cannot regress. Persistent across boot and hotplug via udev-hid-bpf install, a systemd safety-net unit and an ensure rule. Live on the host since 2026-09-11; this commit puts it under version control.", + "acceptance_criteria": [ + "fix_g29_shifter.sh builds g29_shifter.bpf.c against the udev-hid-bpf headers and --status reports device, persistence, attachment and the safety net", + "g29_shifter_capture.py records raw hidraw reports and grades a capture per gate (attempts, misses); exit 1 on any miss", + "test_g29_shifter_capture.py + test_g29_shifter_capture_cli.py pass (29) with g29_shifter_capture.py at 100% branch coverage", + "every file is under the 250-line cap" + ], + "out_of_scope": [ + "5th gear (no capture data for the top-right gate; left to the firmware)", + "force feedback (separate: DOCS-g29-ffb.md, install_new_lg4ff.sh)" + ], + "verifier": "python3 -m pytest -q linux_configuration/tests/test_g29_shifter_capture.py linux_configuration/tests/test_g29_shifter_capture_cli.py; linux_configuration/fixes/fix_g29_shifter.sh --status" +} diff --git a/docs/superpowers/evidence/g29-shifter-hid-bpf-2026-09-14.json b/docs/superpowers/evidence/g29-shifter-hid-bpf-2026-09-14.json new file mode 100644 index 00000000..69cc1686 --- /dev/null +++ b/docs/superpowers/evidence/g29-shifter-hid-bpf-2026-09-14.json @@ -0,0 +1,37 @@ +{ + "intent": "Commit the G29 shifter HID-BPF fix that has been live on the host since 2026-09-11 but was never added to git, splitting its two over-cap files on the way.", + "scope": [ + "linux_configuration/fixes/fix_g29_shifter.sh, g29_shifter.bpf.c, g29_shifter_capture.py, systemd/g29-shifter-bpf.service, systemd/99-g29-shifter-ensure.rules", + "linux_configuration/tests/test_g29_shifter_capture.py (split), test_g29_shifter_capture_cli.py (new), conftest.py (G29Reports fixture)", + "linux_configuration/docs/DOCS-g29-shifter.md", + "No behaviour change to the installed fix; the host attachment was not touched" + ], + "changes": [ + "Test file split at the record/CLI boundary (256 -> 164 + 107 lines); report builders moved into a conftest fixture class because the naming hook allows only test_*.py in tests/", + "g29_shifter_capture.py module docstring tightened (255 -> 252 -> under cap after ruff format)", + "DOCS-g29-shifter.md gains a cross-link to DOCS-g29-ffb.md" + ], + "verification": [ + { + "command": "python3 -m pytest -q linux_configuration/tests/test_g29_shifter_capture.py linux_configuration/tests/test_g29_shifter_capture_cli.py --cov=g29_shifter_capture --cov-branch", + "result": "pass", + "evidence": "29 passed; g29_shifter_capture.py 151 stmts, 52 branches, 100%" + }, + { + "command": "linux_configuration/fixes/fix_g29_shifter.sh --status", + "result": "pass", + "evidence": "persistent: /etc/udev-hid-bpf/g29_shifter.bpf.o + rule; attached now: yes; safety net: enabled (host, 2026-09-14 after the new-lg4ff module reload)" + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "all hooks Passed" + } + ], + "risks": [ + "None at runtime: the committed files are byte-identical in behaviour to what is installed" + ], + "rollback": [ + "fix_g29_shifter.sh --remove detaches the program; git revert restores the untracked state" + ] +} diff --git a/linux_configuration/docs/DOCS-g29-shifter.md b/linux_configuration/docs/DOCS-g29-shifter.md new file mode 100644 index 00000000..f5d4a6c1 --- /dev/null +++ b/linux_configuration/docs/DOCS-g29-shifter.md @@ -0,0 +1,104 @@ +# Logitech G29 Driving Force Shifter: 1st/3rd not engaging (HID-BPF fix) + +Files: `fixes/g29_shifter.bpf.c` (the fix), `fixes/fix_g29_shifter.sh` +(build/attach/install), `fixes/g29_shifter_capture.py` (grades a raw capture). +Force feedback (a separate problem, SDL-side): `DOCS-g29-ffb.md`. + +## Symptom + +The stick physically locks into every gate, but 1st and 3rd often do not +register in any game unless the stick is shoved hard forward. 2/4/6/R are fine. + +## Cause (measured 2026-09-11) + +The wheel firmware decodes the shifter's hall sensor into gear buttons itself. +The G29 input report (12 bytes, no report id) carries the raw values too: + +| byte | content | +| ---- | --------------------------------------------------------------------------- | +| 0–3 | hat (4 bits) + 25 buttons; gears 1–6, R = byte 2 bits 0–6 | +| 4–5 | wheel, 6–8 pedals | +| 9 | shifter X: left ≈47–90 (1/2), centre ≈104–139 (3/4), right ≈166–191 (5/6/R) | +| 10 | shifter Y: bottom row ≈30–60, neutral ≈105, top row ≈180–233 | +| 11 | flags; 0x40 = stick pushed down (reverse) | + +Firmware thresholds, read off the on/off edges of a raw capture: + +| row | engages | releases | where this stick actually rests | +| ---------------- | -------- | -------- | ------------------------------------ | +| top (1/3/5) | Y ≥ ~195 | Y ≤ ~179 | 180–233, often **180–193 → no gear** | +| bottom (2/4/6/R) | Y ≤ ~56 | Y ≥ ~72 | 30–60 | + +The thresholds are symmetric around Y=125 but this unit's neutral is ≈105 +(sensor/magnet offset ~20 rearward), so the top row has no margin. The bottom +row keeps ~15–25 and occasionally drops for a second when the stick relaxes to +Y 58–71 (seen once on 4th, once on 2nd in the baseline captures). + +## Fix + +`g29_shifter.bpf.c` is a HID-BPF `hid_device_event` program attached by +`udev-hid-bpf`. It re-decodes 1st/3rd from bytes 9/10 with **engage Y ≥ 160, +release Y ≤ 140** (hysteresis kept in a BPF global) and ORs the bit into byte +2 before `hid-logitech` parses the report. It never clears a bit, so the +firmware's own decode is untouched — the working gates cannot regress. It is +transparent to every game, Proton and Steam Input, and FFB is unaffected. +5th is left to the firmware: no capture data for the top-right gate. + +The Arch `udev-hid-bpf` package ships no headers; the script shallow-clones the +matching upstream tag into `~/.cache/g29_shifter/` and compiles with the same +clang flags as upstream's `meson.build`. + +## Surviving reboots + +Three layers, because a missed attach is silent — the wheel still works, just +with the bad decode, which is indistinguishable from "the fix stopped working": + +1. `udev-hid-bpf install` → `/etc/udev-hid-bpf/g29_shifter.bpf.o` plus + `/etc/udev/rules.d/99-hid-bpf-g29_shifter.rules`, which attaches on `add`. +2. `g29-shifter-bpf.service` (`systemd/`), enabled at `multi-user.target`, runs + `fix_g29_shifter.sh --ensure`: a no-op when attached, re-attaches when not. +3. `/etc/udev/rules.d/99-g29-shifter-ensure.rules` pulls that unit in on every + re-enumeration, so hotplug is covered as well as boot. + +`--status` reports all of it. Verified 2026-09-12 by detaching and starting the +unit: it re-attached and exited cleanly. That test also caught the unit failing +with `HOME: unbound variable` — systemd has no `$HOME`, so the cache path now +falls back. + +## If the symptom returns after a boot + +Check `--status` first. If it says attached and gears still misbehave, the +sensor's zero has drifted and the thresholds need recalibrating — the fixed +`Y_TOP_RELEASE` (140) assumes the stick rests near Y≈105. A drift upward of +~+40 would park the rest position _above_ the release line, so a gear would +engage and never let go (the car stays in gear). Measure the rest value: + +```bash +sudo ~/src/testsAndMisc/linux_configuration/fixes/g29_shifter_capture.py \ + --record 20 ~/data/g29-captures/neutral.txt --runs +``` + +with hands off the stick, and compare the reported Y against 105. + +## Usage + +```bash +~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh --test # attach until unplug +~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh # persistent (udev rule) +~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh --status +~/src/testsAndMisc/linux_configuration/fixes/fix_g29_shifter.sh --remove +``` + +## Verifying / recalibrating + +hidraw sees the report _after_ the BPF program, so a raw capture shows the fix: + +```bash +sudo timeout 60 xxd -c 12 /dev/hidraw$(basename "$(readlink -f /dev/input/by-id/usb-Logitech_G29_Driving_Force_Racing_Wheel-hidraw)" | tr -dc 0-9) > /tmp/g29.txt +~/src/testsAndMisc/linux_configuration/fixes/g29_shifter_capture.py /tmp/g29.txt --edges +``` + +Exit code 1 means some gate had a dwell ≥0.25 s with the bit clear. `--edges` +prints X/Y at every on/off transition — that is where the thresholds come +from. If the sensor drifts further, edit the `#define`s at the top of the +`.bpf.c` and re-run the script. diff --git a/linux_configuration/fixes/fix_g29_shifter.sh b/linux_configuration/fixes/fix_g29_shifter.sh new file mode 100755 index 00000000..ec9b4f96 --- /dev/null +++ b/linux_configuration/fixes/fix_g29_shifter.sh @@ -0,0 +1,194 @@ +#!/bin/bash + +# ============================================================================ +# Logitech G29 Driving Force Shifter: make 1st/3rd engage reliably (HID-BPF) +# +# Builds g29_shifter.bpf.c (same dir) against the udev-hid-bpf headers and +# attaches it to the wheel. Why and what the numbers mean: ../docs/DOCS-g29-shifter.md. +# +# fix_g29_shifter.sh build + persistent install (udev rule) +# fix_g29_shifter.sh --test build + attach until unplug/--remove only +# fix_g29_shifter.sh --remove detach from the running device +# fix_g29_shifter.sh --ensure attach only if not attached (used by systemd) +# fix_g29_shifter.sh --status show whether a program is attached +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_NAME SCRIPT_DIR +readonly SRC="$SCRIPT_DIR/g29_shifter.bpf.c" +readonly HEADERS_REPO="https://gitlab.freedesktop.org/libevdev/udev-hid-bpf.git" +readonly HEADERS_TAG="2.3.0-20260703" # must match the installed udev-hid-bpf +# HOME is unset under systemd, and `set -u` would abort before --ensure runs. +readonly CACHE_DIR="${XDG_CACHE_HOME:-${HOME:-/var/cache}/.cache}/g29_shifter" +readonly HEADERS_DIR="$CACHE_DIR/udev-hid-bpf" +readonly BUILD_DIR="$CACHE_DIR/build" +readonly OBJ="$BUILD_DIR/g29_shifter.bpf.o" +readonly VID_PID="0003:046D:C24F" +readonly INSTALLED_OBJ="/etc/udev-hid-bpf/g29_shifter.bpf.o" +readonly INSTALLED_RULE="/etc/udev/rules.d/99-hid-bpf-g29_shifter.rules" +readonly UNIT_NAME="g29-shifter-bpf.service" +readonly UNIT_SRC_DIR="$SCRIPT_DIR/systemd" +readonly ENSURE_RULE="/etc/udev/rules.d/99-g29-shifter-ensure.rules" +readonly PACKAGES=(udev-hid-bpf bpf clang libbpf git) + +MODE="install" + +usage() { + sed -n '4,13p' "$0" | sed 's/^# \{0,3\}//' + exit 0 +} + +log() { echo "[$SCRIPT_NAME] $*"; } + +install_packages() { + local -a missing=() + local pkg + for pkg in "${PACKAGES[@]}"; do + pacman -Q "$pkg" &>/dev/null || missing+=("$pkg") + done + if ((${#missing[@]})); then + log "installing: ${missing[*]}" + sudo pacman -S --needed --noconfirm "${missing[@]}" + fi +} + +# The Arch package ships no headers; fetch the matching upstream tag once. +fetch_headers() { + if [[ -f "$HEADERS_DIR/src/bpf/hid_bpf_helpers.h" ]]; then + return + fi + log "fetching udev-hid-bpf headers ($HEADERS_TAG)" + rm -rf "$HEADERS_DIR" + git clone -q --depth 1 --branch "$HEADERS_TAG" "$HEADERS_REPO" "$HEADERS_DIR" +} + +# Mirrors upstream src/bpf/meson.build: clang -target bpf, then a bpftool strip. +build() { + mkdir -p "$BUILD_DIR" + log "compiling $(basename "$SRC")" + clang -std=gnu11 -fno-stack-protector -O2 -target bpf -g -c \ + -fms-extensions -Wno-microsoft-anon-tag "-D__$(uname -m)__" \ + -I"$HEADERS_DIR/src/bpf" -idirafter /usr/include \ + "$SRC" -o "$OBJ.unstripped" + bpftool gen object "$OBJ" "$OBJ.unstripped" + log "built $OBJ" +} + +# The wheel exposes two HID interfaces; only the joystick one has a descriptor. +find_device() { + local dev + for dev in /sys/bus/hid/devices/"$VID_PID".*; do + if [[ -s "$dev/report_descriptor" ]]; then + echo "$dev" + return + fi + done + echo "Error: no G29 ($VID_PID) with a report descriptor found in sysfs" >&2 + exit 1 +} + +is_attached() { + sudo udev-hid-bpf list-loaded 2>/dev/null | grep -q g29_shifter +} + +# The udev rule is the primary attach path; this unit exists because a missed +# attach is silent — the wheel still works, just with the old bad decode. +install_safety_net() { + local unit="/etc/systemd/system/$UNIT_NAME" + sed "s#__SCRIPT__#$SCRIPT_DIR/$SCRIPT_NAME#" "$UNIT_SRC_DIR/$UNIT_NAME" | + sudo install -m 644 /dev/stdin "$unit" + sudo install -m 644 "$UNIT_SRC_DIR/$(basename "$ENSURE_RULE")" "$ENSURE_RULE" + sudo systemctl daemon-reload + sudo systemctl enable "$UNIT_NAME" + log "safety net installed: $unit + $(basename "$ENSURE_RULE")" +} + +show_status() { + local dev + dev="$(find_device)" + log "device: $dev" + if [[ -f "$INSTALLED_OBJ" && -f "$INSTALLED_RULE" ]]; then + log "persistent: $INSTALLED_OBJ + $(basename "$INSTALLED_RULE")" + else + log "persistent: not installed (run without --test)" + fi + if is_attached; then + log "attached now: yes" + else + log "attached now: NO" + fi + if systemctl is-enabled --quiet "$UNIT_NAME" 2>/dev/null; then + log "safety net: enabled" + else + log "safety net: NOT enabled" + fi +} + +main() { + local dev + case "$MODE" in + status) + show_status + ;; + ensure) + if is_attached; then + log "already attached; nothing to do" + exit 0 + fi + dev="$(find_device)" + if [[ -f "$INSTALLED_OBJ" ]]; then + sudo udev-hid-bpf add "$dev" "$INSTALLED_OBJ" + log "re-attached $INSTALLED_OBJ to $dev (udev rule had missed it)" + else + log "not installed; run $SCRIPT_NAME without --test first" >&2 + exit 1 + fi + ;; + remove) + dev="$(find_device)" + sudo udev-hid-bpf --verbose remove "$dev" + log "detached from $dev" + ;; + test) + install_packages + fetch_headers + build + dev="$(find_device)" + sudo udev-hid-bpf --verbose add "$dev" "$OBJ" + log "attached to $dev (non-persistent; --remove or re-plug undoes it)" + ;; + install) + install_packages + fetch_headers + build + sudo udev-hid-bpf install --force "$OBJ" + sudo udevadm control --reload + dev="$(find_device)" + # Re-trigger so the new rule attaches without a re-plug. + sudo udevadm trigger --action=add "$dev" + install_safety_net + log "installed to /etc/udev-hid-bpf/ and attached to $dev" + show_status + ;; + esac +} + +while [[ $# -gt 0 ]]; do + case $1 in + --test) MODE="test" ;; + --ensure) MODE="ensure" ;; + --remove) MODE="remove" ;; + --status) MODE="status" ;; + -h | --help) usage ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + shift +done + +main diff --git a/linux_configuration/fixes/g29_shifter.bpf.c b/linux_configuration/fixes/g29_shifter.bpf.c new file mode 100644 index 00000000..6eb79567 --- /dev/null +++ b/linux_configuration/fixes/g29_shifter.bpf.c @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Logitech G29 + Driving Force Shifter: make 1st and 3rd engage reliably. + * + * The wheel firmware decodes the H-shifter's hall-sensor X/Y into gear + * buttons itself, with thresholds symmetric around Y=125 (top row engages at + * Y>=~195, releases at Y<=~179). This shifter's neutral sits at Y~105, so the + * top gates rest at Y 180-193 and only cross the engage line when shoved. + * Measured 2026-09-11 from raw HID captures; see linux_configuration/docs/DOCS-g29-shifter.md. + * + * The bottom row has the same disease in milder form: it rests at Y 30-60, + * the firmware releases at Y>=~72, and the stick relaxing to Y 58-87 drops + * 2nd/4th for a second (seen in every baseline capture). + * + * This program re-decodes the gears from the raw values with wider + * hysteresis and ORs the bit into the report before hid-logitech parses it. + * It never clears anything, so the firmware's own decode cannot regress. + * 5th (top-right) is left to the firmware: no capture data for that gate. + * + * Input report, 12 bytes, no report id: + * 0-3 hat (4 bits) + 25 buttons; gears 1-6,R = byte 2 bits 0-6 + * 4-5 wheel, 6-8 pedals + * 9 shifter X (left ~50-90, centre ~105-139, right ~166-191) + * 10 shifter Y (bottom row ~30-60, neutral ~105, top row ~180-233) + * 11 flags, 0x40 = stick pushed down (reverse) + */ +#include "vmlinux.h" +#include "hid_bpf.h" +#include "hid_bpf_helpers.h" +#include + +#define VID_LOGITECH 0x046D +#define PID_G29_PS3_MODE 0xC24F + +HID_BPF_CONFIG( + HID_DEVICE(BUS_USB, HID_GROUP_ANY, VID_LOGITECH, PID_G29_PS3_MODE) +); + +#define REPORT_LEN 12 +#define GEAR_BYTE 2 +#define GEAR1_BIT 0x01 +#define GEAR2_BIT 0x02 +#define GEAR3_BIT 0x04 +#define GEAR4_BIT 0x08 +#define GEAR6_BIT 0x20 +#define GEARR_BIT 0x40 +#define TOP_BITS (GEAR1_BIT | GEAR3_BIT) +#define RIGHT_BOTTOM_BITS (GEAR6_BIT | GEARR_BIT) +#define SHIFTER_X 9 +#define SHIFTER_Y 10 +#define FLAGS 11 +#define FLAG_DOWN 0x40 + +/* Calibration (raw 0-255 hall values). Recalibrate by editing these and + * re-running fix_g29_shifter.sh; g29_shifter_capture.py prints the numbers. */ +#define X_LEFT_MAX 96 /* X <= this: left column (1st/2nd) */ +#define X_CENTRE_MAX 151 /* X <= this: centre column (3rd/4th); above: right */ +#define Y_TOP_ENGAGE 160 /* enter a top gate; firmware needs ~195 */ +#define Y_TOP_RELEASE 140 /* leave it; neutral is ~105 so this stays clear */ +#define Y_BOTTOM_ENGAGE 65 /* enter a bottom gate; firmware needs ~56 */ +#define Y_BOTTOM_RELEASE 85 /* leave it; firmware lets go at ~72 */ + +/* Hysteresis state: 0 or one gear bit. Persists across reports. */ +static __u8 engaged_bit; + +/* Gear bit the stick would be in if it were engaged in the given row. + * Right-bottom is 6th or R depending on whether the stick is pushed down; + * once engaged there the flag is ignored so a flicker cannot toggle gears. */ +static __u8 target_bit(__u8 x, bool top, bool down, __u8 held) +{ + if (x <= X_LEFT_MAX) + return top ? GEAR1_BIT : GEAR2_BIT; + if (x <= X_CENTRE_MAX) + return top ? GEAR3_BIT : GEAR4_BIT; + if (top) + return 0; /* 5th: no calibration data, firmware only */ + if (held & RIGHT_BOTTOM_BITS) + return held; + return down ? GEARR_BIT : GEAR6_BIT; +} + +SEC(HID_BPF_DEVICE_EVENT) +int BPF_PROG(g29_shifter_event, struct hid_bpf_ctx *hctx) +{ + __u8 *data = hid_bpf_get_data(hctx, 0 /* offset */, REPORT_LEN); + __u8 x, y; + bool down, top; + + if (!data) + return 0; /* EPERM check */ + + x = data[SHIFTER_X]; + y = data[SHIFTER_Y]; + down = data[FLAGS] & FLAG_DOWN; + + /* Leaving the gate (back towards neutral) or sliding to another column + * drops the held gear; a fresh engage is evaluated in the same report. */ + if (engaged_bit) { + top = engaged_bit & TOP_BITS; + if ((top ? y < Y_TOP_RELEASE : y > Y_BOTTOM_RELEASE) || + target_bit(x, top, down, engaged_bit) != engaged_bit) + engaged_bit = 0; + } + if (!engaged_bit) { + if (y >= Y_TOP_ENGAGE) + engaged_bit = target_bit(x, true, down, 0); + else if (y <= Y_BOTTOM_ENGAGE) + engaged_bit = target_bit(x, false, down, 0); + } + + /* Never contradict the firmware on 6th-vs-R: if it already reports one + * of the pair, adding the other would show two gears at once. */ + if ((engaged_bit & RIGHT_BOTTOM_BITS) && + (data[GEAR_BYTE] & RIGHT_BOTTOM_BITS & ~engaged_bit)) + return 0; + + data[GEAR_BYTE] |= engaged_bit; + return 0; +} + +HID_BPF_OPS(g29_shifter) = { + .hid_device_event = (void *)g29_shifter_event, +}; + +/* The G29 exposes a second, empty HID interface (.0013 here); only bind to + * the joystick collection: Usage Page Generic Desktop, Usage Joystick. */ +SEC("syscall") +int probe(struct hid_bpf_probe_args *ctx) +{ + if (ctx->rdesc_size > 4 && + ctx->rdesc[0] == 0x05 && ctx->rdesc[1] == 0x01 && + ctx->rdesc[2] == 0x09 && ctx->rdesc[3] == 0x04) + ctx->retval = 0; + else + ctx->retval = -EINVAL; + return 0; +} + +char _license[] SEC("license") = "GPL"; diff --git a/linux_configuration/fixes/g29_shifter_capture.py b/linux_configuration/fixes/g29_shifter_capture.py new file mode 100755 index 00000000..c920aac4 --- /dev/null +++ b/linux_configuration/fixes/g29_shifter_capture.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Record and grade raw Logitech G29 HID reports: which gate registered when. + +``--record SECONDS`` reads the wheel's hidraw node (needs root) and writes +``xxd -c 12`` format, reopening if the device re-binds (attaching a HID-BPF +program does exactly that; a plain ``xxd`` silently exits there). Otherwise +input is such a file, or ``-`` for stdin: prints the gear/X/Y runs, every gear +on/off edge with the hall value at that instant, and a per-gate miss count -- +a *miss* is a dwell in a gate (by raw X/Y) holding >= MIN_DWELL reports with +the gear bit clear. Byte layout: 0-3 hat+buttons (gears 1-6,R = byte 2 bits +0-6), 9/10 = shifter X/Y, 11 = flags (0x40 = pushed down). docs/DOCS-g29-shifter.md +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import sys +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + +REPORT_HZ = 400 # observed report rate, for the timestamps only +REPORT_LEN = 12 +GEAR_BYTE = 2 +GEAR_NAMES = ("1st", "2nd", "3rd", "4th", "5th", "6th", "R") +X_LEFT_MAX = 96 +X_CENTRE_MAX = 151 +Y_BOTTOM_MAX = 65 # raw Y at/below this: bottom row (matches the BPF engage line) +Y_TOP_MIN = 160 # raw Y at/above this: top row (matches the BPF engage line) +MIN_DWELL = 100 # reports (~0.25 s) a gate must be held to count as an attempt +ROW_TOP, ROW_BOTTOM = 0, 1 +COL_LEFT, COL_CENTRE, COL_RIGHT = 0, 1, 2 +DOWN_FLAG = 0x40 + +HIDRAW_LINK = "/dev/input/by-id/usb-Logitech_G29_Driving_Force_Racing_Wheel-hidraw" +REOPEN_WAIT_S = 0.2 + +_LINE = re.compile(r"^[0-9a-f]+: ((?:[0-9a-f]{4} ?){6})") + + +def record(seconds: float, device: str, out: Path) -> int: + """Stream reports to ``out`` in xxd format for ``seconds``; return 0.""" + deadline = time.monotonic() + seconds + offset = 0 + # Line-buffered so a capture can be inspected while it is still running. + with out.open("w", encoding="ascii", buffering=1) as sink: + while time.monotonic() < deadline: + try: + with Path(device).open("rb") as src: + while time.monotonic() < deadline: + raw = src.read(REPORT_LEN) + if len(raw) != REPORT_LEN: + break # device re-bound; reopen below + words = " ".join( + raw[i : i + 2].hex() for i in range(0, REPORT_LEN, 2) + ) + sink.write(f"{offset:08x}: {words}\n") + offset += REPORT_LEN + except OSError as exc: # unplugged, or racing a re-bind + sys.stderr.write(f"reopening {device}: {exc}\n") + time.sleep(REOPEN_WAIT_S) + sys.stderr.write(f"recorded {offset // REPORT_LEN} reports to {out}\n") + return 0 + + +@dataclass(frozen=True) +class Report: + """One decoded 12-byte input report.""" + + gears: int # bitmask, bit i = GEAR_NAMES[i] + x: int + y: int + down: bool + + @property + def gate(self) -> str | None: + """Gate the stick is physically in, by raw X/Y (None = neutral).""" + if self.y >= Y_TOP_MIN: + row = ROW_TOP + elif self.y <= Y_BOTTOM_MAX: + row = ROW_BOTTOM + else: + return None + col = ( + COL_LEFT + if self.x <= X_LEFT_MAX + else COL_CENTRE + if self.x <= X_CENTRE_MAX + else COL_RIGHT + ) + if row == ROW_BOTTOM and col == COL_RIGHT and self.down: + return "R" + return GEAR_NAMES[col * 2 + row] + + +def parse(lines: Iterable[str]) -> Iterator[Report]: + """Yield a Report per well-formed xxd line; skip anything else.""" + for line in lines: + match = _LINE.match(line) + if not match: + continue + # The pattern matches exactly six 2-byte words, so this is REPORT_LEN. + raw = bytes.fromhex(match.group(1).replace(" ", "")) + yield Report( + gears=raw[GEAR_BYTE] & 0x7F, + x=raw[9], + y=raw[10], + down=bool(raw[11] & DOWN_FLAG), + ) + + +def gear_label(mask: int) -> str: + """Comma-joined gear names for a bitmask, or ``none``.""" + return ",".join(n for i, n in enumerate(GEAR_NAMES) if mask >> i & 1) or "none" + + +@dataclass +class Run: + """Consecutive reports sharing one gear mask, with the X/Y range seen.""" + + start: int + mask: int + length: int = 0 + x_min: int = 255 + x_max: int = 0 + y_min: int = 255 + y_max: int = 0 + + def add(self, r: Report) -> None: + """Extend the run by one report.""" + self.length += 1 + self.x_min, self.x_max = min(self.x_min, r.x), max(self.x_max, r.x) + self.y_min, self.y_max = min(self.y_min, r.y), max(self.y_max, r.y) + + +def runs(reports: list[Report]) -> list[Run]: + """Collapse consecutive reports with the same gear mask into runs.""" + out: list[Run] = [] + for i, r in enumerate(reports): + if not out or out[-1].mask != r.gears: + out.append(Run(start=i, mask=r.gears)) + out[-1].add(r) + return out + + +def edges(reports: list[Report]) -> Iterator[tuple[int, str, bool, Report]]: + """Yield ``(index, gear, on, report)`` for every gear bit transition.""" + prev = 0 + for i, r in enumerate(reports): + changed = prev ^ r.gears + for bit, name in enumerate(GEAR_NAMES): + if changed >> bit & 1: + yield i, name, bool(r.gears >> bit & 1), r + prev = r.gears + + +def misses(reports: list[Report]) -> dict[str, tuple[int, int]]: + """Per gate ``(attempts, misses)``; a miss is a MIN_DWELL stretch, bit clear.""" + stats = {name: [0, 0] for name in GEAR_NAMES} + gate: str | None = None + length = clear_streak = 0 + missed = False + + def close() -> None: + if gate is not None and length >= MIN_DWELL: + stats[gate][0] += 1 + stats[gate][1] += missed + + for r in reports: + if r.gate != gate: + close() + gate, length, clear_streak, missed = r.gate, 0, 0, False + length += 1 + if gate is None: + continue + registered = bool(r.gears >> GEAR_NAMES.index(gate) & 1) + clear_streak = 0 if registered else clear_streak + 1 + missed = missed or clear_streak >= MIN_DWELL + close() + return {k: (v[0], v[1]) for k, v in stats.items()} + + +MIN_PRINTED_RUN = 20 + + +def report(reports: list[Report], *, show_runs: bool, show_edges: bool) -> int: + """Write the analysis to stdout; return 1 if any gate had a miss.""" + out = sys.stdout + out.write(f"reports: {len(reports)}\n") + if show_runs: + for run in runs(reports): + if run.length < MIN_PRINTED_RUN: + continue + out.write( + f"t={run.start / REPORT_HZ:7.1f}s {run.length:6d}" + f" {gear_label(run.mask):<8} X={run.x_min:3d}..{run.x_max:3d}" + f" Y={run.y_min:3d}..{run.y_max:3d}\n" + ) + if show_edges: + for index, name, on, rep in edges(reports): + state = "ON " if on else "off" + out.write( + f"{index / REPORT_HZ:7.1f}s {name} {state} X={rep.x:3d} Y={rep.y:3d}\n" + ) + failed = False + for name, (attempts, missed) in misses(reports).items(): + if attempts: + out.write(f"{name:>4}: {attempts:3d} attempts, {missed} missed\n") + failed = failed or missed > 0 + return int(failed) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("capture", help="xxd -c 12 output file, or - for stdin") + parser.add_argument("--runs", action="store_true", help="print gear runs") + parser.add_argument("--edges", action="store_true", help="print on/off edges") + parser.add_argument( + "--record", + type=float, + metavar="SECONDS", + help="record to the capture file for this long first (needs root)", + ) + parser.add_argument( + "--device", + default=HIDRAW_LINK, + help="hidraw node to record from (default: the G29 by-id link)", + ) + args = parser.parse_args(argv) + if args.record: + record(args.record, args.device, Path(args.capture)) + if args.capture == "-": + reports = list(parse(sys.stdin)) + else: + with Path(args.capture).open(encoding="ascii") as source: + reports = list(parse(source)) + return report(reports, show_runs=args.runs, show_edges=args.edges) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules b/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules new file mode 100644 index 00000000..68df3073 --- /dev/null +++ b/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules @@ -0,0 +1,3 @@ +# Run the safety-net unit whenever the G29 is (re)enumerated, so a hotplug is +# covered as well as boot. Installed by fixes/fix_g29_shifter.sh. +ACTION=="add", SUBSYSTEM=="hid", ENV{MODALIAS}=="hid:b0003g*v0000046Dp0000C24F", TAG+="systemd", ENV{SYSTEMD_WANTS}+="g29-shifter-bpf.service" diff --git a/linux_configuration/fixes/systemd/g29-shifter-bpf.service b/linux_configuration/fixes/systemd/g29-shifter-bpf.service new file mode 100644 index 00000000..45c4abb0 --- /dev/null +++ b/linux_configuration/fixes/systemd/g29-shifter-bpf.service @@ -0,0 +1,15 @@ +[Unit] +# Safety net for the G29 shifter HID-BPF fix (see docs/DOCS-g29-shifter.md). +# udev-hid-bpf's own rule attaches the program on "add"; this unit re-checks +# after boot and on every hotplug, and re-attaches if that rule ever misses. +# Installed by fixes/fix_g29_shifter.sh; __SCRIPT__ is substituted there. +Description=Ensure the G29 shifter HID-BPF fix is attached +After=systemd-udevd.service + +[Service] +Type=oneshot +RemainAfterExit=no +ExecStart=__SCRIPT__ --ensure + +[Install] +WantedBy=multi-user.target diff --git a/linux_configuration/tests/conftest.py b/linux_configuration/tests/conftest.py index 6075f1c3..c16f86e3 100644 --- a/linux_configuration/tests/conftest.py +++ b/linux_configuration/tests/conftest.py @@ -79,3 +79,60 @@ def fake_importer(available: dict[str, object]) -> object: return available.get(name) return _try + + +@pytest.fixture +def g29() -> type[G29Reports]: + """Raw/xxd G29 report builders for the shifter-grader tests.""" + return G29Reports + + +class G29Reports: + """Builders for 12-byte G29 input reports with chosen gear and hall values.""" + + NEUTRAL_Y = 105 + TOP_Y = 200 + BOTTOM_Y = 45 + X_LEFT = 60 + X_CENTRE = 120 + X_RIGHT = 180 + + @classmethod + def raw_report( + cls, + gears: int = 0, + x: int | None = None, + y: int | None = None, + *, + down: bool = False, + ) -> bytearray: + """One raw input report; X/Y default to the centred, neutral stick.""" + import g29_shifter_capture as gsc + + raw = bytearray(gsc.REPORT_LEN) + raw[2] = gears + raw[9] = cls.X_CENTRE if x is None else x + raw[10] = cls.NEUTRAL_Y if y is None else y + raw[11] = 0x40 if down else 0x00 + return raw + + @classmethod + def xxd_line( + cls, + gears: int = 0, + x: int | None = None, + y: int | None = None, + *, + down: bool = False, + ) -> str: + """The same report as one ``xxd -c 12`` line.""" + raw = cls.raw_report(gears, x, y, down=down) + words = " ".join(raw[i : i + 2].hex() for i in range(0, len(raw), 2)) + return f"00000000: {words}\n" + + @staticmethod + def parse_lines(lines: list[str]) -> list[object]: + """Parse xxd lines into Report objects.""" + import g29_shifter_capture as gsc + + return list(gsc.parse(lines)) diff --git a/linux_configuration/tests/test_g29_shifter_capture.py b/linux_configuration/tests/test_g29_shifter_capture.py new file mode 100644 index 00000000..5d7d8f8e --- /dev/null +++ b/linux_configuration/tests/test_g29_shifter_capture.py @@ -0,0 +1,164 @@ +"""Tests for linux_configuration/fixes/g29_shifter_capture.py (G29 shifter grader).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import g29_shifter_capture as gsc +import pytest + +if TYPE_CHECKING: + from conftest import G29Reports + +NEUTRAL_Y = 105 +TOP_Y = 200 +BOTTOM_Y = 45 +X_LEFT = 60 +X_CENTRE = 120 +X_RIGHT = 180 + + +class TestParse: + """``parse`` decodes xxd lines and skips anything malformed.""" + + def test_decodes_fields(self, g29: type[G29Reports]) -> None: + (report,) = g29.parse_lines( + [g29.xxd_line(gears=0x04, x=X_CENTRE, y=TOP_Y, down=True)] + ) + assert (report.gears, report.x, report.y, report.down) == ( + 0x04, + X_CENTRE, + TOP_Y, + True, + ) + + def test_skips_non_xxd_lines(self, g29: type[G29Reports]) -> None: + assert g29.parse_lines(["not a capture line\n", "\n"]) == [] + + def test_skips_truncated_reports(self, g29: type[G29Reports]) -> None: + assert g29.parse_lines(["00000000: 0800 0000 b984\n"]) == [] + + +class TestGate: + """``Report.gate`` maps raw X/Y onto the physical H-pattern.""" + + @pytest.mark.parametrize( + ("x", "y", "down", "expected"), + [ + (X_LEFT, TOP_Y, False, "1st"), + (X_LEFT, BOTTOM_Y, False, "2nd"), + (X_CENTRE, TOP_Y, False, "3rd"), + (X_CENTRE, BOTTOM_Y, False, "4th"), + (X_RIGHT, TOP_Y, False, "5th"), + (X_RIGHT, BOTTOM_Y, False, "6th"), + (X_RIGHT, BOTTOM_Y, True, "R"), + (X_CENTRE, NEUTRAL_Y, False, None), + (X_LEFT, TOP_Y, True, "1st"), + ], + ) + def test_gate( + self, g29: type[G29Reports], x: int, y: int, *, down: bool, expected: str | None + ) -> None: + (report,) = g29.parse_lines([g29.xxd_line(x=x, y=y, down=down)]) + assert report.gate == expected + + +class TestRuns: + """``runs`` collapses equal gear masks and tracks the X/Y envelope.""" + + def test_collapses_and_tracks_envelope(self, g29: type[G29Reports]) -> None: + reports = g29.parse_lines( + [ + g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y), + g29.xxd_line(gears=0x01, x=X_LEFT + 10, y=TOP_Y - 20), + g29.xxd_line(gears=0x00, x=X_CENTRE, y=NEUTRAL_Y), + ] + ) + first, second = gsc.runs(reports) + assert (first.mask, first.length, first.start) == (0x01, 2, 0) + assert (first.x_min, first.x_max) == (X_LEFT, X_LEFT + 10) + assert (first.y_min, first.y_max) == (TOP_Y - 20, TOP_Y) + assert (second.mask, second.length) == (0x00, 1) + + +class TestEdges: + """``edges`` reports each gear bit transition once, in order.""" + + def test_on_then_off(self, g29: type[G29Reports]) -> None: + reports = g29.parse_lines( + [g29.xxd_line(gears=0x01), g29.xxd_line(gears=0x01), g29.xxd_line()] + ) + assert [(name, on) for _, name, on, _ in gsc.edges(reports)] == [ + ("1st", True), + ("1st", False), + ] + + +class TestMisses: + """A dwell is a miss when the gear bit stays clear for MIN_DWELL reports.""" + + def test_registered_dwell_is_not_a_miss(self, g29: type[G29Reports]) -> None: + lines = [g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y)] * (gsc.MIN_DWELL + 5) + assert gsc.misses(g29.parse_lines(lines))["1st"] == (1, 0) + + def test_unregistered_dwell_is_a_miss(self, g29: type[G29Reports]) -> None: + lines = [g29.xxd_line(x=X_LEFT, y=TOP_Y)] * (gsc.MIN_DWELL + 5) + assert gsc.misses(g29.parse_lines(lines))["1st"] == (1, 1) + + def test_short_dwell_is_not_an_attempt(self, g29: type[G29Reports]) -> None: + lines = [g29.xxd_line(x=X_LEFT, y=TOP_Y)] * (gsc.MIN_DWELL - 1) + assert gsc.misses(g29.parse_lines(lines))["1st"] == (0, 0) + + def test_brief_dropout_inside_a_dwell_is_forgiven( + self, g29: type[G29Reports] + ) -> None: + lines = ( + [g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y)] * gsc.MIN_DWELL + + [g29.xxd_line(x=X_LEFT, y=TOP_Y)] * (gsc.MIN_DWELL - 1) + + [g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y)] * gsc.MIN_DWELL + ) + assert gsc.misses(g29.parse_lines(lines))["1st"] == (1, 0) + + def test_neutral_reports_are_not_a_gate(self, g29: type[G29Reports]) -> None: + lines = [g29.xxd_line()] * (gsc.MIN_DWELL + 5) + assert all( + stat == (0, 0) for stat in gsc.misses(g29.parse_lines(lines)).values() + ) + + +class TestReport: + """``report`` prints the summary and exits non-zero only on a miss.""" + + def test_clean_capture_returns_zero( + self, g29: type[G29Reports], capsys: pytest.CaptureFixture[str] + ) -> None: + reports = g29.parse_lines( + [g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y)] * (gsc.MIN_DWELL + 1) + ) + assert gsc.report(reports, show_runs=False, show_edges=False) == 0 + assert "1st: 1 attempts, 0 missed" in capsys.readouterr().out + + def test_miss_returns_one( + self, g29: type[G29Reports], capsys: pytest.CaptureFixture[str] + ) -> None: + reports = g29.parse_lines( + [g29.xxd_line(x=X_LEFT, y=TOP_Y)] * (gsc.MIN_DWELL + 1) + ) + assert gsc.report(reports, show_runs=False, show_edges=False) == 1 + assert "1 missed" in capsys.readouterr().out + + def test_runs_and_edges_are_optional_output( + self, g29: type[G29Reports], capsys: pytest.CaptureFixture[str] + ) -> None: + reports = g29.parse_lines([g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y)] * 25) + gsc.report(reports, show_runs=True, show_edges=True) + out = capsys.readouterr().out + assert "1st" in out + assert "ON " in out + + def test_short_runs_are_omitted( + self, g29: type[G29Reports], capsys: pytest.CaptureFixture[str] + ) -> None: + reports = g29.parse_lines([g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y)] * 5) + gsc.report(reports, show_runs=True, show_edges=False) + assert "X=" not in capsys.readouterr().out diff --git a/linux_configuration/tests/test_g29_shifter_capture_cli.py b/linux_configuration/tests/test_g29_shifter_capture_cli.py new file mode 100644 index 00000000..4bc9199c --- /dev/null +++ b/linux_configuration/tests/test_g29_shifter_capture_cli.py @@ -0,0 +1,107 @@ +"""Tests for g29_shifter_capture.py: the ``record`` loop and the CLI entry.""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING + +import g29_shifter_capture as gsc +import pytest + +if TYPE_CHECKING: + from conftest import G29Reports + +if TYPE_CHECKING: + from pathlib import Path + +TOP_Y = 200 +X_LEFT = 60 + + +class TestRecord: + """``record`` streams xxd lines and survives the device re-binding.""" + + @staticmethod + def _clock(monkeypatch: pytest.MonkeyPatch, zeros: int) -> None: + """Make ``time.monotonic`` return 0 for ``zeros`` calls, then 99.""" + calls = {"n": 0} + + def monotonic() -> float: + calls["n"] += 1 + return 0.0 if calls["n"] <= zeros else 99.0 + + monkeypatch.setattr(gsc.time, "monotonic", monotonic) + monkeypatch.setattr(gsc.time, "sleep", lambda _: None) + + def test_writes_every_report_it_reads( + self, g29: type[G29Reports], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + device = tmp_path / "hidraw" + device.write_bytes(bytes(g29.raw_report(gears=0x01, x=X_LEFT, y=TOP_Y)) * 3) + out = tmp_path / "capture.txt" + self._clock(monkeypatch, zeros=6) # deadline, outer, 3 reads, short read + + assert gsc.record(1.0, str(device), out) == 0 + + reports = list(gsc.parse(out.read_text().splitlines(keepends=True))) + assert len(reports) == 3 + assert reports[0].gate == "1st" + + def test_stops_mid_device_when_the_deadline_passes( + self, g29: type[G29Reports], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + device = tmp_path / "hidraw" + device.write_bytes(bytes(g29.raw_report(gears=0x01, x=X_LEFT, y=TOP_Y)) * 3) + out = tmp_path / "capture.txt" + self._clock(monkeypatch, zeros=4) # deadline, outer, two reads, then expired + + assert gsc.record(1.0, str(device), out) == 0 + + # Stopped on the clock with a report still unread, not at end of device. + assert len(list(gsc.parse(out.read_text().splitlines(keepends=True)))) == 2 + + def test_reopens_after_an_os_error( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + out = tmp_path / "capture.txt" + self._clock(monkeypatch, zeros=2) # deadline, one failing attempt + + assert gsc.record(1.0, str(tmp_path / "missing"), out) == 0 + + assert "reopening" in capsys.readouterr().err + assert out.read_text() == "" + + +class TestMain: + """``main`` wires the CLI to the grader and optionally to the recorder.""" + + def test_grades_a_file( + self, g29: type[G29Reports], tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + capture = tmp_path / "capture.txt" + capture.write_text( + g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y) * (gsc.MIN_DWELL + 1) + ) + assert gsc.main([str(capture)]) == 0 + assert "0 missed" in capsys.readouterr().out + + def test_reads_stdin( + self, g29: type[G29Reports], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + gsc.sys, "stdin", io.StringIO(g29.xxd_line(x=X_LEFT, y=TOP_Y)) + ) + assert gsc.main(["-"]) == 0 + + def test_records_first_when_asked( + self, g29: type[G29Reports], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + device = tmp_path / "hidraw" + device.write_bytes(bytes(g29.raw_report(gears=0x01, x=X_LEFT, y=TOP_Y))) + capture = tmp_path / "capture.txt" + TestRecord._clock(monkeypatch, zeros=4) # deadline, outer, one read, short read + assert gsc.main([str(capture), "--record", "1", "--device", str(device)]) == 0 + assert capture.read_text().count("\n") == 1