diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7805117..e84d071 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,13 +49,13 @@ jobs: BUILD_TIME=${{ github.event.repository.updated_at }} cache-from: type=gha - - name: Verify ${{ matrix.platform }} runtime and smart-card stack + - name: Verify ${{ matrix.platform }} runtime, QMI, and smart-card stack run: | docker run --rm --platform '${{ matrix.platform }}' \ vocat-smoke:${{ matrix.arch }} version docker run --rm --platform '${{ matrix.platform }}' \ --entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \ - 'command -v pcscd && test -d /usr/lib/pcsc/drivers' + 'command -v qmicli && command -v qmi-network && command -v pcscd && test -d /usr/lib/pcsc/drivers' build-and-push: needs: smoke diff --git a/Dockerfile b/Dockerfile index 9a9a732..ac5e2f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \ # ---- Stage 3: minimal runtime ---- FROM alpine:3.20 -RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \ +RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite qmi-utils tzdata && \ addgroup -S -g 1000 vocat && \ adduser -S -D -H -u 1000 -G vocat vocat diff --git a/README.md b/README.md index d802369..5147fd3 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,18 @@ managers. On Debian/Ubuntu, the equivalent manual setup is VoCat keeps the reader visible in the add-device dialog and reports the missing service or driver instead of silently hiding it. +### QMI command-line utilities + +VoCat uses `qmicli` to verify that a QMI control channel is ready and +`qmi-network` to manage packet-data sessions. The one-click installer installs +and verifies the corresponding utilities automatically. For manual deployment, +Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses +`pacman -S libqmi`, and Alpine uses `apk add qmi-utils`. + +`vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB +driver binding or asserting DTR. If the utility is unavailable, the command +stops with an installation hint and leaves the current device state untouched. + ## Configuration Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence. diff --git a/cmd/vocat/cli.go b/cmd/vocat/cli.go index 6d513da..42ce69a 100644 --- a/cmd/vocat/cli.go +++ b/cmd/vocat/cli.go @@ -22,8 +22,9 @@ Usage: vocat without arguments would enter the menu). vocat version Print the build version and exit. vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths. - Use --repair-dji-qmi on Linux to safely wake a factory-ID - DJI/Baiwang 2ca3:4006 QMI interface without changing NV. + Use --repair-dji-qmi on Linux to restore the factory-ID + DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake + QMI without changing NV. vocat carrier import-ipcc [flags] FILE.ipcc Convert an Apple carrier bundle into a reviewable VoCat profile. Preview is the default; --install writes it to diff --git a/cmd/vocat/doctor.go b/cmd/vocat/doctor.go index fe41105..888f999 100644 --- a/cmd/vocat/doctor.go +++ b/cmd/vocat/doctor.go @@ -33,14 +33,17 @@ type doctorReport struct { } type djiQMIRepairResult struct { - USBName string `json:"usb_name"` - Interface string `json:"interface"` - USBDevice string `json:"usb_device"` - OriginalDriver string `json:"original_driver,omitempty"` - ControlDevice string `json:"control_device"` - NetworkInterface string `json:"network_interface,omitempty"` - QMIProbe string `json:"qmi_probe"` - Attempts int `json:"attempts"` + USBName string `json:"usb_name"` + Interface string `json:"interface"` + USBDevice string `json:"usb_device"` + OriginalDriver string `json:"original_driver,omitempty"` + SerialInterfaces []string `json:"serial_interfaces,omitempty"` + SerialDevices []string `json:"serial_devices,omitempty"` + ATDevice string `json:"at_device,omitempty"` + ControlDevice string `json:"control_device"` + NetworkInterface string `json:"network_interface,omitempty"` + QMIProbe string `json:"qmi_probe"` + Attempts int `json:"attempts"` } func runDoctor(args []string) error { @@ -49,7 +52,7 @@ func runDoctor(args []string) error { proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test") proxyUsername := flags.String("proxy-username", "", "SOCKS5 username") passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password") - repairDJI := flags.Bool("repair-dji-qmi", false, "rebind DJI 2ca3:4006 interface 4 to qmi_wwan and assert DTR (Linux/root only; no NV write)") + repairDJI := flags.Bool("repair-dji-qmi", false, "bind DJI 2ca3:4006 interfaces 0-3 to option and interface 4 to qmi_wwan, then assert DTR (Linux/root only; no NV write)") jsonOutput := flags.Bool("json", false, "write machine-readable JSON") timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout") if err := flags.Parse(args); err != nil { @@ -79,7 +82,7 @@ func runDoctor(args []string) error { if err != nil { return fmt.Errorf("repair DJI QMI binding: %w", err) } - add("dji_qmi_repair", "passed", "dji_qmi_dtr_asserted", "DJI interface 4 was bound to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result) + add("dji_qmi_repair", "passed", "dji_usb_interfaces_repaired", "DJI serial interfaces 0-3 were bound to option and interface 4 to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result) } candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx) diff --git a/cmd/vocat/doctor_dji_linux.go b/cmd/vocat/doctor_dji_linux.go index 8d41e17..485d0be 100644 --- a/cmd/vocat/doctor_dji_linux.go +++ b/cmd/vocat/doctor_dji_linux.go @@ -19,9 +19,12 @@ import ( ) const ( - djiVendorID = "2ca3" - djiProductID = "4006" - djiQMIIndex = 4 + djiVendorID = "2ca3" + djiProductID = "4006" + djiFirstSerialIndex = 0 + djiLastSerialIndex = 3 + djiATIndex = 2 + djiQMIIndex = 4 ) type usbControlTransfer struct { @@ -35,8 +38,12 @@ type usbControlTransfer struct { } func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) { + qmicli, err := exec.LookPath("qmicli") + if err != nil { + return djiQMIRepairResult{}, errors.New("qmicli is required to verify DJI QMI readiness; install libqmi-utils on Debian/Ubuntu/Fedora, libqmi on Arch Linux, or qmi-utils on Alpine") + } return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) { - return repairDJIQMIAt(attemptContext, "/sys", "/dev") + return repairDJIQMIAt(attemptContext, "/sys", "/dev", qmicli) }) } @@ -68,7 +75,7 @@ func retryDJIQMI( return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err) } -func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMIRepairResult, returnErr error) { +func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (result djiQMIRepairResult, returnErr error) { usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") entries, err := os.ReadDir(usbRoot) if err != nil { @@ -105,20 +112,36 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI } result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber)) + driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers") + if err := ensureUSBDriverLoaded(ctx, driversRoot, "qmi_wwan", "qmi_wwan"); err != nil { + return result, err + } + if err := ensureUSBDriverLoaded(ctx, driversRoot, "option", "option"); err != nil { + return result, err + } + + // qmi_wwan's USB dynamic ID is device-wide. Leaving it installed makes it + // probe every vendor-specific interface after a USBIP reconnect; on this DJI + // composition that can turn interfaces 1-3 into bogus cdc-wdm devices and + // remove the AT port. Remove it before detaching anything, then add it only + // briefly below while interface 4 is the sole unbound interface. + qmiDriverRoot := filepath.Join(driversRoot, "qmi_wwan") + if err := removeDynamicUSBID(qmiDriverRoot, djiVendorID+" "+djiProductID); err != nil { + return result, fmt.Errorf("remove broad DJI qmi_wwan dynamic ID: %w", err) + } + + serialInterfaces, serialDevices, atDevice, err := bindDJISerialInterfaces(ctx, sysRoot, devRoot, usbRoot, driversRoot, result.USBName) + if err != nil { + return result, err + } + result.SerialInterfaces = serialInterfaces + result.SerialDevices = serialDevices + result.ATDevice = atDevice + result.OriginalDriver = usbInterfaceDriver(interfacePath) if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" { return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver) } - driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers") - if _, err := os.Stat(filepath.Join(driversRoot, "qmi_wwan")); err != nil { - modprobe, lookErr := exec.LookPath("modprobe") - if lookErr != nil { - return result, errors.New("qmi_wwan is not loaded and modprobe is unavailable") - } - if output, loadErr := exec.CommandContext(ctx, modprobe, "qmi_wwan").CombinedOutput(); loadErr != nil { - return result, fmt.Errorf("load qmi_wwan: %w: %s", loadErr, strings.TrimSpace(string(output))) - } - } interfaceDetached := false restoreOriginal := func() { @@ -128,7 +151,10 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" { _ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface) } - if result.OriginalDriver != "" { + switch result.OriginalDriver { + case "qmi_wwan": + _ = bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface) + case "option": _ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface) } } @@ -147,20 +173,8 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI return result, err } - bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind") - if err := writeSysfs(bindPath, result.Interface); err != nil { - newIDErr := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "new_id"), djiVendorID+" "+djiProductID) - if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) { - return result, fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr) - } - if usbInterfaceDriver(interfacePath) != "qmi_wwan" { - if retryErr := writeSysfs(bindPath, result.Interface); retryErr != nil { - return result, fmt.Errorf("bind qmi_wwan to %s: %w", result.Interface, retryErr) - } - } - } - if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" { - return result, fmt.Errorf("interface %s driver is %q after qmi_wwan bind", result.Interface, driver) + if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil { + return result, err } deadline := time.Now().Add(2 * time.Second) @@ -178,25 +192,168 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI } time.Sleep(25 * time.Millisecond) } + // The requested driver topology is now established. A later DMS timeout is + // a QMI/USBIP readiness problem, so do not roll interface 4 back to option. + interfaceDetached = false time.Sleep(250 * time.Millisecond) - qmicli, err := exec.LookPath("qmicli") - if err != nil { - return result, errors.New("qmicli is required to verify DJI QMI readiness after DTR repair") - } probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second) output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput() + probeContextErr := probeContext.Err() cancelProbe() result.QMIProbe = strings.TrimSpace(string(output)) if probeErr != nil { - if probeContext.Err() != nil { - probeErr = errors.Join(probeErr, probeContext.Err()) + if probeContextErr != nil { + probeErr = errors.Join(probeErr, probeContextErr) } return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe) } - interfaceDetached = false return result, nil } +func bindDJIQMIInterface(driverRoot, interfacePath, interfaceName string) (returnErr error) { + bindPath := filepath.Join(driverRoot, "bind") + dynamicIDAdded := false + defer func() { + if dynamicIDAdded { + removeErr := removeDynamicUSBID(driverRoot, djiVendorID+" "+djiProductID) + if returnErr == nil && removeErr != nil { + returnErr = fmt.Errorf("remove temporary DJI qmi_wwan dynamic ID: %w", removeErr) + } + } + }() + if err := writeSysfs(bindPath, interfaceName); err != nil { + newIDErr := writeSysfs(filepath.Join(driverRoot, "new_id"), djiVendorID+" "+djiProductID) + if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) { + return fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr) + } + dynamicIDAdded = true + if usbInterfaceDriver(interfacePath) != "qmi_wwan" { + if retryErr := writeSysfs(bindPath, interfaceName); retryErr != nil { + return fmt.Errorf("bind qmi_wwan to %s: %w", interfaceName, retryErr) + } + } + } + if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" { + return fmt.Errorf("interface %s driver is %q after qmi_wwan bind", interfaceName, driver) + } + return nil +} + +func ensureUSBDriverLoaded(ctx context.Context, driversRoot, driverName, moduleName string) error { + if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect %s driver: %w", driverName, err) + } + modprobe, err := exec.LookPath("modprobe") + if err != nil { + return fmt.Errorf("%s is not loaded and modprobe is unavailable", driverName) + } + if output, loadErr := exec.CommandContext(ctx, modprobe, moduleName).CombinedOutput(); loadErr != nil { + return fmt.Errorf("load %s: %w: %s", moduleName, loadErr, strings.TrimSpace(string(output))) + } + if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err != nil { + return fmt.Errorf("%s driver is unavailable after loading module %s: %w", driverName, moduleName, err) + } + return nil +} + +func bindDJISerialInterfaces( + ctx context.Context, + sysRoot, devRoot, usbRoot, driversRoot, usbName string, +) ([]string, []string, string, error) { + interfaceNames := make([]string, 0, djiLastSerialIndex-djiFirstSerialIndex+1) + interfacePaths := make([]string, 0, cap(interfaceNames)) + needsDynamicID := false + for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ { + name := fmt.Sprintf("%s:1.%d", usbName, index) + path := filepath.Join(usbRoot, name) + if _, err := os.Stat(path); err != nil { + return nil, nil, "", fmt.Errorf("DJI serial interface %s unavailable: %w", name, err) + } + driver := usbInterfaceDriver(path) + if driver != "" && driver != "option" && driver != "qmi_wwan" { + return nil, nil, "", fmt.Errorf("refusing to replace unexpected driver %q on %s", driver, name) + } + interfaceNames = append(interfaceNames, name) + interfacePaths = append(interfacePaths, path) + needsDynamicID = needsDynamicID || driver != "option" + } + + if needsDynamicID { + // Detach every false QMI claim before option's new_id triggers probing. + for index, path := range interfacePaths { + if usbInterfaceDriver(path) != "qmi_wwan" { + continue + } + if err := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "unbind"), interfaceNames[index]); err != nil { + return nil, nil, "", fmt.Errorf("unbind qmi_wwan from serial interface %s: %w", interfaceNames[index], err) + } + } + + optionSerialRoot := filepath.Join(sysRoot, "bus", "usb-serial", "drivers", "option1") + if _, err := os.Stat(optionSerialRoot); err != nil { + return nil, nil, "", fmt.Errorf("option USB-serial driver is unavailable: %w", err) + } + if err := writeSysfs(filepath.Join(optionSerialRoot, "new_id"), djiVendorID+" "+djiProductID); err != nil && !errors.Is(err, syscall.EEXIST) { + return nil, nil, "", fmt.Errorf("register DJI option dynamic ID: %w", err) + } + + for index, path := range interfacePaths { + if usbInterfaceDriver(path) == "option" { + continue + } + if err := writeSysfs(filepath.Join(driversRoot, "option", "bind"), interfaceNames[index]); err != nil { + return nil, nil, "", fmt.Errorf("bind option to %s: %w", interfaceNames[index], err) + } + } + } + for index, path := range interfacePaths { + if driver := usbInterfaceDriver(path); driver != "option" { + return nil, nil, "", fmt.Errorf("serial interface %s driver is %q after option bind", interfaceNames[index], driver) + } + } + + deadline := time.Now().Add(2 * time.Second) + serialDevices := make([]string, len(interfacePaths)) + for { + complete := true + for index, path := range interfacePaths { + name := firstEntryName(path, "ttyUSB") + if name == "" { + complete = false + continue + } + serialDevices[index] = filepath.Join(devRoot, name) + } + if complete { + break + } + if err := ctx.Err(); err != nil { + return nil, nil, "", err + } + if time.Now().After(deadline) { + return nil, nil, "", fmt.Errorf("option bound but not all ttyUSB nodes appeared for %s", usbName) + } + time.Sleep(25 * time.Millisecond) + } + return interfaceNames, serialDevices, serialDevices[djiATIndex-djiFirstSerialIndex], nil +} + +func removeDynamicUSBID(driverRoot, id string) error { + path := filepath.Join(driverRoot, "remove_id") + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if err := writeSysfs(path, id); err != nil && !errors.Is(err, syscall.ENODEV) && !errors.Is(err, syscall.ENOENT) { + return err + } + return nil +} + func assertUSBDTR(devicePath string, interfaceIndex int) error { fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0) if err != nil { diff --git a/cmd/vocat/doctor_dji_linux_test.go b/cmd/vocat/doctor_dji_linux_test.go index 8967ba4..b74b5a5 100644 --- a/cmd/vocat/doctor_dji_linux_test.go +++ b/cmd/vocat/doctor_dji_linux_test.go @@ -5,8 +5,10 @@ package main import ( "context" "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "time" "unsafe" @@ -49,6 +51,75 @@ func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) { } } +func TestRepairDJIQMIRequiresQMICLIBeforeUSBAccess(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + _, err := repairDJIQMI(context.Background()) + if err == nil { + t.Fatal("repairDJIQMI() unexpectedly succeeded without qmicli") + } + if !strings.Contains(err.Error(), "qmicli is required") || !strings.Contains(err.Error(), "libqmi-utils") { + t.Fatalf("repairDJIQMI() error = %q, want an actionable qmicli prerequisite error", err) + } + if strings.Contains(err.Error(), "DTR repair attempt") || strings.Contains(err.Error(), "USB topology") { + t.Fatalf("repairDJIQMI() touched the repair path before checking qmicli: %v", err) + } +} + +func TestDJISerialInterfaceLayout(t *testing.T) { + if djiFirstSerialIndex != 0 || djiLastSerialIndex != 3 || djiATIndex != 2 || djiQMIIndex != 4 { + t.Fatalf( + "DJI interface layout = serial %d-%d, AT %d, QMI %d; want serial 0-3, AT 2, QMI 4", + djiFirstSerialIndex, + djiLastSerialIndex, + djiATIndex, + djiQMIIndex, + ) + } +} + +func TestBindDJISerialInterfacesAlreadyCorrect(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers") + optionRoot := filepath.Join(driversRoot, "option") + if err := os.MkdirAll(optionRoot, 0o755); err != nil { + t.Fatal(err) + } + for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ { + interfacePath := filepath.Join(usbRoot, fmt.Sprintf("1-1:1.%d", index)) + if err := os.MkdirAll(filepath.Join(interfacePath, fmt.Sprintf("ttyUSB%d", index)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(optionRoot, filepath.Join(interfacePath, "driver")); err != nil { + t.Fatal(err) + } + } + + interfaces, devices, atDevice, err := bindDJISerialInterfaces( + context.Background(), + sysRoot, + devRoot, + usbRoot, + driversRoot, + "1-1", + ) + if err != nil { + t.Fatalf("bindDJISerialInterfaces() error = %v", err) + } + if len(interfaces) != 4 || interfaces[2] != "1-1:1.2" { + t.Fatalf("interfaces = %#v, want four interfaces with AT at 1-1:1.2", interfaces) + } + if len(devices) != 4 || devices[2] != filepath.Join(devRoot, "ttyUSB2") { + t.Fatalf("devices = %#v, want four devices with AT at ttyUSB2", devices) + } + if atDevice != filepath.Join(devRoot, "ttyUSB2") { + t.Fatalf("AT device = %q, want %q", atDevice, filepath.Join(devRoot, "ttyUSB2")) + } +} + func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) { attempts := 0 result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) { diff --git a/cmd/vocat/install_script_test.go b/cmd/vocat/install_script_test.go index 14668cc..a775b80 100644 --- a/cmd/vocat/install_script_test.go +++ b/cmd/vocat/install_script_test.go @@ -29,3 +29,29 @@ func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) { t.Fatal("installer replaces the current binary before validating database compatibility") } } + +func TestInstallerProvidesRequiredQMIUtilities(t *testing.T) { + scriptBytes, err := os.ReadFile("../../scripts/install.sh") + if err != nil { + t.Fatal(err) + } + script := string(scriptBytes) + for _, required := range []string{ + "install_qmi_support()", + "command -v qmicli", + "command -v qmi-network", + "apt-get install -y libqmi-utils", + "dnf install -y libqmi-utils", + "pacman -Sy --noconfirm libqmi", + "apk add --no-cache qmi-utils", + "Could not install or find qmicli/qmi-network", + } { + if !strings.Contains(script, required) { + t.Errorf("installer is missing required QMI handling %q", required) + } + } + mainStart := strings.LastIndex(script, "# --- Main ") + if mainStart < 0 || !strings.Contains(script[mainStart:], "install_qmi_support") { + t.Error("installer does not install QMI utilities from its main path") + } +} diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index a4e9c0e..dc32caf 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -175,6 +175,16 @@ USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支 `apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪, VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。 +### QMI 命令行工具 + +VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理 +分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时, +Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用 +`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`。 + +`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查 +`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。 + ## 配置 Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。 diff --git a/internal/server/device_api.go b/internal/server/device_api.go index e0ba483..2bf6578 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -1636,7 +1636,14 @@ func (s *Server) configuredDeviceOverview( result["id"] = config.ID result["name"] = config.Name result["interface"] = config.Interface - result["at_port"] = config.ATPort + // ttyUSB allocation changes across USB reconnects and boot cycles. The AT + // terminal must use only the currently discovered physical port; a stored + // path may point at another modem after enumeration order changes. + liveATPort := "" + if present { + liveATPort = entry.Candidate.ATPort.OpenPath() + } + result["at_port"] = liveATPort result["audio_device"] = config.AudioDevice result["backend_mode"] = config.DeviceBackend result["control_device"] = config.ControlDevice diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go index 6c38b5a..8ed49c3 100644 --- a/internal/server/device_summary_test.go +++ b/internal/server/device_summary_test.go @@ -134,6 +134,29 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) { } } +func TestConfiguredDeviceOverviewAlwaysUsesLiveDiscoveredATPort(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + s := &Server{store: database} + config := store.Device{ID: "ec20_1", ATPort: "/dev/ttyUSB9"} + entry := device.Device{Candidate: modem.Candidate{ + ATPort: modem.Port{Path: "/dev/ttyUSB2", Role: modem.PortRoleAT}, + }} + + connected := s.configuredDeviceOverview(config, entry, true) + if got := connected["at_port"]; got != "/dev/ttyUSB2" { + t.Fatalf("connected AT port = %#v, want live /dev/ttyUSB2", got) + } + + offline := s.configuredDeviceOverview(config, entry, false) + if got := offline["at_port"]; got != "" { + t.Fatalf("offline AT port = %#v, want empty instead of stored port", got) + } +} + func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) { for _, snapshot := range []*device.Snapshot{ {IMEI: "867123456789012"}, diff --git a/scripts/install.sh b/scripts/install.sh index 79c3f86..407b1f7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -195,6 +195,39 @@ install_linux_ip_tool() { fi } +install_qmi_support() { + msg "正在检查 QMI 命令行工具..." "Checking QMI command-line utilities..." + if command -v qmicli >/dev/null 2>&1 && command -v qmi-network >/dev/null 2>&1; then + return 0 + fi + + if is_openwrt && command -v opkg >/dev/null 2>&1; then + opkg update >/dev/null 2>&1 || true + if opkg_has_package libqmi; then + opkg install libqmi >/dev/null 2>&1 || true + fi + elif command -v apt-get >/dev/null 2>&1; then + apt-get update -qq || true + DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true + elif command -v dnf >/dev/null 2>&1; then + dnf install -y libqmi-utils || true + elif command -v yum >/dev/null 2>&1; then + yum install -y libqmi-utils || true + elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm libqmi || true + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache qmi-utils || true + fi + + if command -v qmicli >/dev/null 2>&1 && command -v qmi-network >/dev/null 2>&1; then + msg "QMI 命令行工具已就绪。" "QMI command-line utilities are ready." + return 0 + fi + die \ + "无法安装或找到 qmicli/qmi-network。请安装系统提供的 libqmi/qmi-utils 软件包后重试。" \ + "Could not install or find qmicli/qmi-network. Install your distribution's libqmi/qmi-utils package and retry." +} + install_pcsc_support() { msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..." local installed=0 @@ -538,6 +571,7 @@ enable_and_start() { # --- Main -------------------------------------------------------------------- detect_arch +install_qmi_support install_pcsc_support check_vowifi_environment if [ "$CHECK_ENV" -eq 1 ]; then