Skip to content

fix(manager): survive Bluetooth reconnects without stale lightbar or stuck menu#1

Merged
NicolasPogorzelski merged 2 commits into
mainfrom
fix/bt-reconnect-robustness
Jul 2, 2026
Merged

fix(manager): survive Bluetooth reconnects without stale lightbar or stuck menu#1
NicolasPogorzelski merged 2 commits into
mainfrom
fix/bt-reconnect-robustness

Conversation

@NicolasPogorzelski

Copy link
Copy Markdown
Owner

Problem

After un/re-pairing a DualSense for console use, it reconnects over Bluetooth on a
new evdev/hidraw node. Two failures followed from that:

  1. Lightbar stuck on the wrong colour. It was written via raw hidraw output
    reports, which race the hid-playstation driver and get dropped over
    Bluetooth, so the pad stayed on its firmware default (blue) instead of the
    mode colour (e.g. green for Emulate-Xbox).
  2. Mode switching greyed out. Instances were keyed by the evdev path, so a
    reconnect looked like a new device. The resulting tray-menu rebuild recycled
    item ids across a structural change and left the mode entries stuck disabled.

Fix

  • Lightbar via the kernel LED class (:rgb:indicator) through a new,
    tightly-scoped root helper controller-led (mirrors the controller-hidraw-gate
    pattern: name/vendor validation, NOPASSWD sudoers). The driver handles USB/BT
    framing, so the colour survives reconnects. Removes the CRC / USB-BT report
    code and the persistent hidraw fd.
  • Identity-keyed instances. Key by stable identity (uniq/MAC, else
    vendor:product) instead of the evdev path. A reconnect is recognised as the
    same pad and rebound in place (only the remapper restarts); a vanished pad
    is dropped only after a short grace window. A reconnect is now a no-op for the
    tray menu.

Verification

  • tests/test_reconcile.py (new) asserts the reconcile decisions:
    reconnect → rebind / no menu change, blip < grace → kept, absence > grace →
    removed, distinct pad → structural change. Wired into validate-repo.sh
    (Check 7); controller-led added to the shellcheck set.
  • validate-repo.sh passes; verified live: manager sets the lightbar green via
    controller-led on apply, no remapper flap.

Deploy note

Adds a root helper + sudoers line — re-run ./install.sh on the target.

…stuck menu

A Bluetooth controller re-registers on a new evdev/hidraw node on every
reconnect (e.g. when un/re-paired for console use). Two failures surfaced
from that churn:

- The lightbar was written via raw hidraw output reports, which race the
  hid-playstation driver and get dropped over Bluetooth, so the colour stayed
  on the firmware default. Drive it through the kernel LED class
  (:rgb:indicator) via a new root helper (controller-led); the driver then
  handles USB/BT framing. Removes the CRC / USB-BT report code and the fd.

- Instances were keyed by the evdev path, so a reconnect looked like a new
  device: the tray menu was rebuilt, recycling item ids across a structural
  change and leaving mode entries stuck disabled. Key instances by stable
  identity (uniq/MAC) and rebind in place on reconnect; drop a vanished pad
  only after a short grace window. A reconnect is now a no-op for the menu.

Adds tests/test_reconcile.py covering the reconcile decisions and wires it
into validate-repo.sh (which also shellchecks controller-led now).
…onnects

A Bluetooth DualSense reconnects on a *reused* /dev/input/eventX path (the number
is recycled) while the device underneath is new: its :rgb:indicator LED node is
renumbered (the name follows the inputN counter, which always bumps), its old
remapper grab has died, and the firmware has reset the lightbar to default blue.
The reconcile only rebound on an evdev *path* change, so this reconnect was
missed entirely — the pad came back un-remapped and on the wrong colour, and the
daemon kept writing the mode colour to the now-dead old LED node (so a later mode
switch changed no colour).

- _apply_led() now re-resolves the :rgb:indicator node from the live evdev path
  on every write and never trusts a cached name, so a colour cannot land on a
  stale node.
- _poll() detects a reconnect by a presence transition (a previously absent
  instance reappearing), not only by a path change, and re-asserts the whole mode
  via rebind() — restarting the remapper, re-gating the hidraw node and repainting
  the lightbar one poll after the pad is stably present (which also makes the
  colour stick past the device/Steam bring-up).
- refresh_led(), run each monitor tick outside the reconcile lock, repaints when
  the live LED node differs from the last painted one, covering a renumber that
  occurs without a polled absence.

Tests: test_reconcile.py gains Scenario E (node absent at bind -> painted once it
appears), F (node renumbers on a reused path -> repaint the new node once) and G
(reconnect on a reused evdev path -> rebind/re-assert, no menu churn). Document
the lightbar path and reconnect handling in the architecture overview and a
troubleshooting entry.
@NicolasPogorzelski

Copy link
Copy Markdown
Owner Author

Follow-up commit 18510fa — corrects the lightbar/remap handling this branch's
first attempt did not fully solve.

Real root cause. A Bluetooth DualSense reconnects on a reused /dev/input/eventX
path (the number is recycled) while the device underneath is new: its :rgb:indicator
LED node is renumbered (name follows the inputN counter), the old remapper grab has
died, and the firmware has reset the lightbar to default blue. The reconcile only rebound
on an evdev path change, so this reconnect was missed — the pad returned un-remapped and
on the wrong colour, and the daemon kept writing the mode colour to the now-dead old LED
node (so a later mode switch changed no colour).

Changes.

  • _apply_led() re-resolves the :rgb:indicator node from the live evdev path on every
    write and never trusts a cached name.
  • _poll() detects a reconnect by a presence transition (previously-absent instance
    reappearing), not only a path change, and re-asserts the whole mode via rebind() one
    poll after the pad is stably present (which also makes the colour stick past the
    device/Steam bring-up).
  • refresh_led() repaints when the live LED node differs from the last painted one.

Verification.

  • Unit tests: test_reconcile.py scenarios A–G pass (E: node absent at bind; F: node
    renumbers on a reused path; G: reconnect on a reused evdev path re-asserts, no menu churn).
  • validate-repo.sh passes.
  • Live on hardware (Bazzite, DualSense over BT): repeated off/on cycles — lightbar returns
    to the mode colour automatically; journal shows gate block + remapper + the green LED
    write landing on the new node each reconnect (input60input66input71), no tracebacks.

Known residual. A single LED write issued at the exact instant of reconnect can still be
overwritten by Steam Input as it comes up; the re-assert one poll later is what makes it
stick, but there is no hard guarantee against Steam owning the lightbar.

@NicolasPogorzelski NicolasPogorzelski merged commit 91bb4e3 into main Jul 2, 2026
2 checks passed
@NicolasPogorzelski NicolasPogorzelski deleted the fix/bt-reconnect-robustness branch July 2, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant