Compare commits

..
4 Commits
Author SHA1 Message Date
MengMengCode 8137fc875b Merge branch 'master' of https://github.com/MengMengCode/VoCat 2026-08-18 14:45:27 +08:00
MengMengCode 1df338f9b3 FIX #59 2026-08-18 14:45:23 +08:00
NayaccoandGitHub 20f91fac72 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.
2026-08-18 11:46:43 +08:00
NayaccoandGitHub 30880f6612 fix: provision QMI tools for DJI doctor (#53) 2026-08-18 01:52:15 +08:00
14 changed files with 421 additions and 53 deletions
+2 -2
View File
@@ -49,13 +49,13 @@ jobs:
BUILD_TIME=${{ github.event.repository.updated_at }} BUILD_TIME=${{ github.event.repository.updated_at }}
cache-from: type=gha cache-from: type=gha
- name: Verify ${{ matrix.platform }} runtime and smart-card stack - name: Verify ${{ matrix.platform }} runtime, QMI, and smart-card stack
run: | run: |
docker run --rm --platform '${{ matrix.platform }}' \ docker run --rm --platform '${{ matrix.platform }}' \
vocat-smoke:${{ matrix.arch }} version vocat-smoke:${{ matrix.arch }} version
docker run --rm --platform '${{ matrix.platform }}' \ docker run --rm --platform '${{ matrix.platform }}' \
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \ --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: build-and-push:
needs: smoke 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 ---- # ---- Stage 3: minimal runtime ----
FROM alpine:3.20 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 && \ addgroup -S -g 1000 vocat && \
adduser -S -D -H -u 1000 -G vocat 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 VoCat keeps the reader visible in the add-device dialog and reports the missing
service or driver instead of silently hiding it. 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 ## Configuration
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence. Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
+3 -2
View File
@@ -22,8 +22,9 @@ Usage:
vocat without arguments would enter the menu). vocat without arguments would enter the menu).
vocat version Print the build version and exit. vocat version Print the build version and exit.
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths. 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 Use --repair-dji-qmi on Linux to restore the factory-ID
DJI/Baiwang 2ca3:4006 QMI interface without changing NV. DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake
QMI without changing NV.
vocat carrier import-ipcc [flags] FILE.ipcc vocat carrier import-ipcc [flags] FILE.ipcc
Convert an Apple carrier bundle into a reviewable VoCat Convert an Apple carrier bundle into a reviewable VoCat
profile. Preview is the default; --install writes it to profile. Preview is the default; --install writes it to
+13 -10
View File
@@ -33,14 +33,17 @@ type doctorReport struct {
} }
type djiQMIRepairResult struct { type djiQMIRepairResult struct {
USBName string `json:"usb_name"` USBName string `json:"usb_name"`
Interface string `json:"interface"` Interface string `json:"interface"`
USBDevice string `json:"usb_device"` USBDevice string `json:"usb_device"`
OriginalDriver string `json:"original_driver,omitempty"` OriginalDriver string `json:"original_driver,omitempty"`
ControlDevice string `json:"control_device"` SerialInterfaces []string `json:"serial_interfaces,omitempty"`
NetworkInterface string `json:"network_interface,omitempty"` SerialDevices []string `json:"serial_devices,omitempty"`
QMIProbe string `json:"qmi_probe"` ATDevice string `json:"at_device,omitempty"`
Attempts int `json:"attempts"` 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 { func runDoctor(args []string) error {
@@ -49,7 +52,7 @@ func runDoctor(args []string) error {
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test") proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username") proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password") 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") jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout") timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
@@ -79,7 +82,7 @@ func runDoctor(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("repair DJI QMI binding: %w", err) 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) candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
+194 -37
View File
@@ -19,9 +19,12 @@ import (
) )
const ( const (
djiVendorID = "2ca3" djiVendorID = "2ca3"
djiProductID = "4006" djiProductID = "4006"
djiQMIIndex = 4 djiFirstSerialIndex = 0
djiLastSerialIndex = 3
djiATIndex = 2
djiQMIIndex = 4
) )
type usbControlTransfer struct { type usbControlTransfer struct {
@@ -35,8 +38,12 @@ type usbControlTransfer struct {
} }
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) { 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 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 +75,7 @@ func retryDJIQMI(
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err) 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") usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
entries, err := os.ReadDir(usbRoot) entries, err := os.ReadDir(usbRoot)
if err != nil { if err != nil {
@@ -105,20 +112,36 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
} }
result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber)) 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) result.OriginalDriver = usbInterfaceDriver(interfacePath)
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" { if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver) 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 interfaceDetached := false
restoreOriginal := func() { restoreOriginal := func() {
@@ -128,7 +151,10 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" { if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface) _ = 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) _ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
} }
} }
@@ -147,20 +173,8 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
return result, err return result, err
} }
bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind") if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
if err := writeSysfs(bindPath, result.Interface); err != nil { return result, err
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) deadline := time.Now().Add(2 * time.Second)
@@ -178,25 +192,168 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
} }
time.Sleep(25 * time.Millisecond) 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) 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) probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput() output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
probeContextErr := probeContext.Err()
cancelProbe() cancelProbe()
result.QMIProbe = strings.TrimSpace(string(output)) result.QMIProbe = strings.TrimSpace(string(output))
if probeErr != nil { if probeErr != nil {
if probeContext.Err() != nil { if probeContextErr != nil {
probeErr = errors.Join(probeErr, probeContext.Err()) probeErr = errors.Join(probeErr, probeContextErr)
} }
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe) return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
} }
interfaceDetached = false
return result, nil 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 { func assertUSBDTR(devicePath string, interfaceIndex int) error {
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0) fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
if err != nil { if err != nil {
+71
View File
@@ -5,8 +5,10 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"unsafe" "unsafe"
@@ -49,6 +51,75 @@ 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 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) { func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
attempts := 0 attempts := 0
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) { 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") 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 尚未就绪, `apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。 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_*` 环境变量。环境变量优先级更高。 Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
+6
View File
@@ -852,7 +852,13 @@ func decodeUserData(
message.Text = string(utf16.Decode(units)) message.Text = string(utf16.Decode(units))
return nil return nil
default: default:
// 8-bit (binary) user data has no portable text representation, so the
// raw payload bytes are rendered as uppercase hexadecimal after the user
// data header is stripped. This keeps the bubble non-empty and gives a
// faithful rendering of the delivered content rather than a blank "".
message.Encoding = SMSEncoding8BitPDU message.Encoding = SMSEncoding8BitPDU
payload := data[headerBytes:]
message.Text = strings.ToUpper(hex.EncodeToString(payload))
return nil return nil
} }
} }
+18
View File
@@ -263,3 +263,21 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
t.Fatalf("messages = %#v", messages) t.Fatalf("messages = %#v", messages)
} }
} }
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
// Built from the GSM-7 deliver vector by swapping the DCS to 0xF5
// and replacing the user data with three raw binary bytes.
message, err := decodeSMSPDU(
"000405912143F500F54210203040500003AABBCC",
)
if err != nil {
t.Fatalf("decode 8-bit: %v", err)
}
if message.Encoding != SMSEncoding8BitPDU ||
message.Text != "AABBCC" ||
message.RawUserData != "AABBCC" {
t.Fatalf("8-bit message = %#v", message)
}
}
+8 -1
View File
@@ -1636,7 +1636,14 @@ func (s *Server) configuredDeviceOverview(
result["id"] = config.ID result["id"] = config.ID
result["name"] = config.Name result["name"] = config.Name
result["interface"] = config.Interface 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["audio_device"] = config.AudioDevice
result["backend_mode"] = config.DeviceBackend result["backend_mode"] = config.DeviceBackend
result["control_device"] = config.ControlDevice 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) { func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
for _, snapshot := range []*device.Snapshot{ for _, snapshot := range []*device.Snapshot{
{IMEI: "867123456789012"}, {IMEI: "867123456789012"},
+34
View File
@@ -195,6 +195,39 @@ install_linux_ip_tool() {
fi 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() { install_pcsc_support() {
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..." msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
local installed=0 local installed=0
@@ -538,6 +571,7 @@ enable_and_start() {
# --- Main -------------------------------------------------------------------- # --- Main --------------------------------------------------------------------
detect_arch detect_arch
install_qmi_support
install_pcsc_support install_pcsc_support
check_vowifi_environment check_vowifi_environment
if [ "$CHECK_ENV" -eq 1 ]; then if [ "$CHECK_ENV" -eq 1 ]; then