mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-16 04:43:43 +08:00
Rollback
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 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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/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. 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user