fix: provision QMI tools for DJI doctor (#53)

This commit is contained in:
Nayacco
2026-08-18 01:52:15 +08:00
committed by GitHub
parent fdb2ac45ce
commit 30880f6612
8 changed files with 110 additions and 11 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+12
View File
@@ -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.
+9 -8
View File
@@ -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)
}
+16
View File
@@ -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) {
+26
View File
@@ -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")
}
}
+10
View File
@@ -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_*` 环境变量。环境变量优先级更高。
+34
View File
@@ -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