mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-19 06:13:42 +08:00
fix: discover USB smart card readers without pcscd
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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_*` 环境变量。环境变量优先级更高。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -185,6 +185,7 @@ export interface DiscoveredDevice {
|
||||
configured: boolean;
|
||||
configuredId?: string;
|
||||
degraded?: boolean;
|
||||
discoveryIssue?: "pcsc_service_unavailable" | "pcsc_driver_missing" | string;
|
||||
usbnetMode?: number | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user