From 386201500e15c8a9df0fc6e8fdd7579961fb3c5e Mon Sep 17 00:00:00 2001 From: Corey Wagehoft Date: Wed, 13 May 2026 08:56:23 -0600 Subject: [PATCH 1/4] fix: windows device recognition --- internal/hidx/transport_windows.go | 161 +++++++++++++++++-- internal/hidx/transport_windows_path_test.go | 86 ++++++++++ 2 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 internal/hidx/transport_windows_path_test.go diff --git a/internal/hidx/transport_windows.go b/internal/hidx/transport_windows.go index e4c5e1c..0e88e20 100644 --- a/internal/hidx/transport_windows.go +++ b/internal/hidx/transport_windows.go @@ -5,6 +5,10 @@ package hidx import ( "errors" "fmt" + "log" + "os" + "regexp" + "strconv" "strings" "syscall" "unsafe" @@ -16,10 +20,14 @@ import ( // No CGO and no third-party DLLs — only hid.dll and setupapi.dll, both // shipped by Windows itself. // -// Enumeration uses the SetupDi* APIs to walk the HID interface class GUID, -// then HidD_GetAttributes / HidD_GetSerialNumberString / HidD_GetProductString -// to populate DeviceInfo. Open issues CreateFile against the device path, -// and the two transfer methods call HidD_GetInputReport / HidD_SetOutputReport. +// Enumeration walks the HID interface class GUID via the SetupDi* APIs. +// VID/PID come from parsing the device interface path string the HID class +// driver constructs from the USB device descriptor — opening the device is +// not required and is the path that previously broke enumeration in user +// contexts where CreateFile on the consumer-control collection was denied. +// String fields (serial / product / manufacturer) are still read by opening +// the device, but their failure is non-fatal: a DeviceInfo with empty strings +// is still returned so the caller can see the device exists. type windowsBackend struct{} func newBackend() Backend { return &windowsBackend{} } @@ -43,6 +51,15 @@ var ( procSetupDiDestroyDeviceInfoList = setupAPIDLL.NewProc("SetupDiDestroyDeviceInfoList") ) +// hidPathVIDPID matches the vid_NNNN / pid_NNNN segments in a Windows HID +// device interface path like +// `\\?\HID#VID_0D8C&PID_0012&MI_03#7&abc&0&0000#{4d1e55b2-...}`. +// Case-insensitive because Windows SDK versions are inconsistent across +// `vid_` / `VID_` / `Vid_`. +// +//nolint:gochecknoglobals // package-level compiled regex per performance.md +var hidPathVIDPID = regexp.MustCompile(`(?i)vid_([0-9a-f]{4}).*pid_([0-9a-f]{4})`) + // SetupDi flags — values from SetupAPI.h. const ( digcfPresent = 0x00000002 @@ -65,6 +82,31 @@ type spDeviceInterfaceData struct { Reserved uintptr } +// parseVIDPIDFromPath extracts the USB VID and PID from a Windows HID device +// interface path. The path is constructed by the HID class driver from the +// USB device descriptor and is reliable without opening the device. +// +// Returns ok=false for paths that don't carry vid_/pid_ markers (e.g. +// Bluetooth HID paths, which use a service-UUID format). +func parseVIDPIDFromPath(path string) (vid, pid uint16, ok bool) { + m := hidPathVIDPID.FindStringSubmatch(path) + if len(m) != 3 { + return 0, 0, false + } + + v, err := strconv.ParseUint(m[1], 16, 16) + if err != nil { + return 0, 0, false + } + + p, err := strconv.ParseUint(m[2], 16, 16) + if err != nil { + return 0, 0, false + } + + return uint16(v), uint16(p), true +} + func (b *windowsBackend) Enumerate(vendorID, productID uint16) ([]DeviceInfo, error) { var hidGUID windows.GUID @@ -84,11 +126,18 @@ func (b *windowsBackend) Enumerate(vendorID, productID uint16) ([]DeviceInfo, er defer procSetupDiDestroyDeviceInfoList.Call(devInfoSet) //nolint:errcheck // best-effort cleanup + var dbg *log.Logger + if os.Getenv("OPENVLM_DEBUG") != "" { + dbg = log.New(os.Stderr, "openvlm: ", 0) + } + results := make([]DeviceInfo, 0, 4) var ( - index uint32 - ifd spDeviceInterfaceData + index uint32 + ifd spDeviceInterfaceData + enumErrors []error + enumerated, matched, stringsRead int ) ifd.CbSize = uint32(unsafe.Sizeof(ifd)) @@ -106,16 +155,60 @@ func (b *windowsBackend) Enumerate(vendorID, productID uint16) ([]DeviceInfo, er } index++ + enumerated++ path, perr := getDeviceInterfacePath(devInfoSet, &ifd) if perr != nil { + enumErrors = append(enumErrors, fmt.Errorf("path at index %d: %w", index-1, perr)) + continue } - // Best-effort: try to open the device read-only just to query its - // attributes; if that fails (permissions, in-use), skip it. - info, ierr := queryDevice(path) - if ierr != nil { + // Primary identification: parse VID/PID from the device path. The + // HID class driver constructs the path from the USB device + // descriptor, so this works without ever opening the device — the + // path that previously failed silently when CreateFile was denied. + if pathVID, pathPID, ok := parseVIDPIDFromPath(path); ok { + if vendorID != 0 && pathVID != vendorID { + continue + } + + if productID != 0 && pathPID != productID { + continue + } + + matched++ + + info := DeviceInfo{ + Path: path, + VendorID: pathVID, + ProductID: pathPID, + } + + // Best-effort: read string fields. The Backend contract + // promises devices the caller can't fully query are still + // returned, so a failure here only annotates enumErrors — + // the DeviceInfo still goes into results. + if got, serr := queryDeviceStrings(path); serr == nil { + info.SerialNumber = got.SerialNumber + info.ProductName = got.ProductName + info.ManufacturerName = got.ManufacturerName + stringsRead++ + } else { + enumErrors = append(enumErrors, fmt.Errorf("read strings %s: %w", path, serr)) + } + + results = append(results, info) + + continue + } + + // Fallback for non-USB HID paths (Bluetooth, virtual HID, etc.): + // open the device and use HidD_GetAttributes for VID/PID. + info, qerr := queryDevice(path) + if qerr != nil { + enumErrors = append(enumErrors, fmt.Errorf("query %s: %w", path, qerr)) + continue } @@ -128,9 +221,21 @@ func (b *windowsBackend) Enumerate(vendorID, productID uint16) ([]DeviceInfo, er } info.Path = path + matched++ + stringsRead++ results = append(results, info) } + if dbg != nil { + dbg.Printf("enumerated %d HID interface paths, matched %d by VID/PID, read strings for %d", + enumerated, matched, stringsRead) + } + + if len(results) == 0 && len(enumErrors) > 0 { + return nil, fmt.Errorf("hidx: no devices found; %d enumeration error(s): %w", + len(enumErrors), errors.Join(enumErrors...)) + } + return results, nil } @@ -257,7 +362,9 @@ func getDeviceInterfacePath(devInfoSet uintptr, ifd *spDeviceInterfaceData) (str } // queryDevice opens the HID device just long enough to read its attributes -// and identifying strings. Closes its own handle before returning. +// and identifying strings. Closes its own handle before returning. Used as +// the fallback for non-USB HID paths (Bluetooth, virtual HID, etc.) where +// the path itself doesn't encode VID/PID. func queryDevice(path string) (DeviceInfo, error) { pathPtr, err := windows.UTF16PtrFromString(path) if err != nil { @@ -296,6 +403,38 @@ func queryDevice(path string) (DeviceInfo, error) { }, nil } +// queryDeviceStrings opens the HID device just long enough to read its +// serial / product / manufacturer strings. Called when VID/PID have already +// been parsed from the device path, so HidD_GetAttributes is not needed. +// Failure here is non-fatal at the caller — strings are best-effort. +func queryDeviceStrings(path string) (DeviceInfo, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return DeviceInfo{}, err //nolint:wrapcheck // direct pass-through of UTF16 error + } + + handle, err := windows.CreateFile( + pathPtr, + 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + 0, + 0, + ) + if err != nil { + return DeviceInfo{}, fmt.Errorf("CreateFile: %w", err) + } + + defer windows.CloseHandle(handle) //nolint:errcheck // best-effort cleanup + + return DeviceInfo{ + SerialNumber: readHidString(handle, procHidDGetSerialNumberString), + ProductName: readHidString(handle, procHidDGetProductString), + ManufacturerName: readHidString(handle, procHidDGetManufacturerString), + }, nil +} + func readHidString(handle windows.Handle, proc *windows.LazyProc) string { const max = 256 diff --git a/internal/hidx/transport_windows_path_test.go b/internal/hidx/transport_windows_path_test.go new file mode 100644 index 0000000..cd07431 --- /dev/null +++ b/internal/hidx/transport_windows_path_test.go @@ -0,0 +1,86 @@ +//go:build windows + +package hidx + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseVIDPIDFromPath(t *testing.T) { + tests := []struct { + name string + path string + wantVID uint16 + wantPID uint16 + wantOK bool + }{ + { + name: "openvlm uppercase", + path: `\\?\HID#VID_0D8C&PID_0012&MI_03#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantVID: 0x0D8C, + wantPID: 0x0012, + wantOK: true, + }, + { + name: "openvlm lowercase", + path: `\\?\hid#vid_0d8c&pid_0012&mi_03#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantVID: 0x0D8C, + wantPID: 0x0012, + wantOK: true, + }, + { + name: "mixed case", + path: `\\?\HID#Vid_0D8C&Pid_0012&MI_03#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantVID: 0x0D8C, + wantPID: 0x0012, + wantOK: true, + }, + { + name: "different vendor", + path: `\\?\HID#VID_046D&PID_C52B&MI_01#7&xyz&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantVID: 0x046D, + wantPID: 0xC52B, + wantOK: true, + }, + { + name: "no vid pid markers", + path: `\\?\HID#{00001812-0000-1000-8000-00805f9b34fb}#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantOK: false, + }, + { + name: "vid only no pid", + path: `\\?\HID#VID_0D8C&MI_03#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantOK: false, + }, + { + name: "pid only no vid", + path: `\\?\HID#PID_0012&MI_03#7&abc&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + wantOK: false, + }, + { + name: "empty", + path: "", + wantOK: false, + }, + { + name: "short hex", + path: `\\?\HID#VID_0D8&PID_0012`, + wantOK: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + vid, pid, ok := parseVIDPIDFromPath(tc.path) + assert.Equal(t, tc.wantOK, ok) + + if tc.wantOK { + assert.Equal(t, tc.wantVID, vid) + assert.Equal(t, tc.wantPID, pid) + } + }) + } +} From 2d6e2fc1f930ea36059b4fc51a53ba364e77188d Mon Sep 17 00:00:00 2001 From: Corey Wagehoft Date: Wed, 13 May 2026 19:16:51 -0600 Subject: [PATCH 2/4] fix: windows functionality --- CLAUDE.md | 5 +- Makefile | 4 + README.md | 6 +- cmd/device.go | 4 +- cmd/dump.go | 7 +- cmd/identify.go | 2 +- cmd/integration_test.go | 10 +- cmd/list.go | 16 ++- cmd/messages.go | 120 ++++++++++--------- cmd/messages_test.go | 34 +++--- cmd/wipe.go | 2 +- docs/cli-reference.md | 44 +++---- internal/cm108/identity_test.go | 2 +- internal/cm108/ids.go | 2 +- internal/eeprom/defaults.go | 2 +- internal/hidx/transport_windows.go | 36 ++++-- internal/hidx/transport_windows_path_test.go | 26 ++++ main.go | 2 +- 18 files changed, 196 insertions(+), 128 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1723b81..95590c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Project-specific guidance for Claude Code working in this repository. Read this ## What this project is -`openvlm` is a cross-platform CLI for reading, writing, and validating the EEPROM on **OpenVLM USB audio dongles**. The hardware is a **C-Media CM108B** USB audio chip wired to a 93C46 SPI EEPROM, with a GPIO1 hardware strap that distinguishes OpenVLM-branded dongles from generic CM108-family devices. +`openvlm` is a cross-platform CLI for reading, writing, and validating the EEPROM on **OpenVLM USB audio devices**. The hardware is a **C-Media CM108B** USB audio chip wired to a 93C46 SPI EEPROM, with a GPIO1 hardware strap that distinguishes OpenVLM-branded devices from generic CM108-family devices. The CLI talks to the chip exclusively over USB-HID class control transfers (`Get_Input_Report` / `Set_Output_Report`) — there is no kernel driver, no vendor blob, and no platform-specific cable. The CM108B datasheet §7.4 documents the HID protocol; §7.1.3 documents the EEPROM word layout. @@ -15,7 +15,7 @@ These facts are not in the datasheet but are required to read the code correctly - **Stock chips ship with a blank EEPROM.** The strings `C-Media Electronics Inc.` / `USB Audio Device` come from the chip's internal ROM, not from EEPROM bytes. There is no "factory image" to read off a virgin device. - **VID/PID are write-locked in the CLI.** Both are sourced from compiled-in constants (`cm108.OpenVLMVendorID = 0x0D8C`, `cm108.OpenVLMProductID = 0x0012`). Programming a different VID/PID would prevent this CLI from finding the device, so YAML, `update`, and per-field flags all refuse to set them. - **Product/manufacturer strings are also write-locked** to the compiled-in `OpenVLMDefaults` (`OpenVLM` / `BuildsByShane`). Same reason — these are identity, not configuration. -- **GPIO1 strap is the OpenVLM identity probe.** A generic CM108-family dongle has GPIO1 floating low; an OpenVLM dongle pulls it high. `openvlm identify` and the `requireOpenVLM` gate on every write verb check this. `--force` bypasses for bench / bootstrap work. +- **GPIO1 strap is the OpenVLM identity probe.** A generic CM108-family device has GPIO1 floating low; an OpenVLM device pulls it high. `openvlm identify` and the `requireOpenVLM` gate on every write verb check this. `--force` bypasses for bench / bootstrap work. - **macOS IOKit returns transient `kIOReturnError` (0xE00002BC)** under host-controller pressure during back-to-back HID reports. The protocol layer absorbs this with bounded retries; do not remove the `transferRetries` / `verifyRetries` machinery without a replacement. ## Datasheet pinning issues (open) @@ -102,6 +102,7 @@ Project-level rules live in [.claude/rules/](.claude/rules/) and are authoritati Project-specific additions: +- **Terminology: never use "dongle" in user-facing text or doc comments.** Refer to the hardware as an "OpenVLM device" (or just "device"). Applies to CLI strings, help text, error messages, docs, and Go-doc comments. Internal symbol names (`OpenVLM` etc.) are unaffected. - **VID/PID and product/manufacturer strings are write-locked.** Do not add CLI surface to set them — every input layer (YAML, per-field flags, `update`) must reject them with a fixed error message. - **`WriteImage` enforces the VID/PID guard.** `WipeAll` is the only documented bypass and exists exclusively for the wipe verb. - **Decimal only for numeric input.** `update`, per-field flags, and YAML reject `0x` / `0b` / `0o` prefixes with `ErrHexInput`. diff --git a/Makefile b/Makefile index cad1efe..e3eadf3 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ build: fmt vet ## Build the openvlm CLI binary. cgobuild: fmt vet ## Build the openvlm CLI binary with CGO_ENABLED=1. CGO_ENABLED=1 GOCACHE=$(HOME)/.gocache go build -trimpath -buildvcs=false -ldflags="-s -w" -o bin/openvlm . +.PHONY: build-windows +build-windows: fmt vet ## Build the openvlm CLI binary. + GOOS=windows CGO_ENABLED=0 GOCACHE=$(HOME)/.gocache go build -trimpath -buildvcs=false -ldflags="-s -w" -o bin/openvlm.exe . + .PHONY: run run: fmt vet ## Run the CLI from your host (forwards args via ARGS=...). go run . $(ARGS) diff --git a/README.md b/README.md index 20739aa..57990a6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # OpenVLM -Cross-platform CLI for reading, writing, and validating the EEPROM on **OpenVLM USB audio dongles**. +Cross-platform CLI for reading, writing, and validating the EEPROM on **OpenVLM USB audio devices**. -OpenVLM dongles are based on the **C-Media CM108B** USB audio chip wired to a 93C46 SPI EEPROM, with a GPIO1 hardware strap that distinguishes OpenVLM-branded hardware from generic CM108-family devices. The `openvlm` CLI talks to the chip exclusively over USB-HID class control transfers — no kernel driver, no vendor blob, no platform-specific cable. The same binary runs on Linux, macOS, and Windows. +OpenVLM devices are based on the **C-Media CM108B** USB audio chip wired to a 93C46 SPI EEPROM, with a GPIO1 hardware strap that distinguishes OpenVLM-branded hardware from generic CM108-family devices. The `openvlm` CLI talks to the chip exclusively over USB-HID class control transfers — no kernel driver, no vendor blob, no platform-specific cable. The same binary runs on Linux, macOS, and Windows. ## Install @@ -17,7 +17,7 @@ make build # produces bin/openvlm ## Quick start ```bash -openvlm list # find attached dongles +openvlm list # find attached devices openvlm identify # confirm GPIO1 strap openvlm provision --serial "00001234" # write OpenVLM defaults openvlm dump --format yaml # inspect live EEPROM diff --git a/cmd/device.go b/cmd/device.go index 8e3f47f..a5b5f4c 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -72,9 +72,9 @@ func requireOpenVLM(d cm108.Descriptor, force bool) error { name := displayName(d.SerialNumber, d.Path) - reason := "this doesn't look like an OpenVLM dongle (its identity bit isn't set)" + reason := "this doesn't look like an OpenVLM device (its identity bit isn't set)" if d.ProbeError != nil { - reason = "couldn't check the dongle's identity bit: " + d.ProbeError.Error() + reason = "couldn't check the device's identity bit: " + d.ProbeError.Error() } return ¬IdentifiedError{ diff --git a/cmd/dump.go b/cmd/dump.go index cefb616..01ba734 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -72,8 +72,13 @@ func printDumpText(cmd *cobra.Command, d cm108.Descriptor, img *eeprom.Image) er out := cmd.OutOrStdout() + device := displayName(d.SerialNumber, d.Path) + if flagVerbose { + device = d.Path + } + fmt.Fprintf(out, "DEVICE %s (serial=%s, openvlm=%s)\n", - d.Path, displaySerial(d.SerialNumber), openvlm) + device, displaySerial(d.SerialNumber), openvlm) fmt.Fprintf(out, "VID:PID 0x%04X:0x%04X (read-only)\n", img.VID(), img.PID()) fmt.Fprintf(out, "PRODUCT-STRING %s\n", view.ProductString) fmt.Fprintf(out, "MANUFACTURER %s\n", view.ManufacturerString) diff --git a/cmd/identify.go b/cmd/identify.go index 4301342..0fc337d 100644 --- a/cmd/identify.go +++ b/cmd/identify.go @@ -32,7 +32,7 @@ func runIdentify(cmd *cobra.Command, _ []string) error { if !d.IsOpenVLM { return ¬IdentifiedError{ - err: fmt.Errorf("%s: this doesn't look like an OpenVLM dongle (its identity bit isn't set)", + err: fmt.Errorf("%s: this doesn't look like an OpenVLM device (its identity bit isn't set)", name), } } diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 0601a16..31b6223 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -357,7 +357,7 @@ func TestIdentify_FriendlySuccess(t *testing.T) { stdout, _, err := runRoot(t, "identify") require.NoError(t, err) - assert.Contains(t, stdout, "this is an OpenVLM dongle") + assert.Contains(t, stdout, "this is an OpenVLM device") } // TestUpdate_FriendlySuccess pins the updated-field line. @@ -396,9 +396,9 @@ func TestProvision_DryRunFriendlyMessage(t *testing.T) { assert.Contains(t, stderr, "No changes made.") } -// TestNoDongles_FriendlyError verifies the cm108.ErrNoDevice translation +// TestNoDevices_FriendlyError verifies the cm108.ErrNoDevice translation // fires end-to-end, with the action hint appended. -func TestNoDongles_FriendlyError(t *testing.T) { +func TestNoDevices_FriendlyError(t *testing.T) { // Empty fake backend — no devices registered. prev := SetBackend(hidx.NewFakeBackend()) @@ -411,7 +411,7 @@ func TestNoDongles_FriendlyError(t *testing.T) { require.Error(t, err) got := friendlyError(err, false) - assert.Contains(t, got, "No OpenVLM dongles are plugged in") + assert.Contains(t, got, "No OpenVLM devices are plugged in") assert.Contains(t, got, "Plug one in") } @@ -427,7 +427,7 @@ func TestStrapLow_FriendlyError(t *testing.T) { require.Error(t, err) got := friendlyError(err, false) - assert.Contains(t, got, "this doesn't look like an OpenVLM dongle") + assert.Contains(t, got, "this doesn't look like an OpenVLM device") } // TestVerbose_PreservesInternalDetail confirms --verbose surfaces the diff --git a/cmd/list.go b/cmd/list.go index ad706d4..7a024b1 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -32,7 +32,15 @@ func runList(cmd *cobra.Command, _ []string) error { w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "Serial\tDevice\tOpenVLM?\tNotes") + // Default mode keeps the table compact and free of platform-specific + // device-path noise. The OS path is only useful for disambiguation when + // multiple devices have no serial number — surface it via --verbose so + // it doesn't pollute the common single-device case. + if flagVerbose { + fmt.Fprintln(w, "Serial\tOpenVLM?\tPath\tNotes") + } else { + fmt.Fprintln(w, "Serial\tOpenVLM?\tNotes") + } for _, d := range descs { note := "" @@ -50,7 +58,11 @@ func runList(cmd *cobra.Command, _ []string) error { serial = "-" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", serial, d.Path, strap, note) + if flagVerbose { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", serial, strap, d.Path, note) + } else { + fmt.Fprintf(w, "%s\t%s\t%s\n", serial, strap, note) + } } return w.Flush() //nolint:wrapcheck // tabwriter.Flush errors are passthrough diff --git a/cmd/messages.go b/cmd/messages.go index 2f1e262..92c6b1c 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -23,13 +23,13 @@ import ( // ===================================================================== const ( - shortRoot = "Read, write, and validate the configuration on OpenVLM USB dongles" + shortRoot = "Read, write, and validate the configuration on OpenVLM USB devices" longRoot = `openvlm reads, writes, and validates the configuration on OpenVLM -USB audio dongles. +USB audio devices. What you can do: - openvlm list show every dongle plugged in - openvlm identify check if a dongle is OpenVLM + openvlm list show every device plugged in + openvlm identify check if a device is OpenVLM openvlm read save the configuration to a file openvlm dump show the configuration openvlm write load a configuration from a file @@ -42,24 +42,24 @@ Run 'openvlm --help' for details on each verb. Add --verbose (-v) to any command for more technical detail in errors, warnings, and diagnostics.` - flagSerialHelp = "pick the dongle whose USB serial-number string matches this value" + flagSerialHelp = "pick the device whose USB serial-number string matches this value" flagVerboseHelp = "show technical detail in errors, warnings, and diagnostics" useList = "list" - shortList = "Show every OpenVLM dongle plugged in" - longList = `Lists every USB audio dongle plugged in that looks like an OpenVLM. + shortList = "Show every OpenVLM device plugged in" + longList = `Lists every USB audio device plugged in that looks like an OpenVLM. -For each dongle it shows the serial number, the device path, whether -the dongle is confirmed as OpenVLM hardware, and any error from probing. +For each device it shows the serial number, the device path, whether +the device is confirmed as OpenVLM hardware, and any error from probing. Example: openvlm list -Exits 0 if at least one matching dongle was found, 1 if none were.` +Exits 0 if at least one matching device was found, 1 if none were.` useIdentify = "identify" - shortIdentify = "Check if a dongle is OpenVLM (exit 0 if yes, 3 if no)" - longIdentify = `Checks whether the selected dongle is an OpenVLM (its identity + shortIdentify = "Check if a device is OpenVLM (exit 0 if yes, 3 if no)" + longIdentify = `Checks whether the selected device is an OpenVLM (its identity bit is set). Useful in shell scripts: @@ -69,13 +69,13 @@ Useful in shell scripts: fi Exit codes: - 0 the dongle is OpenVLM - 1 no dongle plugged in, permission denied, or other error - 3 a dongle is plugged in but it isn't OpenVLM` + 0 the device is OpenVLM + 1 no device plugged in, permission denied, or other error + 3 a device is plugged in but it isn't OpenVLM` useRead = "read" - shortRead = "Save the dongle's configuration to a file (or stdout)" - longRead = `Saves the dongle's full configuration to a file or pipes it to stdout. + shortRead = "Save the device's configuration to a file (or stdout)" + longRead = `Saves the device's full configuration to a file or pipes it to stdout. The output is 128 bytes of raw binary. You can pipe it back through 'openvlm write -i ' to restore an exact copy. @@ -87,8 +87,8 @@ Examples: flagReadOutputHelp = "file to write the 128-byte image to (default: stdout)" useDump = "dump" - shortDump = "Show the dongle's configuration as YAML, text, or hex" - longDump = `Reads the dongle's configuration and shows it in a readable form. + shortDump = "Show the device's configuration as YAML, text, or hex" + longDump = `Reads the device's configuration and shows it in a readable form. Formats: yaml editable config you can pipe back into 'openvlm write -i -' (default) @@ -103,15 +103,15 @@ Examples: flagDumpFormatHelp = "output format: yaml | text | hex" useWrite = "write" - shortWrite = "Load a configuration onto the dongle (binary or YAML)" - longWrite = `Writes a full configuration to the dongle. + shortWrite = "Load a configuration onto the device (binary or YAML)" + longWrite = `Writes a full configuration to the device. Accepts two input forms: - a 128-byte binary backup from 'openvlm read' - a YAML config (full or partial) — missing fields are filled from the OpenVLM defaults -Every value is checked before any byte hits the dongle, and each word +Every value is checked before any byte hits the device, and each word is read back after writing to make sure it stuck. Examples: @@ -121,22 +121,22 @@ Examples: openvlm dump --format yaml | openvlm write -i - A few fields can't be changed — VID, PID, product-string, and -manufacturer-string are part of the dongle's identity. The --force -flag is only needed for fresh dongles whose identity bit hasn't been +manufacturer-string are part of the device's identity. The --force +flag is only needed for fresh devices whose identity bit hasn't been set yet.` flagWriteInputHelp = "input file: 128-byte raw .bin (matches 'read' output) or YAML " + "(matches 'dump --format yaml'); use - for stdin" - flagWriteForceHelp = "program a dongle even if its identity bit isn't set yet" + flagWriteForceHelp = "program a device even if its identity bit isn't set yet" useUpdate = "update " - shortUpdate = "Change one setting on the dongle" + shortUpdate = "Change one setting on the device" - flagUpdateForceHelp = "program a dongle even if its identity bit isn't set yet" + flagUpdateForceHelp = "program a device even if its identity bit isn't set yet" useProvision = "provision" shortProvision = "Apply the OpenVLM factory defaults, with optional overrides" - longProvision = `Writes the OpenVLM factory defaults to the dongle, with optional + longProvision = `Writes the OpenVLM factory defaults to the device, with optional overrides. Overrides come in two layers (later wins over earlier): @@ -149,35 +149,35 @@ Examples: openvlm provision --overrides factory.yaml openvlm provision --overrides factory.yaml --dac-init-volume -6 openvlm provision --dry-run # preview without writing - openvlm provision --force # fresh dongle, skip safety check + openvlm provision --force # fresh device, skip safety check A few values can't be overridden — VID, PID, product-string, and -manufacturer-string are part of the dongle's identity. Bad values are -caught before any byte hits the dongle.` +manufacturer-string are part of the device's identity. Bad values are +caught before any byte hits the device.` flagProvisionOverridesHelp = "YAML file whose keys override the factory defaults" - flagProvisionDryRunHelp = "preview the configuration without writing it to the dongle" - flagProvisionForceHelp = "program a dongle even if its identity bit isn't set yet" + flagProvisionDryRunHelp = "preview the configuration without writing it to the device" + flagProvisionForceHelp = "program a device even if its identity bit isn't set yet" useWipe = "wipe" - shortWipe = "Erase the dongle's configuration" - longWipe = `Erases everything in the dongle's configuration memory. + shortWipe = "Erase the device's configuration" + longWipe = `Erases everything in the device's configuration memory. -After a wipe, the dongle behaves like a brand-new chip: +After a wipe, the device behaves like a brand-new chip: - it identifies as a generic CM108 (not OpenVLM) - re-provisioning it will require --force Safety: --yes is required. There's no interactive prompt. - --force is required if the dongle isn't already confirmed as OpenVLM. + --force is required if the device isn't already confirmed as OpenVLM. Examples: openvlm wipe --yes openvlm wipe --yes --pattern 00 - openvlm wipe --yes --force # for fresh or post-wipe dongles` + openvlm wipe --yes --force # for fresh or post-wipe devices` flagWipeYesHelp = "required confirmation — wipe refuses to run without this" - flagWipeForceHelp = "wipe a dongle even if its identity bit isn't set yet" + flagWipeForceHelp = "wipe a device even if its identity bit isn't set yet" flagWipePatternHelp = "byte pattern to write to every word: FF (default, factory-blank) or 00" ) @@ -186,7 +186,7 @@ Examples: // constant expression. // //nolint:gochecknoglobals // package-level help text built at init -var longUpdate = `Changes one setting on the dongle and writes the result back. +var longUpdate = `Changes one setting on the device and writes the result back. Values are entered as plain decimal numbers, true/false, or named values: @@ -201,25 +201,27 @@ Available fields: ` + eeprom.FieldList() + ` A few fields can't be changed — VID, PID, product-string, and -manufacturer-string are part of the dongle's identity.` +manufacturer-string are part of the device's identity.` // ===================================================================== // Success and progress messages // ===================================================================== -// displayName is the user-facing name for a dongle. Prefer the USB serial -// number (stable across plug-in/plug-out) over the platform-specific device -// path; fall back to the path if the dongle has no serial. -func displayName(serial, path string) string { +// displayName is the user-facing name for a device. Prefer the USB serial +// number (stable across plug-in/plug-out); fall back to a generic "OpenVLM +// device" label when the device has no serial, since the OS device path is +// noisy on Windows (`\\?\hid#vid_...#{guid}`) and not useful to most users. +// Pass --verbose to surface the path on error. +func displayName(serial, _ string) string { if serial != "" { return serial } - return path + return "OpenVLM device" } func msgIdentified(name string) string { - return fmt.Sprintf("%s: confirmed — this is an OpenVLM dongle.", name) + return fmt.Sprintf("%s: confirmed — this is an OpenVLM device.", name) } func msgWritten(name string, n int) string { @@ -246,8 +248,8 @@ func msgReadComplete(name string, n int, dest string) string { return fmt.Sprintf("%s: saved %d bytes to %s.", name, n, dest) } -func msgNoDongles() string { - return "No OpenVLM dongles are plugged in." +func msgNoDevices() string { + return "No OpenVLM devices are plugged in." } // ===================================================================== @@ -255,7 +257,7 @@ func msgNoDongles() string { // ===================================================================== func msgForceWarning() string { - return "Warning: dongle's identity bit isn't set; continuing because --force was passed." + return "Warning: device's identity bit isn't set; continuing because --force was passed." } // msgChipBlank is the warning printed by 'dump --format text' when the chip @@ -263,11 +265,11 @@ func msgForceWarning() string { func msgChipBlank(verbose bool, vid, pid uint16) string { if verbose { return fmt.Sprintf( - "Warning: this dongle's configuration looks blank or corrupted. (read VID:PID 0x%04X:0x%04X)", + "Warning: this device's configuration looks blank or corrupted. (read VID:PID 0x%04X:0x%04X)", vid, pid) } - return "Warning: this dongle's configuration looks blank or corrupted." + return "Warning: this device's configuration looks blank or corrupted." } // ===================================================================== @@ -290,22 +292,22 @@ func friendlyError(err error, verbose bool) string { // check the more specific sentinel first. switch { case errors.Is(err, cm108.ErrSerialNotFound): - return "No dongle has the serial number you asked for.\n" + + return "No device has the serial number you asked for.\n" + "Run 'openvlm list' to see what's connected." case errors.Is(err, cm108.ErrAmbiguousDevice): - return "More than one dongle is plugged in.\n" + + return "More than one device is plugged in.\n" + "Use --serial to pick which one. Run 'openvlm list' to see them." case errors.Is(err, cm108.ErrNoOpenVLMStrapped): - return "Dongles are plugged in, but none of them are confirmed as OpenVLM.\n" + - "Use --force to program one anyway, or --serial to pick a specific dongle." + return "Devices are plugged in, but none of them are confirmed as OpenVLM.\n" + + "Use --force to program one anyway, or --serial to pick a specific device." case errors.Is(err, cm108.ErrNoDevice): - return msgNoDongles() + "\nPlug one in and try again." + return msgNoDevices() + "\nPlug one in and try again." case errors.Is(err, eeprom.ErrFieldLocked): - return "That field is part of the dongle's identity and can't be changed by this tool." + return "That field is part of the device's identity and can't be changed by this tool." case errors.Is(err, eeprom.ErrFieldUnknown): return friendlyUnknownField(err) @@ -341,7 +343,7 @@ func friendlyUnknownField(err error) string { // friendlyVerifyMismatch handles the post-write read-back failure. Verbose // mode includes the offending word address; default mode says what to do. func friendlyVerifyMismatch(err error, verbose bool) string { - base := "The dongle accepted the write but read back a different value.\n" + + base := "The device accepted the write but read back a different value.\n" + "Unplug it, plug it back in, and try the command once more." var ve *eeprom.VerifyError diff --git a/cmd/messages_test.go b/cmd/messages_test.go index a6ef4b3..fdd42a3 100644 --- a/cmd/messages_test.go +++ b/cmd/messages_test.go @@ -24,8 +24,8 @@ func TestDisplayName(t *testing.T) { want string }{ {name: "serial preferred", serial: "ABC123", path: "/dev/hidraw3", want: "ABC123"}, - {name: "path fallback when serial empty", serial: "", path: "/dev/hidraw3", want: "/dev/hidraw3"}, - {name: "both empty returns empty", serial: "", path: "", want: ""}, + {name: "friendly fallback when serial empty", serial: "", path: "/dev/hidraw3", want: "OpenVLM device"}, + {name: "friendly fallback when both empty", serial: "", path: "", want: "OpenVLM device"}, } for _, tc := range cases { @@ -48,7 +48,7 @@ func TestSuccessMessages(t *testing.T) { { name: "msgIdentified", got: msgIdentified("ABC123"), - want: "ABC123: confirmed — this is an OpenVLM dongle.", + want: "ABC123: confirmed — this is an OpenVLM device.", }, { name: "msgWritten", @@ -81,9 +81,9 @@ func TestSuccessMessages(t *testing.T) { want: "ABC123: saved 128 bytes to backup.bin.", }, { - name: "msgNoDongles", - got: msgNoDongles(), - want: "No OpenVLM dongles are plugged in.", + name: "msgNoDevices", + got: msgNoDevices(), + want: "No OpenVLM devices are plugged in.", }, } @@ -99,7 +99,7 @@ func TestSuccessMessages(t *testing.T) { func TestForceWarning(t *testing.T) { t.Parallel() assert.Equal(t, - "Warning: dongle's identity bit isn't set; continuing because --force was passed.", + "Warning: device's identity bit isn't set; continuing because --force was passed.", msgForceWarning()) } @@ -107,11 +107,11 @@ func TestChipBlank(t *testing.T) { t.Parallel() assert.Equal(t, - "Warning: this dongle's configuration looks blank or corrupted.", + "Warning: this device's configuration looks blank or corrupted.", msgChipBlank(false, 0x0D8C, 0x0012)) assert.Equal(t, - "Warning: this dongle's configuration looks blank or corrupted. (read VID:PID 0x0D8C:0x0012)", + "Warning: this device's configuration looks blank or corrupted. (read VID:PID 0x0D8C:0x0012)", msgChipBlank(true, 0x0D8C, 0x0012)) } @@ -131,30 +131,30 @@ func TestFriendlyError_KnownSentinels(t *testing.T) { { name: "no devices", err: cm108.ErrNoDevice, - want: "No OpenVLM dongles are plugged in.\nPlug one in and try again.", + want: "No OpenVLM devices are plugged in.\nPlug one in and try again.", }, { name: "serial not found, wrapped", err: fmt.Errorf("%w: %q", cm108.ErrSerialNotFound, "ABC"), - want: "No dongle has the serial number you asked for.\n" + + want: "No device has the serial number you asked for.\n" + "Run 'openvlm list' to see what's connected.", }, { name: "ambiguous device", err: fmt.Errorf("%w: 2 OpenVLM-strapped devices", cm108.ErrAmbiguousDevice), - want: "More than one dongle is plugged in.\n" + + want: "More than one device is plugged in.\n" + "Use --serial to pick which one. Run 'openvlm list' to see them.", }, { name: "no strapped device", err: fmt.Errorf("%w: 2 CM108 devices, none strapped", cm108.ErrNoOpenVLMStrapped), - want: "Dongles are plugged in, but none of them are confirmed as OpenVLM.\n" + - "Use --force to program one anyway, or --serial to pick a specific dongle.", + want: "Devices are plugged in, but none of them are confirmed as OpenVLM.\n" + + "Use --force to program one anyway, or --serial to pick a specific device.", }, { name: "field locked", err: fmt.Errorf("%w: %q", eeprom.ErrFieldLocked, "vid"), - want: "That field is part of the dongle's identity and can't be changed by this tool.", + want: "That field is part of the device's identity and can't be changed by this tool.", }, { name: "hex input", @@ -164,7 +164,7 @@ func TestFriendlyError_KnownSentinels(t *testing.T) { { name: "verify mismatch sentinel only, default", err: eeprom.ErrVerifyMismatch, - want: "The dongle accepted the write but read back a different value.\n" + + want: "The device accepted the write but read back a different value.\n" + "Unplug it, plug it back in, and try the command once more.", }, } @@ -185,7 +185,7 @@ func TestFriendlyError_VerifyMismatchVerbose(t *testing.T) { got := friendlyError(ve, true) assert.Contains(t, got, - "The dongle accepted the write but read back a different value.") + "The device accepted the write but read back a different value.") assert.Contains(t, got, "word 0x07") assert.Contains(t, got, "wrote 0xCAFE") assert.Contains(t, got, "read 0xBABE") diff --git a/cmd/wipe.go b/cmd/wipe.go index 12014f7..bf89611 100644 --- a/cmd/wipe.go +++ b/cmd/wipe.go @@ -33,7 +33,7 @@ var wipeCmd = &cobra.Command{ func runWipe(cmd *cobra.Command, _ []string) error { if !wipeYes { return &usageError{err: fmt.Errorf( - "wipe needs --yes to run. This erases the dongle's configuration; the flag is the confirmation")} + "wipe needs --yes to run. This erases the device's configuration; the flag is the confirmation")} } pattern, err := parseWipePattern(wipePattern) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 410e91d..e1a8e50 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # openvlm CLI Reference -Complete user reference for `openvlm`, the cross-platform CLI for reading, writing, and validating the EEPROM on **OpenVLM USB audio dongles**. +Complete user reference for `openvlm`, the cross-platform CLI for reading, writing, and validating the EEPROM on **OpenVLM USB audio devices**. --- @@ -34,24 +34,24 @@ Complete user reference for `openvlm`, the cross-platform CLI for reading, writi ## Overview -`openvlm` programs the on-board EEPROM of OpenVLM USB audio dongles. The hardware is a **C-Media CM108B** USB audio chip wired to a **93C46 SPI EEPROM** (64 × 16-bit words, 128 bytes total), with a **GPIO1 hardware strap** that distinguishes OpenVLM-branded dongles from generic CM108-family devices. +`openvlm` programs the on-board EEPROM of OpenVLM USB audio devices. The hardware is a **C-Media CM108B** USB audio chip wired to a **93C46 SPI EEPROM** (64 × 16-bit words, 128 bytes total), with a **GPIO1 hardware strap** that distinguishes OpenVLM-branded devices from generic CM108-family devices. The CLI talks to the chip exclusively over USB-HID class control transfers — there is no kernel driver, no vendor blob, and no platform-specific cable. The same binary runs on Linux, macOS, and Windows. What it does: -- Enumerates and identifies attached dongles +- Enumerates and identifies attached devices - Reads / writes the 128-byte EEPROM image - Decodes the image into a typed view (volumes, USB descriptors, analog config) - Validates every field before any HID transfer -- Provisions a fresh dongle with canonical OpenVLM defaults -- Wipes a dongle back to factory-blank state +- Provisions a fresh device with canonical OpenVLM defaults +- Wipes a device back to factory-blank state What it does **not** do: -- It does not change a dongle's USB VID/PID (write-locked to `0x0D8C:0x0012`) +- It does not change a device's USB VID/PID (write-locked to `0x0D8C:0x0012`) - It does not change the product / manufacturer strings (write-locked to `OpenVLM` / `BuildsByShane`) -- It does not work on non-OpenVLM CM108-family dongles unless `--force` is passed +- It does not work on non-OpenVLM CM108-family devices unless `--force` is passed --- @@ -149,10 +149,10 @@ Layering rule: `cmd` calls into `eeprom` and `cm108`; both call into `hidx`. `hi # 1. See what's plugged in openvlm list -# 2. Confirm the device is an OpenVLM dongle (GPIO1 strap probe) +# 2. Confirm the device is an OpenVLM device (GPIO1 strap probe) openvlm identify -# 3. Program canonical OpenVLM defaults onto a fresh dongle +# 3. Program canonical OpenVLM defaults onto a fresh device openvlm provision --serial "00001234" # 4. Inspect the live EEPROM as YAML @@ -183,7 +183,7 @@ flowchart TD F -- no --> E4[ErrAmbiguousDevice
exit 1] ``` -Use `--serial ` to disambiguate when more than one dongle is plugged in. +Use `--serial ` to disambiguate when more than one device is plugged in. --- @@ -193,7 +193,7 @@ These flags are accepted by every subcommand: | Flag | Default | Description | |------------------|---------|-------------| -| `--serial ` | _empty_ | Pick the device whose USB serial-number string equals this value. Required when more than one dongle is attached. | +| `--serial ` | _empty_ | Pick the device whose USB serial-number string equals this value. Required when more than one device is attached. | | `-v, --verbose` | `false` | Log every HID transfer to stderr. Use for diagnosing transport-level issues. | --- @@ -399,7 +399,7 @@ openvlm update dac-output headset openvlm provision [--overrides ] [-- ]... [--dry-run] [--force] ``` -Write the compiled-in `OpenVLMDefaults` image to the device, optionally with overrides. This is the canonical command for programming a fresh dongle. +Write the compiled-in `OpenVLMDefaults` image to the device, optionally with overrides. This is the canonical command for programming a fresh device. **Flags:** @@ -407,7 +407,7 @@ Write the compiled-in `OpenVLMDefaults` image to the device, optionally with ove |-----------------|---------|-------------| | `--overrides ` | _empty_ | YAML file with a subset of fields to override compiled defaults. | | `--dry-run` | `false` | Encode and validate the image, print it as hex, and exit without touching the device. | -| `--force` | `false` | Bypass the GPIO1 strap safety gate. Required for fresh dongles whose strap has not yet been latched. | +| `--force` | `false` | Bypass the GPIO1 strap safety gate. Required for fresh devices whose strap has not yet been latched. | Plus one CLI flag per overridable field — see the [EEPROM field reference](#eeprom-field-reference). @@ -435,7 +435,7 @@ openvlm provision --overrides factory.yaml --dac-init-volume -6 # Validate an image without writing it openvlm provision --overrides factory.yaml --dry-run -# Bootstrap a fresh, never-programmed dongle +# Bootstrap a fresh, never-programmed device openvlm provision --force ``` @@ -600,7 +600,7 @@ openvlm dump --format yaml | openvlm write -i - ```bash openvlm read -o backup.bin -# ...time passes, dongle is reflashed or moved... +# ...time passes, device is reflashed or moved... openvlm write -i backup.bin ``` @@ -613,7 +613,7 @@ openvlm provision --overrides config.yaml YAML is human-readable and portable across hardware revisions; raw binary is bit-exact. -### Provision a brand-new dongle +### Provision a brand-new device A virgin chip's GPIO1 strap reads low until it has been programmed at least once. The `--force` bypass is required for the first program cycle: @@ -639,7 +639,7 @@ $EDITOR current.yaml openvlm provision --overrides current.yaml ``` -### Reset a dongle to factory blank +### Reset a device to factory blank ```bash openvlm wipe --yes @@ -661,9 +661,9 @@ Catches every validation error and prints the would-be image as hex; never touch ### `no OpenVLM devices found` -- Verify the dongle is plugged in: `lsusb | grep 0d8c:0012` (Linux) or System Information → USB (macOS). +- Verify the device is plugged in: `lsusb | grep 0d8c:0012` (Linux) or System Information → USB (macOS). - On Linux, check udev rules — the user must have read/write access to `/dev/hidrawN` for the OpenVLM VID/PID. See [Platform requirements](#platform-requirements). -- The CLI matches strictly on VID `0x0D8C` and PID `0x0012`. Generic CM108-family dongles with different PIDs are not enumerated. +- The CLI matches strictly on VID `0x0D8C` and PID `0x0012`. Generic CM108-family devices with different PIDs are not enumerated. ### `ambiguous device` / multiple matches @@ -678,9 +678,9 @@ openvlm provision --serial "00001234" The selected device's GPIO1 strap reads low. This means either: -- The dongle is not actually OpenVLM hardware (a generic CM108 dongle that happens to share the VID/PID). -- The dongle is OpenVLM hardware but has never been programmed (virgin chips read low). Use `--force` to bootstrap. -- The dongle was wiped recently and the strap latch is no longer set. Use `--force` to re-provision. +- The device is not actually OpenVLM hardware (a generic CM108 device that happens to share the VID/PID). +- The device is OpenVLM hardware but has never been programmed (virgin chips read low). Use `--force` to bootstrap. +- The device was wiped recently and the strap latch is no longer set. Use `--force` to re-provision. ### macOS — transient HID errors during long writes diff --git a/internal/cm108/identity_test.go b/internal/cm108/identity_test.go index 11eae5c..1afd0bd 100644 --- a/internal/cm108/identity_test.go +++ b/internal/cm108/identity_test.go @@ -123,7 +123,7 @@ func TestPick_NoStrappedDevices(t *testing.T) { } // TestPick_SingleUnstrappedDevice still picks the device when it's the only -// CM108 attached — useful for `provision --force` on a fresh dongle. +// CM108 attached — useful for `provision --force` on a fresh device. func TestPick_SingleUnstrappedDevice(t *testing.T) { t.Parallel() diff --git a/internal/cm108/ids.go b/internal/cm108/ids.go index 78b59e9..d24504b 100644 --- a/internal/cm108/ids.go +++ b/internal/cm108/ids.go @@ -17,7 +17,7 @@ const ( OpenVLMVendorID uint16 = 0x0D8C // OpenVLMProductID identifies the OpenVLM (Open Voice Link Module) USB - // audio dongle. It is the factory-default CM108B PID; programming a + // audio device. It is the factory-default CM108B PID; programming a // different PID would prevent this CLI from finding the device, which // is why VID/PID are write-locked (see the plan, "VID/PID are // write-locked"). diff --git a/internal/eeprom/defaults.go b/internal/eeprom/defaults.go index 3c1308b..cddb47a 100644 --- a/internal/eeprom/defaults.go +++ b/internal/eeprom/defaults.go @@ -1,7 +1,7 @@ package eeprom // OpenVLMDefaults is the canonical EEPROM image programmed onto OpenVLM -// dongles at the factory. +// devices at the factory. // // Edit this struct literal to retune defaults for new hardware revisions. // Runtime overrides (YAML files and per-field CLI flags on diff --git a/internal/hidx/transport_windows.go b/internal/hidx/transport_windows.go index 0e88e20..b607ab2 100644 --- a/internal/hidx/transport_windows.go +++ b/internal/hidx/transport_windows.go @@ -334,14 +334,21 @@ func getDeviceInterfacePath(devInfoSet uintptr, ifd *spDeviceInterfaceData) (str return "", errors.New("hidx: SetupDiGetDeviceInterfaceDetail returned zero size") } - // SP_DEVICE_INTERFACE_DETAIL_DATA_W = { DWORD cbSize; WCHAR DevicePath[ANYSIZE_ARRAY]; } - // On 64-bit Windows the struct is 8-byte aligned, so cbSize must be 8. - // On 32-bit it's 6 (cbSize=DWORD + first WCHAR). We assume 64-bit since - // goreleaser only builds windows/amd64. - const headerSize = 8 + // SP_DEVICE_INTERFACE_DETAIL_DATA_W layout: + // DWORD cbSize; // offset 0, 4 bytes + // WCHAR DevicePath[ANYSIZE_ARRAY]; // offset 4, variable length + // + // sizeof(struct) on amd64 is 8 (cbSize 4 + first WCHAR 2 + 2 trailing + // alignment bytes). The API requires cbSize == sizeof(struct), but the + // trailing alignment sits AFTER the first WCHAR — DevicePath itself + // still starts at byte offset 4. goreleaser only builds windows/amd64. + const ( + cbSize = 8 // sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W) on amd64 + pathOffset = 4 // byte offset of DevicePath member + ) buf := make([]byte, requiredSize) - *(*uint32)(unsafe.Pointer(&buf[0])) = headerSize + *(*uint32)(unsafe.Pointer(&buf[0])) = cbSize ret, _, err := procSetupDiGetDeviceInterfaceDetailW.Call( devInfoSet, @@ -355,10 +362,21 @@ func getDeviceInterfacePath(devInfoSet uintptr, ifd *spDeviceInterfaceData) (str return "", fmt.Errorf("hidx: SetupDiGetDeviceInterfaceDetail: %w", err) } - pathBytes := buf[headerSize:] - pathU16 := unsafe.Slice((*uint16)(unsafe.Pointer(&pathBytes[0])), (len(pathBytes))/2) + return decodeDetailPath(buf[pathOffset:]), nil +} + +// decodeDetailPath converts the WCHAR DevicePath bytes returned by +// SetupDiGetDeviceInterfaceDetailW into a Go string. Split out from +// getDeviceInterfacePath so the buffer-layout assumption is unit-testable +// without going through the SetupAPI. +func decodeDetailPath(pathBytes []byte) string { + if len(pathBytes) == 0 { + return "" + } + + pathU16 := unsafe.Slice((*uint16)(unsafe.Pointer(&pathBytes[0])), len(pathBytes)/2) - return windows.UTF16ToString(pathU16), nil + return windows.UTF16ToString(pathU16) } // queryDevice opens the HID device just long enough to read its attributes diff --git a/internal/hidx/transport_windows_path_test.go b/internal/hidx/transport_windows_path_test.go index cd07431..b6862e2 100644 --- a/internal/hidx/transport_windows_path_test.go +++ b/internal/hidx/transport_windows_path_test.go @@ -4,10 +4,36 @@ package hidx import ( "testing" + "unicode/utf16" "github.com/stretchr/testify/assert" ) +// TestDecodeDetailPath pins the buffer-offset assumption inside +// getDeviceInterfacePath. Regression test for a bug where the DevicePath +// byte offset was conflated with the struct's sizeof, eating the leading +// `\\` of every HID device path on Windows and causing every subsequent +// CreateFile to fail with ERROR_INVALID_NAME. +func TestDecodeDetailPath(t *testing.T) { + // Synthesize what Windows hands back in the post-cbSize portion of + // the SP_DEVICE_INTERFACE_DETAIL_DATA_W buffer: a UTF-16LE, + // null-terminated copy of the device path, optionally followed by + // trailing alignment / slack bytes. + want := `\\?\hid#vid_0d8c&pid_0012&mi_03#7&104c02ef&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}` + + u16 := utf16.Encode([]rune(want)) + u16 = append(u16, 0) // null terminator + + buf := make([]byte, len(u16)*2+4) // +4 to mimic trailing alignment slack + for i, w := range u16 { + buf[i*2] = byte(w) + buf[i*2+1] = byte(w >> 8) + } + + got := decodeDetailPath(buf) + assert.Equal(t, want, got, "leading characters must be preserved; missing \\\\ means the wrong byte offset") +} + func TestParseVIDPIDFromPath(t *testing.T) { tests := []struct { name string diff --git a/main.go b/main.go index 2b7176e..948293b 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ // Command openvlm is the cross-platform CLI for reading, writing, and -// validating the EEPROM on OpenVLM USB audio dongles (C-Media CM108B with a +// validating the EEPROM on OpenVLM USB audio devices (C-Media CM108B with a // GPIO1 hardware strap). // // See `openvlm --help` for the full subcommand reference. From ace19749793223c005846a12d44789703ea9d01f Mon Sep 17 00:00:00 2001 From: Corey Wagehoft Date: Wed, 13 May 2026 19:30:44 -0600 Subject: [PATCH 3/4] fix(windows): add minor execution delay because... windows. --- internal/eeprom/protocol.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/eeprom/protocol.go b/internal/eeprom/protocol.go index 516d647..b4018ea 100644 --- a/internal/eeprom/protocol.go +++ b/internal/eeprom/protocol.go @@ -39,6 +39,15 @@ const ( // slow. readPaceDelay = 1 * time.Millisecond + // readExecuteDelay is the gap between the SetOutputReport that issues + // the EEPROM-read command and the GetInputReport that fetches the + // result. The CM108B's internal SPI bus must clock 16 bits out of the + // 93C46 before HID_IR2/IR3 hold the requested word — without this + // delay, GetInputReport races the chip and returns stale buffer data + // (different bytes on every read; reproducible on Windows). The + // Linux hid-cm108 driver uses 1..2 ms here; we use 2 ms for margin. + readExecuteDelay = 2 * time.Millisecond + // preVerifyDelay paces the gap between WriteWord's tWP sleep and the // next verify ReadWord transfer in WriteAll. Same IOKit blip class as // readPaceDelay but on the write→read transition; without this, a @@ -149,6 +158,10 @@ func ReadWord(t hidx.Transport, addr uint8) (uint16, error) { return 0, fmt.Errorf("eeprom: ReadWord 0x%02X: send output: %w", addr, err) } + // Wait for the chip to clock the 16-bit word out of the 93C46 SPI + // EEPROM into HID_IR2/IR3 before fetching it. See readExecuteDelay. + time.Sleep(readExecuteDelay) + in := make([]byte, reportLen) if err := getInputReportRetry(t, 0, in); err != nil { return 0, fmt.Errorf("eeprom: ReadWord 0x%02X: get input: %w", addr, err) From c7e9850f1132c97dd3c7d6c1034efa0399b390de Mon Sep 17 00:00:00 2001 From: Corey Wagehoft Date: Wed, 13 May 2026 19:34:13 -0600 Subject: [PATCH 4/4] fix(windows): guards for powershell behavior --- cmd/messages.go | 27 ++++++++++++++- cmd/messages_test.go | 16 +++++++++ cmd/read.go | 10 +++++- cmd/shellguard_other.go | 11 ++++++ cmd/shellguard_windows.go | 72 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 cmd/shellguard_other.go create mode 100644 cmd/shellguard_windows.go diff --git a/cmd/messages.go b/cmd/messages.go index 92c6b1c..ed8142c 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -82,10 +82,20 @@ The output is 128 bytes of raw binary. You can pipe it back through Examples: openvlm read -o backup.bin - openvlm read > backup.bin` + openvlm read > backup.bin + +On Windows PowerShell, '>' and '|' silently corrupt binary streams +(UTF-16 BOM / ASCII re-encoding). Prefer -o, or wrap the call with +'cmd /c "openvlm read > backup.bin"'. The CLI refuses to write a raw +binary EEPROM image to stdout when PowerShell is the parent shell; +pass --force-stdout to override if you know your pipeline preserves +raw bytes.` flagReadOutputHelp = "file to write the 128-byte image to (default: stdout)" + flagReadForceStdoutHelp = "bypass the Windows PowerShell binary-stdout guard " + + "(use only if you know your shell preserves raw bytes)" + useDump = "dump" shortDump = "Show the device's configuration as YAML, text, or hex" longDump = `Reads the device's configuration and shows it in a readable form. @@ -260,6 +270,21 @@ func msgForceWarning() string { return "Warning: device's identity bit isn't set; continuing because --force was passed." } +// errPowerShellStdoutGuard is returned when `openvlm read` would write a +// raw 128-byte EEPROM image to stdout from a PowerShell session, where +// PowerShell's `>` (UTF-16LE BOM) and `|` (ASCII via $OutputEncoding) +// silently corrupt binary streams. The message offers three remedies in +// order of preference. +func errPowerShellStdoutGuard() error { + return errors.New( + "PowerShell's '>' redirect and '|' pipe corrupt binary streams " + + "(stdout would be re-encoded as UTF-16 or ASCII, not raw bytes).\n" + + "Use one of:\n" + + " openvlm read -o backup.bin write directly to a file (recommended)\n" + + " cmd /c \"openvlm read > backup.bin\" cmd.exe redirect is binary-safe\n" + + " openvlm read --force-stdout > backup.bin override this check") +} + // msgChipBlank is the warning printed by 'dump --format text' when the chip // isn't programmed yet. Verbose mode appends the raw VID/PID for diagnostics. func msgChipBlank(verbose bool, vid, pid uint16) string { diff --git a/cmd/messages_test.go b/cmd/messages_test.go index fdd42a3..b53a240 100644 --- a/cmd/messages_test.go +++ b/cmd/messages_test.go @@ -103,6 +103,22 @@ func TestForceWarning(t *testing.T) { msgForceWarning()) } +// TestPowerShellStdoutGuardMessage pins the wording of the +// `openvlm read` PowerShell-stdout refusal so a future tone-tweak shows up +// as a deliberate diff. The body must include the three remedies (-o, cmd +// /c, --force-stdout) so the user is never left without an escape hatch. +func TestPowerShellStdoutGuardMessage(t *testing.T) { + t.Parallel() + + msg := errPowerShellStdoutGuard().Error() + + assert.Contains(t, msg, "PowerShell") + assert.Contains(t, msg, "UTF-16") + assert.Contains(t, msg, "-o backup.bin") + assert.Contains(t, msg, "cmd /c") + assert.Contains(t, msg, "--force-stdout") +} + func TestChipBlank(t *testing.T) { t.Parallel() diff --git a/cmd/read.go b/cmd/read.go index eab053f..735f32c 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -9,11 +9,15 @@ import ( ) //nolint:gochecknoglobals // cobra subcommand state -var readOutput string +var ( + readOutput string + readForceStdout bool +) func init() { //nolint:gochecknoinits // cobra subcommand self-registration rootCmd.AddCommand(readCmd) readCmd.Flags().StringVarP(&readOutput, "output", "o", "", flagReadOutputHelp) + readCmd.Flags().BoolVar(&readForceStdout, "force-stdout", false, flagReadForceStdoutHelp) } //nolint:gochecknoglobals // cobra command literal @@ -38,6 +42,10 @@ func runRead(cmd *cobra.Command, _ []string) error { } if readOutput == "" { + if !readForceStdout && stdoutIsPipe() && parentIsPowerShell() { + return &usageError{err: errPowerShellStdoutGuard()} + } + if _, err := cmd.OutOrStdout().Write(img[:]); err != nil { return fmt.Errorf("couldn't write to stdout: %w", err) } diff --git a/cmd/shellguard_other.go b/cmd/shellguard_other.go new file mode 100644 index 0000000..d710001 --- /dev/null +++ b/cmd/shellguard_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package cmd + +// parentIsPowerShell is a Windows-only concern. On every other OS the +// system shell handles binary stdout correctly, so the guard is a no-op. +func parentIsPowerShell() bool { return false } + +// stdoutIsPipe is only consulted by the PowerShell guard, which is +// Windows-only. The non-Windows stub keeps the call site portable. +func stdoutIsPipe() bool { return false } diff --git a/cmd/shellguard_windows.go b/cmd/shellguard_windows.go new file mode 100644 index 0000000..63c56b0 --- /dev/null +++ b/cmd/shellguard_windows.go @@ -0,0 +1,72 @@ +//go:build windows + +package cmd + +import ( + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// parentIsPowerShell reports whether this process was launched by +// powershell.exe (Windows PowerShell 5.x) or pwsh.exe (PowerShell 7+). +// +// PowerShell's `>` redirect encodes stdout as UTF-16LE (with BOM), and its +// `|` pipe re-encodes through $OutputEncoding (ASCII by default) — both +// silently corrupt binary streams. Detecting this at runtime lets us +// refuse `openvlm read` to stdout before producing a broken backup file. +// +// Using parent-process name (not the PSModulePath env var) avoids false +// positives when the user wraps the call in `cmd /c "openvlm read > x.bin"` +// from a PowerShell prompt: cmd.exe inherits the env var but its own +// `>` is binary-safe, and the immediate parent is cmd.exe, not PowerShell. +// +// Returns false on any error — the guard is best-effort. +func parentIsPowerShell() bool { + ppid := uint32(os.Getppid()) + if ppid == 0 { + return false + } + + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return false + } + + defer windows.CloseHandle(snap) //nolint:errcheck // best-effort cleanup + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + if err := windows.Process32First(snap, &entry); err != nil { + return false + } + + for { + if entry.ProcessID == ppid { + name := strings.ToLower(windows.UTF16ToString(entry.ExeFile[:])) + + return name == "powershell.exe" || name == "pwsh.exe" + } + + if err := windows.Process32Next(snap, &entry); err != nil { + return false + } + } +} + +// stdoutIsPipe reports whether stdout is connected to a pipe or regular +// file (anything that is not a TTY/console). When stdout is a TTY the +// PowerShell guard does not need to fire: nothing is being captured into a +// file, so the only consequence of writing binary is garbled terminal +// output, not silently corrupted data. +func stdoutIsPipe() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + + return (fi.Mode() & os.ModeCharDevice) == 0 +}