Add the G29 shifter HID-BPF fix (1st/3rd engage reliably)

Live on this host since 2026-09-11 but never committed. The wheel firmware
decodes the shifter's hall sensor itself and this unit's top row rests at
Y 180-193, below the ~195 engage threshold, so 1st/3rd often did not
register. g29_shifter.bpf.c re-decodes those two gates from the raw X/Y
bytes with hysteresis (engage >= 160, release <= 140) and ORs the bit in
before hid-logitech parses the report; it never clears a bit, so the
firmware's working gates cannot regress. fix_g29_shifter.sh builds,
attaches and installs it persistently (udev-hid-bpf rule + a systemd
safety-net unit pulled in on every re-enumeration); g29_shifter_capture.py
records and grades raw captures per gate.

Two files were over the 250-line cap: the test file is split at the
record/CLI boundary with the report builders moved into a conftest fixture
(the naming hook allows only test_*.py in tests/), and the grader's
docstrings are tightened. No behaviour change to the installed fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
This commit is contained in:
Krzysztof Rudnicki 2026-09-14 22:10:02 +02:00
parent 2d54765f6f
commit 7e2d78c7db
11 changed files with 1082 additions and 0 deletions

View File

@ -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"
}

View File

@ -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 <all shifter 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"
]
}

View File

@ -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.

View File

@ -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

View File

@ -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 <bpf/bpf_tracing.h>
#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";

View File

@ -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())

View File

@ -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"

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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