Browse Source

Fix Impact 80 QMK Raw HID control

Paul Klumpp 2 weeks ago
parent
commit
d4f4757c68

+ 6 - 6
AGENTS.md

@@ -29,13 +29,13 @@ wobkey rgb info                   # Show current RGB state
 
 ## VIA Protocol Essentials
 
-- Transport: HID reports, `Report ID 0x52`
+- Transport: QMK Raw HID over a 32-byte hidraw report, report number `0`
 - Message types:
-  - `0x01` — Enable/Handshake
-  - `0x02` — Set Value
-  - `0x03` — Get Value
-- RGB runs via QMK `rgblight` subsystem (`LED-Type 0x01`)
-- No existing Go package for VIA/QMK — must implement minimal protocol
+  - `0x07` — Custom set value
+  - `0x08` — Custom get value
+- Impact 80 lighting channels: `0x02` logo, `0x03` backlight, `0x04` side
+- RGB value IDs: brightness `0x01`, effect `0x02`, speed `0x03`, color `0x04`
+- No separate enable handshake is used
 
 ## Keyboards (Known Wobkey VID/PID)
 

+ 16 - 68
PLAN.md

@@ -5,95 +5,43 @@
 - ✅ Go module initialized (`github.com/wobkey/rgb`)
 - ✅ `git init` + AGENTS.md + README.md
 - ✅ Directory structure: `cmd/wobkey/`, `internal/device/`, `internal/hid/`, `internal/rgb/`, `internal/via/`
-- ✅ Pure-Go HID layer: reads `/dev/hidraw*` + parses `/sys/bus/hid/devices/*/uevent` (no cgo, no libudev)
+- ✅ Pure-Go HID layer: reads `/dev/hidraw*`, `/sys/class/hidraw/*/report_descriptor`, and HID metadata (no cgo, no libudev)
 - ✅ Keyboard discovery: detects Impact 80 (VID `0x36B0`, PID `0x309F`) and Rainy 75
 - ✅ All 8 RGB subcommands implemented (effect, brightness, speed, color, mode, enable, disable, info)
-- ✅ `go build` ✅ `go vet` ✅ `go test ./internal/rgb/...` (18/18 pass) ✅ `gofmt` clean
-- ✅ VIA protocol: Enable handshake + Set/Get commands
+- ✅ `go build` ✅ `go vet` ✅ `go test ./...` ✅ `gofmt` clean
+- ✅ QMK v12 Raw HID protocol: custom set/get values with 32-byte reports and response validation
+- ✅ Impact 80 three-zone lighting control (logo, backlight, side)
 - ✅ `keyboards.json` with decimal VID/PID
 
 ## Open Issues
 
-### 1. Linux HID Permissions — BLOCKS ALL FUNCTIONAL TESTING
+### 1. Impact 80 Effect Mapping
 
-All `/dev/hidraw*` are `root:root 600`. The tool can't open them as `paul`.
+The keyboard's official VIA definition uses model-specific effect IDs for its three lighting zones. The generic QMK effect list in `internal/rgb/effects.go` does not yet map names such as `rainbow` to those IDs.
 
-**Action for next session:**
-1. Run: `sudo chown root:adm /dev/hidraw* && sudo chmod 660 /dev/hidraw*`
-2. Or: apply the udev rule from README.md for a permanent fix
-3. Verify: `ls -la /dev/hidraw*` shows group `adm` with `rw` for group
-4. Run: `/tmp/wobkey keyboard info` — should output JSON with the Impact 80
+### 2. Cross-Platform HID Backends
 
-### 2. Test the Keyboard — Needs Step 1 First
+The current transport is Linux-specific (`/dev/hidraw*` and Linux syscalls). macOS and Windows builds need platform-specific HID backends or a shared library.
 
-Once permissions are fixed:
-1. `/tmp/wobkey keyboard info` — verify Impact 80 is discovered
-2. `/tmp/wobkey rgb disable` — test disable
-3. `/tmp/wobkey rgb enable` — test enable
-4. `/tmp/wobkey rgb info` — verify it returns JSON state
-5. `/tmp/wobkey rgb effect rainbow` — test effect switching
-6. `/tmp/wobkey rgb brightness 200` — test brightness
-7. `/tmp/wobkey rgb color ff0000` — test red color
+### 3. Documentation and Release Artifacts
 
-### 3. Color Command — RGB to HSV Conversion
-
-Current implementation is a hack: it treats R→hue, G→saturation with no actual RGB→HSV conversion.
-
-**Action:**
-- Add `rgbto.hsv()` conversion in `internal/rgb/effects.go`
-- R=red channel → HSV H, G=green → HSV S, B=blue → use as V when setting static
-- This makes `wobkey rgb color ff0000` actually red instead of unpredictable
-
-### 4. QMK rgblight Parameter Numbers
-
-Current code uses hardcoded numbers that need verification against QMK spec:
-- `rgb.Mode` (param 0) ✅
-- `rgb.Brightness` (param 1) ✅  
-- `rgb.Speed` (param 2) ✅
-- `rgb.Enable` (param 3) ✅
-- Hue = 26, Sat = 27 — unverified
-- Effect param = 48, effect_sw = 49, bright_set = 50 — missing
-
-**Action:**
-- Add all QMK rgblight param constants to `internal/rgb/effects.go`
-- Map them properly
-
-### 5. Test Coverage
-
-Currently only `internal/rgb/effects_test.go` has tests (18/18 pass).
-
-**Action:**
-- `internal/device/device_test.go` — test `LoadKeyboards` (parse JSON file)
-- `internal/hid/hid_test.go` — test `parseHex` and `splitLines` helper functions
-- `internal/via/protocol_test.go` — mock-based tests for protocol methods
-
-### 6. Cross-Compile Binaries
-
-The pure-Go HID layer should work on macOS/Windows, but needs verification.
-
-**Action:**
-- `GOOS=darwin GOARCH=amd64 go build -o dist/wobkey-darwin-amd64 ./cmd/wobkey/`
-- `GOOS=darwin GOARCH=arm64 go build -o dist/wobkey-darwin-arm64 ./cmd/wobkey/`
-- `GOOS=windows GOARCH=amd64 go build -o dist/wobkey-windows-amd64.exe ./cmd/wobkey/`
-- The Linux HID layer (`/dev/hidraw*`) won't work on macOS/Windows — need conditional compilation or platform-specific HID backends
+Keep protocol documentation, keyboard metadata, and generated graph outputs synchronized with the implementation.
 
 ## Quick Commands for Next Session
 
 ```bash
-# 1. Fix permissions (requires sudo)
-sudo chown root:adm /dev/hidraw* && sudo chmod 660 /dev/hidraw*
-
-# 2. Rebuild with latest code
+# Rebuild with latest code
 go build -o /tmp/wobkey ./cmd/wobkey/
 
-# 3. Test discovery
+# Discover the QMK Raw HID interface
 /tmp/wobkey keyboard info
 
-# 4. Test RGB commands
+# Test RGB state
 /tmp/wobkey rgb disable
-/tmp/wobkey rgb enable
 /tmp/wobkey rgb info
 
-# 5. Run all tests
+# Run all checks
+gofmt -w .
+go vet ./...
 go test ./...
 ```

+ 5 - 4
README.md

@@ -107,8 +107,9 @@ internal/via/     # VIA protocol implementation
 
 ## Protocol
 
-Communicates via the VIA protocol over HID reports (report ID `0x52`). RGB is controlled through the QMK `rgblight` subsystem.
+Communicates via the QMK Raw HID interface (`Usage Page 0xFF60`, `Usage 0x61`) with 32-byte reports. The Linux hidraw transport uses report number `0` followed by the 32-byte QMK payload.
 
-- `0x01` — Enable/Handshake
-- `0x02` — Set Value
-- `0x03` — Get Value
+- `0x07` — Custom set value
+- `0x08` — Custom get value
+- Lighting channels: `0x02` logo, `0x03` backlight, `0x04` side lighting
+- RGB values: brightness `0x01`, effect `0x02`, speed `0x03`, color `0x04`

+ 0 - 5
cmd/wobkey/rgb/brightness.go

@@ -29,11 +29,6 @@ func NewBrightnessCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
 			if err := proto.SetValue(via.RGBLight, uint8(rgb.Brightness), val); err != nil {
 				fmt.Fprintf(os.Stderr, "Error setting brightness: %v\n", err)
 				os.Exit(1)

+ 4 - 18
cmd/wobkey/rgb/color.go

@@ -12,7 +12,7 @@ import (
 func NewColorCmd() *cobra.Command {
 	return &cobra.Command{
 		Use:   "color <hex>",
-		Short: "Set solid color",
+		Short: "Set RGB color",
 		Long:  "Set the RGB color using a 6-digit hex code (e.g. \"ff0000\" for red).",
 		Args:  cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
@@ -29,27 +29,13 @@ func NewColorCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Mode), uint8(rgb.Static)); err != nil {
-				fmt.Fprintf(os.Stderr, "Error setting mode to static: %v\n", err)
-				os.Exit(1)
-			}
-
 			h, s, _ := c.HSV()
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Hue), h); err != nil {
-				fmt.Fprintf(os.Stderr, "Error setting hue: %v\n", err)
-				os.Exit(1)
-			}
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Saturation), s); err != nil {
-				fmt.Fprintf(os.Stderr, "Error setting saturation: %v\n", err)
+			if err := proto.SetColor(via.RGBLight, h, s); err != nil {
+				fmt.Fprintf(os.Stderr, "Error setting color: %v\n", err)
 				os.Exit(1)
 			}
 
-			fmt.Printf("Color set to %s (static mode)\n", args[0])
+			fmt.Printf("Color set to %s\n", args[0])
 		},
 	}
 }

+ 1 - 8
cmd/wobkey/rgb/disable.go

@@ -5,8 +5,6 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	"github.com/wobkey/rgb/internal/rgb"
-	"github.com/wobkey/rgb/internal/via"
 )
 
 func NewDisableCmd() *cobra.Command {
@@ -21,12 +19,7 @@ func NewDisableCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Enable), 0); err != nil {
+			if err := proto.DisableLighting(); err != nil {
 				fmt.Fprintf(os.Stderr, "Error disabling RGB: %v\n", err)
 				os.Exit(1)
 			}

+ 1 - 6
cmd/wobkey/rgb/effect.go

@@ -26,11 +26,6 @@ func NewEffectCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
 			e, err := rgb.ParseEffect(args[0])
 			if err != nil {
 				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -51,7 +46,7 @@ func NewEffectCmd() *cobra.Command {
 				}
 			}
 
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Mode), uint8(e)); err != nil {
+			if err := proto.SetValue(via.RGBLight, uint8(rgb.EffectID), uint8(e)); err != nil {
 				fmt.Fprintf(os.Stderr, "Error setting effect: %v\n", err)
 				os.Exit(1)
 			}

+ 1 - 8
cmd/wobkey/rgb/enable.go

@@ -5,8 +5,6 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	"github.com/wobkey/rgb/internal/rgb"
-	"github.com/wobkey/rgb/internal/via"
 )
 
 func NewEnableCmd() *cobra.Command {
@@ -21,12 +19,7 @@ func NewEnableCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Enable), 1); err != nil {
+			if err := proto.EnableLighting(); err != nil {
 				fmt.Fprintf(os.Stderr, "Error enabling RGB: %v\n", err)
 				os.Exit(1)
 			}

+ 25 - 25
cmd/wobkey/rgb/info.go

@@ -22,31 +22,31 @@ func NewInfoCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
-			var state rgb.State
-
-			resp, err := proto.GetValue(via.RGBLight, uint8(rgb.Brightness))
-			if err == nil && len(resp) >= 5 {
-				state.Brightness = uint8(resp[4])
+			getValue := func(param uint8) (uint8, error) {
+				value, err := proto.GetValue(via.RGBLight, param)
+				if err != nil {
+					return 0, err
+				}
+				if len(value) == 0 {
+					return 0, fmt.Errorf("empty value response")
+				}
+				return value[0], nil
 			}
 
-			resp, err = proto.GetValue(via.RGBLight, uint8(rgb.Speed))
-			if err == nil && len(resp) >= 5 {
-				state.Speed = uint8(resp[4])
+			brightness, err := getValue(uint8(rgb.Brightness))
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "Error getting brightness: %v\n", err)
+				os.Exit(1)
 			}
-
-			resp, err = proto.GetValue(via.RGBLight, uint8(rgb.Mode))
-			if err == nil && len(resp) >= 5 {
-				state.Mode = rgb.Effect(resp[4])
+			speed, err := getValue(uint8(rgb.Speed))
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "Error getting speed: %v\n", err)
+				os.Exit(1)
 			}
-
-			resp, err = proto.GetValue(via.RGBLight, uint8(rgb.Enable))
-			if err == nil && len(resp) >= 5 {
-				state.Enabled = resp[4] != 0
+			mode, err := getValue(uint8(rgb.EffectID))
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "Error getting mode: %v\n", err)
+				os.Exit(1)
 			}
 
 			out := struct {
@@ -55,10 +55,10 @@ func NewInfoCmd() *cobra.Command {
 				Brightness uint8  `json:"brightness"`
 				Speed      uint8  `json:"speed"`
 			}{
-				Enabled:    state.Enabled,
-				Mode:       state.Mode.String(),
-				Brightness: state.Brightness,
-				Speed:      state.Speed,
+				Enabled:    lightingEnabled(mode, brightness),
+				Mode:       rgb.Effect(mode).String(),
+				Brightness: brightness,
+				Speed:      speed,
 			}
 
 			data, _ := json.MarshalIndent(out, "", "  ")

+ 1 - 6
cmd/wobkey/rgb/mode.go

@@ -29,12 +29,7 @@ func NewModeCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
-			if err := proto.SetValue(via.RGBLight, uint8(rgb.Mode), val); err != nil {
+			if err := proto.SetValue(via.RGBLight, uint8(rgb.EffectID), val); err != nil {
 				fmt.Fprintf(os.Stderr, "Error setting mode: %v\n", err)
 				os.Exit(1)
 			}

+ 5 - 1
cmd/wobkey/rgb/rgb.go

@@ -51,6 +51,10 @@ func OpenDevice() (*via.Protocol, intdevice.Keyboard, error) {
 	return proto, dev.Keyboard, nil
 }
 
+func lightingEnabled(mode, brightness uint8) bool {
+	return mode != 0 && brightness > 0
+}
+
 func ParseUint8(s string) (uint8, error) {
 	v, err := strconv.ParseUint(s, 10, 8)
 	if err != nil {
@@ -64,6 +68,6 @@ func Init() *cobra.Command {
 		Use:   "rgb",
 		Short: "RGB lighting commands",
 	}
-	cmd.Flags().StringVar(&targetDevice, "device", "", "HID device path to use")
+	cmd.PersistentFlags().StringVar(&targetDevice, "device", "", "HID device path to use")
 	return cmd
 }

+ 54 - 0
cmd/wobkey/rgb/rgb_test.go

@@ -0,0 +1,54 @@
+package rgb
+
+import (
+	"testing"
+
+	"github.com/spf13/cobra"
+)
+
+func TestLightingEnabledRequiresActiveEffectAndBrightness(t *testing.T) {
+	cases := []struct {
+		name       string
+		mode       uint8
+		brightness uint8
+		want       bool
+	}{
+		{"active", 1, 160, true},
+		{"zero brightness", 1, 0, false},
+		{"disabled effect", 0, 160, false},
+		{"both zero", 0, 0, false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := lightingEnabled(tc.mode, tc.brightness); got != tc.want {
+				t.Errorf("lightingEnabled(%d, %d) = %t, want %t", tc.mode, tc.brightness, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestDeviceFlagIsInheritedByRGBCommands(t *testing.T) {
+	originalTargetDevice := targetDevice
+	t.Cleanup(func() { targetDevice = originalTargetDevice })
+
+	const wantPath = "/dev/hidraw5"
+	ran := false
+
+	cmd := Init()
+	cmd.AddCommand(&cobra.Command{
+		Use: "probe",
+		Run: func(*cobra.Command, []string) { ran = true },
+	})
+	cmd.SetArgs([]string{"--device", wantPath, "probe"})
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("Execute() error = %v", err)
+	}
+	if !ran {
+		t.Fatal("child command did not run")
+	}
+	if targetDevice != wantPath {
+		t.Errorf("targetDevice = %q, want %q", targetDevice, wantPath)
+	}
+}

+ 0 - 5
cmd/wobkey/rgb/speed.go

@@ -29,11 +29,6 @@ func NewSpeedCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			if err := proto.EnableHandshake(); err != nil {
-				fmt.Fprintf(os.Stderr, "Handshake failed: %v\n", err)
-				os.Exit(1)
-			}
-
 			if err := proto.SetValue(via.RGBLight, uint8(rgb.Speed), val); err != nil {
 				fmt.Fprintf(os.Stderr, "Error setting speed: %v\n", err)
 				os.Exit(1)

+ 22 - 11
internal/device/device.go

@@ -22,9 +22,7 @@ type Keyboard struct {
 // Device represents a connected keyboard device.
 type Device struct {
 	Keyboard
-	Path      string
-	VendorID  uint16
-	ProductID uint16
+	Path string
 }
 
 // LoadKeyboards reads keyboard definitions from keyboards.json.
@@ -80,19 +78,32 @@ func Discover(keyboards []Keyboard) ([]Device, error) {
 		return nil, fmt.Errorf("enumerate HID devices: %w", err)
 	}
 
+	fmt.Fprintf(os.Stderr, "DEBUG discover: keyboards=%d, hid_devices=%d\n", len(keyboards), len(devices))
+	for _, kb := range keyboards {
+		fmt.Fprintf(os.Stderr, "DEBUG discover:   kb=%s VID=%d PID=%d\n", kb.Name, kb.VendorID, kb.ProductID)
+	}
+	for _, d := range devices {
+		fmt.Fprintf(os.Stderr, "DEBUG discover:   hid=%s VID=%d PID=%d raw=%t\n", d.Path, d.VendorID, d.ProductID, d.RawHID)
+	}
+
+	return matchKeyboards(keyboards, devices), nil
+}
+
+func matchKeyboards(keyboards []Keyboard, devices []hid.DeviceInfo) []Device {
 	var found []Device
 	for _, kb := range keyboards {
 		for _, d := range devices {
-			if d.VendorID == kb.VendorID && d.ProductID == kb.ProductID {
-				found = append(found, Device{
-					Keyboard:  kb,
-					Path:      d.Path,
-					VendorID:  d.VendorID,
-					ProductID: d.ProductID,
-				})
+			if !d.RawHID || d.VendorID != kb.VendorID || d.ProductID != kb.ProductID {
+				continue
 			}
+			fmt.Fprintf(os.Stderr, "DEBUG discover: MATCH %s %s rawHID=true\n", kb.Name, d.Path)
+			found = append(found, Device{
+				Keyboard: kb,
+				Path:     d.Path,
+			})
 		}
 	}
 
-	return found, nil
+	fmt.Fprintf(os.Stderr, "DEBUG discover: found=%d\n", len(found))
+	return found
 }

+ 23 - 0
internal/device/device_test.go

@@ -4,6 +4,8 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+
+	"github.com/wobkey/rgb/internal/hid"
 )
 
 func TestLoadKeyboards(t *testing.T) {
@@ -72,6 +74,27 @@ func TestLoadKeyboardsInvalid(t *testing.T) {
 	}
 }
 
+func TestMatchKeyboardsSelectsRawHIDInterface(t *testing.T) {
+	keyboards := []Keyboard{{
+		Name:      "Wobkey Impact 80",
+		VendorID:  0x36b0,
+		ProductID: 0x309f,
+	}}
+	devices := []hid.DeviceInfo{
+		{Path: "/dev/hidraw3", VendorID: 0x36b0, ProductID: 0x309f},
+		{Path: "/dev/hidraw4", VendorID: 0x36b0, ProductID: 0x309f, RawHID: true},
+		{Path: "/dev/hidraw5", VendorID: 0x36b0, ProductID: 0x309f},
+	}
+
+	got := matchKeyboards(keyboards, devices)
+	if len(got) != 1 {
+		t.Fatalf("matchKeyboards() returned %d devices, want 1", len(got))
+	}
+	if got[0].Path != "/dev/hidraw4" {
+		t.Errorf("matchKeyboards() path = %q, want /dev/hidraw4", got[0].Path)
+	}
+}
+
 func TestLoadKeyboardsEmptyFile(t *testing.T) {
 	tmpDir := t.TempDir()
 	tmpFile := filepath.Join(tmpDir, "keyboards.json")

+ 47 - 2
internal/hid/hid.go

@@ -1,10 +1,12 @@
 package hid
 
 import (
+	"bytes"
 	"fmt"
 	"os"
 	"strings"
 	"syscall"
+	"time"
 	"unsafe"
 )
 
@@ -36,6 +38,7 @@ type DeviceInfo struct {
 	Path      string
 	VendorID  uint16
 	ProductID uint16
+	RawHID    bool
 }
 
 // OpenPath opens a HID device by path (e.g. "/dev/hidraw0").
@@ -71,7 +74,27 @@ func (d *Device) SendReport(reportID byte, report []byte) (int, error) {
 }
 
 // Read reads a response report.
+var hidReadTimeout = 500 * time.Millisecond
+
 func (d *Device) Read(buf []byte) (int, error) {
+	if len(buf) == 0 {
+		return 0, nil
+	}
+
+	var readSet syscall.FdSet
+	readSet.Bits[d.fd/64] |= 1 << uint(d.fd%64)
+	timeout := syscall.Timeval{
+		Sec:  int64(hidReadTimeout / time.Second),
+		Usec: int64((hidReadTimeout % time.Second) / time.Microsecond),
+	}
+	ready, err := syscall.Select(d.fd+1, &readSet, nil, nil, &timeout)
+	if err != nil {
+		return 0, fmt.Errorf("select: %w", err)
+	}
+	if ready == 0 {
+		return 0, fmt.Errorf("read timeout")
+	}
+
 	n, _, errno := syscall.Syscall(syscall.SYS_READ, uintptr(d.fd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
 	if errno != 0 {
 		return 0, fmt.Errorf("read: %w", errno)
@@ -104,11 +127,19 @@ func DiscoverAll() ([]DeviceInfo, error) {
 
 		data, err := os.ReadFile(sysEventPath)
 		if err != nil {
+			fmt.Fprintf(os.Stderr, "DEBUG: %s: uevent read err: %v\n", entry.Name(), err)
 			continue
 		}
 
 		vid, pid := parseUEVENT(string(data))
 		if vid == 0 || pid == 0 {
+			fmt.Fprintf(os.Stderr, "DEBUG: %s: vid=0x%04X pid=0x%04X (zero!)\n", entry.Name(), vid, pid)
+			continue
+		}
+
+		descriptor, err := os.ReadFile("/sys/class/hidraw/" + entry.Name() + "/device/report_descriptor")
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "DEBUG: %s: report descriptor read err: %v\n", entry.Name(), err)
 			continue
 		}
 
@@ -116,12 +147,26 @@ func DiscoverAll() ([]DeviceInfo, error) {
 			Path:      hidrawPath,
 			VendorID:  vid,
 			ProductID: pid,
+			RawHID:    isRawHIDDescriptor(descriptor),
 		})
 	}
 
+	fmt.Fprintf(os.Stderr, "DEBUG: DiscoverAll done, %d devices\n", len(devices))
 	return devices, nil
 }
 
+func isRawHIDDescriptor(data []byte) bool {
+	inputReport := []byte{
+		0x09, 0x62, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x20, 0x75, 0x08, 0x81, 0x02,
+	}
+	outputReport := []byte{
+		0x09, 0x63, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x20, 0x75, 0x08, 0x91, 0x02,
+	}
+	usage := []byte{0x06, 0x60, 0xff, 0x09, 0x61, 0xa1, 0x01}
+
+	return bytes.Contains(data, usage) && bytes.Contains(data, inputReport) && bytes.Contains(data, outputReport)
+}
+
 func parseUEVENT(s string) (vid, pid uint16) {
 	for _, line := range splitLines(s) {
 		if len(line) >= 25 && line[:12] == "HID_ID=0003:" {
@@ -135,10 +180,10 @@ func parseUEVENT(s string) (vid, pid uint16) {
 			}
 			if colon > 0 && colon+1 < len(data) {
 				if v, e := parseHex(data[:colon]); e == nil {
-					vid = v
+					vid = v & 0xFFFF
 				}
 				if p, e := parseHex(data[colon+1:]); e == nil {
-					pid = p
+					pid = p & 0xFFFF
 				}
 			}
 		}

+ 41 - 0
internal/hid/hid_test.go

@@ -1,9 +1,29 @@
 package hid
 
 import (
+	"os"
 	"testing"
+	"time"
 )
 
+func TestReadTimesOutWithoutResponse(t *testing.T) {
+	reader, writer, err := os.Pipe()
+	if err != nil {
+		t.Fatalf("os.Pipe() error = %v", err)
+	}
+	defer reader.Close()
+	defer writer.Close()
+
+	oldTimeout := hidReadTimeout
+	hidReadTimeout = 10 * time.Millisecond
+	defer func() { hidReadTimeout = oldTimeout }()
+
+	device := Device{fd: int(reader.Fd())}
+	if _, err := device.Read(make([]byte, 1)); err == nil {
+		t.Fatal("Read() error = nil, want timeout")
+	}
+}
+
 func TestParseHex(t *testing.T) {
 	cases := []struct {
 		name  string
@@ -67,6 +87,27 @@ func TestSplitLines(t *testing.T) {
 	}
 }
 
+func TestIsRawHIDDescriptor(t *testing.T) {
+	rawHID := []byte{
+		0x06, 0x60, 0xff, 0x09, 0x61, 0xa1, 0x01,
+		0x09, 0x62, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x20, 0x75, 0x08, 0x81, 0x02,
+		0x09, 0x63, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x20, 0x75, 0x08, 0x91, 0x02,
+		0xc0,
+	}
+	keyboard := []byte{
+		0x05, 0x01, 0x09, 0x06, 0xa1, 0x01,
+		0x05, 0x07, 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00, 0x25, 0x01,
+		0x95, 0x08, 0x75, 0x01, 0x81, 0x02, 0xc0,
+	}
+
+	if !isRawHIDDescriptor(rawHID) {
+		t.Error("isRawHIDDescriptor(rawHID) = false, want true")
+	}
+	if isRawHIDDescriptor(keyboard) {
+		t.Error("isRawHIDDescriptor(keyboard) = true, want false")
+	}
+}
+
 func TestParseUEVENT(t *testing.T) {
 	ueventData := `DRIVER=hid-generic
 HID_ID=0003:000036B0:0000309F

+ 20 - 24
internal/rgb/effects.go

@@ -3,6 +3,7 @@ package rgb
 import (
 	"fmt"
 	"math"
+	"strconv"
 )
 
 // Effect defines an RGB lighting effect.
@@ -113,15 +114,13 @@ func ParseEffect(name string) (Effect, error) {
 type LEDParam uint8
 
 const (
-	Mode         LEDParam = 0
-	Brightness   LEDParam = 1
-	Speed        LEDParam = 2
-	Enable       LEDParam = 3
-	Hue          LEDParam = 26
-	Saturation   LEDParam = 27
-	EffectID     LEDParam = 48
-	EffectSwitch LEDParam = 49
-	BrightSet    LEDParam = 50
+	Brightness LEDParam = 1
+	EffectID   LEDParam = 2
+	Speed      LEDParam = 3
+	ColorValue LEDParam = 4
+
+	Mode   LEDParam = EffectID
+	Enable LEDParam = EffectID
 )
 
 // Color represents an RGB color.
@@ -136,23 +135,20 @@ func ParseHexColor(s string) (Color, error) {
 	if len(s) != 6 {
 		return Color{}, fmt.Errorf("invalid hex color: %s", s)
 	}
-	r := hexDigit(s[0])<<4 + hexDigit(s[1])
-	g := hexDigit(s[2])<<4 + hexDigit(s[3])
-	b := hexDigit(s[4])<<4 + hexDigit(s[5])
-	return Color{R: r, G: g, B: b}, nil
-}
 
-func hexDigit(c byte) uint8 {
-	switch {
-	case c >= '0' && c <= '9':
-		return c - '0'
-	case c >= 'a' && c <= 'f':
-		return c - 'a' + 10
-	case c >= 'A' && c <= 'F':
-		return c - 'A' + 10
-	default:
-		return 0
+	r, err := strconv.ParseUint(s[0:2], 16, 8)
+	if err != nil {
+		return Color{}, fmt.Errorf("invalid hex color: %s", s)
+	}
+	g, err := strconv.ParseUint(s[2:4], 16, 8)
+	if err != nil {
+		return Color{}, fmt.Errorf("invalid hex color: %s", s)
+	}
+	b, err := strconv.ParseUint(s[4:6], 16, 8)
+	if err != nil {
+		return Color{}, fmt.Errorf("invalid hex color: %s", s)
 	}
+	return Color{R: uint8(r), G: uint8(g), B: uint8(b)}, nil
 }
 
 // State represents the current RGB state from the keyboard.

+ 16 - 1
internal/rgb/effects_test.go

@@ -4,6 +4,21 @@ import (
 	"testing"
 )
 
+func TestQMKValueIDs(t *testing.T) {
+	if Brightness != 1 {
+		t.Errorf("Brightness = %d, want 1", Brightness)
+	}
+	if EffectID != 2 {
+		t.Errorf("EffectID = %d, want 2", EffectID)
+	}
+	if Speed != 3 {
+		t.Errorf("Speed = %d, want 3", Speed)
+	}
+	if ColorValue != 4 {
+		t.Errorf("ColorValue = %d, want 4", ColorValue)
+	}
+}
+
 func TestParseEffect(t *testing.T) {
 	cases := []struct {
 		name  string
@@ -81,7 +96,7 @@ func TestParseHexColor(t *testing.T) {
 		{"short", "ff000", Color{0, 0, 0}, true},
 		{"long", "ff00000", Color{0, 0, 0}, true},
 		{"empty", "", Color{0, 0, 0}, true},
-		{"invalid", "gggggg", Color{R: 0, G: 0, B: 0}, false},
+		{"invalid", "gggggg", Color{}, true},
 	}
 
 	for _, tc := range cases {

+ 94 - 21
internal/via/protocol.go

@@ -7,9 +7,15 @@ import (
 	"github.com/wobkey/rgb/internal/hid"
 )
 
+type transport interface {
+	SendReport(reportID byte, report []byte) (int, error)
+	Read(buf []byte) (int, error)
+	Close() error
+}
+
 // Protocol implements the VIA protocol for communication with a keyboard.
 type Protocol struct {
-	handle *hid.Device
+	handle transport
 	kb     device.Keyboard
 }
 
@@ -34,52 +40,119 @@ func (p *Protocol) Close() error {
 	return nil
 }
 
-// ReportID is the VIA report ID.
-const ReportID = 0x52
-
 // Message defines VIA message types.
 type Message uint8
 
 const (
-	Enable Message = 0x01
-	Set    Message = 0x02
-	Get    Message = 0x03
+	CustomSet Message = 0x07
+	CustomGet Message = 0x08
+	Unhandled Message = 0xff
 )
 
 // LEDType defines QMK LED subsystem types.
 type LEDType uint8
 
 const (
-	RGBLight LEDType = 0x01
+	RGBLight  LEDType = 0x02
+	RGBMatrix LEDType = 0x03
+	SideLight LEDType = 0x04
 )
 
-// EnableHandshake sends the enable message to initialize the VIA connection.
-func (p *Protocol) EnableHandshake() error {
-	report := []byte{0x00, byte(Enable), 0x00, 0x00, 0x00}
-	_, err := p.handle.SendReport(report[0], report[1:])
+// SetValue sends a set value command for the given LED type and parameter.
+func (p *Protocol) SetValue(ledType LEDType, param uint8, value uint8) error {
+	report := make([]byte, 32)
+	report[0] = byte(CustomSet)
+	report[1] = byte(ledType)
+	report[2] = param
+	report[3] = value
+
+	if _, err := p.handle.SendReport(0x00, report); err != nil {
+		return err
+	}
+	_, err := p.readResponse(CustomSet, ledType, param)
 	return err
 }
 
-// SetValue sends a set value command for the given LED type and parameter.
-func (p *Protocol) SetValue(ledType LEDType, param uint8, value uint8) error {
-	report := []byte{0x00, byte(Set), byte(ledType), param, value}
-	_, err := p.handle.SendReport(report[0], report[1:])
+func (p *Protocol) DisableLighting() error {
+	for _, channel := range []LEDType{RGBLight, RGBMatrix, SideLight} {
+		if err := p.SetValue(channel, 0x02, 0x00); err != nil {
+			return err
+		}
+		if err := p.SetValue(channel, 0x01, 0x00); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (p *Protocol) EnableLighting() error {
+	for _, channel := range []LEDType{RGBLight, RGBMatrix, SideLight} {
+		if err := p.SetValue(channel, 0x02, 0x01); err != nil {
+			return err
+		}
+		if err := p.SetValue(channel, 0x01, 160); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (p *Protocol) SetColor(ledType LEDType, hue uint8, saturation uint8) error {
+	report := make([]byte, 32)
+	report[0] = byte(CustomSet)
+	report[1] = byte(ledType)
+	report[2] = 0x04
+	report[3] = hue
+	report[4] = saturation
+
+	if _, err := p.handle.SendReport(0x00, report); err != nil {
+		return err
+	}
+	_, err := p.readResponse(CustomSet, ledType, 0x04)
 	return err
 }
 
 // GetValue sends a get value request and reads the response.
 func (p *Protocol) GetValue(ledType LEDType, param uint8) ([]byte, error) {
-	report := []byte{0x00, byte(Get), byte(ledType), param, 0x00}
-	_, err := p.handle.SendReport(report[0], report[1:])
+	report := make([]byte, 32)
+	report[0] = byte(CustomGet)
+	report[1] = byte(ledType)
+	report[2] = param
+
+	_, err := p.handle.SendReport(0x00, report)
 	if err != nil {
 		return nil, fmt.Errorf("send get request: %w", err)
 	}
 
-	buf := make([]byte, 64)
+	buf, err := p.readResponse(CustomGet, ledType, param)
+	if err != nil {
+		return nil, err
+	}
+
+	valueSize := 1
+	if param == 0x04 {
+		valueSize = 2
+	}
+	return append([]byte(nil), buf[3:3+valueSize]...), nil
+}
+
+func (p *Protocol) readResponse(command Message, ledType LEDType, param uint8) ([]byte, error) {
+	buf := make([]byte, 32)
 	n, err := p.handle.Read(buf)
 	if err != nil {
 		return nil, fmt.Errorf("read response: %w", err)
 	}
-
-	return buf[:n], nil
+	if n != len(buf) {
+		return nil, fmt.Errorf("short response: got %d bytes, want 32", n)
+	}
+	if buf[0] == byte(Unhandled) {
+		return nil, fmt.Errorf("unhandled response")
+	}
+	if buf[0] != byte(command) {
+		return nil, fmt.Errorf("unexpected response command: 0x%02x", buf[0])
+	}
+	if buf[1] != byte(ledType) || buf[2] != param {
+		return nil, fmt.Errorf("response value mismatch: got channel 0x%02x value 0x%02x", buf[1], buf[2])
+	}
+	return buf, nil
 }

+ 178 - 0
internal/via/protocol_test.go

@@ -0,0 +1,178 @@
+package via
+
+import (
+	"bytes"
+	"testing"
+)
+
+type fakeTransport struct {
+	reportIDs []byte
+	reports   [][]byte
+	response  []byte
+	readCalls int
+}
+
+func (f *fakeTransport) SendReport(reportID byte, report []byte) (int, error) {
+	f.reportIDs = append(f.reportIDs, reportID)
+	f.reports = append(f.reports, bytes.Clone(report))
+	return len(report), nil
+}
+
+func (f *fakeTransport) Read(buf []byte) (int, error) {
+	f.readCalls++
+	if len(f.response) > 0 {
+		return copy(buf, f.response), nil
+	}
+	if len(f.reports) > 0 {
+		return copy(buf, f.reports[len(f.reports)-1]), nil
+	}
+	return copy(buf, make([]byte, 32)), nil
+}
+
+func (f *fakeTransport) Close() error { return nil }
+
+func TestSetValueUsesQMKRGBLightPayload(t *testing.T) {
+	transport := &fakeTransport{}
+	protocol := Protocol{handle: transport}
+
+	if err := protocol.SetValue(RGBLight, 0x02, 0x00); err != nil {
+		t.Fatalf("SetValue() error = %v", err)
+	}
+
+	want := make([]byte, 32)
+	want[0] = 0x07
+	want[1] = 0x02
+	want[2] = 0x02
+	want[3] = 0x00
+
+	if len(transport.reports) != 1 {
+		t.Fatalf("SendReport() calls = %d, want 1", len(transport.reports))
+	}
+	if transport.reportIDs[0] != 0x00 {
+		t.Errorf("report ID = 0x%02x, want 0x00", transport.reportIDs[0])
+	}
+	if !bytes.Equal(transport.reports[0], want) {
+		t.Errorf("report = %v, want %v", transport.reports[0], want)
+	}
+}
+
+func TestSetValueConsumesQMKResponse(t *testing.T) {
+	response := make([]byte, 32)
+	response[0] = 0x07
+	response[1] = 0x02
+	response[2] = 0x02
+	response[3] = 0x00
+	transport := &fakeTransport{response: response}
+	protocol := Protocol{handle: transport}
+
+	if err := protocol.SetValue(RGBLight, 0x02, 0x00); err != nil {
+		t.Fatalf("SetValue() error = %v", err)
+	}
+	if transport.readCalls != 1 {
+		t.Errorf("Read() calls = %d, want 1", transport.readCalls)
+	}
+}
+
+func TestSetColorUsesQMKColorValue(t *testing.T) {
+	transport := &fakeTransport{}
+	protocol := Protocol{handle: transport}
+
+	if err := protocol.SetColor(RGBLight, 0x2a, 0x80); err != nil {
+		t.Fatalf("SetColor() error = %v", err)
+	}
+
+	want := make([]byte, 32)
+	want[0] = 0x07
+	want[1] = 0x02
+	want[2] = 0x04
+	want[3] = 0x2a
+	want[4] = 0x80
+
+	if !bytes.Equal(transport.reports[0], want) {
+		t.Errorf("report = %v, want %v", transport.reports[0], want)
+	}
+}
+
+func TestDisableLightingUsesAllImpact80Channels(t *testing.T) {
+	transport := &fakeTransport{}
+	protocol := Protocol{handle: transport}
+
+	if err := protocol.DisableLighting(); err != nil {
+		t.Fatalf("DisableLighting() error = %v", err)
+	}
+
+	if len(transport.reports) != 6 {
+		t.Fatalf("SendReport() calls = %d, want 6", len(transport.reports))
+	}
+	wantChannels := []byte{0x02, 0x03, 0x04}
+	for i, channel := range wantChannels {
+		effect := transport.reports[i*2]
+		brightness := transport.reports[i*2+1]
+		if effect[1] != channel || effect[2] != 0x02 || effect[3] != 0x00 {
+			t.Errorf("effect report %d = %v, want channel 0x%02x effect 0", i, effect, channel)
+		}
+		if brightness[1] != channel || brightness[2] != 0x01 || brightness[3] != 0x00 {
+			t.Errorf("brightness report %d = %v, want channel 0x%02x brightness 0", i, brightness, channel)
+		}
+	}
+}
+
+func TestEnableLightingUsesAllImpact80Channels(t *testing.T) {
+	transport := &fakeTransport{}
+	protocol := Protocol{handle: transport}
+
+	if err := protocol.EnableLighting(); err != nil {
+		t.Fatalf("EnableLighting() error = %v", err)
+	}
+
+	if len(transport.reports) != 6 {
+		t.Fatalf("SendReport() calls = %d, want 6", len(transport.reports))
+	}
+	wantChannels := []byte{0x02, 0x03, 0x04}
+	for i, channel := range wantChannels {
+		effect := transport.reports[i*2]
+		brightness := transport.reports[i*2+1]
+		if effect[1] != channel || effect[2] != 0x02 || effect[3] != 0x01 {
+			t.Errorf("effect report %d = %v, want channel 0x%02x effect 1", i, effect, channel)
+		}
+		if brightness[1] != channel || brightness[2] != 0x01 || brightness[3] != 160 {
+			t.Errorf("brightness report %d = %v, want channel 0x%02x brightness 160", i, brightness, channel)
+		}
+	}
+}
+
+func TestGetValueReturnsQMKValueData(t *testing.T) {
+	response := make([]byte, 32)
+	response[0] = 0x08
+	response[1] = 0x02
+	response[2] = 0x01
+	response[3] = 0xc8
+	transport := &fakeTransport{response: response}
+	protocol := Protocol{handle: transport}
+
+	got, err := protocol.GetValue(RGBLight, 0x01)
+	if err != nil {
+		t.Fatalf("GetValue() error = %v", err)
+	}
+	if !bytes.Equal(got, []byte{0xc8}) {
+		t.Errorf("GetValue() = %v, want [192]", got)
+	}
+
+	want := make([]byte, 32)
+	want[0] = 0x08
+	want[1] = 0x02
+	want[2] = 0x01
+	if !bytes.Equal(transport.reports[0], want) {
+		t.Errorf("request = %v, want %v", transport.reports[0], want)
+	}
+}
+
+func TestGetValueRejectsUnhandledResponse(t *testing.T) {
+	response := make([]byte, 32)
+	response[0] = 0xff
+	protocol := Protocol{handle: &fakeTransport{response: response}}
+
+	if _, err := protocol.GetValue(RGBLight, 0x01); err == nil {
+		t.Fatal("GetValue() error = nil, want unhandled response error")
+	}
+}

+ 1 - 1
keyboards.json

@@ -9,7 +9,7 @@
   },
   {
     "name": "Wobkey Impact 80",
-    "vendorId": 13968,
+    "vendorId": 14000,
     "productId": 12447,
     "protocol": "via",
     "viaVersion": 3,