mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-18 22:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8137fc875b | ||
|
|
1df338f9b3 | ||
|
|
20f91fac72 | ||
|
|
30880f6612 |
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_*` 环境变量。环境变量优先级更高。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user