From ad66456d2f60f2de8fe6acc2d36ef589121a7079 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:41:42 +0800 Subject: [PATCH] FIX #51 --- internal/modem/discovery.go | 43 ++++++- internal/modem/discovery_test.go | 109 ++++++++++++++++++ .../devices/DiscoveredDeviceRow.tsx | 4 +- web/src/pages/DevicesPage.tsx | 4 + 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go index 2ef67e4..80a6d0c 100644 --- a/internal/modem/discovery.go +++ b/internal/modem/discovery.go @@ -14,7 +14,13 @@ import ( const ( djiVendorID = "2ca3" dji4GProductID = "4006" + // quectelVendorID covers Quectel USB modems exposed purely as serial or + // RNDIS/ECM devices (for example the EC200A at 2c7c:6005). Their control + // interface is not bound to qmi_wwan, so the QMI-binding gate would skip + // them even though they expose a usable AT serial port. + quectelVendorID = "2c7c" ) + type SysFSDiscoverer struct { SysRoot string DevRoot string @@ -83,7 +89,15 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) { vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor"))) productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct"))) if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) { - continue + // A bound qmi_wwan interface is the strongest vendor-neutral "this is + // a live QMI modem" signal, but it excludes Quectel modules running + // in a serial or RNDIS/ECM USB composition (no qmi_wwan binding). + // Re-admit them by vendor so their AT serial ports stay discoverable; + // the candidate is only kept if a ttyUSB/ttyACM node is actually + // found below, which is exactly the AT-bearing composition we want. + if !isQuectelUSBModem(vendorID) { + continue + } } state := devices[deviceName] @@ -142,11 +156,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) { assignQuectelPortRoles(state.candidate.Ports) state.candidate.ATPort = selectATPort(state.candidate.Ports) if !state.candidate.HasATPort() { - // A bound QMI interface proves the modem is alive, but the snapshot, - // SMS, USSD and eSIM (AT+CSIM) paths all require an AT port. A missing - // ttyUSB/ttyACM node almost always means the option/qcserial driver - // does not claim the serial interfaces (often a missing PID in its - // device-ID table), not that the module lacks an AT interface. + // A modem without a usable AT port cannot be driven by vocat, but it + // is far more useful to surface it with a discovery issue than to + // silently drop it: the operator sees the device is present and gets + // told why it is unusable. Two shapes land here: + // * qmi_wwan is bound but no ttyUSB/ttyACM exists — the option/qcserial + // driver did not claim the serial interfaces (often a missing PID + // in its device-ID table, common on Ubuntu for EG25-G carrier + // builds). The modem is alive; it just lacks an AT node. + // * no qmi_wwan binding (Quectel re-admitted by vendor) and no AT + // port — typically an MBIM/RNDIS/ECM composition. The module is on + // the bus but exposes no AT serial interface vocat can open. + // Both resolve the same operator action: add the PID to the option + // driver or switch the module to a QMI+AT composition. state.candidate.DiscoveryIssue = "at_port_missing" } result = append(result, state.candidate) @@ -168,6 +190,15 @@ func IsDJI4GUSB(vendorID, productID string) bool { strings.EqualFold(strings.TrimSpace(productID), dji4GProductID) } +// isQuectelUSBModem reports whether a USB identity belongs to a Quectel +// module. Quectel's serial/RNDIS/ECM compositions (e.g. EC200A at 2c7c:6005) +// do not bind qmi_wwan, so discovery must fall back to the vendor ID to keep +// them visible. The candidate is only retained if it exposes an AT serial +// port, which filters out unrelated Quectel-branded peripherals. +func isQuectelUSBModem(vendorID string) bool { + return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID) +} + type discoveredWWANDevice struct { index string ports []Port diff --git a/internal/modem/discovery_test.go b/internal/modem/discovery_test.go index f56dd6d..71382e7 100644 --- a/internal/modem/discovery_test.go +++ b/internal/modem/discovery_test.go @@ -444,6 +444,115 @@ func TestParseWWANPortName(t *testing.T) { } } +func TestSysFSDiscoveryFindsQuectelSerialModemWithoutQMIWWANBinding(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + // A Quectel EC200A in its USB-serial composition (2c7c:6005) exposes ttyUSB + // control ports but no qmi_wwan-bound interface, so discovery must re-admit + // it by vendor instead of skipping it. + mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "6005\n") + for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} { + interfaceName := "1-6:1." + strconv.Itoa(number) + mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number)) + mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty)) + } + + candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + candidate := candidates[0] + if candidate.VendorID != "2c7c" || candidate.ProductID != "6005" { + t.Fatalf("candidate = %#v", candidate) + } + if candidate.ID != "usb-2c7c-6005-1-6" { + t.Fatalf("ID = %q", candidate.ID) + } + if candidate.ATPort.Name != "ttyUSB2" || candidate.ATPort.Role != PortRoleAT { + t.Fatalf("AT port = %#v, want ttyUSB2 at role AT", candidate.ATPort) + } + if candidate.DiscoveryIssue != "" { + t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue) + } +} + +func TestSysFSDiscoveryMarksQuectelPeripheralWithoutATPort(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + // A Quectel-branded peripheral exposing only a network interface (no + // ttyUSB/ttyACM, no qmi_wwan binding) cannot be driven yet, but vocat + // surfaces it with at_port_missing instead of silently dropping it so the + // operator sees the device is present and learns what to fix. + mustWrite(t, filepath.Join(usbRoot, "1-8", "idVendor"), "2c7c\n") + mustWrite(t, filepath.Join(usbRoot, "1-8", "idProduct"), "6005\n") + mustMkdir(t, filepath.Join(usbRoot, "1-8:1.0", "net", "enx001122334455")) + + candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + candidate := candidates[0] + if candidate.DiscoveryIssue != "at_port_missing" { + t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue) + } + if candidate.HasATPort() { + t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort) + } + if candidate.NetworkInterface != "enx001122334455" { + t.Fatalf("network interface = %q", candidate.NetworkInterface) + } +} + +func TestSysFSDiscoveryMarksQuectelMBIMCompositionWithoutATPort(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + // An EG25-G in MBIM composition (2c7c:0900) exposes cdc-wdm + net but no + // ttyUSB and has no qmi_wwan binding (cdc_mbim binds the control interface + // instead). vocat has no MBIM backend, so it must surface the device with + // at_port_missing rather than hiding it. + mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "0900\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "product"), "EG25-G\n") + mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "usbmisc", "cdc-wdm0")) + mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "wwp0s20f0u6")) + + candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + candidate := candidates[0] + if candidate.DiscoveryIssue != "at_port_missing" { + t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue) + } + if candidate.HasATPort() { + t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort) + } + if candidate.Product != "EG25-G" { + t.Fatalf("product = %q", candidate.Product) + } + // cdc-wdm0 sits under usbmisc/, which scanUSBInterface reports as a QMI + // control name; either way the device must appear present, not vanish. + if candidate.QMIControl == "" && candidate.NetworkInterface == "" { + t.Fatalf("candidate has neither QMI control nor net interface: %#v", candidate) + } +} + func mustWrite(t *testing.T, path, value string) { t.Helper() mustMkdir(t, filepath.Dir(path)) diff --git a/web/src/components/devices/DiscoveredDeviceRow.tsx b/web/src/components/devices/DiscoveredDeviceRow.tsx index 3054c7e..9365474 100644 --- a/web/src/components/devices/DiscoveredDeviceRow.tsx +++ b/web/src/components/devices/DiscoveredDeviceRow.tsx @@ -23,7 +23,9 @@ export function DiscoveredDeviceRow({ ? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。") : device.discoveryIssue === "pcsc_driver_missing" ? t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。") - : ""; + : device.discoveryIssue === "at_port_missing" + ? t("已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 `echo 2c7c | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` 后重扫,或用 AT+QCFG 切到 QMI+AT 组态。") + : ""; return (