mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 06:43:42 +08:00
FIX #51
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user