mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-16 04:43:43 +08:00
feat: support Sierra EM7430 MBIM modems
This commit is contained in:
@@ -49,13 +49,13 @@ jobs:
|
||||
BUILD_TIME=${{ github.event.repository.updated_at }}
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
|
||||
- name: Verify ${{ matrix.platform }} runtime and hardware stacks
|
||||
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 pcscd && test -d /usr/lib/pcsc/drivers && command -v mbim-network && command -v qmi-network && command -v ip'
|
||||
|
||||
build-and-push:
|
||||
needs: smoke
|
||||
|
||||
+1
-1
@@ -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 pcsc-lite tzdata && \
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 kmod libmbim-tools pcsc-lite qmi-utils tzdata && \
|
||||
addgroup -S -g 1000 vocat && \
|
||||
adduser -S -D -H -u 1000 -G vocat vocat
|
||||
|
||||
|
||||
+6
-1
@@ -57,7 +57,12 @@ services:
|
||||
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
|
||||
# devices added after the container starts.
|
||||
- /dev:/dev
|
||||
- /sys:/sys:ro
|
||||
# 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
|
||||
|
||||
volumes:
|
||||
vocat-data:
|
||||
|
||||
+14
-2
@@ -86,13 +86,15 @@ func (manager *Manager) SetNetwork(
|
||||
candidate := manager.candidateFor(state)
|
||||
backend := strings.ToLower(strings.TrimSpace(request.Backend))
|
||||
if backend == "" {
|
||||
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
if candidate.ControlProtocol == "mbim" && candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
backend = "mbim"
|
||||
} else if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
backend = "qmi"
|
||||
} else {
|
||||
backend = "at"
|
||||
}
|
||||
}
|
||||
if backend != "at" && backend != "qmi" {
|
||||
if backend != "at" && backend != "qmi" && backend != "mbim" {
|
||||
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
|
||||
}
|
||||
if backend == "qmi" {
|
||||
@@ -108,6 +110,16 @@ 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 {
|
||||
|
||||
@@ -122,6 +122,179 @@ 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, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
@@ -21,3 +21,16 @@ 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)
|
||||
}
|
||||
|
||||
@@ -981,6 +981,10 @@ 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
|
||||
@@ -996,7 +1000,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
}
|
||||
lastErr = err
|
||||
} else {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
for _, command := range iccidCommands {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
@@ -1004,7 +1008,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:", "!ICCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
|
||||
@@ -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 != "pcsc" {
|
||||
if backend != "at" && backend != "qmi" && backend != "mbim" && backend != "pcsc" {
|
||||
return fmt.Errorf("unsupported device backend %q", backend)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
|
||||
@@ -151,6 +151,10 @@ 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) {
|
||||
@@ -184,11 +188,36 @@ 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 {
|
||||
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 {
|
||||
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 TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -52,13 +54,16 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||
}
|
||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||
if ccidErr != nil && candidate.HardwareKind == "sierra_usb" {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT!ICCID?")
|
||||
}
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:", "!ICCID:"}, 18, 22)
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||
@@ -85,8 +90,15 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||
}
|
||||
servingPLMN := ""
|
||||
if response, ok := optional(`AT+QENG="servingcell"`); ok {
|
||||
servingCommand := `AT+QENG="servingcell"`
|
||||
if candidate.HardwareKind == "sierra_usb" {
|
||||
servingCommand = "AT!GSTATUS?"
|
||||
}
|
||||
if response, ok := optional(servingCommand); ok {
|
||||
metrics := parseQENG(response)
|
||||
if candidate.HardwareKind == "sierra_usb" {
|
||||
metrics = parseSierraGStatus(response)
|
||||
}
|
||||
servingPLMN = metrics.PLMN
|
||||
snapshot.AccessTech = metrics.AccessTech
|
||||
snapshot.Band = metrics.Band
|
||||
@@ -320,6 +332,48 @@ 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 {
|
||||
|
||||
+112
-9
@@ -5,13 +5,17 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const quectelVendorID = "2c7c"
|
||||
const (
|
||||
quectelVendorID = "2c7c"
|
||||
sierraVendorID = "1199"
|
||||
)
|
||||
|
||||
type SysFSDiscoverer struct {
|
||||
SysRoot string
|
||||
@@ -45,6 +49,10 @@ 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, load MBIM first and add only this exact serial ID so the
|
||||
// class driver keeps interfaces 12/13 while option exposes diag/NMEA/AT.
|
||||
d.prepareSierraEM7430(ctx, usbRoot, entries)
|
||||
devices := make(map[string]*discoveredUSBDevice)
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -70,17 +78,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
resolvedDevice = devicePath
|
||||
}
|
||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||
if vendorID != quectelVendorID {
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
hardwareKind, idPrefix, supported := supportedUSBModem(vendorID, productID)
|
||||
if !supported {
|
||||
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{
|
||||
ID: candidateID(productID, serialNumber, deviceName),
|
||||
HardwareKind: hardwareKind,
|
||||
ID: candidateID(idPrefix, productID, serialNumber, deviceName),
|
||||
VendorID: vendorID,
|
||||
ProductID: productID,
|
||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||
@@ -104,9 +114,13 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
StablePath: aliases[name],
|
||||
Name: name,
|
||||
InterfaceNumber: interfaceNumber,
|
||||
Role: quecPortRole(interfaceNumber, name),
|
||||
Role: usbPortRole(vendorID, 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])
|
||||
}
|
||||
@@ -128,8 +142,15 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
}
|
||||
return left.Name < right.Name
|
||||
})
|
||||
assignQuectelPortRoles(state.candidate.Ports)
|
||||
if state.candidate.VendorID == sierraVendorID {
|
||||
assignSierraPortRoles(state.candidate.Ports)
|
||||
} else {
|
||||
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)
|
||||
@@ -239,6 +260,7 @@ 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)
|
||||
}
|
||||
@@ -385,7 +407,7 @@ func readSerialAliases(root string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func candidateID(productID, serialNumber, usbName string) string {
|
||||
func candidateID(prefix, 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
|
||||
@@ -394,9 +416,90 @@ func candidateID(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 "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
}
|
||||
return "quectel-" + sanitizeID(productID+"-"+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,
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeID(value string) string {
|
||||
|
||||
@@ -235,6 +235,67 @@ 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"), "LR93228600041019\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-lr93228600041019-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 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")
|
||||
|
||||
+16
-12
@@ -44,18 +44,22 @@ 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"`
|
||||
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"`
|
||||
NetworkInterface string `json:"networkInterface,omitempty"`
|
||||
DiscoveryIssue string `json:"discoveryIssue,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1785,7 +1785,7 @@ func deviceSummary(entry device.Device) map[string]any {
|
||||
"public_ip": "",
|
||||
"private_ip": "",
|
||||
"interface": entry.Candidate.NetworkInterface,
|
||||
"esim_transport": backendMode(entry.Candidate),
|
||||
"esim_transport": candidateESIMTransport(entry.Candidate),
|
||||
"sms_enabled": true,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": false,
|
||||
@@ -1897,10 +1897,22 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
config.DeviceBackend = backendMode(candidate)
|
||||
}
|
||||
if config.ESIMTransport == "" {
|
||||
config.ESIMTransport = config.DeviceBackend
|
||||
config.ESIMTransport = candidateESIMTransport(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -2035,6 +2047,9 @@ func backendMode(candidate modem.Candidate) string {
|
||||
if candidate.HardwareKind == "pcsc" {
|
||||
return "pcsc"
|
||||
}
|
||||
if candidate.ControlProtocol == "mbim" {
|
||||
return "mbim"
|
||||
}
|
||||
if candidate.QMIControl != "" {
|
||||
return "qmi"
|
||||
}
|
||||
|
||||
@@ -6,10 +6,24 @@ 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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
DeviceTypeDJI4G = "dji_4g"
|
||||
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
|
||||
DeviceTypeUSBSIMReader = "usb_sim_reader"
|
||||
DeviceTypeSierraEM74xx = "sierra_em74xx"
|
||||
)
|
||||
|
||||
// NormalizeDeviceType returns a stable persisted device type identifier.
|
||||
@@ -30,6 +31,8 @@ func NormalizeDeviceType(value string) string {
|
||||
return DeviceTypeDJI4G
|
||||
case DeviceTypeUSBSIMReader:
|
||||
return DeviceTypeUSBSIMReader
|
||||
case DeviceTypeSierraEM74xx:
|
||||
return DeviceTypeSierraEM74xx
|
||||
case "", DeviceTypePCIeEC20EC25:
|
||||
return DeviceTypePCIeEC20EC25
|
||||
default:
|
||||
@@ -135,7 +138,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 != "pcsc" {
|
||||
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "mbim" && value.DeviceBackend != "pcsc" {
|
||||
return fmt.Errorf("unsupported device backend %q", value.DeviceBackend)
|
||||
}
|
||||
if value.ESIMTransport == "" {
|
||||
|
||||
@@ -391,6 +391,26 @@ 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:")
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Some kernels omit the EM7430's 1199:9077 serial ID. Load MBIM before option
|
||||
# so interfaces 12/13 stay with cdc_mbim, then expose interface 3 as the AT
|
||||
# port. This is intentionally restricted to the exact device ID.
|
||||
if [ "$(id -u)" = "0" ] && [ -e /sys/bus/usb/devices ]; then
|
||||
modprobe cdc_mbim >/dev/null 2>&1 || true
|
||||
modprobe option >/dev/null 2>&1 || true
|
||||
if [ -w /sys/bus/usb-serial/drivers/option1/new_id ]; then
|
||||
printf '%s\n' '1199 9077' > /sys/bus/usb-serial/drivers/option1/new_id 2>/dev/null || true
|
||||
fi
|
||||
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
|
||||
|
||||
@@ -237,6 +237,71 @@ 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/umbim;MBIM 模组仍可进行 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. Register only this exact ID, with MBIM loaded first so option cannot
|
||||
# claim the MBIM control/data interfaces.
|
||||
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
|
||||
if [ -w /sys/bus/usb-serial/drivers/option1/new_id ]; then
|
||||
printf '%s\n' '1199 9077' > /sys/bus/usb-serial/drivers/option1/new_id 2>/dev/null || true
|
||||
fi
|
||||
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
|
||||
modprobe cdc_mbim >/dev/null 2>&1 || true
|
||||
modprobe option >/dev/null 2>&1 || true
|
||||
[ -w /sys/bus/usb-serial/drivers/option1/new_id ] && \
|
||||
printf '%s\n' '1199 9077' > /sys/bus/usb-serial/drivers/option1/new_id 2>/dev/null || 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", ATTR{idVendor}=="1199", ATTR{idProduct}=="9077", RUN+="/bin/sh -c 'modprobe cdc_mbim; modprobe option; printf 1199\\ 9077\\n > /sys/bus/usb-serial/drivers/option1/new_id'"
|
||||
EOF
|
||||
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
check_vowifi_environment() {
|
||||
if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then
|
||||
msg \
|
||||
@@ -523,6 +588,7 @@ 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,6 +23,8 @@ 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
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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",
|
||||
|
||||
@@ -375,6 +375,10 @@ 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;
|
||||
@@ -383,6 +387,7 @@ 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,
|
||||
@@ -392,8 +397,8 @@ export default function DevicesPage() {
|
||||
modemImei: d.imei || "",
|
||||
usbPath: d.usbPath || "",
|
||||
deviceBackend: backend,
|
||||
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
|
||||
esimTransport: isReader ? "pcsc" : backend,
|
||||
deviceType: isReader ? "usb_sim_reader" : isSierra ? "sierra_em74xx" : prev.deviceType,
|
||||
esimTransport: isReader ? "pcsc" : backend === "mbim" ? "at" : backend,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
export type ApiStatus = "ok" | "error";
|
||||
|
||||
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader";
|
||||
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader" | "sierra_em74xx";
|
||||
|
||||
export interface Session {
|
||||
authenticated: boolean;
|
||||
@@ -206,7 +206,7 @@ export interface DeviceConfig {
|
||||
dataBits: number;
|
||||
stopBits: number;
|
||||
parity: string;
|
||||
deviceBackend: "at" | "qmi" | "pcsc";
|
||||
deviceBackend: "at" | "qmi" | "mbim" | "pcsc";
|
||||
esimTransport: "at" | "qmi" | "pcsc" | "none";
|
||||
qmiUseProxy: boolean;
|
||||
qmiProxyPath?: string;
|
||||
|
||||
Reference in New Issue
Block a user