From 3719d40858cd4d17ff0af5df6b476fd7920e239b Mon Sep 17 00:00:00 2001 From: Jeff Hagadorn Date: Thu, 13 Aug 2026 15:47:32 -0700 Subject: [PATCH] fix: fix the logic guarding against ps4/5 touchpad-as-mouse grabs from also locking the touchpad and screen --- docs/installation.md | 14 ++ internal/gui/gamepad/gamepad.go | 235 +++++++++++++++++----- internal/gui/gamepad/gamepad_test.go | 280 +++++++++++++++++++++++++++ 3 files changed, 484 insertions(+), 45 deletions(-) create mode 100644 internal/gui/gamepad/gamepad_test.go diff --git a/docs/installation.md b/docs/installation.md index a45fa79..541618a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -299,6 +299,20 @@ Run with debug logging to see GTK and initialization output: z13gui --debug ``` +**Touchscreen or touchpad stops responding while the drawer is open** + +Fixed in 1.4.1. On 1.4.0 and earlier, z13gui could mistake the machine's own +touchpad and touchscreen for a game controller's touchpad and take exclusive +access (`EVIOCGRAB`) to them for as long as the drawer was open, which stopped +touch input reaching the desktop entirely. A stylus was unaffected, and pressing +`Esc` to dismiss the drawer gave the devices back. + +Only users whose account can open those device nodes were affected — normally +that means membership of the `input` group, since stock udev rules grant the +session user access to joysticks but not to touch devices. + +Upgrade to 1.4.1. + **Gamescope: controller input not suppressed while drawer is open** Grant BPF capabilities so z13gui can block controller input at the kernel level: diff --git a/internal/gui/gamepad/gamepad.go b/internal/gui/gamepad/gamepad.go index 64c7c0d..6fe47f4 100644 --- a/internal/gui/gamepad/gamepad.go +++ b/internal/gui/gamepad/gamepad.go @@ -9,10 +9,12 @@ // Device classification: // - gamepad: full controllers (Xbox, PS, Switch, virtual Steam devices) → // read events + EVIOCGRAB to suppress background game input -// - grab-only: related input devices (PS touchpad) → EVIOCGRAB only, -// events discarded (prevents touchpad acting as mouse in background) +// - grab-only: a controller's *own* secondary devices (the PS touchpad) → +// EVIOCGRAB only, events discarded (prevents touchpad acting as mouse in +// background). A multitouch device only qualifies when a gamepad from the +// same physical controller is already tracked; see classify. // - ignored: accelerometers/gyro (INPUT_PROP_ACCELEROMETER), keyboards, -// mice, and other non-gamepad devices +// mice, the machine's own touchpad and touchscreen, and everything else // // Permissions: on modern systemd (Arch, Fedora, Ubuntu 22.04+), the uaccess // udev tag in 70-uaccess.rules grants the active session user ACL access to @@ -24,6 +26,7 @@ import ( "fmt" "log/slog" "os" + "strings" "sync" "time" @@ -179,12 +182,29 @@ func (r *Reader) SetGrabbed(seq uint64, grab bool) { } // scan enumerates /dev/input/event* and starts readers for new devices. +// +// It opens and inspects everything before classifying anything, because a +// controller's touchpad can only be recognised once its gamepad node is known and +// /dev/input/event* enumerates in node order rather than device order — the +// touchpad routinely comes first. Devices that stay unmatched are simply ignored +// and left untracked, so if the controller is plugged in later the 5s rescan +// picks its touchpad up then. func (r *Reader) scan() { paths, err := evdev.ListDevicePaths() if err != nil { slog.Debug("gamepad: scan failed", "err", err) return } + + type candidate struct { + path string + dev *evdev.InputDevice + info deviceInfo + } + var ( + candidates []candidate + gamepads []deviceInfo + ) for _, p := range paths { r.mu.Lock() _, inDevices := r.devices[p.Path] @@ -193,29 +213,50 @@ func (r *Reader) scan() { if inDevices || inGrabOnly { continue } - r.tryOpen(p.Path) + dev, err := evdev.OpenWithFlags(p.Path, os.O_RDONLY) + if err != nil { + continue + } + info := inspect(dev) + candidates = append(candidates, candidate{path: p.Path, dev: dev, info: info}) + // The gamepad verdict never depends on the sibling list, so this pass can + // settle it and build the list the touchpad verdict needs. + if classify(info, nil) == deviceGamepad { + gamepads = append(gamepads, info) + } + } + // Controllers found by an earlier scan count too: the touchpad may be the only + // new node this time round, after a reconnect. + gamepads = append(gamepads, r.trackedGamepads()...) + + for _, c := range candidates { + r.adopt(c.path, c.dev, c.info, classify(c.info, gamepads)) } } -// tryOpen opens a device, classifies it, and starts the appropriate handler. -func (r *Reader) tryOpen(path string) { - dev, err := evdev.OpenWithFlags(path, os.O_RDONLY) - if err != nil { - return +// trackedGamepads returns the identity of every gamepad currently tracked. +func (r *Reader) trackedGamepads() []deviceInfo { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]deviceInfo, 0, len(r.devices)) + for _, dev := range r.devices { + out = append(out, inspect(dev)) } + return out +} - class := classifyDevice(dev) +// adopt starts the handler for a classified device, or closes it if it is not one +// we care about. +func (r *Reader) adopt(path string, dev *evdev.InputDevice, info deviceInfo, class deviceClass) { if class == deviceIgnore { _ = dev.Close() return } - name, _ := dev.Name() - id, _ := dev.InputID() attrs := []any{ "path", path, - "name", name, - "id", fmt.Sprintf("%04x:%04x", id.Vendor, id.Product), + "name", info.name, + "id", fmt.Sprintf("%04x:%04x", info.id.Vendor, info.id.Product), } r.mu.Lock() @@ -244,52 +285,156 @@ func (r *Reader) tryOpen(path string) { } } -// classifyDevice determines how to handle an evdev device. -func classifyDevice(dev *evdev.InputDevice) deviceClass { +// Steam's virtual gamepad, the controller Steam Input presents to games. +const ( + steamVendor = 0x28DE + steamVirtualProduct = 0x11FF +) + +// deviceInfo is the identity and capability set classification reads. Keeping it +// separate from the open device is what lets classify be a pure function, and so +// the only way to test classification without the hardware in hand. +type deviceInfo struct { + name string + id evdev.InputID + props []evdev.EvProp + keys []evdev.EvCode + abs []evdev.EvCode + uniq string // EVIOCGUNIQ: serial or MAC; most devices report nothing + phys string // EVIOCGPHYS: physical port path +} + +// inspect reads what classify needs. Not every driver implements the EVIOCG* +// ioctls behind uniq and phys, and an error there means only that this device +// does not report one — classify treats an empty string as "unknown" rather than +// as a mismatch. +func inspect(dev *evdev.InputDevice) deviceInfo { + name, _ := dev.Name() + id, _ := dev.InputID() + uniq, _ := dev.UniqueID() + phys, _ := dev.PhysicalLocation() + return deviceInfo{ + name: name, + id: id, + props: dev.Properties(), + keys: dev.CapableEvents(evdev.EV_KEY), + abs: dev.CapableEvents(evdev.EV_ABS), + uniq: uniq, + phys: phys, + } +} + +// classify determines how to handle a device. gamepads carries the devices +// already classified as deviceGamepad, which is what tells a controller's own +// touchpad apart from the machine's; callers that have not enumerated the +// gamepads yet may pass nil, and get deviceIgnore for touchpads. +func classify(d deviceInfo, gamepads []deviceInfo) deviceClass { // Skip accelerometers/gyro (PS motion sensors). High-frequency events, // not routable to game input — grabbing is wasteful. - for _, p := range dev.Properties() { - if p == evdev.INPUT_PROP_ACCELEROMETER { - return deviceIgnore - } + if hasProp(d, evdev.INPUT_PROP_ACCELEROMETER) { + return deviceIgnore } - // Check for gamepad button capabilities (Xbox, PS, Switch, virtual). - keys := dev.CapableEvents(evdev.EV_KEY) - hasGamepadBtn := false - for _, k := range keys { - for _, gb := range gamepadButtons { - if k == gb { - hasGamepadBtn = true - break - } - } - if hasGamepadBtn { - break - } - } - if hasGamepadBtn { - // Steam virtual gamepad (VID 28de, PID 11ff) — grab to block game's - // evdev reader, but don't read events (we read the physical device). - id, err := dev.InputID() - if err == nil && id.Vendor == 0x28DE && id.Product == 0x11FF { + if hasGamepadButton(d) { + // Steam virtual gamepad — grab to block game's evdev reader, but don't + // read events (we read the physical device). + if d.id.Vendor == steamVendor && d.id.Product == steamVirtualProduct { return deviceGrabOnly } return deviceGamepad } - // Check for touchpad (PS controller touchpad): has multitouch but no - // gamepad buttons. Must be grabbed to prevent it acting as a mouse. - abs := dev.CapableEvents(evdev.EV_ABS) - for _, a := range abs { - if a == evdev.ABS_MT_POSITION_X { + // A controller's own touchpad: multitouch, but no gamepad buttons of its own + // because they live on the sibling node. Grab it so it stops acting as a + // mouse while the drawer is open. + // + // Multitouch alone does not identify one. The machine's own touchpad and + // touchscreen answer that test just as well, and grabbing those takes them + // from the compositor for as long as the drawer is open — touch input dies + // system-wide, leaving the keyboard as the only way to dismiss the drawer and + // get it back (issue #18). So the device has to be shown to belong to a + // controller before it is grabbed. + if !hasCode(d.abs, evdev.ABS_MT_POSITION_X) { + return deviceIgnore + } + // A touchscreen reports coordinates in screen space. No controller touchpad + // does, so this rules the built-in panel out ahead of any matching. + if hasProp(d, evdev.INPUT_PROP_DIRECT) { + return deviceIgnore + } + for _, g := range gamepads { + if sameController(d, g) { return deviceGrabOnly } } - return deviceIgnore } +// sameController reports whether two device nodes belong to one physical +// controller. +// +// Every input node a HID driver creates inherits bus, vendor and product from the +// parent hid_device, so a controller's touchpad always carries the same +// vendor:product as its gamepad node (DualSense 054c:0ce6, DualShock 4 054c:09cc). +// That is what carries the decision: the machine's own touchpad and touchscreen +// have their own vendor IDs and cannot match a controller's. +// +// uniq (the controller's MAC) and phys then separate two controllers of the same +// model. They only refine the answer — mistaking one attached DualSense's +// touchpad for another's still grabs a controller touchpad, which is the intended +// outcome either way — so a device reporting neither still matches on +// vendor:product alone. +func sameController(a, b deviceInfo) bool { + if a.id.Vendor != b.id.Vendor || a.id.Product != b.id.Product { + return false + } + if a.uniq != "" && b.uniq != "" { + return a.uniq == b.uniq + } + if a.phys != "" && b.phys != "" { + return physRoot(a.phys) == physRoot(b.phys) + } + return true +} + +// physRoot drops the per-node suffix from an EVIOCGPHYS path, leaving the part +// every node of one device shares: "usb-0000:0a:00.3-2/input0" → "usb-0000:0a:00.3-2". +func physRoot(phys string) string { + if i := strings.IndexByte(phys, '/'); i >= 0 { + return phys[:i] + } + return phys +} + +// hasGamepadButton reports whether d carries any button that marks it a gamepad +// (Xbox, PS, Switch, virtual). +func hasGamepadButton(d deviceInfo) bool { + for _, gb := range gamepadButtons { + if hasCode(d.keys, gb) { + return true + } + } + return false +} + +func hasProp(d deviceInfo, want evdev.EvProp) bool { + for _, p := range d.props { + if p == want { + return true + } + } + return false +} + +func hasCode(codes []evdev.EvCode, want evdev.EvCode) bool { + for _, c := range codes { + if c == want { + return true + } + } + return false +} + // repeat timing constants. const ( repeatInitial = 400 * time.Millisecond diff --git a/internal/gui/gamepad/gamepad_test.go b/internal/gui/gamepad/gamepad_test.go new file mode 100644 index 0000000..673f3c1 --- /dev/null +++ b/internal/gui/gamepad/gamepad_test.go @@ -0,0 +1,280 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package gamepad + +import ( + "testing" + + evdev "github.com/holoplot/go-evdev" +) + +// The fixtures below are the real capability sets of the devices in issue #18, +// transcribed from /proc/bus/input/devices on a ROG Flow Z13 (GZ302EA). The +// touchpad and the touchscreen both advertise ABS_MT_POSITION_X and neither +// carries a gamepad button, which is exactly why the unqualified multitouch test +// used to classify them as grab-only and take them away from the compositor for +// as long as the drawer was open. The stylus escaped only because it reports +// pressure and tilt instead of multitouch slots. + +// z13Touchpad is the detachable keyboard's touchpad (0b05:1a30, event5). +func z13Touchpad() deviceInfo { + return deviceInfo{ + name: "ASUSTeK Computer Inc. GZ302EA-Keyboard Touchpad", + id: evdev.InputID{BusType: 0x0003, Vendor: 0x0B05, Product: 0x1A30, Version: 0x0110}, + props: []evdev.EvProp{evdev.INPUT_PROP_POINTER, evdev.INPUT_PROP_BUTTONPAD}, + keys: []evdev.EvCode{ + evdev.BTN_LEFT, evdev.BTN_TOOL_FINGER, evdev.BTN_TOUCH, + evdev.BTN_TOOL_DOUBLETAP, evdev.BTN_TOOL_TRIPLETAP, + evdev.BTN_TOOL_QUADTAP, evdev.BTN_TOOL_QUINTTAP, + }, + abs: []evdev.EvCode{ + evdev.ABS_X, evdev.ABS_Y, evdev.ABS_MT_SLOT, + evdev.ABS_MT_POSITION_X, evdev.ABS_MT_POSITION_Y, + evdev.ABS_MT_TOOL_TYPE, evdev.ABS_MT_TRACKING_ID, + }, + phys: "usb-0000:c6:00.0-4/input3", + } +} + +// z13Touchscreen is the built-in ELAN panel (04f3:43c7, event11). +func z13Touchscreen() deviceInfo { + return deviceInfo{ + name: "ELAN9008:00 04F3:43C7", + id: evdev.InputID{BusType: 0x0018, Vendor: 0x04F3, Product: 0x43C7, Version: 0x0100}, + props: []evdev.EvProp{evdev.INPUT_PROP_DIRECT}, + keys: []evdev.EvCode{evdev.BTN_TOUCH}, + abs: []evdev.EvCode{ + evdev.ABS_X, evdev.ABS_Y, evdev.ABS_MT_SLOT, + evdev.ABS_MT_TOUCH_MAJOR, evdev.ABS_MT_TOUCH_MINOR, + evdev.ABS_MT_ORIENTATION, evdev.ABS_MT_POSITION_X, + evdev.ABS_MT_POSITION_Y, evdev.ABS_MT_TRACKING_ID, + }, + phys: "i2c-ELAN9008:00", + } +} + +// z13Stylus is the pen digitiser (event14). Same vendor:product and phys as the +// touchscreen — they are two nodes of one panel. +func z13Stylus() deviceInfo { + return deviceInfo{ + name: "ELAN9008:00 04F3:43C7 Stylus", + id: evdev.InputID{BusType: 0x0018, Vendor: 0x04F3, Product: 0x43C7, Version: 0x0100}, + props: []evdev.EvProp{evdev.INPUT_PROP_DIRECT}, + keys: []evdev.EvCode{ + evdev.BTN_TOOL_PEN, evdev.BTN_TOOL_RUBBER, evdev.BTN_TOUCH, + evdev.BTN_STYLUS, evdev.BTN_STYLUS2, + }, + abs: []evdev.EvCode{ + evdev.ABS_X, evdev.ABS_Y, evdev.ABS_PRESSURE, + evdev.ABS_TILT_X, evdev.ABS_TILT_Y, evdev.ABS_MISC, + }, + phys: "i2c-ELAN9008:00", + } +} + +const dualSenseMAC = "a0:ab:51:11:22:33" + +// dualSenseGamepad is the controller node hid-playstation creates (054c:0ce6). +func dualSenseGamepad() deviceInfo { + return deviceInfo{ + name: "Sony Interactive Entertainment DualSense Wireless Controller", + id: evdev.InputID{BusType: 0x0003, Vendor: 0x054C, Product: 0x0CE6, Version: 0x8111}, + keys: []evdev.EvCode{ + evdev.BTN_SOUTH, evdev.BTN_EAST, evdev.BTN_NORTH, evdev.BTN_WEST, + evdev.BTN_TL, evdev.BTN_TR, evdev.BTN_TL2, evdev.BTN_TR2, + evdev.BTN_SELECT, evdev.BTN_START, evdev.BTN_MODE, + evdev.BTN_THUMBL, evdev.BTN_THUMBR, + }, + abs: []evdev.EvCode{ + evdev.ABS_X, evdev.ABS_Y, evdev.ABS_Z, + evdev.ABS_RX, evdev.ABS_RY, evdev.ABS_RZ, + evdev.ABS_HAT0X, evdev.ABS_HAT0Y, + }, + uniq: dualSenseMAC, + phys: "usb-0000:00:14.0-3/input0", + } +} + +// dualSenseTouchpad is the controller's own touchpad — the device the grab-only +// class exists for. +func dualSenseTouchpad() deviceInfo { + return deviceInfo{ + name: "Sony Interactive Entertainment DualSense Wireless Controller Touchpad", + id: evdev.InputID{BusType: 0x0003, Vendor: 0x054C, Product: 0x0CE6, Version: 0x8111}, + props: []evdev.EvProp{evdev.INPUT_PROP_POINTER, evdev.INPUT_PROP_BUTTONPAD}, + keys: []evdev.EvCode{evdev.BTN_LEFT, evdev.BTN_TOOL_FINGER, evdev.BTN_TOUCH, evdev.BTN_TOOL_DOUBLETAP}, + abs: []evdev.EvCode{ + evdev.ABS_X, evdev.ABS_Y, evdev.ABS_MT_SLOT, + evdev.ABS_MT_POSITION_X, evdev.ABS_MT_POSITION_Y, + evdev.ABS_MT_TRACKING_ID, + }, + uniq: dualSenseMAC, + phys: "usb-0000:00:14.0-3/input0", + } +} + +// dualSenseMotion is the controller's accelerometer/gyro node. +func dualSenseMotion() deviceInfo { + return deviceInfo{ + name: "Sony Interactive Entertainment DualSense Wireless Controller Motion Sensors", + id: evdev.InputID{BusType: 0x0003, Vendor: 0x054C, Product: 0x0CE6, Version: 0x8111}, + props: []evdev.EvProp{evdev.INPUT_PROP_ACCELEROMETER}, + abs: []evdev.EvCode{evdev.ABS_X, evdev.ABS_Y, evdev.ABS_Z, evdev.ABS_RX, evdev.ABS_RY, evdev.ABS_RZ}, + uniq: dualSenseMAC, + phys: "usb-0000:00:14.0-3/input0", + } +} + +func steamVirtualGamepad() deviceInfo { + return deviceInfo{ + name: "Microsoft X-Box 360 pad 0", + id: evdev.InputID{BusType: 0x0003, Vendor: steamVendor, Product: steamVirtualProduct, Version: 0x0001}, + keys: []evdev.EvCode{evdev.BTN_SOUTH, evdev.BTN_EAST, evdev.BTN_MODE, evdev.BTN_START}, + abs: []evdev.EvCode{evdev.ABS_X, evdev.ABS_Y, evdev.ABS_HAT0X, evdev.ABS_HAT0Y}, + } +} + +func keyboard() deviceInfo { + return deviceInfo{ + name: "AT Translated Set 2 keyboard", + id: evdev.InputID{BusType: 0x0011, Vendor: 0x0001, Product: 0x0001, Version: 0xAB83}, + keys: []evdev.EvCode{evdev.KEY_A, evdev.KEY_ESC, evdev.KEY_LEFTSHIFT}, + } +} + +func TestClassify(t *testing.T) { + withController := []deviceInfo{dualSenseGamepad()} + + tests := []struct { + name string + device deviceInfo + gamepads []deviceInfo + want deviceClass + }{ + // Issue #18: the machine's own touch devices must never be grabbed. The + // "with a controller attached" cases are the real regression guard — + // plugging a DualSense in must not resurrect the bug. + {"z13 touchpad alone", z13Touchpad(), nil, deviceIgnore}, + {"z13 touchpad with a controller attached", z13Touchpad(), withController, deviceIgnore}, + {"z13 touchscreen alone", z13Touchscreen(), nil, deviceIgnore}, + {"z13 touchscreen with a controller attached", z13Touchscreen(), withController, deviceIgnore}, + {"z13 stylus", z13Stylus(), withController, deviceIgnore}, + + // The behaviour being preserved. + {"dualsense gamepad", dualSenseGamepad(), nil, deviceGamepad}, + {"dualsense touchpad with its gamepad", dualSenseTouchpad(), withController, deviceGrabOnly}, + {"dualsense motion sensors", dualSenseMotion(), withController, deviceIgnore}, + + // Enumerated before its own gamepad node: ignored for now, picked up by + // the next scan once the controller is tracked. + {"dualsense touchpad before its gamepad", dualSenseTouchpad(), nil, deviceIgnore}, + // A second controller's touchpad must not be matched by the first's. + {"dualsense touchpad with only the z13 touchpad known", dualSenseTouchpad(), []deviceInfo{z13Touchpad()}, deviceIgnore}, + + {"steam virtual gamepad", steamVirtualGamepad(), nil, deviceGrabOnly}, + {"keyboard", keyboard(), withController, deviceIgnore}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classify(tt.device, tt.gamepads); got != tt.want { + t.Errorf("classify() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestClassifyWholeSystem runs every attached device through classification the +// way scan() does — derive the gamepad list first, then classify against it — +// and checks the verdict for all of them at once. This is issue #18 in full: a +// Z13 with a DualSense plugged in must keep its own touchpad and touchscreen +// while still suppressing the controller's touchpad. +func TestClassifyWholeSystem(t *testing.T) { + attached := []struct { + device deviceInfo + want deviceClass + }{ + {z13Touchpad(), deviceIgnore}, + {z13Touchscreen(), deviceIgnore}, + {z13Stylus(), deviceIgnore}, + {keyboard(), deviceIgnore}, + {dualSenseGamepad(), deviceGamepad}, + {dualSenseTouchpad(), deviceGrabOnly}, + {dualSenseMotion(), deviceIgnore}, + {steamVirtualGamepad(), deviceGrabOnly}, + } + + // scan() only admits a device to the gamepad list once it classifies as one. + var gamepads []deviceInfo + for _, a := range attached { + if classify(a.device, nil) == deviceGamepad { + gamepads = append(gamepads, a.device) + } + } + + for _, a := range attached { + if got := classify(a.device, gamepads); got != a.want { + t.Errorf("classify(%q) = %v, want %v", a.device.name, got, a.want) + } + } +} + +func TestSameController(t *testing.T) { + otherMAC := dualSenseGamepad() + otherMAC.uniq = "a0:ab:51:44:55:66" + otherMAC.phys = "usb-0000:00:14.0-4/input0" + + noIDs := dualSenseGamepad() + noIDs.uniq, noIDs.phys = "", "" + + otherPhys := dualSenseTouchpad() + otherPhys.uniq = "" + otherPhys.phys = "usb-0000:00:14.0-4/input1" + + samePhys := dualSenseTouchpad() + samePhys.uniq = "" + + tests := []struct { + name string + a, b deviceInfo + want bool + }{ + {"same controller, matching uniq", dualSenseTouchpad(), dualSenseGamepad(), true}, + {"two controllers of one model", dualSenseTouchpad(), otherMAC, false}, + {"no uniq, same phys root", samePhys, dualSenseGamepad(), true}, + {"no uniq, different phys root", otherPhys, dualSenseGamepad(), false}, + {"neither reports uniq or phys", dualSenseTouchpad(), noIDs, true}, + {"different vendor", z13Touchpad(), dualSenseGamepad(), false}, + // The Z13 panel's own two nodes do share everything — harmless, since + // neither is ever a gamepad, but it shows vendor:product alone is not + // what makes a device grabbable. + {"touchscreen and its stylus", z13Touchscreen(), z13Stylus(), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sameController(tt.a, tt.b); got != tt.want { + t.Errorf("sameController() = %v, want %v", got, tt.want) + } + if got := sameController(tt.b, tt.a); got != tt.want { + t.Errorf("sameController() reversed = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPhysRoot(t *testing.T) { + tests := []struct{ in, want string }{ + {"usb-0000:00:14.0-3/input0", "usb-0000:00:14.0-3"}, + {"usb-0000:00:14.0-3/input1", "usb-0000:00:14.0-3"}, + {"i2c-ELAN9008:00", "i2c-ELAN9008:00"}, + {"", ""}, + {"/input0", ""}, + } + for _, tt := range tests { + if got := physRoot(tt.in); got != tt.want { + t.Errorf("physRoot(%q) = %q, want %q", tt.in, got, tt.want) + } + } +}