This commit is contained in:
MengMengCode
2026-08-19 21:41:42 +08:00
parent 161aa667c9
commit ad66456d2f
4 changed files with 153 additions and 7 deletions
+37 -6
View File
@@ -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
+109
View File
@@ -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))
@@ -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 <pid> | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` 后重扫,或用 AT+QCFG 切到 QMI+AT 组态。")
: "";
return (
<button
type="button"
+4
View File
@@ -381,6 +381,10 @@ export default function DevicesPage() {
message.warning(t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。"));
return;
}
if (d.discoveryIssue === "at_port_missing") {
message.warning(t("已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 `echo 2c7c <pid> | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` 后重扫,或用 AT+QCFG 切到 QMI+AT 组态。"));
return;
}
if (d.degraded) {
message.warning(t("无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试"));
return;