Skip to content

Commit 532f30c

Browse files
mcuelenaereclaude
andcommitted
feat(usbgadget): warn in UI when USB endpoint budget is exceeded
The RV1106 dwc3 controller has a limited number of IN (device->host) and OUT (host->device) endpoints -- separate pools, ~7 each. Each gadget function claims some: HID mice/wake and audio capture take 1 IN; the keyboard takes 1 IN + 1 OUT (LED reports); mass storage takes 1 IN + 1 OUT; CDC-ACM and CDC-NCM take 2 IN + 1 OUT each. When the enabled set exceeds either pool a function silently fails to allocate its endpoint(s) -- CDC-NCM in particular comes up looking connected (RX works) while TX is a black hole. That's painful to diagnose, so warn before the user commits to an over-budget combo. - internal/usbgadget/endpoints.go: per-function {in,out} endpoint cost map, the two budget constants (with the empirical derivation documented; the IN ceiling of 7 is confirmed on-device, OUT is assumed symmetric), and ExceedsEndpointBudget. The true low-level limiter may be dwc3 TX-FIFO SRAM rather than a raw count, but the effective ceiling is the same; FIFO RAM is not modeled separately. - internal/usbgadget/config.go: extract isGadgetConfigItemEnabledForDevices so endpoint demand can be computed for any hypothetical Devices selection. - jsonrpc.go: getUsbEndpointReport RPC returning {exceedsBudget} for a given device set. - UsbDeviceSetting.tsx: query it live as toggles change and show an inline amber warning when the set exceeds the budget, suggesting the user free an endpoint (e.g. disable Relative Mouse). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee67cc4 commit 532f30c

4 files changed

Lines changed: 117 additions & 7 deletions

File tree

internal/usbgadget/config.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,21 +70,28 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
7070
}
7171

7272
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
73+
return isGadgetConfigItemEnabledForDevices(itemKey, &u.enabledDevices)
74+
}
75+
76+
// isGadgetConfigItemEnabledForDevices reports whether a gadget config item is
77+
// enabled for an arbitrary Devices selection. Items without an explicit case
78+
// (base, base_info, wake_hid, audio, ...) are always enabled.
79+
func isGadgetConfigItemEnabledForDevices(itemKey string, devices *Devices) bool {
7380
switch itemKey {
7481
case "absolute_mouse":
75-
return u.enabledDevices.AbsoluteMouse
82+
return devices.AbsoluteMouse
7683
case "relative_mouse":
77-
return u.enabledDevices.RelativeMouse
84+
return devices.RelativeMouse
7885
case "keyboard":
79-
return u.enabledDevices.Keyboard
86+
return devices.Keyboard
8087
case "mass_storage_base":
81-
return u.enabledDevices.MassStorage
88+
return devices.MassStorage
8289
case "mass_storage_lun0":
83-
return u.enabledDevices.MassStorage
90+
return devices.MassStorage
8491
case "serial_console":
85-
return u.enabledDevices.SerialConsole
92+
return devices.SerialConsole
8693
case "ncm":
87-
return u.enabledDevices.Ncm
94+
return devices.Ncm
8895
default:
8996
return true
9097
}

internal/usbgadget/endpoints.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package usbgadget
2+
3+
// USB endpoint budget for the JetKVM RV1106 dwc3 controller.
4+
//
5+
// dwc3 exposes IN (device->host) and OUT (host->device) endpoints as separate
6+
// hardware resources, so they are budgeted independently — allocating a
7+
// bulk-OUT never steals from the IN pool.
8+
//
9+
// The IN budget of 7 is empirically confirmed: the full default function set
10+
// plus CDC-NCM needs 8 IN endpoints and NCM's bulk-IN silently fails to
11+
// allocate (the link enumerates and usb0 comes up RX-only, TX is a black
12+
// hole); dropping any single IN-using function (-> 7) makes it work. The OUT
13+
// pool on this part is symmetric with IN and OUT demand stays far below it in
14+
// practice, so the same ceiling is used for both.
15+
//
16+
// Note: the true low-level limiter for IN endpoints may be dwc3's TX-FIFO
17+
// SRAM (each IN endpoint reserves FIFO space sized to its max packet) rather
18+
// than a raw endpoint count, but the effective ceiling we observed is 7, so a
19+
// simple count captures it. FIFO RAM is not modeled separately.
20+
const (
21+
usbInEndpointBudget uint = 7
22+
usbOutEndpointBudget uint = 7
23+
)
24+
25+
// endpointCost is the number of USB IN and OUT endpoints a gadget function
26+
// consumes.
27+
type endpointCost struct {
28+
in uint
29+
out uint
30+
}
31+
32+
// endpointCosts maps a gadget config item key to its endpoint cost. Keys match
33+
// entries in defaultGadgetConfig; items absent here cost nothing (base,
34+
// base_info, mass_storage_lun0, ...). HID OUT cost follows the function's
35+
// no_out_endpoint attribute (keyboard has an interrupt-OUT for LED reports;
36+
// the mice and wake HID do not).
37+
var endpointCosts = map[string]endpointCost{
38+
"keyboard": {in: 1, out: 1}, // interrupt IN + interrupt OUT (LED reports)
39+
"wake_hid": {in: 1, out: 0}, // interrupt IN only
40+
"absolute_mouse": {in: 1, out: 0}, // interrupt IN only
41+
"relative_mouse": {in: 1, out: 0}, // interrupt IN only
42+
"audio": {in: 1, out: 0}, // UAC1 capture: isochronous IN
43+
"mass_storage_base": {in: 1, out: 1}, // bulk IN + bulk OUT
44+
"serial_console": {in: 2, out: 1}, // CDC-ACM: bulk IN + notify IN + bulk OUT
45+
"ncm": {in: 2, out: 1}, // CDC-NCM: bulk IN + notify IN + bulk OUT
46+
}
47+
48+
// endpointUsage returns the IN and OUT endpoint demand of a device selection,
49+
// including always-on functions (wake_hid, audio).
50+
func endpointUsage(devices *Devices) (in, out uint) {
51+
for key, cost := range endpointCosts {
52+
if isGadgetConfigItemEnabledForDevices(key, devices) {
53+
in += cost.in
54+
out += cost.out
55+
}
56+
}
57+
return in, out
58+
}
59+
60+
// ExceedsEndpointBudget reports whether the given device selection would exceed
61+
// the controller's IN or OUT endpoint budget. An over-budget gadget still
62+
// enumerates but leaves some function's endpoint(s) silently unallocated (e.g.
63+
// CDC-NCM comes up RX-only with a dead TX path), so the UI uses this to warn
64+
// before the user commits to the combination.
65+
func ExceedsEndpointBudget(devices *Devices) bool {
66+
in, out := endpointUsage(devices)
67+
return in > usbInEndpointBudget || out > usbOutEndpointBudget
68+
}

jsonrpc.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,20 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
968968
return *config.UsbDevices, nil
969969
}
970970

971+
// UsbEndpointReport tells the UI whether a given device selection would exceed
972+
// the controller's USB endpoint budget (IN or OUT). Over-budget combinations
973+
// leave a function silently non-functional — notably CDC-NCM, which needs two
974+
// IN endpoints, comes up RX-only with a dead TX path.
975+
type UsbEndpointReport struct {
976+
ExceedsBudget bool `json:"exceedsBudget"`
977+
}
978+
979+
func rpcGetUsbEndpointReport(devices usbgadget.Devices) (UsbEndpointReport, error) {
980+
return UsbEndpointReport{
981+
ExceedsBudget: usbgadget.ExceedsEndpointBudget(&devices),
982+
}, nil
983+
}
984+
971985
func updateUsbRelatedConfig() error {
972986
if err := gadget.UpdateGadgetConfig(); err != nil {
973987
return fmt.Errorf("failed to write gadget config: %w", err)
@@ -1399,6 +1413,7 @@ var rpcHandlers = map[string]RPCHandler{
13991413
"deleteSerialCommandHistory": {Func: rpcDeleteSerialCommandHistory},
14001414
"setTerminalPaused": {Func: rpcSetTerminalPaused, Params: []string{"terminalPaused"}},
14011415
"getUsbDevices": {Func: rpcGetUsbDevices},
1416+
"getUsbEndpointReport": {Func: rpcGetUsbEndpointReport, Params: []string{"devices"}},
14021417
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
14031418
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
14041419
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},

ui/src/components/UsbDeviceSetting.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function UsbDeviceSetting() {
7676

7777
const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
7878
const [selectedPreset, setSelectedPreset] = useState<string>("default");
79+
const [overBudget, setOverBudget] = useState(false);
7980

8081
const syncUsbDeviceConfig = useCallback(() => {
8182
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
@@ -158,6 +159,17 @@ export function UsbDeviceSetting() {
158159
syncUsbDeviceConfig();
159160
}, [syncUsbDeviceConfig]);
160161

162+
// Check whether the currently-selected function set fits the controller's USB
163+
// endpoint budget. The dwc3 controller has a limited number of IN/OUT
164+
// endpoints; an over-budget combination can leave a function (notably
165+
// CDC-NCM) silently non-functional.
166+
useEffect(() => {
167+
send("getUsbEndpointReport", { devices: usbDeviceConfig }, (resp: JsonRpcResponse) => {
168+
if ("error" in resp) return;
169+
setOverBudget((resp.result as { exceedsBudget: boolean }).exceedsBudget);
170+
});
171+
}, [send, usbDeviceConfig]);
172+
161173
return (
162174
<Fieldset disabled={loading} className="space-y-4">
163175
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
@@ -250,6 +262,14 @@ export function UsbDeviceSetting() {
250262
</SettingsItem>
251263
</div>
252264
</div>
265+
{overBudget && (
266+
<div className="mt-4 rounded-md border border-amber-500/30 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-400/20 dark:bg-amber-900/20 dark:text-amber-200">
267+
This combination exceeds the device&apos;s available USB endpoints. Functions beyond
268+
the limit may silently fail to work &mdash; Ethernet over USB (CDC-NCM) in particular
269+
will appear connected but won&apos;t pass traffic. Disable a function (e.g. Relative
270+
Mouse) to free an endpoint.
271+
</div>
272+
)}
253273
<div className="mt-6 flex gap-x-2">
254274
<Button
255275
size="SM"

0 commit comments

Comments
 (0)