This commit is contained in:
MengMengCode
2026-08-13 23:38:45 +08:00
parent 30c0d2d9d5
commit e546e810fe
27 changed files with 49 additions and 915 deletions
+2 -2
View File
@@ -49,13 +49,13 @@ jobs:
BUILD_TIME=${{ github.event.repository.updated_at }}
cache-from: type=gha
- name: Verify ${{ matrix.platform }} runtime and hardware stacks
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
run: |
docker run --rm --platform '${{ matrix.platform }}' \
vocat-smoke:${{ matrix.arch }} version
docker run --rm --platform '${{ matrix.platform }}' \
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
'command -v pcscd && test -d /usr/lib/pcsc/drivers && command -v mbim-network && command -v qmi-network && command -v ip'
'command -v pcscd && test -d /usr/lib/pcsc/drivers'
build-and-push:
needs: smoke
+2 -3
View File
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
# ---- Stage 3: minimal runtime ----
FROM alpine:3.20
RUN apk add --no-cache ca-certificates ccid iproute2 kmod libmbim-tools pcsc-lite qmi-utils tzdata && \
RUN apk add --no-cache ca-certificates ccid pcsc-lite tzdata && \
addgroup -S -g 1000 vocat && \
adduser -S -D -H -u 1000 -G vocat vocat
@@ -45,11 +45,10 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \
COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat
COPY scripts/docker-entrypoint.sh /usr/local/bin/vocat-entrypoint
COPY scripts/bind-sierra-em7430.sh /usr/local/bin/vocat-bind-em7430
# Symlink into /usr/local/bin so `docker exec <ctr> vocat ...` finds it via $PATH.
RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat && \
chmod 0755 /usr/local/bin/vocat-entrypoint /usr/local/bin/vocat-bind-em7430
chmod 0755 /usr/local/bin/vocat-entrypoint
# Hardware access and the bundled pcscd daemon require root inside the
# container. The container already needs host networking and privileged device
+1 -6
View File
@@ -57,12 +57,7 @@ services:
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
# devices added after the container starts.
- /dev:/dev
# Writable sysfs lets VoCat bind the exact Sierra EM7430 1199:9077 ID to
# the serial driver when a distro's kernel table does not contain it.
- /sys:/sys
# modprobe inside the privileged container uses the host kernel's module
# tree; keep the tree itself read-only.
- /lib/modules:/lib/modules:ro
- /sys:/sys:ro
volumes:
vocat-data:
+2 -14
View File
@@ -86,15 +86,13 @@ func (manager *Manager) SetNetwork(
candidate := manager.candidateFor(state)
backend := strings.ToLower(strings.TrimSpace(request.Backend))
if backend == "" {
if candidate.ControlProtocol == "mbim" && candidate.QMIControl != "" && candidate.NetworkInterface != "" {
backend = "mbim"
} else if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
backend = "qmi"
} else {
backend = "at"
}
}
if backend != "at" && backend != "qmi" && backend != "mbim" {
if backend != "at" && backend != "qmi" {
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
}
if backend == "qmi" {
@@ -110,16 +108,6 @@ func (manager *Manager) SetNetwork(
}
return result, err
}
if backend == "mbim" {
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
return NetworkResult{}, fmt.Errorf("%w: MBIM control device and network interface are required", ErrDataBackendUnavailable)
}
result, err := setMBIMNetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
if err != nil && (request.Username != "" || request.Password != "") {
return NetworkResult{}, errors.New("authenticated MBIM cellular data operation failed")
}
return result, err
}
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
-173
View File
@@ -122,179 +122,6 @@ func setQMINetwork(
}, nil
}
func setMBIMNetwork(
ctx context.Context,
candidate modem.Candidate,
enabled bool,
apn string,
ipVersion string,
username string,
password string,
authentication string,
) (NetworkResult, error) {
mbimNetwork, networkErr := exec.LookPath("mbim-network")
umbim, umbimErr := exec.LookPath("umbim")
if networkErr != nil && umbimErr != nil {
return NetworkResult{}, fmt.Errorf("%w: install libmbim-utils or umbim to control %s", ErrDataBackendUnavailable, candidate.QMIControl)
}
detail := ""
var err error
if networkErr == nil {
detail, err = runMBIMNetwork(ctx, mbimNetwork, candidate.QMIControl, enabled, apn, username, password, authentication)
} else {
detail, err = runUMBIM(ctx, umbim, candidate.QMIControl, enabled, apn, ipVersion, username, password, authentication)
}
if err != nil {
return NetworkResult{}, err
}
ipCommand, lookErr := exec.LookPath("ip")
if lookErr != nil {
return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
}
linkAction := "down"
if enabled {
linkAction = "up"
}
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
if linkErr != nil {
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
}
if enabled {
busybox, busyboxErr := exec.LookPath("busybox")
if busyboxErr != nil {
return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
}
dhcpDetail, dhcpErr := configureExportProxyDHCP(ctx, busybox, ipCommand, candidate.NetworkInterface)
if dhcpErr != nil {
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
defer cancelRollback()
clearExportProxyRoute(rollbackCtx, candidate.NetworkInterface)
if networkErr == nil {
_, _ = exec.CommandContext(rollbackCtx, mbimNetwork, candidate.QMIControl, "stop").CombinedOutput()
} else {
_, _ = exec.CommandContext(rollbackCtx, umbim, "-d", candidate.QMIControl, "disconnect").CombinedOutput()
}
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
return NetworkResult{}, fmt.Errorf("MBIM session started but protected DHCP failed: %w", dhcpErr)
}
detail = strings.TrimSpace(detail + "\n" + dhcpDetail)
} else {
clearExportProxyRoute(ctx, candidate.NetworkInterface)
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "addr", "flush", "dev", candidate.NetworkInterface, "scope", "global").CombinedOutput()
}
if username != "" || password != "" {
// Both reference tools may echo profile fields; never retain them in API
// responses, state, or logs.
detail = map[bool]string{true: "authenticated MBIM session started", false: "authenticated MBIM session stopped"}[enabled]
}
return NetworkResult{
Enabled: enabled, Backend: "mbim", Interface: candidate.NetworkInterface,
ControlDevice: candidate.QMIControl, APN: apn, IPVersion: ipVersion, Detail: detail,
}, nil
}
func runMBIMNetwork(
ctx context.Context,
command, control string,
enabled bool,
apn, username, password, authentication string,
) (string, error) {
profile, err := os.CreateTemp("", "vocat-mbim-*.conf")
if err != nil {
return "", fmt.Errorf("create temporary MBIM profile: %w", err)
}
profilePath := profile.Name()
defer os.Remove(profilePath)
profileText := "PROXY=yes\n"
if apn != "" {
profileText = "APN=" + shellProfileValue(apn) + "\n" + profileText
}
if username != "" {
profileText += "APN_USER=" + shellProfileValue(username) + "\n"
}
if password != "" {
profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
}
if authentication != "" && authentication != "NONE" {
if authentication == "PAP_OR_CHAP" {
authentication = "PAP"
}
profileText += "APN_AUTH=" + shellProfileValue(authentication) + "\n"
}
if _, err := fmt.Fprint(profile, profileText); err != nil {
_ = profile.Close()
return "", fmt.Errorf("write temporary MBIM profile: %w", err)
}
if err := profile.Chmod(0o600); err != nil {
_ = profile.Close()
return "", fmt.Errorf("protect temporary MBIM profile: %w", err)
}
if err := profile.Close(); err != nil {
return "", fmt.Errorf("close temporary MBIM profile: %w", err)
}
action := "stop"
if enabled {
action = "start"
}
output, err := exec.CommandContext(ctx, command, "--profile="+profilePath, control, action).CombinedOutput()
detail := strings.TrimSpace(string(output))
if err != nil {
return "", fmt.Errorf("mbim-network %s failed: %w: %s", action, err, detail)
}
return detail, nil
}
func runUMBIM(
ctx context.Context,
command, control string,
enabled bool,
apn, ipVersion, username, password, authentication string,
) (string, error) {
if !enabled {
output, err := exec.CommandContext(ctx, command, "-d", control, "disconnect").CombinedOutput()
if err != nil && !strings.Contains(strings.ToLower(string(output)), "not connected") {
return "", fmt.Errorf("umbim disconnect failed: %w: %s", err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output)), nil
}
type umbimCommand struct {
name string
args []string
}
commands := []umbimCommand{
{name: "caps", args: []string{"-n", "-d", control, "caps"}},
{name: "subscriber", args: []string{"-n", "-t", "2", "-d", control, "subscriber"}},
{name: "attach", args: []string{"-n", "-t", "3", "-d", control, "attach"}},
}
pdpType := map[string]string{"IP": "ipv4", "IPV6": "ipv6", "IPV4V6": "ipv4v6"}[ipVersion]
auth := strings.ToLower(authentication)
if auth == "none" {
auth = ""
} else if auth == "pap_or_chap" {
auth = "pap"
}
commands = append(commands, umbimCommand{
name: "connect",
args: []string{"-n", "-t", "4", "-d", control, "connect", pdpType + ":" + apn, auth, username, password},
})
outputs := make([]string, 0, len(commands))
for _, operation := range commands {
output, err := exec.CommandContext(ctx, command, operation.args...).CombinedOutput()
if err != nil {
cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
_, _ = exec.CommandContext(cleanupCtx, command, "-d", control, "disconnect").CombinedOutput()
cancelCleanup()
return "", fmt.Errorf("umbim %s failed: %w: %s", operation.name, err, strings.TrimSpace(string(output)))
}
if value := strings.TrimSpace(string(output)); value != "" {
outputs = append(outputs, value)
}
}
return strings.Join(outputs, "\n"), nil
}
func shellProfileValue(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
-13
View File
@@ -21,16 +21,3 @@ func setQMINetwork(
) (NetworkResult, error) {
return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable)
}
func setMBIMNetwork(
context.Context,
modem.Candidate,
bool,
string,
string,
string,
string,
string,
) (NetworkResult, error) {
return NetworkResult{}, fmt.Errorf("%w: MBIM control is supported only on Linux", ErrDataBackendUnavailable)
}
+2 -6
View File
@@ -981,10 +981,6 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
// is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
expected = strings.TrimSpace(expected)
iccidCommands := []string{"AT+CCID", "AT+QCCID"}
if current, err := manager.Get(id); err == nil && current.Candidate.HardwareKind == "sierra_usb" {
iccidCommands = []string{"AT+CCID", "AT!ICCID?", "AT+QCCID"}
}
const attempts = 6
var lastICCID string
var lastErr error
@@ -1000,7 +996,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
}
lastErr = err
} else {
for _, command := range iccidCommands {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
@@ -1008,7 +1004,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
lastErr = err
continue
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:", "!ICCID:"}, 18, 22)
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
+1 -1
View File
@@ -471,7 +471,7 @@ func (manager *Manager) SetSIMPin(id, pin string) error {
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" && backend != "mbim" && backend != "pcsc" {
if backend != "at" && backend != "qmi" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
+1 -49
View File
@@ -151,10 +151,6 @@ func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) {
if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" {
t.Fatalf("parseICCIDIdentifier = %q", got)
}
sierra := okResponse("!ICCID: 89441000400316048687")
if got := parseICCIDIdentifier(sierra, []string{"+CCID:", "+QCCID:", "!ICCID:"}, 18, 22); got != "89441000400316048687" {
t.Fatalf("parse Sierra ICCID = %q", got)
}
}
func TestManagerRequiresStartAndKnownDevice(t *testing.T) {
@@ -188,55 +184,11 @@ func TestManagerBackendSelectionIsExplicit(t *testing.T) {
if got := manager.backendFor(state); got != "qmi" {
t.Fatalf("backend = %q, want qmi", got)
}
if err := manager.SetBackend(id, "mbim"); err != nil {
t.Fatalf("MBIM backend was rejected: %v", err)
}
if got := manager.backendFor(state); got != "mbim" {
t.Fatalf("backend = %q, want mbim", got)
}
if err := manager.SetBackend(id, "invalid"); err == nil {
if err := manager.SetBackend(id, "mbim"); err == nil {
t.Fatal("unsupported backend was accepted")
}
}
func TestParseSierraGStatus(t *testing.T) {
metrics := parseSierraGStatus(okResponse(
"!GSTATUS:",
"System mode: LTE PS state: Attached",
"LTE band: B3 LTE bw: 20 MHz",
"LTE Rx chan: 1650 LTE Tx chan: 19650",
"RSSI (dBm): -63.0 Tx Power: --",
"RSRP (dBm): -92.0 RSRQ (dB): -7.2",
"SINR (dB): 17.6",
))
if metrics.AccessTech != "LTE" || metrics.Band != "B3" || metrics.Channel != "1650" {
t.Fatalf("identity metrics = %#v", metrics)
}
if metrics.RSSI == nil || *metrics.RSSI != -63 || metrics.RSRP == nil || *metrics.RSRP != -92 ||
metrics.RSRQ == nil || *metrics.RSRQ != -7 || metrics.SINR == nil || *metrics.SINR != 18 {
t.Fatalf("radio metrics = %#v", metrics)
}
}
func TestParseSierraSIMIdentityFromCRSM(t *testing.T) {
iccid := parseCRSMICCID(okResponse(`+CRSM: 144,0,"98103254769810325476"`))
if iccid != "89012345678901234567" {
t.Fatalf("ICCID = %q", iccid)
}
imsi := parseCRSMIMSI(okResponse(`+CRSM: 144,0,"080910108967452301"`))
if imsi != "001019876543210" {
t.Fatalf("IMSI = %q", imsi)
}
manufacturer, model, firmware := parseATI([]string{
"Manufacturer: Sierra Wireless, Incorporated",
"Model: EM7430",
"Revision: SWI9X30C_02.24.05.06 r7040",
})
if manufacturer != "Sierra Wireless, Incorporated" || model != "EM7430" || firmware != "SWI9X30C_02.24.05.06 r7040" {
t.Fatalf("ATI = manufacturer %q model %q firmware %q", manufacturer, model, firmware)
}
}
func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
+8 -129
View File
@@ -6,8 +6,6 @@ import (
"encoding/hex"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
@@ -53,28 +51,14 @@ func (manager *Manager) readSnapshot(
if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
}
var ccidErr error
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
response, commandErr := manager.command(ctx, client, command)
if commandErr != nil {
ccidErr = commandErr
continue
}
snapshot.ICCID = parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if snapshot.ICCID != "" {
break
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if snapshot.ICCID == "" && candidate.HardwareKind == "sierra_usb" {
response, commandErr := manager.command(ctx, client, "AT+CRSM=176,12258,0,0,10")
if commandErr == nil {
snapshot.ICCID = parseCRSMICCID(response)
} else {
ccidErr = commandErr
}
}
if snapshot.ICCID == "" && ccidErr != nil {
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
previousICCID = strings.TrimSpace(previousICCID)
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
@@ -86,18 +70,8 @@ func (manager *Manager) readSnapshot(
}
snapshot.SIMChanged = true
}
if response, commandErr := manager.command(ctx, client, "AT+CIMI"); commandErr == nil {
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
} else if candidate.HardwareKind != "sierra_usb" {
snapshot.Warnings = append(snapshot.Warnings, commandErr.Error())
}
if snapshot.IMSI == "" && candidate.HardwareKind == "sierra_usb" {
response, commandErr := manager.command(ctx, client, "AT+CRSM=176,28423,0,0,9")
if commandErr == nil {
snapshot.IMSI = parseCRSMIMSI(response)
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMSI: "+commandErr.Error())
}
}
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
@@ -111,15 +85,8 @@ func (manager *Manager) readSnapshot(
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
servingPLMN := ""
servingCommand := `AT+QENG="servingcell"`
if candidate.HardwareKind == "sierra_usb" {
servingCommand = "AT!GSTATUS?"
}
if response, ok := optional(servingCommand); ok {
if response, ok := optional(`AT+QENG="servingcell"`); ok {
metrics := parseQENG(response)
if candidate.HardwareKind == "sierra_usb" {
metrics = parseSierraGStatus(response)
}
servingPLMN = metrics.PLMN
snapshot.AccessTech = metrics.AccessTech
snapshot.Band = metrics.Band
@@ -244,48 +211,6 @@ func parseSPN(response modem.Response) string {
return strings.TrimSpace(string(printable))
}
func parseCRSMICCID(response modem.Response) string {
digits := decodeICCID(crsmPayload(response))
if !decimalDigits(digits, 18, 22) {
return ""
}
return digits
}
func parseCRSMIMSI(response modem.Response) string {
raw := crsmPayload(response)
if len(raw) < 2 {
return ""
}
length := int(raw[0])
if length < 1 || length > len(raw)-1 {
return ""
}
encoded := raw[1 : 1+length]
var digits strings.Builder
// EF_IMSI stores the first digit in the high nibble of byte 1; its low
// nibble contains parity/type metadata. Remaining bytes are normal swapped
// BCD, as specified by 3GPP TS 31.102.
if first := encoded[0] >> 4; first <= 9 {
digits.WriteByte('0' + first)
} else {
return ""
}
for _, value := range encoded[1:] {
if low := value & 0x0f; low <= 9 {
digits.WriteByte('0' + low)
}
if high := value >> 4; high <= 9 {
digits.WriteByte('0' + high)
}
}
result := digits.String()
if !decimalDigits(result, 10, 18) {
return ""
}
return result
}
func parseRegistrationStatus(response modem.Response) (int, bool) {
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
values := csvValues(valueAfterPrefix(response, prefix))
@@ -310,10 +235,6 @@ func parseATI(lines []string) (manufacturer, model, firmware string) {
line = strings.TrimSpace(line)
upper := strings.ToUpper(line)
switch {
case strings.HasPrefix(upper, "MANUFACTURER:"):
manufacturer = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
case strings.HasPrefix(upper, "MODEL:"):
model = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
case strings.HasPrefix(upper, "REVISION:"):
firmware = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
case strings.Contains(upper, "QUECTEL"):
@@ -399,48 +320,6 @@ func parseQENG(response modem.Response) qengMetrics {
return qengMetrics{}
}
var sierraGStatusFields = map[string]*regexp.Regexp{
"mode": regexp.MustCompile(`(?i)System mode:\s*([^\s]+)`),
"band": regexp.MustCompile(`(?i)LTE band:\s*([^\s]+)`),
"channel": regexp.MustCompile(`(?i)LTE Rx chan:\s*([0-9]+)`),
"rssi": regexp.MustCompile(`(?i)RSSI \(dBm\):\s*(-?[0-9]+(?:\.[0-9]+)?)`),
"rsrp": regexp.MustCompile(`(?i)RSRP \(dBm\):\s*(-?[0-9]+(?:\.[0-9]+)?)`),
"rsrq": regexp.MustCompile(`(?i)RSRQ \(dB\):\s*(-?[0-9]+(?:\.[0-9]+)?)`),
"sinr": regexp.MustCompile(`(?i)SINR \(dB\):\s*(-?[0-9]+(?:\.[0-9]+)?)`),
}
func parseSierraGStatus(response modem.Response) qengMetrics {
text := strings.Join(response.Lines, "\n")
result := qengMetrics{}
if match := sierraGStatusFields["mode"].FindStringSubmatch(text); len(match) == 2 {
result.AccessTech = strings.ToUpper(strings.TrimSpace(match[1]))
}
if match := sierraGStatusFields["band"].FindStringSubmatch(text); len(match) == 2 {
result.Band = strings.ToUpper(strings.TrimSpace(match[1]))
}
if match := sierraGStatusFields["channel"].FindStringSubmatch(text); len(match) == 2 {
result.Channel = match[1]
}
result.RSSI = parseSierraDecimalMetric(text, "rssi")
result.RSRP = parseSierraDecimalMetric(text, "rsrp")
result.RSRQ = parseSierraDecimalMetric(text, "rsrq")
result.SINR = parseSierraDecimalMetric(text, "sinr")
return result
}
func parseSierraDecimalMetric(text, field string) *int {
match := sierraGStatusFields[field].FindStringSubmatch(text)
if len(match) != 2 {
return nil
}
value, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return nil
}
result := int(math.Round(value))
return &result
}
func decimalDigits(value string, minimum, maximum int) bool {
value = strings.TrimSpace(value)
return len(value) >= minimum && len(value) <= maximum && strings.IndexFunc(value, func(character rune) bool {
+9 -167
View File
@@ -5,17 +5,13 @@ import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
quectelVendorID = "2c7c"
sierraVendorID = "1199"
)
const quectelVendorID = "2c7c"
type SysFSDiscoverer struct {
SysRoot string
@@ -49,11 +45,6 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
}
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
// EM7430 PID 9077 is missing from several distro qcserial/option ID tables.
// On a real host, register that exact ID and then repair the MBIM binding.
// A dynamic option ID also matches interfaces 12/13 after a later USB reset,
// even when cdc_mbim was loaded first, so ordering alone is not sufficient.
d.prepareSierraEM7430(ctx, usbRoot, entries)
devices := make(map[string]*discoveredUSBDevice)
for _, entry := range entries {
if err := ctx.Err(); err != nil {
@@ -79,19 +70,17 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
resolvedDevice = devicePath
}
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
hardwareKind, idPrefix, supported := supportedUSBModem(vendorID, productID)
if !supported {
if vendorID != quectelVendorID {
continue
}
state := devices[deviceName]
if state == nil {
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
state = &discoveredUSBDevice{
candidate: Candidate{
HardwareKind: hardwareKind,
ID: candidateID(idPrefix, productID, serialNumber, deviceName),
ID: candidateID(productID, serialNumber, deviceName),
VendorID: vendorID,
ProductID: productID,
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
@@ -115,13 +104,9 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
StablePath: aliases[name],
Name: name,
InterfaceNumber: interfaceNumber,
Role: usbPortRole(vendorID, interfaceNumber, name),
Role: quecPortRole(interfaceNumber, name),
}
}
if protocol := usbControlProtocol(resolvedInterface); protocol != "" &&
(state.candidate.ControlProtocol == "" || protocol == "mbim") {
state.candidate.ControlProtocol = protocol
}
if state.candidate.QMIControl == "" && len(qmiControls) > 0 {
state.candidate.QMIControl = filepath.Join(d.DevRoot, qmiControls[0])
}
@@ -143,15 +128,8 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
}
return left.Name < right.Name
})
if state.candidate.VendorID == sierraVendorID {
assignSierraPortRoles(state.candidate.Ports)
} else {
assignQuectelPortRoles(state.candidate.Ports)
}
assignQuectelPortRoles(state.candidate.Ports)
state.candidate.ATPort = selectATPort(state.candidate.Ports)
if state.candidate.VendorID == sierraVendorID && !state.candidate.HasATPort() {
state.candidate.DiscoveryIssue = "sierra_serial_driver_missing"
}
result = append(result, state.candidate)
}
wwanCandidates, err := d.discoverWWAN(ctx)
@@ -261,7 +239,6 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
}
if len(group.qmiNames) > 0 {
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
candidate.ControlProtocol = "qmi"
}
result = append(result, candidate)
}
@@ -408,7 +385,7 @@ func readSerialAliases(root string) map[string]string {
return result
}
func candidateID(prefix, productID, serialNumber, usbName string) string {
func candidateID(productID, serialNumber, usbName string) string {
serialNumber = strings.TrimSpace(serialNumber)
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
// A surprising number of EC20/EC25 carrier boards expose the same
@@ -417,144 +394,9 @@ func candidateID(prefix, productID, serialNumber, usbName string) string {
// to the same hub into one entry. Include the physical USB topology in the
// discovery key; configured devices remain stable through ATMapper's
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
}
return prefix + "-" + sanitizeID(productID+"-"+usbName)
}
func supportedUSBModem(vendorID, productID string) (hardwareKind, idPrefix string, ok bool) {
switch strings.ToLower(vendorID) {
case quectelVendorID:
return "usb", "quectel", true
case sierraVendorID:
switch strings.ToLower(productID) {
case "9077", "9078", "9079", "907a", "907b":
return "sierra_usb", "sierra", true
}
}
return "", "", false
}
func usbPortRole(vendorID string, interfaceNumber int, name string) PortRole {
if vendorID == sierraVendorID {
switch interfaceNumber {
case 0:
return PortRoleDiagnostic
case 2:
return PortRoleNMEA
case 3:
return PortRoleAT
}
return PortRoleUnknown
}
return quecPortRole(interfaceNumber, name)
}
func assignSierraPortRoles(ports []Port) {
for index := range ports {
ports[index].Role = usbPortRole(sierraVendorID, ports[index].InterfaceNumber, ports[index].Name)
}
}
func usbControlProtocol(interfacePath string) string {
if target, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver")); err == nil {
switch filepath.Base(target) {
case "cdc_mbim":
return "mbim"
case "qmi_wwan":
return "qmi"
}
}
class := strings.ToLower(readTrimmed(filepath.Join(interfacePath, "bInterfaceClass")))
subclass := strings.ToLower(readTrimmed(filepath.Join(interfacePath, "bInterfaceSubClass")))
if class == "02" && subclass == "0e" {
return "mbim"
}
return ""
}
func (d *SysFSDiscoverer) prepareSierraEM7430(ctx context.Context, usbRoot string, entries []os.DirEntry) {
if filepath.Clean(d.SysRoot) != filepath.Clean("/sys") || filepath.Clean(d.DevRoot) != filepath.Clean("/dev") {
return
}
found := false
for _, entry := range entries {
if strings.Contains(entry.Name(), ":") {
continue
}
path := filepath.Join(usbRoot, entry.Name())
if strings.EqualFold(readTrimmed(filepath.Join(path, "idVendor")), sierraVendorID) &&
strings.EqualFold(readTrimmed(filepath.Join(path, "idProduct")), "9077") {
found = true
break
}
}
if !found {
return
}
// Ignore errors here: discovery will still return a degraded candidate with
// a precise remediation code instead of hiding the hardware completely.
_ = exec.CommandContext(ctx, "modprobe", "cdc_mbim").Run()
_ = exec.CommandContext(ctx, "modprobe", "option").Run()
_ = os.WriteFile(
filepath.Join(d.SysRoot, "bus", "usb-serial", "drivers", "option1", "new_id"),
[]byte(sierraVendorID+" 9077\n"),
0o200,
)
d.repairSierraEM7430MBIM(usbRoot, entries)
}
func (d *SysFSDiscoverer) repairSierraEM7430MBIM(usbRoot string, entries []os.DirEntry) {
devices := make(map[string]struct{})
for _, entry := range entries {
if strings.Contains(entry.Name(), ":") {
continue
}
path := filepath.Join(usbRoot, entry.Name())
if strings.EqualFold(readTrimmed(filepath.Join(path, "idVendor")), sierraVendorID) &&
strings.EqualFold(readTrimmed(filepath.Join(path, "idProduct")), "9077") {
devices[entry.Name()] = struct{}{}
}
}
var controlsToBind []string
for _, entry := range entries {
deviceName, _, ok := strings.Cut(entry.Name(), ":")
if !ok {
continue
}
if _, ok := devices[deviceName]; !ok {
continue
}
interfacePath := filepath.Join(usbRoot, entry.Name())
class := strings.ToLower(readTrimmed(filepath.Join(interfacePath, "bInterfaceClass")))
subclass := strings.ToLower(readTrimmed(filepath.Join(interfacePath, "bInterfaceSubClass")))
protocol := strings.ToLower(readTrimmed(filepath.Join(interfacePath, "bInterfaceProtocol")))
isControl := class == "02" && subclass == "0e"
isData := class == "0a" && protocol == "02"
if !isControl && !isData {
continue
}
driverPath, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver"))
if err == nil && filepath.Base(driverPath) == "cdc_mbim" {
continue
}
if isControl {
controlsToBind = append(controlsToBind, entry.Name())
}
if err == nil && filepath.Base(driverPath) == "option" {
_ = os.WriteFile(filepath.Join(driverPath, "unbind"), []byte(entry.Name()), 0o200)
}
}
bindPath := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "cdc_mbim", "bind")
for _, interfaceName := range controlsToBind {
// Binding the MBIM control interface also claims its paired CDC data
// interface. Errors are intentionally non-fatal so discovery can return a
// degraded device with actionable details on kernels without cdc_mbim.
_ = os.WriteFile(bindPath, []byte(interfaceName), 0o200)
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
}
return "quectel-" + sanitizeID(productID+"-"+usbName)
}
func sanitizeID(value string) string {
-94
View File
@@ -235,100 +235,6 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
}
}
func TestSysFSDiscoveryFindsSierraEM7430MBIMComposition(t *testing.T) {
root := t.TempDir()
sysRoot := filepath.Join(root, "sys")
devRoot := filepath.Join(root, "dev")
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
deviceName := "2-1"
mustWrite(t, filepath.Join(usbRoot, deviceName, "idVendor"), "1199\n")
mustWrite(t, filepath.Join(usbRoot, deviceName, "idProduct"), "9077\n")
mustWrite(t, filepath.Join(usbRoot, deviceName, "manufacturer"), "Sierra Wireless, Incorporated\n")
mustWrite(t, filepath.Join(usbRoot, deviceName, "product"), "EM7430\n")
mustWrite(t, filepath.Join(usbRoot, deviceName, "serial"), "TESTEM74300001\n")
for _, item := range []struct {
interfaceNumber string
tty string
}{
{"00", "ttyUSB0"},
{"02", "ttyUSB1"},
{"03", "ttyUSB2"},
} {
interfaceName := deviceName + ":1." + fmt.Sprint(mustHexInt(t, item.interfaceNumber))
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), item.interfaceNumber+"\n")
mustMkdir(t, filepath.Join(usbRoot, interfaceName, item.tty, "tty", item.tty))
}
mbimInterface := filepath.Join(usbRoot, deviceName+":1.12")
mustWrite(t, filepath.Join(mbimInterface, "bInterfaceNumber"), "0c\n")
mustWrite(t, filepath.Join(mbimInterface, "bInterfaceClass"), "02\n")
mustWrite(t, filepath.Join(mbimInterface, "bInterfaceSubClass"), "0e\n")
mustMkdir(t, filepath.Join(mbimInterface, "usbmisc", "cdc-wdm0"))
mustMkdir(t, filepath.Join(mbimInterface, "net", "wwan0"))
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
if err != nil {
t.Fatal(err)
}
if len(candidates) != 1 {
t.Fatalf("candidates = %#v, want one Sierra modem", candidates)
}
candidate := candidates[0]
if candidate.ID != "sierra-testem74300001-2-1" || candidate.HardwareKind != "sierra_usb" {
t.Fatalf("identity = %#v", candidate)
}
if candidate.ATPort.Name != "ttyUSB2" || candidate.ATPort.InterfaceNumber != 3 {
t.Fatalf("AT port = %#v", candidate.ATPort)
}
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") || candidate.ControlProtocol != "mbim" {
t.Fatalf("MBIM control = %q protocol=%q", candidate.QMIControl, candidate.ControlProtocol)
}
if candidate.NetworkInterface != "wwan0" || candidate.DiscoveryIssue != "" {
t.Fatalf("candidate = %#v", candidate)
}
}
func TestRepairSierraEM7430MBIMRebindsOptionControlInterface(t *testing.T) {
root := t.TempDir()
sysRoot := filepath.Join(root, "sys")
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
deviceName := "1-7"
interfaceName := deviceName + ":1.12"
mustWrite(t, filepath.Join(usbRoot, deviceName, "idVendor"), "1199\n")
mustWrite(t, filepath.Join(usbRoot, deviceName, "idProduct"), "9077\n")
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceClass"), "02\n")
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceSubClass"), "0e\n")
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceProtocol"), "00\n")
optionDriver := filepath.Join(sysRoot, "bus", "usb", "drivers", "option")
mbimDriver := filepath.Join(sysRoot, "bus", "usb", "drivers", "cdc_mbim")
mustWrite(t, filepath.Join(optionDriver, "unbind"), "")
mustWrite(t, filepath.Join(mbimDriver, "bind"), "")
if err := os.Symlink(optionDriver, filepath.Join(usbRoot, interfaceName, "driver")); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(usbRoot)
if err != nil {
t.Fatal(err)
}
NewSysFSDiscoverer(sysRoot, filepath.Join(root, "dev")).repairSierraEM7430MBIM(usbRoot, entries)
if got := readTrimmed(filepath.Join(optionDriver, "unbind")); got != interfaceName {
t.Fatalf("option unbind = %q, want %q", got, interfaceName)
}
if got := readTrimmed(filepath.Join(mbimDriver, "bind")); got != interfaceName {
t.Fatalf("cdc_mbim bind = %q, want %q", got, interfaceName)
}
}
func mustHexInt(t *testing.T, value string) int64 {
t.Helper()
number, err := strconv.ParseInt(value, 16, 32)
if err != nil {
t.Fatal(err)
}
return number
}
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
root := t.TempDir()
sysRoot := filepath.Join(root, "sys")
+12 -16
View File
@@ -44,22 +44,18 @@ func (p Port) OpenPath() string {
}
type Candidate struct {
HardwareKind string `json:"hardwareKind,omitempty"`
ReaderName string `json:"readerName,omitempty"`
ID string `json:"id"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
Manufacturer string `json:"manufacturer,omitempty"`
Product string `json:"product,omitempty"`
SerialNumber string `json:"serialNumber,omitempty"`
USBPath string `json:"usbPath"`
ATPort Port `json:"atPort"`
Ports []Port `json:"ports"`
QMIControl string `json:"qmiControl,omitempty"`
// ControlProtocol identifies the protocol carried by QMIControl. The
// historical field name is retained for API/database compatibility because
// both QMI and MBIM use cdc-wdm device nodes on Linux.
ControlProtocol string `json:"controlProtocol,omitempty"`
HardwareKind string `json:"hardwareKind,omitempty"`
ReaderName string `json:"readerName,omitempty"`
ID string `json:"id"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
Manufacturer string `json:"manufacturer,omitempty"`
Product string `json:"product,omitempty"`
SerialNumber string `json:"serialNumber,omitempty"`
USBPath string `json:"usbPath"`
ATPort Port `json:"atPort"`
Ports []Port `json:"ports"`
QMIControl string `json:"qmiControl,omitempty"`
NetworkInterface string `json:"networkInterface,omitempty"`
DiscoveryIssue string `json:"discoveryIssue,omitempty"`
}
+2 -17
View File
@@ -1785,7 +1785,7 @@ func deviceSummary(entry device.Device) map[string]any {
"public_ip": "",
"private_ip": "",
"interface": entry.Candidate.NetworkInterface,
"esim_transport": candidateESIMTransport(entry.Candidate),
"esim_transport": backendMode(entry.Candidate),
"sms_enabled": true,
"network_enabled": false,
"vowifi_enabled": false,
@@ -1897,22 +1897,10 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
config.DeviceBackend = backendMode(candidate)
}
if config.ESIMTransport == "" {
config.ESIMTransport = candidateESIMTransport(candidate)
config.ESIMTransport = config.DeviceBackend
}
}
func candidateESIMTransport(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return "pcsc"
}
// VoCat performs APDU/eSIM operations through the EM74xx AT serial port;
// MBIM remains dedicated to packet-data control.
if backendMode(candidate) == "mbim" {
return "at"
}
return backendMode(candidate)
}
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
if snapshot == nil {
return map[string]any{
@@ -2047,9 +2035,6 @@ func backendMode(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return "pcsc"
}
if candidate.ControlProtocol == "mbim" {
return "mbim"
}
if candidate.QMIControl != "" {
return "qmi"
}
-14
View File
@@ -6,24 +6,10 @@ import (
"time"
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestSierraCandidateUsesMBIMDataAndATForESIM(t *testing.T) {
candidate := modem.Candidate{
HardwareKind: "sierra_usb", QMIControl: "/dev/cdc-wdm0", ControlProtocol: "mbim",
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
}
if got := backendMode(candidate); got != "mbim" {
t.Fatalf("backendMode = %q", got)
}
if got := candidateESIMTransport(candidate); got != "at" {
t.Fatalf("candidateESIMTransport = %q", got)
}
}
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
+1 -4
View File
@@ -18,7 +18,6 @@ const (
DeviceTypeDJI4G = "dji_4g"
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
DeviceTypeUSBSIMReader = "usb_sim_reader"
DeviceTypeSierraEM74xx = "sierra_em74xx"
)
// NormalizeDeviceType returns a stable persisted device type identifier.
@@ -31,8 +30,6 @@ func NormalizeDeviceType(value string) string {
return DeviceTypeDJI4G
case DeviceTypeUSBSIMReader:
return DeviceTypeUSBSIMReader
case DeviceTypeSierraEM74xx:
return DeviceTypeSierraEM74xx
case "", DeviceTypePCIeEC20EC25:
return DeviceTypePCIeEC20EC25
default:
@@ -138,7 +135,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
value.DeviceBackend = "at"
}
value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend))
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "mbim" && value.DeviceBackend != "pcsc" {
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", value.DeviceBackend)
}
if value.ESIMTransport == "" {
-20
View File
@@ -391,26 +391,6 @@ func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) {
}
}
func TestSierraMBIMConfigurationRoundTrips(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
value := Device{
ID: "em7430-1", Name: "EM7430", DeviceType: DeviceTypeSierraEM74xx,
Interface: "wwan0", ControlDevice: "/dev/cdc-wdm0", ATPort: "/dev/ttyUSB2",
DeviceBackend: "mbim", ESIMTransport: "at", NetworkEnabled: true,
}
if err := database.UpsertDevice(ctx, value); err != nil {
t.Fatal(err)
}
got, err := database.Device(ctx, value.ID)
if err != nil {
t.Fatal(err)
}
if got.DeviceType != DeviceTypeSierraEM74xx || got.DeviceBackend != "mbim" || got.ESIMTransport != "at" {
t.Fatalf("Sierra config = %+v", got)
}
}
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
-62
View File
@@ -1,62 +0,0 @@
#!/bin/sh
# Expose the EM7430's diagnostic/NMEA/AT ports while keeping its MBIM
# control/data pair on cdc_mbim. Linux's dynamic option ID matches every USB
# interface after a reset, including interfaces 12/13, so loading cdc_mbim
# first is not enough on its own.
SYS_ROOT="${VOCAT_SYS_ROOT:-/sys}"
USB_ROOT="$SYS_ROOT/bus/usb/devices"
OPTION_NEW_ID="$SYS_ROOT/bus/usb-serial/drivers/option1/new_id"
OPTION_UNBIND="$SYS_ROOT/bus/usb/drivers/option/unbind"
MBIM_BIND="$SYS_ROOT/bus/usb/drivers/cdc_mbim/bind"
command -v modprobe >/dev/null 2>&1 && modprobe cdc_mbim >/dev/null 2>&1 || true
command -v modprobe >/dev/null 2>&1 && modprobe option >/dev/null 2>&1 || true
repair_mbim_bindings() {
[ -d "$USB_ROOT" ] || return 0
for device in "$USB_ROOT"/*; do
[ -f "$device/idVendor" ] || continue
[ "$(tr '[:upper:]' '[:lower:]' < "$device/idVendor" 2>/dev/null)" = "1199" ] || continue
[ "$(tr '[:upper:]' '[:lower:]' < "$device/idProduct" 2>/dev/null)" = "9077" ] || continue
control=""
control_needs_bind=0
for interface in "${device}":*; do
[ -d "$interface" ] || continue
class=$(tr '[:upper:]' '[:lower:]' < "$interface/bInterfaceClass" 2>/dev/null || true)
subclass=$(tr '[:upper:]' '[:lower:]' < "$interface/bInterfaceSubClass" 2>/dev/null || true)
protocol=$(tr '[:upper:]' '[:lower:]' < "$interface/bInterfaceProtocol" 2>/dev/null || true)
is_control=0
is_data=0
[ "$class/$subclass" = "02/0e" ] && is_control=1
[ "$class/$protocol" = "0a/02" ] && is_data=1
[ "$is_control" -eq 1 ] || [ "$is_data" -eq 1 ] || continue
name=$(basename "$interface")
driver=""
if [ -L "$interface/driver" ]; then
driver=$(basename "$(readlink "$interface/driver")")
fi
if [ "$is_control" -eq 1 ]; then
control="$name"
[ "$driver" = "cdc_mbim" ] || control_needs_bind=1
fi
if [ "$driver" = "option" ] && [ -w "$OPTION_UNBIND" ]; then
printf '%s' "$name" > "$OPTION_UNBIND" 2>/dev/null || true
fi
done
if [ "$control_needs_bind" -eq 1 ] && [ -n "$control" ] && [ -w "$MBIM_BIND" ]; then
printf '%s' "$control" > "$MBIM_BIND" 2>/dev/null || true
fi
done
}
# Repair a previous dynamic binding first, register the serial ID, then repair
# once more for the synchronous probes triggered by new_id.
repair_mbim_bindings
if [ -w "$OPTION_NEW_ID" ]; then
printf '%s\n' '1199 9077' > "$OPTION_NEW_ID" 2>/dev/null || true
fi
repair_mbim_bindings
-6
View File
@@ -1,12 +1,6 @@
#!/bin/sh
set -eu
# Some kernels omit the EM7430's 1199:9077 serial ID. The helper exposes its
# AT port and repairs MBIM interfaces that option may claim after a USB reset.
if [ "$(id -u)" = "0" ] && [ -e /sys/bus/usb/devices ]; then
/usr/local/bin/vocat-bind-em7430 || true
fi
# pcscd daemonizes after startup. Keep failure non-fatal so modem-only
# deployments remain usable and the UI can report a reader diagnostic.
if [ "$(id -u)" = "0" ] && command -v pcscd >/dev/null 2>&1; then
-104
View File
@@ -237,109 +237,6 @@ install_pcsc_support() {
fi
}
install_cellular_control_support() {
msg "正在检查 QMI/MBIM 蜂窝控制环境..." "Checking the QMI/MBIM cellular control environment..."
if is_openwrt && command -v opkg >/dev/null 2>&1; then
opkg update >/dev/null 2>&1 || true
local packages=""
local package
for package in \
kmod-usb-net-cdc-mbim kmod-usb-serial-option \
kmod-usb-serial-qualcomm umbim; do
if opkg_has_package "$package"; then
packages="$packages $package"
fi
done
if [ -n "$packages" ]; then
# Never override OpenWrt's kernel ABI checks.
# shellcheck disable=SC2086
opkg install $packages >/dev/null 2>&1 || true
fi
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y busybox iproute2 libmbim-utils || true
elif command -v dnf >/dev/null 2>&1; then
dnf install -y busybox iproute libmbim-utils || true
elif command -v yum >/dev/null 2>&1; then
yum install -y busybox iproute libmbim-utils || true
elif command -v pacman >/dev/null 2>&1; then
pacman -Sy --noconfirm busybox iproute2 libmbim || true
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache iproute2 libmbim-tools || true
fi
if command -v mbim-network >/dev/null 2>&1 || command -v umbim >/dev/null 2>&1; then
msg "MBIM 蜂窝控制环境已就绪。" "The MBIM cellular control environment is ready."
else
msg \
"警告:未找到 mbim-network/umbimMBIM 模组仍可进行 AT、SIM/eSIM 与 VoWiFi 操作,但蜂窝数据拨号不可用。" \
"Warning: mbim-network/umbim is unavailable; AT, SIM/eSIM and VoWiFi still work, but MBIM data dialing is unavailable."
fi
# Linux's upstream serial tables do not consistently include EM7430 PID
# 9077. Keep a small host helper because a dynamic option ID can also claim
# the MBIM interfaces after any later USB reset.
install -d -m 0755 "$INSTALL_DIR"
cat > "${INSTALL_DIR}/vocat-bind-em7430" <<'EOF'
#!/bin/sh
SYS_ROOT="${VOCAT_SYS_ROOT:-/sys}"
USB_ROOT="$SYS_ROOT/bus/usb/devices"
OPTION_NEW_ID="$SYS_ROOT/bus/usb-serial/drivers/option1/new_id"
OPTION_UNBIND="$SYS_ROOT/bus/usb/drivers/option/unbind"
MBIM_BIND="$SYS_ROOT/bus/usb/drivers/cdc_mbim/bind"
command -v modprobe >/dev/null 2>&1 && modprobe cdc_mbim >/dev/null 2>&1 || true
command -v modprobe >/dev/null 2>&1 && modprobe option >/dev/null 2>&1 || true
repair() {
[ -d "$USB_ROOT" ] || return 0
for device in "$USB_ROOT"/*; do
[ -f "$device/idVendor" ] || continue
[ "$(tr '[:upper:]' '[:lower:]' < "$device/idVendor" 2>/dev/null)" = 1199 ] || continue
[ "$(tr '[:upper:]' '[:lower:]' < "$device/idProduct" 2>/dev/null)" = 9077 ] || continue
control=""; needs_bind=0
for interface in "${device}":*; do
[ -d "$interface" ] || continue
class=$(tr '[:upper:]' '[:lower:]' < "$interface/bInterfaceClass" 2>/dev/null || true)
subclass=$(tr '[:upper:]' '[:lower:]' < "$interface/bInterfaceSubClass" 2>/dev/null || true)
protocol=$(tr '[:upper:]' '[:lower:]' < "$interface/bInterfaceProtocol" 2>/dev/null || true)
is_control=0; is_data=0
[ "$class/$subclass" = 02/0e ] && is_control=1
[ "$class/$protocol" = 0a/02 ] && is_data=1
[ "$is_control" -eq 1 ] || [ "$is_data" -eq 1 ] || continue
name=$(basename "$interface"); driver=""
[ ! -L "$interface/driver" ] || driver=$(basename "$(readlink "$interface/driver")")
if [ "$is_control" -eq 1 ]; then
control="$name"; [ "$driver" = cdc_mbim ] || needs_bind=1
fi
if [ "$driver" = option ] && [ -w "$OPTION_UNBIND" ]; then
printf '%s' "$name" > "$OPTION_UNBIND" 2>/dev/null || true
fi
done
if [ "$needs_bind" -eq 1 ] && [ -n "$control" ] && [ -w "$MBIM_BIND" ]; then
printf '%s' "$control" > "$MBIM_BIND" 2>/dev/null || true
fi
done
}
repair
[ ! -w "$OPTION_NEW_ID" ] || printf '%s\n' '1199 9077' > "$OPTION_NEW_ID" 2>/dev/null || true
repair
EOF
chmod 0755 "${INSTALL_DIR}/vocat-bind-em7430"
"${INSTALL_DIR}/vocat-bind-em7430" || true
if is_openwrt; then
install -d -m 0755 /etc/hotplug.d/usb
cat > /etc/hotplug.d/usb/95-vocat-em7430 <<'EOF'
#!/bin/sh
[ "$ACTION" = add ] || exit 0
case "${PRODUCT:-}" in 1199/9077/*) ;; *) exit 0 ;; esac
/opt/vocat/bin/vocat-bind-em7430 || true
EOF
chmod 0755 /etc/hotplug.d/usb/95-vocat-em7430
elif command -v udevadm >/dev/null 2>&1 && [ -d /etc/udev/rules.d ]; then
cat > /etc/udev/rules.d/95-vocat-em7430.rules <<'EOF'
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1199", ATTRS{idProduct}=="9077", RUN+="/opt/vocat/bin/vocat-bind-em7430"
EOF
udevadm control --reload-rules >/dev/null 2>&1 || true
fi
}
check_vowifi_environment() {
if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then
msg \
@@ -626,7 +523,6 @@ enable_and_start() {
# --- Main --------------------------------------------------------------------
detect_arch
install_cellular_control_support
install_pcsc_support
check_vowifi_environment
if [ "$CHECK_ENV" -eq 1 ]; then
@@ -45,8 +45,8 @@ function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
export function DeviceAddDialog(props: DeviceAddDialogProps) {
const { t } = useI18n();
const { addSelected, addConfig } = props;
const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim";
const fixedQmi = !isMbim && isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc";
useEffect(() => {
@@ -32,8 +32,8 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
const interfaceName = deviceStatus?.interface || editConfig?.interface;
const atPort = deviceStatus?.atPort || editConfig?.atPort;
const usbPath = deviceStatus?.usbPath || editConfig?.usbPath;
const isQmi = isQmiControl(controlDevice);
const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim";
const isQmi = !isMbim && isQmiControl(controlDevice);
const isReader = editConfig?.deviceType === "usb_sim_reader";
useEffect(() => {
@@ -23,8 +23,6 @@ export function DiscoveredDeviceRow({
? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。")
: device.discoveryIssue === "pcsc_driver_missing"
? t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。")
: device.discoveryIssue === "sierra_serial_driver_missing"
? t("已发现 Sierra EM7430,但 AT 串口驱动未绑定;请安装 option/qcserial 驱动后重新插拔设备。")
: "";
return (
<button
-1
View File
@@ -7,7 +7,6 @@ export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; ima
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
{ value: "usb_sim_reader", label: "USB SIM 读卡器(仅 WiFi Calling", image: "/sim-reader.svg" },
{ value: "sierra_em74xx", label: "Sierra Wireless EM73xx/EM74xx", image: "/410.png" },
];
export function normalizeDeviceType(value?: string | null): DeviceType {
-1
View File
@@ -8,7 +8,6 @@ export const EN_DICT: Record<string, string> = {
"未知设备": "Unknown device",
"系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。": "The USB card reader was found, but the PC/SC service is not running. Install and start pcscd, then scan again.",
"系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。": "The USB card reader was found, but its PC/SC driver is not loaded. Install libccid or the vendor driver, then scan again.",
"已发现 Sierra EM7430,但 AT 串口驱动未绑定;请安装 option/qcserial 驱动后重新插拔设备。": "A Sierra EM7430 was found, but its AT serial driver is not bound. Install the option/qcserial driver and reconnect the device.",
: "Hardware Path",
"USB SIM 读卡器(仅 WiFi Calling": "USB SIM Reader (WiFi Calling only)",
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
+2 -7
View File
@@ -375,10 +375,6 @@ export default function DevicesPage() {
message.warning(t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。"));
return;
}
if (d.discoveryIssue === "sierra_serial_driver_missing") {
message.warning(t("已发现 Sierra EM7430,但 AT 串口驱动未绑定;请安装 option/qcserial 驱动后重新插拔设备。"));
return;
}
if (d.degraded) {
message.warning(t("无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试"));
return;
@@ -387,7 +383,6 @@ export default function DevicesPage() {
setAddConfig((prev) => {
const mode = String(d.mode || "").toLowerCase();
const isReader = d.hardwareKind === "pcsc" || mode === "pcsc";
const isSierra = d.hardwareKind === "sierra_usb";
const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
return {
...prev,
@@ -397,8 +392,8 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : isSierra ? "sierra_em74xx" : prev.deviceType,
esimTransport: isReader ? "pcsc" : backend === "mbim" ? "at" : backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
esimTransport: isReader ? "pcsc" : backend,
};
});
}, []);
+2 -2
View File
@@ -1,6 +1,6 @@
export type ApiStatus = "ok" | "error";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader" | "sierra_em74xx";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader";
export interface Session {
authenticated: boolean;
@@ -206,7 +206,7 @@ export interface DeviceConfig {
dataBits: number;
stopBits: number;
parity: string;
deviceBackend: "at" | "qmi" | "mbim" | "pcsc";
deviceBackend: "at" | "qmi" | "pcsc";
esimTransport: "at" | "qmi" | "pcsc" | "none";
qmiUseProxy: boolean;
qmiProxyPath?: string;