fix: restore DJI modem AT availability (#55)

Normalize the DJI USB serial and QMI interface bindings without leaving a broad qmi_wwan dynamic ID, and expose only the live-discovered AT port to the terminal UI.
This commit is contained in:
Nayacco
2026-08-18 11:46:43 +08:00
committed by GitHub
parent 30880f6612
commit 20f91fac72
6 changed files with 287 additions and 42 deletions
+3 -2
View File
@@ -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
+13 -10
View File
@@ -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)
+185 -29
View File
@@ -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 {
@@ -109,20 +112,36 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (resul
}
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() {
@@ -132,7 +151,10 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (resul
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)
}
}
@@ -151,20 +173,8 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (resul
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)
@@ -182,6 +192,9 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (resul
}
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)
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
@@ -194,10 +207,153 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (resul
}
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 {
+55
View File
@@ -5,6 +5,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -65,6 +66,60 @@ func TestRepairDJIQMIRequiresQMICLIBeforeUSBAccess(t *testing.T) {
}
}
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) {
+8 -1
View File
@@ -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
+23
View File
@@ -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"},