Explorar o código

select keyboards by number instead of HID path

--device now takes the 1-based number that `keyboard info` prints, not a
HID path. Paths are reassigned on reboot and most keyboards report no
serial number, so they were never a stable reference. Without --device,
commands still run only when exactly one keyboard is connected and
otherwise fail with the list of selectable numbers.

This also fixes a real bug: OpenDevice() resolved devices through
Discover(), which only looked at VID/PID pairs listed in keyboards.json.
Unknown QMK keyboards showed up in `keyboard info` but could not be
driven by any RGB command. Both paths now share one discovery built on the
QMK Raw HID signature.

keyboards.json is now only a name lookup, and is genuinely optional: absent
or malformed, discovery still finds every keyboard and RGB commands still
work, they just lose the friendly name. Tests cover both cases.

Cleanups alongside:

- Rename Device.Supported to Device.Known. It only ever meant "listed in
  keyboards.json", so `supported: false` on a fully controllable keyboard
  read as a compatibility verdict. Tests pin the JSON key and assert the
  old one is gone.
- Drop the parsed-but-never-read protocol, viaVersion and ledLayout fields
  from Keyboard, and from keyboards.json. OpenDevice() loses its Keyboard
  return value that all seven callers discarded, and via.Protocol loses its
  unused kb field.
- `keyboard info` no longer prints a human-readable banner on stderr next to
  the JSON; the banner only duplicated fields the JSON already carried.
- Route all JSON through one encodeJSON helper. `keyboard info` had drifted
  to compact json.NewEncoder output while effect --list and info used
  MarshalIndent. The helper also propagates the error that effect --list was
  discarding.
- Ignore the built binary at the repo root; it was untracked but not
  ignored, so `git add -A` would have committed it.
Paul Klumpp hai 1 semana
pai
achega
0424164351

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 .worktrees/
+/qmk-rgb-tool

+ 31 - 6
AGENTS.md

@@ -1,16 +1,16 @@
 # Agent Guidelines — QMK RGB Tool
 
 ## Objective
-Cross-platform Go CLI library for programmatic/agent-friendly control of VIA-compatible keyboard RGB lighting (starting with Impact 80).
+Cross-platform Go CLI library for programmatic/agent-friendly control of QMK keyboard RGB lighting.
 
 ## Architecture
 
 ```
 cmd/                    # Cobra-based CLI entrypoints
-internal/device/        # HID device discovery (VID/PID matching)
+internal/device/        # QMK Raw HID discovery and keyboard numbering
 internal/via/           # VIA protocol implementation + LED subsystem
 internal/rgb/           # Effects, values, color definitions
-keyboards.json          # VID/PID database + keyboard metadata
+keyboards.json          # Optional name/metadata lookup for known keyboards
 ```
 
 ## CLI Interface
@@ -31,8 +31,23 @@ qmk-rgb-tool save <name>        # Save current RGB state to a profile
 qmk-rgb-tool load <name>        # Load a profile and apply it to the keyboard
 qmk-rgb-tool delete <name>      # Delete a saved profile
 qmk-rgb-tool list               # List saved profiles
+qmk-rgb-tool --device <n> ...   # Target keyboard number from `keyboard info`
 ```
 
+## Device Selection
+
+Discovery matches every connected keyboard exposing the QMK Raw HID signature
+(Usage Page `0xFF60`, Usage `0x61`) — the same check `qmk/qmk_udev` performs.
+`keyboards.json` is only consulted to attach a name to a recognized model; an
+absent or malformed file costs names, not the ability to drive the keyboard.
+
+`keyboard info` numbers the connected keyboards from 1 in a stable order
+(vendor ID, product ID, path). `--device` accepts that number, never a HID path.
+Without `--device`, commands proceed only when exactly one keyboard is connected;
+otherwise they fail and list the selectable numbers, so a command never targets
+an unintended keyboard. Device numbers are stable for the current session only —
+HID paths are reassigned on reboot and most keyboards report no serial number.
+
 `--zone` is persistent across commands and accepts `logo`, `backlight`, or `side`.
 Without `--zone`, commands target Logo, Backlight, and Side in that order.
 With `--zone`, commands target exactly one zone. Unsupported default targets
@@ -90,7 +105,9 @@ prints JSON before returning non-zero.
 | Wobkey Rainy 75  | 0x6666 | 0x0001 |
 | Wobkey Impact 80 | 0x36B0 | 0x309F |
 
-`keyboards.json` maps VID+PID → VIA protocol config + RGB layout metadata.
+`keyboards.json` maps VID+PID → display name. Only `name` is read by the
+code; unknown fields in the file are ignored, so entries may carry extra keys
+without affecting discovery.
 
 ## Stack
 
@@ -106,14 +123,22 @@ prints JSON before returning non-zero.
 
 ## Consistency
 
-Names used in code must appear identically everywhere. Before any edit:
+Documentation describes real, verifiable behavior. Names are only the start —
+behavior counts too. Before any edit:
 
 1. **Binary name** — the `go build -o` target (usually `$GOBIN/$module_name`) must match every example in README, AGENTS.md, comments, and docs.
 2. **CLI command** — the root command name (e.g. `qmk-rgb-tool`) is the binary name; every usage example, CLI reference table, and doc must use the same string.
 3. **Module path** — the `go.mod` module path is the source of truth for `go install` targets.
 4. **Zone names** — `logo`, `backlight`, `side` must be identical in code, docs, and examples.
 
-When you see a name in one file, grep for it across the whole repo before deciding if a change is consistent.
+Documented behavior must also match the code:
+
+5. **Flags and accepted values** — what a flag accepts and rejects, its type, its default, and whether it persists across commands. A doc promising `--device 1` must not accept a path.
+6. **Error messages and exit codes** — the exact strings a user is told to look for, which error takes precedence when several apply, and when JSON is still printed before a non-zero exit.
+7. **JSON output shape** — field names, types, and whether a field is always present, omitted, or `null`. Agents and scripts parse this.
+8. **Ranges, defaults, and stability** — accepted ranges, default values, boundary behavior, and any ordering or stability guarantee together with the scope it holds over (a device number is stable for one session, not across reboots).
+
+When you see a name or a behavior in one file, grep for it across the whole repo before deciding if a change is consistent.
 
 ## Code vs Documentation
 

+ 36 - 10
README.md

@@ -54,8 +54,8 @@ go install ./cmd/qmk-rgb-tool/
 ./qmk-rgb-tool list
 ./qmk-rgb-tool delete paul
 
-# Specify a target device (when multiple are connected)
-./qmk-rgb-tool --device /dev/hidrawX enable
+# Select a keyboard by the number shown in `keyboard info`
+./qmk-rgb-tool --device 1 enable
 ```
 
 `--zone` is persistent across commands and accepts `logo`, `backlight`, or `side`.
@@ -66,6 +66,26 @@ command skips unsupported zones and prints a warning on stderr.
 
 All output is machine-parseable JSON when applicable.
 
+## Selecting a Keyboard
+
+`keyboard info` numbers every connected QMK keyboard starting at 1, and
+`--device` takes that number:
+
+```bash
+./qmk-rgb-tool keyboard info
+./qmk-rgb-tool --device 2 brightness 160
+```
+
+Without `--device`, commands run only when exactly one keyboard is connected.
+With two or more, they fail and list the numbers you can choose from, so a
+command never hits an unintended keyboard.
+
+Numbers are assigned in a stable order (vendor ID, product ID, path), but they
+are only guaranteed for the current session. Keyboards are identified by their
+HID path, which the operating system reassigns on reboot, and most keyboards
+report no serial number. Re-run `keyboard info` after reconnecting a keyboard
+rather than storing the number.
+
 ## Features
 
 - **Cross-platform** — Linux, macOS, Windows via [hidapi](https://github.com/libusb/hidapi)
@@ -76,7 +96,7 @@ All output is machine-parseable JSON when applicable.
 - **Reactive & splash effects** — honor `rgb color` and `rgb speed` for key-press illumination
 - **Machine-parseable output** — JSON for `keyboard info`, `info`, `list`, and `effect --list`
 - **Agent-friendly** — designed for automation, scripting, and CLI-first workflows
-- **Multiple devices** — `--device` flag to target a specific keyboard when several are connected
+- **Multiple devices** — `keyboard info` numbers each keyboard; `--device <n>` targets one
 
 ## Impact 80 Zones and Effects
 
@@ -216,18 +236,24 @@ No setup required. Windows applications access HID devices directly through the
 
 After Linux setup, run the tool as your regular user (no `sudo` needed).
 
-## Supported Keyboards
+## Known Keyboards
 
 Any keyboard running QMK with the RGB Matrix subsystem and VIA support is
 detected automatically via the QMK Raw HID signature (Usage Page 0xFF60,
 Usage 0x61). No manual configuration required.
 
-Two keyboards are fully tested and mapped:
+`keyboards.json` is optional. When present it only supplies friendly names:
+`keyboard info` reports `"known": true` for models listed there and
+`"known": false` for every other QMK keyboard. Both are fully controllable —
+`known` describes the name lookup, not compatibility. Deleting or corrupting
+the file costs you names only.
+
+Two models are listed in `keyboards.json`:
 
-| Keyboard        | VID    | PID    | Status    |
-|-----------------|--------|--------|-----------|
-| Wobkey Rainy 75 | 0x6666 | 0x0001 | Supported |
-| Wobkey Impact 80| 0x36B0 | 0x309F | Supported |
+| Keyboard        | VID    | PID    |
+|-----------------|--------|--------|
+| Wobkey Rainy 75 | 0x6666 | 0x0001 |
+| Wobkey Impact 80| 0x36B0 | 0x309F |
 
 ## CLI Reference
 
@@ -243,7 +269,7 @@ Two keyboards are fully tested and mapped:
 | `qmk-rgb-tool color <hex>`            | Set color (e.g. `ff0000`) on selected zones |
 | `qmk-rgb-tool mode <index>`           | Set a raw zone-specific effect ID         |
 | `qmk-rgb-tool --zone <zone> ...`      | Target `logo`, `backlight`, or `side`     |
-| `qmk-rgb-tool --device <path> ...`    | Specify HID device path                   |
+| `qmk-rgb-tool --device <n> ...`      | Target keyboard by number (see `keyboard info`) |
 | `qmk-rgb-tool save [name]`            | Save current RGB state to `profiles/<name>.json` |
 | `qmk-rgb-tool load [name]`            | Load and apply a profile from `profiles/` |
 | `qmk-rgb-tool list`                   | List saved profiles                        |

+ 1 - 1
cmd/qmk-rgb-tool/brightness.go

@@ -26,7 +26,7 @@ func NewBrightnessCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			proto, _, err := OpenDevice()
+			proto, err := OpenDevice()
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 				os.Exit(1)

+ 1 - 1
cmd/qmk-rgb-tool/color.go

@@ -27,7 +27,7 @@ func NewColorCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			proto, _, err := OpenDevice()
+			proto, err := OpenDevice()
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 				os.Exit(1)

+ 1 - 1
cmd/qmk-rgb-tool/disable.go

@@ -18,7 +18,7 @@ func NewDisableCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			proto, _, err := OpenDevice()
+			proto, err := OpenDevice()
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 				os.Exit(1)

+ 1 - 4
cmd/qmk-rgb-tool/effect.go

@@ -1,7 +1,6 @@
 package main
 
 import (
-	"encoding/json"
 	"fmt"
 	"strings"
 
@@ -58,9 +57,7 @@ func listAllEffects(cmd *cobra.Command) error {
 		}
 	}
 
-	data, _ := json.MarshalIndent(list.Zones, "", "  ")
-	fmt.Fprintf(cmd.OutOrStdout(), "%s\n", string(data))
-	return nil
+	return encodeJSON(cmd.OutOrStdout(), list.Zones)
 }
 
 func runEffectSet(cmd *cobra.Command, args []string) error {

+ 1 - 1
cmd/qmk-rgb-tool/enable.go

@@ -18,7 +18,7 @@ func NewEnableCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			proto, _, err := OpenDevice()
+			proto, err := OpenDevice()
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 				os.Exit(1)

+ 1 - 4
cmd/qmk-rgb-tool/info.go

@@ -1,7 +1,6 @@
 package main
 
 import (
-	"encoding/json"
 	"errors"
 	"fmt"
 
@@ -122,11 +121,9 @@ func NewInfoCmd() *cobra.Command {
 			defer proto.Close()
 
 			out, queryErr := readInfo(proto, zones)
-			data, err := json.MarshalIndent(out, "", "  ")
-			if err != nil {
+			if err := encodeJSON(cmd.OutOrStdout(), out); err != nil {
 				return err
 			}
-			fmt.Fprintln(cmd.OutOrStdout(), string(data))
 			return queryErr
 		},
 	}

+ 21 - 0
cmd/qmk-rgb-tool/json.go

@@ -0,0 +1,21 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+)
+
+// encodeJSON writes v as two-space indented JSON followed by a newline.
+// Every JSON-emitting command goes through here so their output shape stays
+// identical — consumers should not have to special-case one command.
+func encodeJSON(w io.Writer, v any) error {
+	data, err := json.MarshalIndent(v, "", "  ")
+	if err != nil {
+		return fmt.Errorf("encode json: %w", err)
+	}
+	if _, err := fmt.Fprintln(w, string(data)); err != nil {
+		return err
+	}
+	return nil
+}

+ 120 - 0
cmd/qmk-rgb-tool/keyboard_info_test.go

@@ -0,0 +1,120 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"strings"
+	"testing"
+
+	intdevice "netdome.biz/paul/impact-80/internal/device"
+)
+
+type deviceInfoOutput struct {
+	Devices []intdevice.Device `json:"devices"`
+	Total   int                `json:"total"`
+}
+
+func stubDiscovery(t *testing.T, devices []intdevice.Device) {
+	t.Helper()
+	orig := discoverAll
+	discoverAll = func() ([]intdevice.Device, error) { return devices, nil }
+	t.Cleanup(func() { discoverAll = orig })
+}
+
+// runRealKeyboardInfo executes the shipped command so a re-added
+// human-readable banner is caught, not just changes inside a helper.
+func runRealKeyboardInfo(t *testing.T) (stdout, stderr string, err error) {
+	t.Helper()
+
+	var out, errOut bytes.Buffer
+	origOut, origErr := keyboardInfoCmd.OutOrStdout(), keyboardInfoCmd.ErrOrStderr()
+	keyboardInfoCmd.SetOut(&out)
+	keyboardInfoCmd.SetErr(&errOut)
+	t.Cleanup(func() {
+		keyboardInfoCmd.SetOut(origOut)
+		keyboardInfoCmd.SetErr(origErr)
+	})
+
+	err = keyboardInfoCmd.RunE(keyboardInfoCmd, nil)
+	return out.String(), errOut.String(), err
+}
+
+func TestKeyboardInfoWritesOnlyJSON(t *testing.T) {
+	stubDiscovery(t, []intdevice.Device{
+		{Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Wobkey Impact 80", Known: true},
+	})
+
+	stdout, stderr, err := runRealKeyboardInfo(t)
+	if err != nil {
+		t.Fatalf("keyboard info returned error: %v", err)
+	}
+
+	if stderr != "" {
+		t.Errorf("keyboard info wrote %q to stderr, want no human-readable banner next to the JSON", stderr)
+	}
+
+	var got deviceInfoOutput
+	if err := json.Unmarshal([]byte(stdout), &got); err != nil {
+		t.Fatalf("stdout is not valid JSON: %v (output %q)", err, stdout)
+	}
+	if got.Total != 1 {
+		t.Errorf("total = %d, want 1", got.Total)
+	}
+	if len(got.Devices) != 1 {
+		t.Fatalf("devices = %d entries, want 1", len(got.Devices))
+	}
+	if got.Devices[0].Index != 1 {
+		t.Errorf("devices[0].index = %d, want 1", got.Devices[0].Index)
+	}
+	if got.Devices[0].Path != "/dev/hidraw7" {
+		t.Errorf("devices[0].path = %q, want %q", got.Devices[0].Path, "/dev/hidraw7")
+	}
+}
+
+func TestKeyboardInfoEmitsIndentedJSON(t *testing.T) {
+	stubDiscovery(t, []intdevice.Device{
+		{Index: 1, Path: "/dev/hidraw7", Name: "Wobkey Impact 80", Known: true},
+	})
+
+	stdout, _, err := runRealKeyboardInfo(t)
+	if err != nil {
+		t.Fatalf("keyboard info returned error: %v", err)
+	}
+
+	if !strings.Contains(stdout, "\n  \"devices\": [") {
+		t.Errorf("stdout = %q, want two-space indented JSON like the other commands emit", stdout)
+	}
+	if !strings.HasSuffix(stdout, "\n") {
+		t.Errorf("stdout = %q, want a trailing newline", stdout)
+	}
+}
+
+func TestKeyboardInfoEmitsEmptyArrayNotNull(t *testing.T) {
+	stubDiscovery(t, nil)
+
+	stdout, _, err := runRealKeyboardInfo(t)
+	if err != nil {
+		t.Fatalf("keyboard info returned error: %v", err)
+	}
+
+	if !strings.Contains(stdout, `"devices": []`) {
+		t.Errorf("stdout = %q, want an empty array so consumers can iterate unconditionally", stdout)
+	}
+}
+
+func TestKeyboardInfoPropagatesDiscoveryError(t *testing.T) {
+	orig := discoverAll
+	discoverAll = func() ([]intdevice.Device, error) { return nil, errStub }
+	t.Cleanup(func() { discoverAll = orig })
+
+	_, _, err := runRealKeyboardInfo(t)
+	if err == nil {
+		t.Fatal("keyboard info expected discovery error, got nil")
+	}
+	if !strings.Contains(err.Error(), "enumerate") {
+		t.Errorf("error = %v, want it to wrap the discovery failure", err)
+	}
+}
+
+var errStub = errors.New("enumerate: stub failure")

+ 28 - 38
cmd/qmk-rgb-tool/main.go

@@ -1,8 +1,8 @@
 package main
 
 import (
-	"encoding/json"
 	"fmt"
+	"io"
 	"os"
 
 	"github.com/spf13/cobra"
@@ -51,46 +51,36 @@ var keyboardCmd = &cobra.Command{
 	Short: "Keyboard management commands",
 }
 
+// discoverAll is a seam for tests.
+var discoverAll = device.DiscoverAll
+
+// runKeyboardInfo writes the connected QMK keyboards as JSON. The 1-based
+// index that --device accepts travels in the "index" field, so the command
+// emits no human-readable banner alongside it.
+func runKeyboardInfo(out io.Writer) error {
+	devices, err := discoverAll()
+	if err != nil {
+		return err
+	}
+
+	if devices == nil {
+		devices = []device.Device{}
+	}
+
+	type info struct {
+		Devices []device.Device `json:"devices"`
+		Total   int             `json:"total"`
+	}
+	return encodeJSON(out, info{Devices: devices, Total: len(devices)})
+}
+
 var keyboardInfoCmd = &cobra.Command{
 	Use:   "info",
 	Short: "Discover connected QMK keyboards",
-	Long:  "Scan for connected QMK keyboards (both supported and unknown) and print device info as JSON.",
-	Run: func(cmd *cobra.Command, args []string) {
-		keyboards, err := device.LoadKeyboards()
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-			os.Exit(1)
-		}
-
-		devices, err := device.DiscoverAllQMK(keyboards)
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-			os.Exit(1)
-		}
-
-		if len(devices) == 0 {
-			fmt.Println(`{"devices":[],"unknown":[],"total":0}`)
-			return
-		}
-
-		var known []device.UnknownDevice
-		var unknown []device.UnknownDevice
-		for _, d := range devices {
-			if d.Supported {
-				known = append(known, d)
-			} else {
-				unknown = append(unknown, d)
-			}
-		}
-
-		type Info struct {
-			Devices  []device.UnknownDevice `json:"devices"`
-			Unknown  []device.UnknownDevice `json:"unknown"`
-			Total    int                    `json:"total"`
-		}
-		out := Info{Devices: devices, Unknown: unknown, Total: len(devices)}
-		data, _ := json.MarshalIndent(out, "", "  ")
-		fmt.Println(string(data))
+	Long: "Scan for connected QMK keyboards and print device info as JSON.\n" +
+		"Each device carries the 1-based index that --device accepts.",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return runKeyboardInfo(cmd.OutOrStdout())
 	},
 }
 

+ 1 - 1
cmd/qmk-rgb-tool/mode.go

@@ -26,7 +26,7 @@ func NewModeCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			proto, _, err := OpenDevice()
+			proto, err := OpenDevice()
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 				os.Exit(1)

+ 12 - 34
cmd/qmk-rgb-tool/rgb.go

@@ -21,11 +21,7 @@ type rgbProtocol interface {
 }
 
 var openRGBProtocol = func() (rgbProtocol, error) {
-	proto, _, err := OpenDevice()
-	if err != nil {
-		return nil, err
-	}
-	return proto, nil
+	return OpenDevice()
 }
 
 func forEachSelectedZone(zones []intrgb.Zone, fn func(intrgb.Zone, via.LEDType) error) error {
@@ -80,48 +76,30 @@ func setModeOnZones(proto zoneProtocol, zones []intrgb.Zone, value uint8) error
 	return setValueOnZones(proto, zones, uint8(intrgb.EffectID), value)
 }
 
-func OpenDevice() (*via.Protocol, intdevice.Keyboard, error) {
+// OpenDevice discovers the connected QMK keyboards and opens the one selected
+// by --device. Without --device it only proceeds when exactly one keyboard is
+// connected, so a command can never hit an unintended keyboard.
+func OpenDevice() (*via.Protocol, error) {
 	if _, err := selectedZones(); err != nil {
-		return nil, intdevice.Keyboard{}, err
+		return nil, err
 	}
 
-	keyboards, err := intdevice.LoadKeyboards()
+	devices, err := intdevice.DiscoverAll()
 	if err != nil {
-		return nil, intdevice.Keyboard{}, fmt.Errorf("load keyboards: %w", err)
+		return nil, fmt.Errorf("discover: %w", err)
 	}
 
-	devices, err := intdevice.Discover(keyboards)
+	dev, err := selectDevice(devices, targetDevice)
 	if err != nil {
-		return nil, intdevice.Keyboard{}, fmt.Errorf("discover: %w", err)
-	}
-
-	if len(devices) == 0 {
-		return nil, intdevice.Keyboard{}, fmt.Errorf("no QMK keyboard found")
-	}
-
-	var dev intdevice.Device
-	if targetDevice != "" {
-		for _, d := range devices {
-			if d.Path == targetDevice {
-				dev = d
-				break
-			}
-		}
-		if dev.Path == "" {
-			return nil, intdevice.Keyboard{}, fmt.Errorf("device %s not found", targetDevice)
-		}
-	} else if len(devices) == 1 {
-		dev = devices[0]
-	} else {
-		return nil, intdevice.Keyboard{}, fmt.Errorf("multiple devices found, use --device to specify")
+		return nil, err
 	}
 
 	proto, err := via.New(dev)
 	if err != nil {
-		return nil, intdevice.Keyboard{}, fmt.Errorf("open protocol: %w", err)
+		return nil, fmt.Errorf("open protocol: %w", err)
 	}
 
-	return proto, dev.Keyboard, nil
+	return proto, nil
 }
 
 func lightingEnabled(mode, brightness uint8) bool {

+ 2 - 2
cmd/qmk-rgb-tool/rgb_test.go

@@ -157,7 +157,7 @@ func TestZoneValidationPrecedesDeviceOpening(t *testing.T) {
 	t.Cleanup(func() { targetZone = originalTargetZone })
 	targetZone = "matrix"
 
-	_, _, err := OpenDevice()
+	_, err := OpenDevice()
 	if err == nil {
 		t.Fatal("OpenDevice() expected zone error, got nil")
 	}
@@ -180,7 +180,7 @@ func TestZoneValidationRunsBeforeCommandOpener(t *testing.T) {
 		Use: "probe",
 		RunE: func(*cobra.Command, []string) error {
 			openerCalled = true
-			_, _, err := OpenDevice()
+			_, err := OpenDevice()
 			return err
 		},
 	})

+ 57 - 0
cmd/qmk-rgb-tool/select.go

@@ -0,0 +1,57 @@
+package main
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	intdevice "netdome.biz/paul/impact-80/internal/device"
+)
+
+// selectDevice resolves the --device selector against the discovered
+// keyboards. An empty selector picks the only keyboard when exactly one is
+// connected and fails otherwise, so commands never silently target the wrong
+// keyboard.
+func selectDevice(devices []intdevice.Device, selector string) (intdevice.Device, error) {
+	if len(devices) == 0 {
+		return intdevice.Device{}, fmt.Errorf("no QMK keyboard found")
+	}
+
+	if selector == "" {
+		if len(devices) > 1 {
+			return intdevice.Device{}, fmt.Errorf(
+				"multiple QMK keyboards found, use --device to select:\n%s",
+				formatDeviceList(devices))
+		}
+		return devices[0], nil
+	}
+
+	invalid := fmt.Errorf("invalid --device %q: expected a keyboard number\n%s",
+		selector, formatDeviceList(devices))
+
+	index, err := strconv.Atoi(selector)
+	if err != nil {
+		return intdevice.Device{}, invalid
+	}
+
+	if index < 1 || index > len(devices) {
+		return intdevice.Device{}, invalid
+	}
+
+	return devices[index-1], nil
+}
+
+// formatDeviceList renders the selectable keyboards with the numbers that
+// --device accepts.
+func formatDeviceList(devices []intdevice.Device) string {
+	var b strings.Builder
+	for _, d := range devices {
+		name := d.Name
+		if name == "" {
+			name = "Unknown QMK keyboard"
+		}
+		fmt.Fprintf(&b, "  %d  %s  VID=0x%04x PID=0x%04x  %s\n",
+			d.Index, name, d.VendorID, d.ProductID, d.Path)
+	}
+	return b.String()
+}

+ 123 - 0
cmd/qmk-rgb-tool/select_test.go

@@ -0,0 +1,123 @@
+package main
+
+import (
+	"strings"
+	"testing"
+
+	intdevice "netdome.biz/paul/impact-80/internal/device"
+)
+
+func testDevices() []intdevice.Device {
+	return []intdevice.Device{
+		{Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Wobkey Impact 80", Known: true},
+		{Index: 2, Path: "/dev/hidraw9", VendorID: 0x1234, ProductID: 0x5678, Name: "Unknown QMK keyboard", Known: false},
+	}
+}
+
+func TestSelectDeviceByIndex(t *testing.T) {
+	tests := []struct {
+		selector string
+		wantPath string
+	}{
+		{"1", "/dev/hidraw7"},
+		{"2", "/dev/hidraw9"},
+	}
+
+	for _, tt := range tests {
+		dev, err := selectDevice(testDevices(), tt.selector)
+		if err != nil {
+			t.Errorf("selectDevice(%q) returned error: %v", tt.selector, err)
+			continue
+		}
+		if dev.Path != tt.wantPath {
+			t.Errorf("selectDevice(%q) = %q, want %q", tt.selector, dev.Path, tt.wantPath)
+		}
+	}
+}
+
+func TestSelectDeviceEmptySelectorRequiresSingle(t *testing.T) {
+	single := testDevices()[:1]
+
+	dev, err := selectDevice(single, "")
+	if err != nil {
+		t.Fatalf("selectDevice(single, \"\") returned error: %v", err)
+	}
+	if dev.Path != "/dev/hidraw7" {
+		t.Errorf("selectDevice(single, \"\") = %q, want %q", dev.Path, "/dev/hidraw7")
+	}
+}
+
+func TestSelectDeviceEmptySelectorRejectsMultiple(t *testing.T) {
+	_, err := selectDevice(testDevices(), "")
+	if err == nil {
+		t.Fatal("selectDevice(multiple, \"\") expected error, got nil")
+	}
+	if !strings.Contains(err.Error(), "--device") {
+		t.Errorf("selectDevice(multiple, \"\") error = %q, want it to mention --device", err)
+	}
+	if !strings.Contains(err.Error(), "/dev/hidraw7") || !strings.Contains(err.Error(), "/dev/hidraw9") {
+		t.Errorf("selectDevice(multiple, \"\") error = %q, want it to list both device paths", err)
+	}
+}
+
+func TestSelectDeviceEmptySelectorRejectsNone(t *testing.T) {
+	_, err := selectDevice(nil, "")
+	if err == nil {
+		t.Fatal("selectDevice(nil, \"\") expected error, got nil")
+	}
+	if !strings.Contains(err.Error(), "no QMK keyboard") {
+		t.Errorf("selectDevice(nil, \"\") error = %q, want it to report no QMK keyboard", err)
+	}
+}
+
+func TestSelectDeviceOutOfRange(t *testing.T) {
+	tests := []string{"0", "3", "-1"}
+
+	for _, selector := range tests {
+		_, err := selectDevice(testDevices(), selector)
+		if err == nil {
+			t.Errorf("selectDevice(%q) expected error, got nil", selector)
+		}
+	}
+}
+
+func TestSelectDeviceRejectsNonNumeric(t *testing.T) {
+	tests := []string{"/dev/hidraw7", "abc", "1a", " ", "0x1"}
+
+	for _, selector := range tests {
+		_, err := selectDevice(testDevices(), selector)
+		if err == nil {
+			t.Errorf("selectDevice(%q) expected error, got nil", selector)
+		}
+	}
+}
+
+func TestSelectDeviceReportsMissingKeyboardBeforeBadSelector(t *testing.T) {
+	_, err := selectDevice(nil, "1")
+	if err == nil {
+		t.Fatal("selectDevice(nil, \"1\") expected error, got nil")
+	}
+	if !strings.Contains(err.Error(), "no QMK keyboard") {
+		t.Errorf("selectDevice(nil, \"1\") error = %q, want it to report no QMK keyboard", err)
+	}
+}
+
+func TestSelectDeviceErrorListsDevicesOnSeparateLines(t *testing.T) {
+	_, err := selectDevice(testDevices(), "9")
+	if err == nil {
+		t.Fatal("selectDevice(devices, \"9\") expected error, got nil")
+	}
+	if !strings.Contains(err.Error(), "\n  1  Wobkey Impact 80") {
+		t.Errorf("selectDevice() error = %q, want the device list to start on its own indented line", err)
+	}
+}
+
+func TestFormatDeviceList(t *testing.T) {
+	got := formatDeviceList(testDevices())
+
+	for _, want := range []string{"1", "2", "Wobkey Impact 80", "Unknown QMK keyboard", "/dev/hidraw7", "/dev/hidraw9"} {
+		if !strings.Contains(got, want) {
+			t.Errorf("formatDeviceList() = %q, want it to contain %q", got, want)
+		}
+	}
+}

+ 1 - 1
cmd/qmk-rgb-tool/speed.go

@@ -26,7 +26,7 @@ func NewSpeedCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			proto, _, err := OpenDevice()
+			proto, err := OpenDevice()
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 				os.Exit(1)

+ 88 - 68
internal/device/device.go

@@ -5,24 +5,102 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"sort"
 
 	"netdome.biz/paul/impact-80/internal/hid"
 )
 
-// Keyboard represents a keyboard definition from keyboards.json.
+// Keyboard is a metadata record from keyboards.json.
+// It names a known keyboard, but it is not required for discovery: unknown
+// QMK keyboards are usable too. Fields the tool does not read are ignored,
+// so existing entries may keep carrying them.
 type Keyboard struct {
-	Name       string `json:"name"`
-	VendorID   uint16 `json:"vendorId"`
-	ProductID  uint16 `json:"productId"`
-	Protocol   string `json:"protocol"`
-	VIAVersion int    `json:"viaVersion"`
-	LEDLayout  string `json:"ledLayout"`
+	Name      string `json:"name"`
+	VendorID  uint16 `json:"vendorId"`
+	ProductID uint16 `json:"productId"`
 }
 
-// Device represents a connected keyboard device.
+// Device is a connected QMK Raw HID keyboard.
+// Index is 1-based and is what --device accepts.
 type Device struct {
-	Keyboard
-	Path string
+	Index     int    `json:"index"`
+	Path      string `json:"path"`
+	VendorID  uint16 `json:"vendorId"`
+	ProductID uint16 `json:"productId"`
+	Name      string `json:"name,omitempty"`
+	Known     bool   `json:"known"`
+}
+
+// DiscoverAll returns every connected QMK Raw HID keyboard, ordered
+// deterministically and numbered from 1. Keyboards absent from
+// keyboards.json are included with Known false and no Name.
+func DiscoverAll() ([]Device, error) {
+	infos, err := hid.DiscoverAll()
+	if err != nil {
+		return nil, fmt.Errorf("discover qmk devices: %w", err)
+	}
+
+	known, err := knownKeyboards()
+	if err != nil {
+		// Missing or malformed keyboards.json only costs us names, not
+		// the ability to drive the keyboard.
+		known = nil
+	}
+
+	devices := make([]Device, 0, len(infos))
+	for _, info := range infos {
+		kb, ok := known[deviceKey(info.VendorID, info.ProductID)]
+		devices = append(devices, Device{
+			Path:      info.Path,
+			VendorID:  info.VendorID,
+			ProductID: info.ProductID,
+			Name:      kb.Name,
+			Known:     ok,
+		})
+	}
+
+	return indexDevices(devices), nil
+}
+
+// indexDevices sorts devices deterministically and assigns 1-based indexes.
+// The input slice is left untouched.
+func indexDevices(devices []Device) []Device {
+	sorted := make([]Device, len(devices))
+	copy(sorted, devices)
+
+	sort.SliceStable(sorted, func(i, j int) bool {
+		a, b := sorted[i], sorted[j]
+		switch {
+		case a.VendorID != b.VendorID:
+			return a.VendorID < b.VendorID
+		case a.ProductID != b.ProductID:
+			return a.ProductID < b.ProductID
+		default:
+			return a.Path < b.Path
+		}
+	})
+
+	for i := range sorted {
+		sorted[i].Index = i + 1
+	}
+
+	return sorted
+}
+
+func deviceKey(vendorID, productID uint16) string {
+	return fmt.Sprintf("%04x:%04x", vendorID, productID)
+}
+
+func knownKeyboards() (map[string]Keyboard, error) {
+	keyboards, err := LoadKeyboards()
+	if err != nil {
+		return nil, err
+	}
+	known := make(map[string]Keyboard, len(keyboards))
+	for _, kb := range keyboards {
+		known[deviceKey(kb.VendorID, kb.ProductID)] = kb
+	}
+	return known, nil
 }
 
 // LoadKeyboards reads keyboard definitions from keyboards.json.
@@ -70,61 +148,3 @@ var findKeyboardsJSON = func() (string, error) {
 
 	return "", fmt.Errorf("keyboards.json not found")
 }
-
-// UnknownDevice represents a QMK Raw HID keyboard not yet in keyboards.json.
-type UnknownDevice struct {
-	VendorID  uint16 `json:"vendorId"`
-	ProductID uint16 `json:"productId"`
-	Path      string `json:"path"`
-	Supported bool   `json:"supported"`
-}
-
-// DiscoverAllQMK scans for all connected QMK keyboards (both known and unknown).
-func DiscoverAllQMK(keyboards []Keyboard) ([]UnknownDevice, error) {
-	qmkDevices, err := hid.DiscoverAll()
-	if err != nil {
-		return nil, fmt.Errorf("discover qmk devices: %w", err)
-	}
-
-	known := make(map[string]bool)
-	for _, kb := range keyboards {
-		known[fmt.Sprintf("%04x:%04x", kb.VendorID, kb.ProductID)] = true
-	}
-
-	var results []UnknownDevice
-	for _, q := range qmkDevices {
-		key := fmt.Sprintf("%04x:%04x", q.VendorID, q.ProductID)
-		results = append(results, UnknownDevice{
-			VendorID:  q.VendorID,
-			ProductID: q.ProductID,
-			Path:      q.Path,
-			Supported: known[key],
-		})
-	}
-	return results, nil
-}
-func Discover(keyboards []Keyboard) ([]Device, error) {
-	var found []Device
-	for _, kb := range keyboards {
-		err := hid.Enumerate(kb.VendorID, kb.ProductID, func(info *hid.DeviceInfo) error {
-			found = append(found, Device{
-				Keyboard: kb,
-				Path:     info.Path,
-			})
-			return nil
-		})
-		if err != nil {
-			return nil, fmt.Errorf("enumerate VID=0x%04x PID=0x%04x: %w", kb.VendorID, kb.ProductID, err)
-		}
-	}
-
-	if len(found) > 0 {
-		fmt.Fprintf(os.Stderr, "Discovered %d keyboard(s):\n", len(found))
-		for _, d := range found {
-			fmt.Fprintf(os.Stderr, "  - %s (VID=0x%04x PID=0x%04x) %s\n", d.Name, d.VendorID, d.ProductID, d.Path)
-		}
-	} else {
-		fmt.Fprintln(os.Stderr, "No keyboards found.")
-	}
-	return found, nil
-}

+ 158 - 0
internal/device/device_test.go

@@ -1,11 +1,169 @@
 package device
 
 import (
+	"encoding/json"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 )
 
+func TestIndexDevices(t *testing.T) {
+	tests := []struct {
+		name    string
+		devices []Device
+		want    []string
+	}{
+		{
+			name:    "empty",
+			devices: nil,
+			want:    nil,
+		},
+		{
+			name:    "single",
+			devices: []Device{{Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f}},
+			want:    []string{"/dev/hidraw7"},
+		},
+		{
+			name: "sorted by vendor then product then path",
+			devices: []Device{
+				{Path: "/dev/hidraw9", VendorID: 0x9999, ProductID: 0x0001},
+				{Path: "/dev/hidraw5", VendorID: 0x1111, ProductID: 0x0002},
+				{Path: "/dev/hidraw2", VendorID: 0x1111, ProductID: 0x0001},
+			},
+			want: []string{"/dev/hidraw2", "/dev/hidraw5", "/dev/hidraw9"},
+		},
+		{
+			name: "identical vendor and product sorted by path",
+			devices: []Device{
+				{Path: "/dev/hidrawB", VendorID: 0x36b0, ProductID: 0x309f},
+				{Path: "/dev/hidrawA", VendorID: 0x36b0, ProductID: 0x309f},
+			},
+			want: []string{"/dev/hidrawA", "/dev/hidrawB"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := indexDevices(tt.devices)
+
+			if len(got) != len(tt.want) {
+				t.Fatalf("indexDevices() returned %d devices, want %d", len(got), len(tt.want))
+			}
+			for i, wantPath := range tt.want {
+				if got[i].Path != wantPath {
+					t.Errorf("indexDevices()[%d].Path = %q, want %q", i, got[i].Path, wantPath)
+				}
+				if got[i].Index != i+1 {
+					t.Errorf("indexDevices()[%d].Index = %d, want %d", i, got[i].Index, i+1)
+				}
+			}
+		})
+	}
+}
+
+func TestIndexDevicesDoesNotMutateInput(t *testing.T) {
+	input := []Device{
+		{Path: "/dev/hidraw9", VendorID: 0x9999, ProductID: 0x0001},
+		{Path: "/dev/hidraw2", VendorID: 0x1111, ProductID: 0x0001},
+	}
+
+	indexDevices(input)
+
+	if input[0].Path != "/dev/hidraw9" {
+		t.Errorf("indexDevices() mutated input: input[0].Path = %q, want %q", input[0].Path, "/dev/hidraw9")
+	}
+	if input[0].Index != 0 {
+		t.Errorf("indexDevices() mutated input: input[0].Index = %d, want 0", input[0].Index)
+	}
+}
+
+func TestDeviceJSONFieldNames(t *testing.T) {
+	dev := Device{
+		Index:     1,
+		Path:      "/dev/hidraw7",
+		VendorID:  0x36b0,
+		ProductID: 0x309f,
+		Name:      "Wobkey Impact 80",
+		Known:     true,
+	}
+
+	data, err := json.Marshal(dev)
+	if err != nil {
+		t.Fatalf("Marshal() returned error: %v", err)
+	}
+
+	var got map[string]any
+	if err := json.Unmarshal(data, &got); err != nil {
+		t.Fatalf("Unmarshal() returned error: %v", err)
+	}
+
+	for _, field := range []string{"index", "path", "vendorId", "productId", "name", "known"} {
+		if _, ok := got[field]; !ok {
+			t.Errorf("Device JSON = %s, missing field %q", data, field)
+		}
+	}
+
+	if _, ok := got["supported"]; ok {
+		t.Errorf("Device JSON = %s, must not contain the misleading field \"supported\"", data)
+	}
+}
+
+func TestDeviceJSONOmitsUnnamedName(t *testing.T) {
+	data, err := json.Marshal(Device{Index: 2, Path: "/dev/hidraw9", Known: false})
+	if err != nil {
+		t.Fatalf("Marshal() returned error: %v", err)
+	}
+
+	if strings.Contains(string(data), `"name"`) {
+		t.Errorf("Device JSON = %s, want the name omitted for an unknown keyboard", data)
+	}
+}
+
+func TestKeyboardIgnoresUnknownFields(t *testing.T) {
+	tmpDir := t.TempDir()
+	tmpFile := filepath.Join(tmpDir, "keyboards.json")
+
+	// Entries may still carry fields the tool does not use; loading must
+	// succeed and yield only the fields the code actually reads.
+	testData := `[
+		{"name":"Impact 80","vendorId":14000,"productId":12447,
+		 "protocol":"via","viaVersion":3,"ledLayout":"rgblight","futureField":"ignored"}
+	]`
+
+	if err := os.WriteFile(tmpFile, []byte(testData), 0644); err != nil {
+		t.Fatalf("failed to write test file: %v", err)
+	}
+
+	origFind := findKeyboardsJSON
+	findKeyboardsJSON = func() (string, error) { return tmpFile, nil }
+	defer func() { findKeyboardsJSON = origFind }()
+
+	keyboards, err := LoadKeyboards()
+	if err != nil {
+		t.Fatalf("LoadKeyboards() returned error: %v", err)
+	}
+	if len(keyboards) != 1 {
+		t.Fatalf("LoadKeyboards() returned %d keyboards, want 1", len(keyboards))
+	}
+	if keyboards[0].Name != "Impact 80" {
+		t.Errorf("Name = %q, want %q", keyboards[0].Name, "Impact 80")
+	}
+}
+
+func TestKeyboardJSONOmitsRemovedFields(t *testing.T) {
+	data, err := json.Marshal(Keyboard{Name: "Impact 80", VendorID: 0x36b0, ProductID: 0x309f})
+	if err != nil {
+		t.Fatalf("Marshal() returned error: %v", err)
+	}
+
+	for _, dead := range []string{"protocol", "viaVersion", "ledLayout"} {
+		if strings.Contains(string(data), dead) {
+			t.Errorf("Keyboard JSON = %s, want no %q field", data, dead)
+		}
+	}
+}
+
 func TestLoadKeyboards(t *testing.T) {
 	tmpDir := t.TempDir()
 	tmpFile := filepath.Join(tmpDir, "keyboards.json")

+ 2 - 13
internal/via/protocol.go

@@ -16,27 +16,16 @@ type transport interface {
 // Protocol implements the VIA protocol for communication with a keyboard.
 type Protocol struct {
 	handle transport
-	kb     device.Keyboard
 }
 
 // New creates a new VIA protocol handler for a connected device.
 func New(dev device.Device) (*Protocol, error) {
-	var h *hid.Device
-	var err error
-
-	if dev.Path != "" {
-		h, err = hid.OpenPath(dev.Path)
-	} else {
-		h, err = hid.Open(dev.VendorID, dev.ProductID)
-	}
+	h, err := hid.OpenPath(dev.Path)
 	if err != nil {
 		return nil, fmt.Errorf("open device: %w", err)
 	}
 
-	return &Protocol{
-		handle: h,
-		kb:     dev.Keyboard,
-	}, nil
+	return &Protocol{handle: h}, nil
 }
 
 // Close releases the device handle.

+ 2 - 8
keyboards.json

@@ -2,17 +2,11 @@
   {
     "name": "Wobkey Rainy 75",
     "vendorId": 26214,
-    "productId": 1,
-    "protocol": "via",
-    "viaVersion": 3,
-    "ledLayout": "rgblight"
+    "productId": 1
   },
   {
     "name": "Wobkey Impact 80",
     "vendorId": 14000,
-    "productId": 12447,
-    "protocol": "via",
-    "viaVersion": 3,
-    "ledLayout": "rgblight"
+    "productId": 12447
   }
 ]