From c7c7174b45bd5b58bf4be667e9f07c3527cd75c3 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:05:07 +0800 Subject: [PATCH] feat: support Sierra EM7430 MBIM modems --- .github/workflows/docker.yml | 4 +- Dockerfile | 2 +- docker-compose.yml | 7 +- internal/device/data.go | 16 +- internal/device/data_linux.go | 173 ++++++++++++++++++ internal/device/data_other.go | 13 ++ internal/device/esim.go | 8 +- internal/device/manager.go | 2 +- internal/device/manager_test.go | 31 +++- internal/device/snapshot.go | 58 +++++- internal/modem/discovery.go | 121 +++++++++++- internal/modem/discovery_test.go | 61 ++++++ internal/modem/types.go | 28 +-- internal/server/device_api.go | 19 +- internal/server/device_summary_test.go | 14 ++ internal/store/devices.go | 5 +- internal/store/domain_test.go | 20 ++ scripts/docker-entrypoint.sh | 11 ++ scripts/install.sh | 66 +++++++ .../components/devices/DeviceAddDialog.tsx | 2 +- .../components/devices/DeviceConfigTab.tsx | 2 +- .../devices/DiscoveredDeviceRow.tsx | 2 + web/src/lib/deviceTypes.ts | 1 + web/src/lib/i18n-en.ts | 1 + web/src/pages/DevicesPage.tsx | 9 +- web/src/types.ts | 4 +- 26 files changed, 638 insertions(+), 42 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7805117..7d92e68 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 543dc8a..6ac8854 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c73e21d..c2ed422 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/internal/device/data.go b/internal/device/data.go index ebf10b7..f124c51 100644 --- a/internal/device/data.go +++ b/internal/device/data.go @@ -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 { diff --git a/internal/device/data_linux.go b/internal/device/data_linux.go index 34930ad..f0bbdd8 100644 --- a/internal/device/data_linux.go +++ b/internal/device/data_linux.go @@ -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, "'", `'"'"'`) + "'" } diff --git a/internal/device/data_other.go b/internal/device/data_other.go index 243e5ca..bef4dc1 100644 --- a/internal/device/data_other.go +++ b/internal/device/data_other.go @@ -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) +} diff --git a/internal/device/esim.go b/internal/device/esim.go index 254cc0b..d006fef 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -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 diff --git a/internal/device/manager.go b/internal/device/manager.go index 6d43b96..2806cc4 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -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() diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index b69ece5..9f520a1 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -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")}, diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go index 4ddb9f8..272027f 100644 --- a/internal/device/snapshot.go +++ b/internal/device/snapshot.go @@ -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 { diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go index 33aa786..6e12f40 100644 --- a/internal/modem/discovery.go +++ b/internal/modem/discovery.go @@ -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 { diff --git a/internal/modem/discovery_test.go b/internal/modem/discovery_test.go index 69c4ebe..845a951 100644 --- a/internal/modem/discovery_test.go +++ b/internal/modem/discovery_test.go @@ -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") diff --git a/internal/modem/types.go b/internal/modem/types.go index a447e0f..d6dc72e 100644 --- a/internal/modem/types.go +++ b/internal/modem/types.go @@ -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"` } diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 8685c0a..75b87f3 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -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" } diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go index bf52e3c..2e63ee1 100644 --- a/internal/server/device_summary_test.go +++ b/internal/server/device_summary_test.go @@ -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 { diff --git a/internal/store/devices.go b/internal/store/devices.go index 0a6e070..6b40210 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -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 == "" { diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index e7129a8..441f427 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -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:") diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index a3d2ec5..7ea6e0c 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 1a2eef6..663ba4e 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 diff --git a/web/src/components/devices/DeviceAddDialog.tsx b/web/src/components/devices/DeviceAddDialog.tsx index f6d40b8..228a653 100644 --- a/web/src/components/devices/DeviceAddDialog.tsx +++ b/web/src/components/devices/DeviceAddDialog.tsx @@ -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(() => { diff --git a/web/src/components/devices/DeviceConfigTab.tsx b/web/src/components/devices/DeviceConfigTab.tsx index ec2e71a..64be1c4 100644 --- a/web/src/components/devices/DeviceConfigTab.tsx +++ b/web/src/components/devices/DeviceConfigTab.tsx @@ -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(() => { diff --git a/web/src/components/devices/DiscoveredDeviceRow.tsx b/web/src/components/devices/DiscoveredDeviceRow.tsx index 3054c7e..f909ae2 100644 --- a/web/src/components/devices/DiscoveredDeviceRow.tsx +++ b/web/src/components/devices/DiscoveredDeviceRow.tsx @@ -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 (