diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7d92e68..7805117 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 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 diff --git a/Dockerfile b/Dockerfile index ca34770..543dc8a 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 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 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 diff --git a/docker-compose.yml b/docker-compose.yml index c2ed422..c73e21d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/internal/device/data.go b/internal/device/data.go index f124c51..ebf10b7 100644 --- a/internal/device/data.go +++ b/internal/device/data.go @@ -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 { diff --git a/internal/device/data_linux.go b/internal/device/data_linux.go index f0bbdd8..34930ad 100644 --- a/internal/device/data_linux.go +++ b/internal/device/data_linux.go @@ -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, "'", `'"'"'`) + "'" } diff --git a/internal/device/data_other.go b/internal/device/data_other.go index bef4dc1..243e5ca 100644 --- a/internal/device/data_other.go +++ b/internal/device/data_other.go @@ -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) -} diff --git a/internal/device/esim.go b/internal/device/esim.go index d006fef..254cc0b 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -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 diff --git a/internal/device/manager.go b/internal/device/manager.go index 2806cc4..6d43b96 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 != "mbim" && backend != "pcsc" { + if backend != "at" && backend != "qmi" && 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 18c37fe..b69ece5 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -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")}, diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go index 7f6d618..4ddb9f8 100644 --- a/internal/device/snapshot.go +++ b/internal/device/snapshot.go @@ -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 { diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go index b6237ff..33aa786 100644 --- a/internal/modem/discovery.go +++ b/internal/modem/discovery.go @@ -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 { diff --git a/internal/modem/discovery_test.go b/internal/modem/discovery_test.go index 9deb503..69c4ebe 100644 --- a/internal/modem/discovery_test.go +++ b/internal/modem/discovery_test.go @@ -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") diff --git a/internal/modem/types.go b/internal/modem/types.go index d6dc72e..a447e0f 100644 --- a/internal/modem/types.go +++ b/internal/modem/types.go @@ -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"` } diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 75b87f3..8685c0a 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": 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" } diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go index 2e63ee1..bf52e3c 100644 --- a/internal/server/device_summary_test.go +++ b/internal/server/device_summary_test.go @@ -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 { diff --git a/internal/store/devices.go b/internal/store/devices.go index 6b40210..0a6e070 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -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 == "" { diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index 441f427..e7129a8 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -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:") diff --git a/scripts/bind-sierra-em7430.sh b/scripts/bind-sierra-em7430.sh deleted file mode 100644 index 35a37ab..0000000 --- a/scripts/bind-sierra-em7430.sh +++ /dev/null @@ -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 diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 2419c3b..a3d2ec5 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 0399b37..1a2eef6 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 diff --git a/web/src/components/devices/DeviceAddDialog.tsx b/web/src/components/devices/DeviceAddDialog.tsx index 228a653..f6d40b8 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 64be1c4..ec2e71a 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 f909ae2..3054c7e 100644 --- a/web/src/components/devices/DiscoveredDeviceRow.tsx +++ b/web/src/components/devices/DiscoveredDeviceRow.tsx @@ -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 (