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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfC2T6DyvCFqse9A99zef
This commit is contained in:
Krzysztof Rudnicki 2026-09-18 10:44:45 +02:00
parent b3f3f55863
commit 3ae53499e6
8 changed files with 357 additions and 26 deletions

View File

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

View File

@ -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 <the six changed 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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:"* ]]
}