From 67079c99b99d96099ffec24f2a5b9cd8d2d61efe Mon Sep 17 00:00:00 2001 From: Rain Seven <128443127+RAiNY7Study@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:35:38 +0800 Subject: [PATCH] feat: add DJI QMI recovery doctor (#41) Co-authored-by: Meng Meng <227010654+MengMengCode@users.noreply.github.com> --- cmd/vocat/cli.go | 3 + cmd/vocat/doctor.go | 172 +++++++++++++++++ cmd/vocat/doctor_dji_linux.go | 298 +++++++++++++++++++++++++++++ cmd/vocat/doctor_dji_linux_test.go | 81 ++++++++ cmd/vocat/doctor_dji_other.go | 12 ++ cmd/vocat/doctor_test.go | 9 + cmd/vocat/main.go | 5 + 7 files changed, 580 insertions(+) create mode 100644 cmd/vocat/doctor.go create mode 100644 cmd/vocat/doctor_dji_linux.go create mode 100644 cmd/vocat/doctor_dji_linux_test.go create mode 100644 cmd/vocat/doctor_dji_other.go create mode 100644 cmd/vocat/doctor_test.go diff --git a/cmd/vocat/cli.go b/cmd/vocat/cli.go index a9db695..9667729 100644 --- a/cmd/vocat/cli.go +++ b/cmd/vocat/cli.go @@ -21,6 +21,9 @@ Usage: vocat serve Run the server in the foreground (use from a TTY when 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. vocat update Check GitHub for a newer release and self-update. Flags: --check Only report whether an update is available. diff --git a/cmd/vocat/doctor.go b/cmd/vocat/doctor.go new file mode 100644 index 0000000..fe41105 --- /dev/null +++ b/cmd/vocat/doctor.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "vocat/internal/modem" + "vocat/internal/pcsc" + "vocat/internal/proxy" +) + +type doctorCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Code string `json:"code,omitempty"` + Message string `json:"message"` + Evidence any `json:"evidence,omitempty"` +} + +type doctorReport struct { + Time time.Time `json:"time"` + OS string `json:"os"` + Arch string `json:"arch"` + Checks []doctorCheck `json:"checks"` +} + +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"` +} + +func runDoctor(args []string) error { + flags := flag.NewFlagSet("doctor", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + 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)") + 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 { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if flags.NArg() != 0 || *timeout <= 0 || *timeout > time.Minute { + return errors.New("usage: vocat doctor [--repair-dji-qmi] [--proxy host:port] [--proxy-username name] [--proxy-password-env ENV] [--json]") + } + report := doctorReport{Time: time.Now().UTC(), OS: runtime.GOOS, Arch: runtime.GOARCH} + add := func(name, status, code, message string, evidence any) { + report.Checks = append(report.Checks, doctorCheck{Name: name, Status: status, Code: code, Message: message, Evidence: evidence}) + } + + if data, err := os.ReadFile("/proc/version"); err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") { + add("host", "warning", "wsl_usbip_detected", "WSL/USBIP detected; QMI control transfers may time out even when /dev/cdc-wdm exists", nil) + } else { + add("host", "passed", "native_host", "No WSL kernel marker detected", nil) + } + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + if *repairDJI { + result, err := repairDJIQMI(ctx) + 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) + } + + candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx) + if discoverErr != nil { + add("modem_discovery", "failed", "modem_discovery_failed", discoverErr.Error(), nil) + } else if len(candidates) == 0 { + add("modem_discovery", "warning", "no_modem", "No USB modem was discovered", nil) + } else { + add("modem_discovery", "passed", "modem_discovered", fmt.Sprintf("Discovered %d modem candidate(s)", len(candidates)), candidates) + } + for _, candidate := range candidates { + name := "modem:" + candidate.ID + if candidate.HasATPort() { + probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 5*time.Second)) + client, openErr := (modem.SerialOpener{}).Open(probeContext, candidate.ATPort) + if openErr != nil { + add(name+":at", "warning", "at_open_failed", openErr.Error(), candidate.ATPort.OpenPath()) + } else { + response, commandErr := client.Execute(probeContext, "AT+CFUN?") + _ = client.Close() + if commandErr != nil { + add(name+":at", "warning", "at_probe_failed", commandErr.Error(), candidate.ATPort.OpenPath()) + } else { + add(name+":at", "passed", "at_ready", "AT control channel responded to a read-only CFUN query", response.Text()) + } + } + cancelProbe() + } else { + add(name+":at", "failed", "at_missing", "No AT port was selected", nil) + } + if strings.TrimSpace(candidate.QMIControl) == "" { + add(name+":qmi", "warning", "qmi_missing", "No cdc-wdm/QMI control node was discovered", nil) + } else if qmicli, lookErr := exec.LookPath("qmicli"); lookErr != nil { + add(name+":qmi", "warning", "qmicli_missing", "QMI node exists but qmicli is unavailable for an active DMS check", candidate.QMIControl) + } else { + probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 8*time.Second)) + command := exec.CommandContext(probeContext, qmicli, "-d", candidate.QMIControl, "--dms-get-operating-mode") + output, commandErr := command.CombinedOutput() + message := strings.TrimSpace(string(output)) + cancelProbe() + if commandErr != nil { + code := "qmi_cid_failed" + if errors.Is(probeContext.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timed out") { + code = "qmi_cid_timeout" + } + add(name+":qmi", "failed", code, "qmicli DMS client allocation/read failed", message) + } else { + add(name+":qmi", "passed", "qmi_dms_ready", "qmicli allocated DMS and completed a read-only request", message) + } + } + } + + readers, readerErr := pcsc.New().Readers(ctx) + if readerErr == nil { + add("pcsc", "passed", "pcsc_ready", fmt.Sprintf("PC/SC reported %d reader(s)", len(readers)), readers) + } else if errors.Is(readerErr, pcsc.ErrUnsupported) || errors.Is(readerErr, pcsc.ErrUnavailable) { + add("pcsc", "warning", "pcsc_unavailable", readerErr.Error(), nil) + } else { + add("pcsc", "failed", "pcsc_failed", readerErr.Error(), nil) + } + + if strings.TrimSpace(*proxyAddress) != "" { + password := os.Getenv(strings.TrimSpace(*passwordEnv)) + probeContext, cancelProbe := context.WithTimeout(context.Background(), *timeout) + result, probeErr := proxy.ProbeSOCKS5(probeContext, *proxyAddress, *proxyUsername, password, *timeout) + cancelProbe() + status := "passed" + if probeErr != nil { + status = "failed" + } + add("proxy_udp", status, result.Diagnosis, result.Hint, result) + } + + if *jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(report) + } + for _, check := range report.Checks { + fmt.Printf("%-8s %-26s %-28s %s\n", strings.ToUpper(check.Status), check.Name, check.Code, check.Message) + } + return nil +} + +func minDuration(left, right time.Duration) time.Duration { + if left < right { + return left + } + return right +} diff --git a/cmd/vocat/doctor_dji_linux.go b/cmd/vocat/doctor_dji_linux.go new file mode 100644 index 0000000..8d41e17 --- /dev/null +++ b/cmd/vocat/doctor_dji_linux.go @@ -0,0 +1,298 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/unix" +) + +const ( + djiVendorID = "2ca3" + djiProductID = "4006" + djiQMIIndex = 4 +) + +type usbControlTransfer struct { + RequestType uint8 + Request uint8 + Value uint16 + Index uint16 + Length uint16 + Timeout uint32 + Data uintptr +} + +func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) { + return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) { + return repairDJIQMIAt(attemptContext, "/sys", "/dev") + }) +} + +func retryDJIQMI( + ctx context.Context, + maxAttempts int, + delay time.Duration, + attempt func(context.Context) (djiQMIRepairResult, error), +) (djiQMIRepairResult, error) { + var result djiQMIRepairResult + var err error + for attemptNumber := 1; attemptNumber <= maxAttempts; attemptNumber++ { + result, err = attempt(ctx) + result.Attempts = attemptNumber + if err == nil { + return result, nil + } + if ctx.Err() != nil { + break + } + timer := time.NewTimer(time.Duration(attemptNumber) * delay) + select { + case <-ctx.Done(): + timer.Stop() + return result, errors.Join(err, ctx.Err()) + case <-timer.C: + } + } + 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) { + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + entries, err := os.ReadDir(usbRoot) + if err != nil { + return result, fmt.Errorf("read USB topology: %w", err) + } + var usbNames []string + for _, entry := range entries { + devicePath := filepath.Join(usbRoot, entry.Name()) + vendor, vendorErr := readTrimmedFile(filepath.Join(devicePath, "idVendor")) + product, productErr := readTrimmedFile(filepath.Join(devicePath, "idProduct")) + if vendorErr == nil && productErr == nil && + strings.EqualFold(vendor, djiVendorID) && strings.EqualFold(product, djiProductID) { + usbNames = append(usbNames, entry.Name()) + } + } + if len(usbNames) != 1 { + return result, fmt.Errorf("expected exactly one DJI %s:%s USB device, found %d", djiVendorID, djiProductID, len(usbNames)) + } + result.USBName = usbNames[0] + result.Interface = fmt.Sprintf("%s:1.%d", result.USBName, djiQMIIndex) + devicePath := filepath.Join(usbRoot, result.USBName) + interfacePath := filepath.Join(usbRoot, result.Interface) + if _, err := os.Stat(interfacePath); err != nil { + return result, fmt.Errorf("DJI QMI interface %s unavailable: %w", result.Interface, err) + } + + busNumber, err := readUSBNumber(filepath.Join(devicePath, "busnum")) + if err != nil { + return result, err + } + deviceNumber, err := readUSBNumber(filepath.Join(devicePath, "devnum")) + if err != nil { + return result, err + } + result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber)) + + 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() { + if !interfaceDetached { + return + } + if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" { + _ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface) + } + if result.OriginalDriver != "" { + _ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface) + } + } + defer func() { + if returnErr != nil { + restoreOriginal() + } + }() + if result.OriginalDriver != "" { + if err := writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "unbind"), result.Interface); err != nil { + return result, fmt.Errorf("unbind %s from %s: %w", result.OriginalDriver, result.Interface, err) + } + interfaceDetached = true + } + if err := assertUSBDTR(result.USBDevice, djiQMIIndex); err != nil { + 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) + } + + deadline := time.Now().Add(2 * time.Second) + for { + result.ControlDevice = firstDeviceNode(filepath.Join(interfacePath, "usbmisc"), devRoot, "cdc-wdm") + result.NetworkInterface = firstEntryName(filepath.Join(interfacePath, "net"), "") + if result.ControlDevice != "" { + break + } + if err := ctx.Err(); err != nil { + return result, err + } + if time.Now().After(deadline) { + return result, fmt.Errorf("qmi_wwan bound but no cdc-wdm node appeared for %s", result.Interface) + } + 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() + cancelProbe() + result.QMIProbe = strings.TrimSpace(string(output)) + if probeErr != nil { + if probeContext.Err() != nil { + probeErr = errors.Join(probeErr, probeContext.Err()) + } + return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe) + } + interfaceDetached = false + return result, nil +} + +func assertUSBDTR(devicePath string, interfaceIndex int) error { + fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("open USB device %s: %w", devicePath, err) + } + defer unix.Close(fd) + if err := setUSBControlLineState(fd, interfaceIndex, false); err != nil { + return fmt.Errorf("clear CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err) + } + time.Sleep(50 * time.Millisecond) + if err := setUSBControlLineState(fd, interfaceIndex, true); err != nil { + return fmt.Errorf("assert CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err) + } + // QDC507 acknowledges the control transfer before its QMI firmware is ready. + time.Sleep(time.Second) + return nil +} + +func setUSBControlLineState(fd, interfaceIndex int, dtr bool) error { + var value uint16 + if dtr { + value = 1 // USB_CDC_CTRL_DTR + } + transfer := usbControlTransfer{ + RequestType: 0x21, // host-to-device, class, interface + Request: 0x22, // USB_CDC_REQ_SET_CONTROL_LINE_STATE + Value: value, + Index: uint16(interfaceIndex), + Timeout: 5000, + } + const ioctlDirectionReadWrite = uintptr(3) + request := ioctlDirectionReadWrite<<30 | + uintptr(unsafe.Sizeof(transfer))<<16 | + uintptr('U')<<8 + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(unsafe.Pointer(&transfer))) + if errno != 0 { + return errno + } + return nil +} + +func readTrimmedFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func readUSBNumber(path string) (int, error) { + value, err := readTrimmedFile(path) + if err != nil { + return 0, fmt.Errorf("read %s: %w", filepath.Base(path), err) + } + number, err := strconv.Atoi(value) + if err != nil || number < 1 || number > 999 { + return 0, fmt.Errorf("invalid %s %q", filepath.Base(path), value) + } + return number, nil +} + +func usbInterfaceDriver(interfacePath string) string { + resolved, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver")) + if err != nil { + return "" + } + return filepath.Base(resolved) +} + +func writeSysfs(path, value string) error { + file, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return err + } + _, writeErr := file.WriteString(value) + closeErr := file.Close() + return errors.Join(writeErr, closeErr) +} + +func firstDeviceNode(directory, devRoot, prefix string) string { + name := firstEntryName(directory, prefix) + if name == "" { + return "" + } + return filepath.Join(devRoot, name) +} + +func firstEntryName(directory, prefix string) string { + entries, err := os.ReadDir(directory) + if err != nil { + return "" + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + return entry.Name() + } + } + return "" +} diff --git a/cmd/vocat/doctor_dji_linux_test.go b/cmd/vocat/doctor_dji_linux_test.go new file mode 100644 index 0000000..8967ba4 --- /dev/null +++ b/cmd/vocat/doctor_dji_linux_test.go @@ -0,0 +1,81 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + "unsafe" +) + +func TestDJIUSBControlTransferLayout(t *testing.T) { + var transfer usbControlTransfer + if got := unsafe.Sizeof(transfer); got != 24 { + t.Fatalf("usbControlTransfer size = %d, want 24", got) + } + if transfer.RequestType != 0 || transfer.Request != 0 { + t.Fatal("zero-value transfer unexpectedly initialized") + } +} + +func TestReadUSBNumber(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "busnum") + if err := os.WriteFile(path, []byte("12\n"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := readUSBNumber(path); err != nil || got != 12 { + t.Fatalf("readUSBNumber() = %d, %v, want 12, nil", got, err) + } + if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readUSBNumber(path); err == nil { + t.Fatal("readUSBNumber(0) unexpectedly succeeded") + } +} + +func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing") + if err := writeSysfs(path, "value"); err == nil { + t.Fatal("writeSysfs(missing) unexpectedly succeeded") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("missing sysfs path was created: %v", err) + } +} + +func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) { + attempts := 0 + result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) { + attempts++ + if attempts < 3 { + return djiQMIRepairResult{}, errors.New("transient QMI timeout") + } + return djiQMIRepairResult{ControlDevice: "/dev/cdc-wdm0"}, nil + }) + if err != nil { + t.Fatalf("retryDJIQMI() error = %v", err) + } + if attempts != 3 || result.Attempts != 3 { + t.Fatalf("attempts = %d, result.Attempts = %d, want 3", attempts, result.Attempts) + } +} + +func TestRetryDJIQMIStopsAfterBoundedAttempts(t *testing.T) { + attempts := 0 + _, err := retryDJIQMI(context.Background(), 2, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) { + attempts++ + return djiQMIRepairResult{}, errors.New("persistent failure") + }) + if err == nil { + t.Fatal("retryDJIQMI() unexpectedly succeeded") + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2", attempts) + } +} diff --git a/cmd/vocat/doctor_dji_other.go b/cmd/vocat/doctor_dji_other.go new file mode 100644 index 0000000..7a45522 --- /dev/null +++ b/cmd/vocat/doctor_dji_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package main + +import ( + "context" + "errors" +) + +func repairDJIQMI(context.Context) (djiQMIRepairResult, error) { + return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux") +} diff --git a/cmd/vocat/doctor_test.go b/cmd/vocat/doctor_test.go new file mode 100644 index 0000000..8ccded0 --- /dev/null +++ b/cmd/vocat/doctor_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestDoctorHelpIsSuccessful(t *testing.T) { + if err := runDoctor([]string{"--help"}); err != nil { + t.Fatalf("runDoctor(--help) error = %v", err) + } +} diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index caebec8..48b4d2d 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -77,6 +77,11 @@ func main() { logger.Error("update failed", "error", err) os.Exit(1) } + case "doctor": + if err := runDoctor(rest); err != nil { + logger.Error("doctor failed", "error", err) + os.Exit(1) + } case "menu": if err := runMenu(logger); err != nil { logger.Error("menu failed", "error", err)