feat: add DJI QMI recovery doctor (#41)

Co-authored-by: Meng Meng <[email protected]>
This commit is contained in:
Rain Seven
2026-08-16 23:35:38 +08:00
committed by GitHub
co-authored by Meng Meng
parent f949001480
commit 67079c99b9
7 changed files with 580 additions and 0 deletions
+3
View File
@@ -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.
+172
View File
@@ -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
}
+298
View File
@@ -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 ""
}
+81
View File
@@ -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)
}
}
+12
View File
@@ -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")
}
+9
View File
@@ -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)
}
}
+5
View File
@@ -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)