From 3ae53499e6e73226ec4df9df296d013adc586e41 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 18 Sep 2026 10:44:45 +0200 Subject: [PATCH] g29 shifter: detect PS4 mode (046D:C260) instead of failing silently On the 2026-09-15 and 2026-09-17 boots the wheel enumerated as 046D:C260 (PS3/PS4 selector in PS4 position, bound by hid-generic). The BPF udev rule, the ensure unit and its hotplug rule all keyed on C24F, so the unit failed once with "no G29 found" and nothing was said; 1st/3rd stayed on the firmware's bad decode until the selector was flipped a day later. - fixes/g29_wheel_mode.sh (new, sourced): classify the HID bus as native/ps4/compat/absent; root-safe desktop notification via /run/user/*/bus + sudo -u. - --ensure: absent -> exit 0; ps4/compat -> exit 1 + critical notification; native -> attach. --status prints the mode first. - g29-shifter-bpf.service: Restart=on-failure, RestartSec=5min so a notification lost before the session bus exists at boot is re-sent. - 99-g29-shifter-ensure.rules: also fires on C260. - tests/test_fix_g29_shifter.bats: 13 tests against a fake sysfs tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NVfC2T6DyvCFqse9A99zef --- .../g29-shifter-ps4-mode-2026-09-18.json | 16 +++ .../g29-shifter-ps4-mode-2026-09-18.json | 47 ++++++ linux_configuration/docs/DOCS-g29-shifter.md | 42 ++++-- linux_configuration/fixes/fix_g29_shifter.sh | 52 ++++--- linux_configuration/fixes/g29_wheel_mode.sh | 79 +++++++++++ .../fixes/systemd/99-g29-shifter-ensure.rules | 7 +- .../fixes/systemd/g29-shifter-bpf.service | 6 + .../tests/test_fix_g29_shifter.bats | 134 ++++++++++++++++++ 8 files changed, 357 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/contracts/g29-shifter-ps4-mode-2026-09-18.json create mode 100644 docs/superpowers/evidence/g29-shifter-ps4-mode-2026-09-18.json create mode 100755 linux_configuration/fixes/g29_wheel_mode.sh create mode 100755 linux_configuration/tests/test_fix_g29_shifter.bats diff --git a/docs/superpowers/contracts/g29-shifter-ps4-mode-2026-09-18.json b/docs/superpowers/contracts/g29-shifter-ps4-mode-2026-09-18.json new file mode 100644 index 00000000..61cc7dcc --- /dev/null +++ b/docs/superpowers/contracts/g29-shifter-ps4-mode-2026-09-18.json @@ -0,0 +1,16 @@ +{ + "title": "G29 shifter fix: detect PS4 mode instead of failing silently", + "objective": "On two consecutive boots the wheel enumerated as 046D:C260 (PS4 selector), hid-generic bound it, and the HID-BPF shifter fix, its udev rule and its ensure unit all looked for C24F only - the unit failed once with 'no G29 found' and 1st/3rd stayed broken all day. Make every layer recognise the mode, tell the desktop, and keep retrying until the selector is flipped.", + "acceptance_criteria": [ + "g29_wheel_mode.sh classifies the HID bus as native/ps4/compat/absent from sysfs and is testable against a fake tree", + "fix_g29_shifter.sh --ensure exits 0 when no wheel is present, exits 1 and sends a critical desktop notification when the wheel is in PS4/compat mode, attaches when native", + "g29-shifter-bpf.service retries on failure every 5 minutes; 99-g29-shifter-ensure.rules also fires on C260", + "--status prints the wheel mode first", + "tests/test_fix_g29_shifter.bats passes (13) and every file is under the 250-line cap" + ], + "out_of_scope": [ + "making the BPF program work in PS4 mode (different report layout, unmeasured; no kernel driver binds C260)", + "utils/toggle_wheel.sh, which also keys on c24f (separate power-toggle utility)" + ], + "verifier": "bats linux_configuration/tests/test_fix_g29_shifter.bats; linux_configuration/fixes/fix_g29_shifter.sh --status" +} diff --git a/docs/superpowers/evidence/g29-shifter-ps4-mode-2026-09-18.json b/docs/superpowers/evidence/g29-shifter-ps4-mode-2026-09-18.json new file mode 100644 index 00000000..48efaf65 --- /dev/null +++ b/docs/superpowers/evidence/g29-shifter-ps4-mode-2026-09-18.json @@ -0,0 +1,47 @@ +{ + "intent": "The G29 shifter fix stops failing silently when the wheel is powered on with its selector in the PS4 position: the ensure unit names the mode, sends a critical desktop notification, and retries every 5 minutes until the selector is flipped; a wheel that is merely absent no longer fails the unit.", + "scope": [ + "linux_configuration/fixes/g29_wheel_mode.sh (new, sourced): mode detection native/ps4/compat/absent, explanation strings, root-safe desktop notification", + "linux_configuration/fixes/fix_g29_shifter.sh: find_device/--ensure/--status use the helper; udev reload moved after the ensure rule is installed", + "linux_configuration/fixes/systemd/g29-shifter-bpf.service: Restart=on-failure, RestartSec=5min, StartLimitIntervalSec=0", + "linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules: also fires on 046D:C260", + "linux_configuration/tests/test_fix_g29_shifter.bats (new)", + "linux_configuration/docs/DOCS-g29-shifter.md", + "non-goal: supporting PS4 mode itself (C260 has a different report layout and no kernel driver; only the physical selector leaves it)" + ], + "changes": [ + "Root cause from the journal: boots on 2026-09-15 and 2026-09-17 enumerated the wheel as 046D:C260 (PS4 mode, hid-generic). Every layer keyed on C24F, so the unit exited 1 with 'no G29 found' once at boot and nothing was said; 1st/3rd stayed on the firmware decode until the selector was flipped a day later (C260 -> disconnect -> C294 -> C24F).", + "g29_wheel_mode() classifies the HID bus; --ensure exits 0 when absent, exits 1 + notify-send -u critical (fixed id, replaces) when ps4/compat, attaches when native.", + "Unit retries on failure every 5 min so a notification lost before the session bus exists at boot is re-sent; ensure udev rule matches C260 so a wheel powered on in PS4 mode is reported at hotplug too." + ], + "verification": [ + { + "command": "bats linux_configuration/tests/test_fix_g29_shifter.bats", + "result": "pass", + "evidence": "1..13, all ok (mode classification, notify via sudo -u, --ensure exit codes 0/1 with fake sysfs, --status mode line)" + }, + { + "command": "linux_configuration/fixes/fix_g29_shifter.sh (install mode, on the host)", + "result": "pass", + "evidence": "rebuilt + installed; systemctl cat shows Restart=on-failure/RestartSec=5min; rule contains C260; udevadm trigger re-ran the unit: 'already attached; nothing to do'; --status: wheel mode: native, attached now: yes" + }, + { + "command": "sudo bash -c 'source g29_wheel_mode.sh; g29_notify_desktop ...' (root context, real /run/user/1000/bus)", + "result": "pass", + "evidence": "sent as uid 0 via sudo -u kuhy; on-screen appearance is user-confirmed" + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "shellcheck clean after replacing printf-stub with quoted heredoc and '! grep' with 'run ! grep'; 250-line cap, shell-library-has-tests, codespell all pass" + } + ], + "risks": [ + "PS4-mode notification repeats every 5 minutes until the selector is flipped (same notification id, so it replaces rather than stacks) - intended, but noisy if PS4 mode is ever wanted on purpose", + "vmbox deliberately skipped: the guest has no G29, so a sandbox pass proves nothing for a physical-input fix" + ], + "rollback": [ + "git revert the commit, then re-run linux_configuration/fixes/fix_g29_shifter.sh to reinstall the previous unit + rule", + "validate with fix_g29_shifter.sh --status and systemctl cat g29-shifter-bpf.service" + ] +} diff --git a/linux_configuration/docs/DOCS-g29-shifter.md b/linux_configuration/docs/DOCS-g29-shifter.md index f5d4a6c1..f6f3da95 100644 --- a/linux_configuration/docs/DOCS-g29-shifter.md +++ b/linux_configuration/docs/DOCS-g29-shifter.md @@ -1,7 +1,8 @@ # 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). +(build/attach/install), `fixes/g29_wheel_mode.sh` (PS3/PS4 mode detection + +desktop notification), `fixes/g29_shifter_capture.py` (grades a raw capture). Force feedback (a separate problem, SDL-side): `DOCS-g29-ffb.md`. ## Symptom @@ -48,6 +49,22 @@ 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`. +## The PS3/PS4 selector (found 2026-09-18) + +The wheel has a physical PS3/PS4 switch and the USB identity follows it: + +| selector | enumerates as | bound by | shifter fix | +| -------- | ------------------------- | ------------------------ | ----------- | +| PS3 | `046D:C294` → `046D:C24F` | hid-logitech (new-lg4ff) | attaches | +| PS4 | `046D:C260` | hid-generic | **cannot** | + +`C260` has a different report layout, no kernel driver knows it (new-lg4ff +0.5.0's table stops at `c24f`), and there is no software way out — only the +switch. On the 2026-09-15 and 2026-09-17 boots the wheel came up as `C260`; +every layer below looked for `C24F` only, so the unit failed once with +"no G29 found", nothing was said, and 1st/3rd were dead until the selector was +flipped ~1 day later (the journal shows `C260` → disconnect → `C294` → `C24F`). + ## Surviving reboots Three layers, because a missed attach is silent — the wheel still works, just @@ -57,18 +74,27 @@ with the bad decode, which is indistinguishable from "the fix stopped working": `/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. + No wheel → exit 0 (layer 3 re-runs it on plug-in). Wheel present in PS4 or + compatibility mode → exit 1 **and a critical desktop notification** to every + user with a session bus (`g29_notify_desktop`, via `sudo -u`, because the + unit is a root oneshot with no `$DISPLAY`); `Restart=on-failure` + + `RestartSec=5min` keeps re-checking and re-notifying (same notification id, + so it replaces rather than stacks) until the selector is flipped. 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. + re-enumeration of `C24F` **or `C260`**, so a wheel powered on in the wrong + mode is reported at once, and 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. +`--status` reports all of it, mode first. 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. The mode/notification logic is covered by +`tests/test_fix_g29_shifter.bats` against a fake sysfs tree. ## 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 +Check `--status` first. `wheel mode: ps4` → flip the selector. If it says +native + 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: diff --git a/linux_configuration/fixes/fix_g29_shifter.sh b/linux_configuration/fixes/fix_g29_shifter.sh index ec9b4f96..61b8ec7a 100755 --- a/linux_configuration/fixes/fix_g29_shifter.sh +++ b/linux_configuration/fixes/fix_g29_shifter.sh @@ -26,7 +26,6 @@ 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" @@ -34,6 +33,9 @@ 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) +# shellcheck source=g29_wheel_mode.sh +source "$SCRIPT_DIR/g29_wheel_mode.sh" + MODE="install" usage() { @@ -77,16 +79,14 @@ build() { log "built $OBJ" } -# The wheel exposes two HID interfaces; only the joystick one has a descriptor. +# Native-mode device only; any other mode is named, not just "not found". 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 + if dev="$(g29_sysfs_device "$G29_PID_NATIVE")"; then + echo "$dev" + return + fi + echo "Error: $(g29_mode_explanation "$(g29_wheel_mode)")" >&2 exit 1 } @@ -101,15 +101,19 @@ install_safety_net() { 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 udevadm control --reload 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" + local mode + mode="$(g29_wheel_mode)" + log "wheel mode: $mode ($(g29_mode_explanation "$mode"))" + if [[ "$mode" == native ]]; then + log "device: $(find_device)" + fi if [[ -f "$INSTALLED_OBJ" && -f "$INSTALLED_RULE" ]]; then log "persistent: $INSTALLED_OBJ + $(basename "$INSTALLED_RULE")" else @@ -138,6 +142,23 @@ main() { log "already attached; nothing to do" exit 0 fi + case "$(g29_wheel_mode)" in + native) ;; + absent) + # Not a failure: the udev rule re-runs this unit when it appears. + log "$(g29_mode_explanation absent); nothing to attach to" + exit 0 + ;; + *) + # The wheel still "works" here -- with the bad 1st/3rd decode this + # fix exists to remove -- so say it where it is seen. Exit 1 makes + # the unit retry (and re-notify) until the selector is flipped. + log "$(g29_mode_explanation "$(g29_wheel_mode)")" >&2 + g29_notify_desktop "G29 shifter fix NOT active" \ + "$(g29_mode_explanation "$(g29_wheel_mode)")" + exit 1 + ;; + esac dev="$(find_device)" if [[ -f "$INSTALLED_OBJ" ]]; then sudo udev-hid-bpf add "$dev" "$INSTALLED_OBJ" @@ -165,11 +186,10 @@ main() { 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 + dev="$(find_device)" + # Re-trigger so the new rules attach without a re-plug. + sudo udevadm trigger --action=add "$dev" log "installed to /etc/udev-hid-bpf/ and attached to $dev" show_status ;; diff --git a/linux_configuration/fixes/g29_wheel_mode.sh b/linux_configuration/fixes/g29_wheel_mode.sh new file mode 100755 index 00000000..0569c5e2 --- /dev/null +++ b/linux_configuration/fixes/g29_wheel_mode.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# ============================================================================ +# Logitech G29: which mode is the wheel in, and how to tell the desktop. +# +# Sourced by fix_g29_shifter.sh and tests/test_fix_g29_shifter.bats. +# +# The G29 has a physical PS3/PS4 selector. Only the PS3 position ends up as +# the native 046D:C24F device that hid-logitech (new-lg4ff) binds and the +# HID-BPF shifter fix targets. The PS4 position enumerates as 046D:C260: +# hid-generic, a different report layout, no kernel driver, no shifter fix. +# Nothing used to notice -- on the 2026-09-15 and 2026-09-17 boots the wheel +# came up in PS4 mode and 1st/3rd were dead all day while every layer of the +# safety net looked for C24F only. Details: ../docs/DOCS-g29-shifter.md. +# ============================================================================ + +[[ -n "${_G29_WHEEL_MODE_LOADED:-}" ]] && return 0 +_G29_WHEEL_MODE_LOADED=1 + +# Overridable so tests can point at a fake sysfs tree / run dir. +G29_HID_SYSFS="${G29_HID_SYSFS:-/sys/bus/hid/devices}" +G29_RUN_USER_DIR="${G29_RUN_USER_DIR:-/run/user}" +readonly G29_PID_NATIVE="0003:046D:C24F" +readonly G29_PID_PS4="0003:046D:C260" +readonly G29_PID_COMPAT="0003:046D:C294" +readonly G29_NOTIFY_ID=29029 # fixed notify-send id: repeats replace, never stack + +# Print the sysfs path of the first $1 device that has a report descriptor +# (the wheel exposes two HID interfaces; only the joystick one has one). +g29_sysfs_device() { + local dev + for dev in "$G29_HID_SYSFS/$1".*; do + if [[ -s "$dev/report_descriptor" ]]; then + echo "$dev" + return 0 + fi + done + return 1 +} + +# Print one of: native | ps4 | compat | absent +g29_wheel_mode() { + if g29_sysfs_device "$G29_PID_NATIVE" >/dev/null; then + echo native + elif g29_sysfs_device "$G29_PID_PS4" >/dev/null; then + echo ps4 + elif g29_sysfs_device "$G29_PID_COMPAT" >/dev/null; then + echo compat + else + echo absent + fi +} + +# One line a human can act on, for any non-native mode. +g29_mode_explanation() { + case "$1" in + ps4) echo "G29 is in PS4 mode (046D:C260): flip the PS3/PS4 selector on the wheel to PS3. The shifter fix, hid-logitech and FFB only work in PS3 mode." ;; + compat) echo "G29 is stuck in compatibility mode (046D:C294): hid-logitech did not switch it to native. Check 'modinfo hid_logitech', then replug." ;; + absent) echo "no G29 on the HID bus" ;; + native) echo "G29 in native mode (046D:C24F)" ;; + *) echo "unknown G29 mode '$1'" ;; + esac +} + +# Critical desktop notification to every user with a session bus. This runs +# from a root oneshot at boot/hotplug, so $DISPLAY / $DBUS_SESSION_BUS_ADDRESS +# are not set -- it walks /run/user/*/bus instead. Never fails the caller. +g29_notify_desktop() { + local title="$1" body="$2" bus uid user + for bus in "$G29_RUN_USER_DIR"/*/bus; do + [[ -e "$bus" ]] || continue + uid="${bus#"$G29_RUN_USER_DIR"/}" + uid="${uid%/bus}" + user="$(id -nu "$uid" 2>/dev/null)" || continue + sudo -u "$user" DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ + notify-send -u critical -r "$G29_NOTIFY_ID" -a g29-shifter \ + "$title" "$body" 2>/dev/null || true + done +} diff --git a/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules b/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules index 68df3073..4886d9b8 100644 --- a/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules +++ b/linux_configuration/fixes/systemd/99-g29-shifter-ensure.rules @@ -1,3 +1,6 @@ # 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" +# covered as well as boot. Matches the native PID (C24F, PS3 selector) AND the +# PS4-mode PID (C260) so a wheel powered on in the wrong mode is reported at +# once instead of silently falling back to the firmware's bad 1st/3rd decode. +# Installed by fixes/fix_g29_shifter.sh. +ACTION=="add", SUBSYSTEM=="hid", ENV{MODALIAS}=="hid:b0003g*v0000046Dp0000C24F|hid:b0003g*v0000046Dp0000C260", 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 index 45c4abb0..cd545928 100644 --- a/linux_configuration/fixes/systemd/g29-shifter-bpf.service +++ b/linux_configuration/fixes/systemd/g29-shifter-bpf.service @@ -2,14 +2,20 @@ # 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. +# It exits 0 with no wheel present, and FAILS when the wheel is present in a +# mode the fix cannot attach to (PS4 selector -> 046D:C260); the retry below +# keeps re-checking and re-notifying the desktop until the selector is flipped. # 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 +StartLimitIntervalSec=0 [Service] Type=oneshot RemainAfterExit=no ExecStart=__SCRIPT__ --ensure +Restart=on-failure +RestartSec=5min [Install] WantedBy=multi-user.target diff --git a/linux_configuration/tests/test_fix_g29_shifter.bats b/linux_configuration/tests/test_fix_g29_shifter.bats new file mode 100755 index 00000000..f62ce3cb --- /dev/null +++ b/linux_configuration/tests/test_fix_g29_shifter.bats @@ -0,0 +1,134 @@ +#!/usr/bin/env bats +# Tests for fixes/g29_wheel_mode.sh and the --ensure / --status paths of +# fixes/fix_g29_shifter.sh, against a fake sysfs tree. +# +# Requires: bats (extra/bats). Run with: +# bats linux_configuration/tests/test_fix_g29_shifter.bats +# +# The BPF build/attach itself needs the real wheel and root, so it is not +# covered here. What IS covered is the logic that failed silently on the +# 2026-09-15 and 2026-09-17 boots: telling "wheel in PS4 mode" apart from +# "no wheel", and turning that into an exit code plus a desktop notification. + +setup() { + REPO_DIR="$(cd -- "$(dirname -- "$BATS_TEST_FILENAME")/.." && pwd)" + SCRIPT="$REPO_DIR/fixes/fix_g29_shifter.sh" + export G29_HID_SYSFS="$BATS_TEST_TMPDIR/hid" + export G29_RUN_USER_DIR="$BATS_TEST_TMPDIR/run" + mkdir -p "$G29_HID_SYSFS" "$G29_RUN_USER_DIR" + # sudo stub: records every invocation, never attached, never fails. + STUB_LOG="$BATS_TEST_TMPDIR/sudo.log" + export STUB_LOG + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat >"$BATS_TEST_TMPDIR/bin/sudo" <<'STUB' +#!/bin/bash +echo "$*" >>"$STUB_LOG" +STUB + chmod +x "$BATS_TEST_TMPDIR/bin/sudo" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + # shellcheck source=/dev/null + source "$REPO_DIR/fixes/g29_wheel_mode.sh" +} + +# $1 = PID (e.g. 0003:046D:C24F), $2 = instance, $3 = "desc" to give it a +# report descriptor. The wheel enumerates two interfaces; only one has one. +fake_hid() { + local dir="$G29_HID_SYSFS/$1.$2" + mkdir -p "$dir" + if [[ "${3:-}" == desc ]]; then + printf 'x' >"$dir/report_descriptor" + else + : >"$dir/report_descriptor" + fi +} + +# --- g29_wheel_mode -------------------------------------------------------- + +@test "mode: empty sysfs is absent" { + [ "$(g29_wheel_mode)" = absent ] +} + +@test "mode: native C24F with a descriptor" { + fake_hid 0003:046D:C24F 0008 + fake_hid 0003:046D:C24F 0009 desc + [ "$(g29_wheel_mode)" = native ] + [ "$(g29_sysfs_device "$G29_PID_NATIVE")" = "$G29_HID_SYSFS/0003:046D:C24F.0009" ] +} + +@test "mode: an interface with an empty descriptor does not count" { + fake_hid 0003:046D:C24F 0001 + [ "$(g29_wheel_mode)" = absent ] +} + +@test "mode: C260 is ps4 (the 2026-09-17 boot)" { + fake_hid 0003:046D:C260 0001 desc + [ "$(g29_wheel_mode)" = ps4 ] +} + +@test "mode: C294 is compat" { + fake_hid 0003:046D:C294 0008 desc + [ "$(g29_wheel_mode)" = compat ] +} + +@test "mode: native wins when a stale C294 node is also present" { + fake_hid 0003:046D:C294 0008 desc + fake_hid 0003:046D:C24F 0009 desc + [ "$(g29_wheel_mode)" = native ] +} + +@test "explanation: ps4 names the selector and the PID" { + run g29_mode_explanation ps4 + [[ "$output" == *"PS4 mode"* && "$output" == *"C260"* && "$output" == *"PS3"* ]] +} + +# --- g29_notify_desktop ---------------------------------------------------- + +@test "notify: reaches every user with a session bus, via sudo -u" { + mkdir -p "$G29_RUN_USER_DIR/$(id -u)" + : >"$G29_RUN_USER_DIR/$(id -u)/bus" + g29_notify_desktop "title" "body" + grep -q -- "-u $(id -nu) DBUS_SESSION_BUS_ADDRESS=unix:path=$G29_RUN_USER_DIR/$(id -u)/bus notify-send -u critical -r $G29_NOTIFY_ID" "$STUB_LOG" +} + +@test "notify: no session bus means no call and no error" { + g29_notify_desktop "title" "body" + [ ! -e "$STUB_LOG" ] +} + +# --- fix_g29_shifter.sh --ensure / --status -------------------------------- + +@test "ensure: no wheel exits 0 without notifying" { + run "$SCRIPT" --ensure + [ "$status" -eq 0 ] + [[ "$output" == *"no G29 on the HID bus"* ]] + run ! grep -q notify-send "$STUB_LOG" +} + +@test "ensure: PS4 mode exits 1 and notifies the desktop" { + fake_hid 0003:046D:C260 0001 desc + mkdir -p "$G29_RUN_USER_DIR/$(id -u)" + : >"$G29_RUN_USER_DIR/$(id -u)/bus" + run "$SCRIPT" --ensure + [ "$status" -eq 1 ] + [[ "$output" == *"PS4 mode"* ]] + grep -q "notify-send -u critical .*G29 shifter fix NOT active" "$STUB_LOG" +} + +@test "ensure: already attached is a no-op even in PS4 mode" { + fake_hid 0003:046D:C260 0001 desc + cat >"$BATS_TEST_TMPDIR/bin/sudo" <<'STUB' +#!/bin/bash +[[ "$*" == *list-loaded* ]] && echo g29_shifter_bpf +STUB + run "$SCRIPT" --ensure + [ "$status" -eq 0 ] + [[ "$output" == *"already attached"* ]] +} + +@test "status: reports the wheel mode first" { + fake_hid 0003:046D:C260 0001 desc + run "$SCRIPT" --status + [ "$status" -eq 0 ] + [[ "$output" == *"wheel mode: ps4"* ]] + [[ "$output" != *"device:"* ]] +}