diff --git a/README.md b/README.md index 4203152..aeef525 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,15 @@ those fixed nodes and does not provide complete multi-device or hot-plug discove The GHCR image is published for `linux/amd64` and `linux/arm64`. +### USB SIM readers + +USB SIM readers use the Linux PC/SC service. The one-click installer installs +and starts `pcscd` plus the CCID driver automatically on supported package +managers. On Debian/Ubuntu, the equivalent manual setup is +`apt install pcscd libccid`. If USB sees a CCID reader but PC/SC is unavailable, +VoCat keeps the reader visible in the add-device dialog and reports the missing +service or driver instead of silently hiding it. + ## Configuration Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence. diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 4cd51fe..4d3ecb9 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -942,7 +942,7 @@ func pollDeviceSnapshots( var refreshGroup sync.WaitGroup refreshSlots := make(chan struct{}, 4) for _, entry := range entries { - if !entry.Discovered { + if !entry.Discovered || entry.Candidate.DiscoveryIssue != "" { continue } entry := entry diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 2f572ee..1720fed 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -168,6 +168,13 @@ docker run -d \ GHCR 镜像发布为 `linux/amd64` 与 `linux/arm64`。 +### USB SIM 读卡器 + +USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上 +自动安装并启动 `pcscd` 和 CCID 驱动;Debian/Ubuntu 手动安装命令为 +`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪, +VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。 + ## 配置 Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。 diff --git a/internal/device/manager.go b/internal/device/manager.go index 09c8676..6d43b96 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -181,6 +181,7 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) { ReaderName: reader.Name, USBPath: reader.USBPath, VendorID: reader.VendorID, ProductID: reader.ProductID, Manufacturer: reader.Manufacturer, Product: reader.Product, + DiscoveryIssue: reader.DiscoveryIssue, }) } } diff --git a/internal/modem/types.go b/internal/modem/types.go index 5a50aa5..a447e0f 100644 --- a/internal/modem/types.go +++ b/internal/modem/types.go @@ -57,6 +57,7 @@ type Candidate struct { Ports []Port `json:"ports"` QMIControl string `json:"qmiControl,omitempty"` NetworkInterface string `json:"networkInterface,omitempty"` + DiscoveryIssue string `json:"discoveryIssue,omitempty"` } func (c Candidate) HasATPort() bool { diff --git a/internal/pcsc/backend_linux.go b/internal/pcsc/backend_linux.go index b970ee6..a3bbe61 100644 --- a/internal/pcsc/backend_linux.go +++ b/internal/pcsc/backend_linux.go @@ -16,9 +16,9 @@ import ( "time" ) -type nativeBackend struct{} +type nativeBackend struct{ sysRoot string } -func newNativeBackend() Backend { return &nativeBackend{} } +func newNativeBackend() Backend { return &nativeBackend{sysRoot: "/sys"} } func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) { paths := []string{strings.TrimSpace(os.Getenv("PCSCLITE_CSOCK_NAME")), "/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"} @@ -45,8 +45,15 @@ func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) { } func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { + physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing") client, err := backend.dial(ctx) if err != nil { + if len(physical) > 0 { + for index := range physical { + physical[index].DiscoveryIssue = "pcsc_service_unavailable" + } + return physical, nil + } return nil, err } defer client.closeContext(context.Background()) @@ -63,10 +70,10 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { } if path, ok := backend.readerUSBPath(ctx, client, state.name); ok { reader.USBPath = path - reader.VendorID = readSysfsText(path, "idVendor") - reader.ProductID = readSysfsText(path, "idProduct") - reader.Manufacturer = readSysfsText(path, "manufacturer") - reader.Product = readSysfsText(path, "product") + reader.VendorID = backend.readSysfsText(path, "idVendor") + reader.ProductID = backend.readSysfsText(path, "idProduct") + reader.Manufacturer = backend.readSysfsText(path, "manufacturer") + reader.Product = backend.readSysfsText(path, "product") } else { reader.USBPath = "pcsc:" + state.name } @@ -75,7 +82,7 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { } readers = append(readers, reader) } - return readers, nil + return mergePCSCAndUSBReaders(readers, physical), nil } func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdClient, name string) (string, bool) { @@ -94,7 +101,8 @@ func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdCl return "", false } bus, device := int((channel>>8)&0xff), int(channel&0xff) - entries, err := os.ReadDir("/sys/bus/usb/devices") + usbRoot := filepath.Join(backend.sysRoot, "bus", "usb", "devices") + entries, err := os.ReadDir(usbRoot) if err != nil { return "", false } @@ -102,7 +110,7 @@ func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdCl if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { continue } - path := filepath.Join("/sys/bus/usb/devices", entry.Name()) + path := filepath.Join(usbRoot, entry.Name()) entryBus, busErr := readSysfsInt(path, "busnum") entryDevice, deviceErr := readSysfsInt(path, "devnum") if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device { @@ -121,6 +129,9 @@ func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card if !ok { return nil, ErrReaderNotFound } + if reader.DiscoveryIssue != "" { + return nil, fmt.Errorf("%w: %s", ErrUnavailable, reader.DiscoveryIssue) + } if !reader.CardPresent { return nil, ErrNoCard } @@ -221,8 +232,8 @@ func (card *nativeCard) close(disposition uint32) error { return errors.Join(result...) } -func readSysfsText(usbPath, name string) string { - value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name)) +func (backend *nativeBackend) readSysfsText(usbPath, name string) string { + value, err := os.ReadFile(filepath.Join(backend.sysRoot, "bus", "usb", "devices", usbPath, name)) if err != nil { return "" } diff --git a/internal/pcsc/service.go b/internal/pcsc/service.go index 1af74d0..78e51a1 100644 --- a/internal/pcsc/service.go +++ b/internal/pcsc/service.go @@ -123,6 +123,9 @@ func (service *Service) Snapshot(ctx context.Context, selector Selector, pin str return Snapshot{}, ErrReaderNotFound } result := Snapshot{Reader: reader} + if reader.DiscoveryIssue != "" { + return result, fmt.Errorf("%w: %s", ErrUnavailable, reader.DiscoveryIssue) + } if !reader.CardPresent { return result, ErrNoCard } diff --git a/internal/pcsc/service_test.go b/internal/pcsc/service_test.go index 0a99488..4a5daec 100644 --- a/internal/pcsc/service_test.go +++ b/internal/pcsc/service_test.go @@ -17,6 +17,16 @@ type scriptedCard struct { calls [][]byte } +type unavailableReaderBackend struct{} + +func (unavailableReaderBackend) Readers(context.Context) ([]Reader, error) { + return []Reader{{Name: "ACR38", USBPath: "2-1", DiscoveryIssue: "pcsc_service_unavailable"}}, nil +} + +func (unavailableReaderBackend) Open(context.Context, Selector) (Card, error) { + return nil, errors.New("Open must not be called for a diagnostic-only reader") +} + func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) { card.calls = append(card.calls, append([]byte(nil), command...)) if len(card.replies) == 0 { @@ -82,3 +92,11 @@ func TestDeviceIDUsesStableUSBPath(t *testing.T) { t.Fatalf("device IDs = %q, %q", a, b) } } + +func TestSnapshotRejectsPhysicalReaderUntilPCSCDIsReady(t *testing.T) { + service := NewWithBackend(unavailableReaderBackend{}) + snapshot, err := service.Snapshot(context.Background(), Selector{USBPath: "2-1"}, "") + if !errors.Is(err, ErrUnavailable) || snapshot.Reader.USBPath != "2-1" { + t.Fatalf("Snapshot() = %#v, %v", snapshot, err) + } +} diff --git a/internal/pcsc/types.go b/internal/pcsc/types.go index 500ba66..b3ad173 100644 --- a/internal/pcsc/types.go +++ b/internal/pcsc/types.go @@ -31,6 +31,10 @@ type Reader struct { Product string CardPresent bool ATR string + // DiscoveryIssue is set when USB sees a smart-card reader but pcscd cannot + // expose it yet. Keeping the physical reader visible lets the UI explain the + // missing service/driver instead of silently showing an empty device list. + DiscoveryIssue string } type Selector struct { diff --git a/internal/pcsc/usb_discovery.go b/internal/pcsc/usb_discovery.go new file mode 100644 index 0000000..4050d44 --- /dev/null +++ b/internal/pcsc/usb_discovery.go @@ -0,0 +1,97 @@ +package pcsc + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const usbSmartCardInterfaceClass = "0b" + +// discoverUSBSmartCardReaders finds physical USB CCID interfaces directly in +// sysfs. It is a diagnostic fallback; APDU access still goes through pcscd. +func discoverUSBSmartCardReaders(sysRoot, issue string) []Reader { + usbRoot := filepath.Join(filepath.Clean(sysRoot), "bus", "usb", "devices") + entries, err := os.ReadDir(usbRoot) + if err != nil { + return nil + } + deviceNames := make(map[string]struct{}) + for _, entry := range entries { + name := entry.Name() + class := strings.ToLower(readTrimmedFile(filepath.Join(usbRoot, name, "bInterfaceClass"))) + if deviceName, ok := smartCardUSBDeviceName(name, class); ok { + deviceNames[deviceName] = struct{}{} + } + } + result := make([]Reader, 0, len(deviceNames)) + for deviceName := range deviceNames { + path := filepath.Join(usbRoot, deviceName) + vendorID := strings.ToLower(readTrimmedFile(filepath.Join(path, "idVendor"))) + productID := strings.ToLower(readTrimmedFile(filepath.Join(path, "idProduct"))) + if vendorID == "" || productID == "" { + continue + } + product := readTrimmedFile(filepath.Join(path, "product")) + if product == "" { + product = fmt.Sprintf("USB smart card reader %s:%s", vendorID, productID) + } + result = append(result, Reader{ + Name: product, USBPath: deviceName, + VendorID: vendorID, ProductID: productID, + Manufacturer: readTrimmedFile(filepath.Join(path, "manufacturer")), + Product: product, DiscoveryIssue: issue, + }) + } + sort.Slice(result, func(i, j int) bool { return result[i].USBPath < result[j].USBPath }) + return result +} + +func smartCardUSBDeviceName(interfaceName, class string) (string, bool) { + deviceName, _, interfaceEntry := strings.Cut(interfaceName, ":") + return deviceName, interfaceEntry && deviceName != "" && strings.EqualFold(strings.TrimSpace(class), usbSmartCardInterfaceClass) +} + +func readTrimmedFile(path string) string { + value, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(value)) +} + +func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader { + if len(readers) == 1 && len(physical) == 1 && strings.HasPrefix(readers[0].USBPath, "pcsc:") { + readers[0] = enrichPCSCReader(readers[0], physical[0]) + return readers + } + seen := make(map[string]bool, len(readers)) + for i := range readers { + seen[readers[i].USBPath] = true + for _, usbReader := range physical { + if readers[i].USBPath == usbReader.USBPath { + readers[i] = enrichPCSCReader(readers[i], usbReader) + } + } + } + for _, usbReader := range physical { + if !seen[usbReader.USBPath] { + readers = append(readers, usbReader) + } + } + return readers +} + +func enrichPCSCReader(reader, physical Reader) Reader { + reader.USBPath = physical.USBPath + reader.VendorID = physical.VendorID + reader.ProductID = physical.ProductID + reader.Manufacturer = physical.Manufacturer + if reader.Product == "" { + reader.Product = physical.Product + } + reader.DiscoveryIssue = "" + return reader +} diff --git a/internal/pcsc/usb_discovery_test.go b/internal/pcsc/usb_discovery_test.go new file mode 100644 index 0000000..8077099 --- /dev/null +++ b/internal/pcsc/usb_discovery_test.go @@ -0,0 +1,61 @@ +package pcsc + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestDiscoverUSBSmartCardReadersFindsACR38(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filenames cannot represent Linux sysfs interface names containing a colon") + } + root := t.TempDir() + usbRoot := filepath.Join(root, "bus", "usb", "devices") + writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "idVendor"), "072f\n") + writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "idProduct"), "90cc\n") + writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "manufacturer"), "Advanced Card Systems\n") + writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "product"), "ACR38 SmartCard Reader\n") + writeUSBTestFile(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceClass"), "0b\n") + writeUSBTestFile(t, filepath.Join(usbRoot, "3-1", "idVendor"), "2c7c\n") + writeUSBTestFile(t, filepath.Join(usbRoot, "3-1:1.0", "bInterfaceClass"), "ff\n") + + readers := discoverUSBSmartCardReaders(root, "pcsc_service_unavailable") + if len(readers) != 1 { + t.Fatalf("readers = %#v", readers) + } + reader := readers[0] + if reader.USBPath != "2-1" || reader.VendorID != "072f" || reader.ProductID != "90cc" || reader.DiscoveryIssue != "pcsc_service_unavailable" { + t.Fatalf("reader = %#v", reader) + } +} + +func TestSmartCardUSBDeviceName(t *testing.T) { + if name, ok := smartCardUSBDeviceName("2-1:1.0", "0B"); !ok || name != "2-1" { + t.Fatalf("smartCardUSBDeviceName() = %q, %v", name, ok) + } + if _, ok := smartCardUSBDeviceName("2-1:1.0", "ff"); ok { + t.Fatal("vendor-specific interface was accepted as CCID") + } +} + +func TestMergePCSCAndSingleUSBReaderEnrichesFallbackPath(t *testing.T) { + readers := mergePCSCAndUSBReaders( + []Reader{{Name: "ACS ACR38 00 00", USBPath: "pcsc:ACS ACR38 00 00", CardPresent: true}}, + []Reader{{Name: "ACR38", USBPath: "2-1", VendorID: "072f", ProductID: "90cc", DiscoveryIssue: "pcsc_driver_missing"}}, + ) + if len(readers) != 1 || readers[0].USBPath != "2-1" || readers[0].DiscoveryIssue != "" || !readers[0].CardPresent { + t.Fatalf("readers = %#v", readers) + } +} + +func writeUSBTestFile(t *testing.T, path, value string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 9361ce0..8685c0a 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -379,7 +379,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) "usb_path": candidate.USBPath, "vendor_id": parseHexID(candidate.VendorID), "product_id": parseHexID(candidate.ProductID), - "driver_name": "", + "driver_name": candidate.Product, "at_ports": atPorts, "at_port": candidate.ATPort.OpenPath(), "imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }), @@ -387,7 +387,8 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) "network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""), "configured": configuredID != "", "configured_id": configuredID, - "degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(), + "degraded": candidate.DiscoveryIssue != "" || (candidate.HardwareKind != "pcsc" && !candidate.HasATPort()), + "discovery_issue": candidate.DiscoveryIssue, }) } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}}) diff --git a/scripts/install.sh b/scripts/install.sh index c505a77..1a2eef6 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -195,6 +195,48 @@ install_linux_ip_tool() { fi } +install_pcsc_support() { + msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..." + local installed=0 + if is_openwrt && command -v opkg >/dev/null 2>&1; then + opkg update >/dev/null 2>&1 || true + local packages="" + opkg_has_package pcscd && packages="$packages pcscd" + opkg_has_package ccid && packages="$packages ccid" + if [ -n "$packages" ]; then + # shellcheck disable=SC2086 + opkg install $packages >/dev/null 2>&1 && installed=1 || true + fi + elif command -v apt-get >/dev/null 2>&1; then + if apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y pcscd libccid; then + installed=1 + fi + elif command -v dnf >/dev/null 2>&1; then + dnf install -y pcsc-lite pcsc-lite-ccid && installed=1 || true + elif command -v yum >/dev/null 2>&1; then + yum install -y pcsc-lite pcsc-lite-ccid && installed=1 || true + elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm pcsclite ccid && installed=1 || true + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache pcsc-lite ccid && installed=1 || true + fi + + if command -v systemctl >/dev/null 2>&1; then + systemctl enable --now pcscd.socket >/dev/null 2>&1 || \ + systemctl restart pcscd >/dev/null 2>&1 || true + elif [ -x /etc/init.d/pcscd ]; then + /etc/init.d/pcscd enable >/dev/null 2>&1 || true + /etc/init.d/pcscd restart >/dev/null 2>&1 || /etc/init.d/pcscd start >/dev/null 2>&1 || true + fi + if command -v pcscd >/dev/null 2>&1 || [ "$installed" -eq 1 ]; then + msg "USB SIM 读卡器 PC/SC 环境已就绪。" "USB SIM reader PC/SC environment is ready." + else + msg \ + "警告:未能自动安装 pcscd/CCID 驱动;系统仍会显示读卡器并给出修复提示。" \ + "Warning: pcscd/CCID could not be installed automatically; VoCat will still show the reader with a remediation hint." + fi +} + check_vowifi_environment() { if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then msg \ @@ -481,6 +523,7 @@ enable_and_start() { # --- Main -------------------------------------------------------------------- detect_arch +install_pcsc_support check_vowifi_environment if [ "$CHECK_ENV" -eq 1 ]; then msg "VoCat 运行环境检查完成。" "VoCat host environment check completed." diff --git a/web/src/components/devices/DiscoveredDeviceRow.tsx b/web/src/components/devices/DiscoveredDeviceRow.tsx index 2232dce..3054c7e 100644 --- a/web/src/components/devices/DiscoveredDeviceRow.tsx +++ b/web/src/components/devices/DiscoveredDeviceRow.tsx @@ -18,6 +18,12 @@ export function DiscoveredDeviceRow({ }) { const { t } = useI18n(); const degraded = !!device.degraded; + const displayName = device.readerName || device.driverName || device.netInterface || device.controlPath || t("未知设备"); + const discoveryMessage = device.discoveryIssue === "pcsc_service_unavailable" + ? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。") + : device.discoveryIssue === "pcsc_driver_missing" + ? t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。") + : ""; return ( ); } diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 83c1c03..b3547a0 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -5,6 +5,9 @@ * 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。 */ export const EN_DICT: Record = { + "未知设备": "Unknown device", + "系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。": "The USB card reader was found, but the PC/SC service is not running. Install and start pcscd, then scan again.", + "系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。": "The USB card reader was found, but its PC/SC driver is not loaded. Install libccid or the vendor driver, then scan again.", 硬件路径: "Hardware Path", "USB SIM 读卡器(仅 WiFi Calling)": "USB SIM Reader (WiFi Calling only)", "仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled", diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 58fe996..30dbdd9 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -367,6 +367,14 @@ export default function DevicesPage() { }, [loadDiscovered]); const selectDiscovered = useCallback((d: DiscoveredDevice) => { + if (d.discoveryIssue === "pcsc_service_unavailable") { + message.warning(t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。")); + return; + } + if (d.discoveryIssue === "pcsc_driver_missing") { + message.warning(t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。")); + return; + } if (d.degraded) { message.warning(t("无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试")); return; diff --git a/web/src/types.ts b/web/src/types.ts index b0fa6ac..87523d7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -185,6 +185,7 @@ export interface DiscoveredDevice { configured: boolean; configuredId?: string; degraded?: boolean; + discoveryIssue?: "pcsc_service_unavailable" | "pcsc_driver_missing" | string; usbnetMode?: number | null; }