fix: discover USB smart card readers without pcscd

This commit is contained in:
MengMengCode
2026-08-13 10:53:56 +08:00
parent c9656ea3fa
commit 1100f20dc5
17 changed files with 293 additions and 19 deletions
+9
View File
@@ -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.
+1 -1
View File
@@ -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
+7
View File
@@ -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_*` 环境变量。环境变量优先级更高。
+1
View File
@@ -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,
})
}
}
+1
View File
@@ -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 {
+22 -11
View File
@@ -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 ""
}
+3
View File
@@ -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
}
+18
View File
@@ -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)
}
}
+4
View File
@@ -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 {
+97
View File
@@ -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
}
+61
View File
@@ -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)
}
}
+3 -2
View File
@@ -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}})
+43
View File
@@ -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."
@@ -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 (
<button
type="button"
@@ -31,15 +37,15 @@ export function DiscoveredDeviceRow({
)}
>
<div className="flex items-center gap-2 font-bold text-gray-800">
<span>
{device.netInterface || "--"} · {device.driverName || "--"}
</span>
<span>{displayName}</span>
<Tag type={isQmi ? "success" : "warning"}>{modeLabel}</Tag>
</div>
<div className="mt-0.5 truncate text-xs text-gray-500">
{device.controlPath} · AT: {device.atPort || "--"} · IMEI: {device.imei || "--"} · USB: {device.usbPath || "--"}
{device.hardwareKind === "pcsc"
? `USB: ${device.usbPath || "--"} · VID:PID ${device.vendorId.toString(16).padStart(4, "0")}:${device.productId.toString(16).padStart(4, "0")}`
: `${device.controlPath} · AT: ${device.atPort || "--"} · IMEI: ${device.imei || "--"} · USB: ${device.usbPath || "--"}`}
</div>
{degraded ? <div className="mt-1 text-xs text-amber-700">{t("未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。")}</div> : null}
{degraded ? <div className="mt-1 text-xs text-amber-700">{discoveryMessage || t("未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。")}</div> : null}
</button>
);
}
+3
View File
@@ -5,6 +5,9 @@
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
*/
export const EN_DICT: Record<string, string> = {
"未知设备": "Unknown device",
"系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。": "The USB card reader was found, but the PC/SC service is not running. Install and start pcscd, then scan again.",
"系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。": "The USB card reader was found, but its PC/SC driver is not loaded. Install libccid or the vendor driver, then scan again.",
: "Hardware Path",
"USB SIM 读卡器(仅 WiFi Calling": "USB SIM Reader (WiFi Calling only)",
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
+8
View File
@@ -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;
+1
View File
@@ -185,6 +185,7 @@ export interface DiscoveredDevice {
configured: boolean;
configuredId?: string;
degraded?: boolean;
discoveryIssue?: "pcsc_service_unavailable" | "pcsc_driver_missing" | string;
usbnetMode?: number | null;
}