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/doctor_dji_linux.go b/cmd/vocat/doctor_dji_linux.go index 8d41e17..dde3b34 100644 --- a/cmd/vocat/doctor_dji_linux.go +++ b/cmd/vocat/doctor_dji_linux.go @@ -35,8 +35,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 +72,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 { @@ -179,17 +183,14 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI time.Sleep(25 * time.Millisecond) } 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) } diff --git a/cmd/vocat/doctor_dji_linux_test.go b/cmd/vocat/doctor_dji_linux_test.go index 8967ba4..28fe4f7 100644 --- a/cmd/vocat/doctor_dji_linux_test.go +++ b/cmd/vocat/doctor_dji_linux_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" "unsafe" @@ -49,6 +50,21 @@ 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 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/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