feat: implement fresh device discovery and update UI for device refresh

This commit is contained in:
MengMengCode
2026-08-15 23:48:32 +08:00
parent 70bdb88e4c
commit df9dbfee74
7 changed files with 140 additions and 8 deletions
+14 -1
View File
@@ -232,7 +232,20 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
state.opMu.Unlock()
}
manager.resetChangedClients()
return manager.List(), nil
// List retains previously discovered devices so configured hardware can be
// rendered as offline after it is unplugged. Discover, however, is a fresh
// physical scan and must only return devices that are present now. Returning
// the retained entries here allowed an unplugged modem to be selected and
// added again from the device discovery screen.
devices := manager.List()
present := devices[:0]
for _, entry := range devices {
if entry.Discovered {
present = append(present, entry)
}
}
return present, nil
}
func (manager *Manager) resetChangedClients() {
+23
View File
@@ -45,6 +45,29 @@ func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
}
}
func TestManagerDiscoverReturnsOnlyCurrentlyPresentDevices(t *testing.T) {
manager, id := newStartedTestManager(t, nil)
if devices := manager.List(); len(devices) != 1 || devices[0].ID != id || !devices[0].Discovered {
t.Fatalf("initial devices = %#v", devices)
}
manager.discoverer = staticDiscoverer{}
present, err := manager.Discover(context.Background())
if err != nil {
t.Fatalf("Discover after unplug: %v", err)
}
if len(present) != 0 {
t.Fatalf("present devices after unplug = %#v, want none", present)
}
// The retained entry is still available to the configured-device dashboard,
// but is explicitly offline and cannot be offered by fresh discovery.
retained := manager.List()
if len(retained) != 1 || retained[0].ID != id || retained[0].Discovered {
t.Fatalf("retained devices after unplug = %#v", retained)
}
}
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
+11 -1
View File
@@ -344,7 +344,14 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}})
return true
}
devices := s.devices.List()
// This endpoint backs the add-device dialog. Always perform a new physical
// scan instead of serving Manager.List(), which intentionally retains
// unplugged configured devices so the main device list can show them offline.
devices, err := s.devices.Discover(r.Context())
if err != nil {
s.writeDeviceError(w, err)
return true
}
configured, err := s.store.ListDevices(r.Context())
if err != nil {
s.writeStoreError(w, err)
@@ -352,6 +359,9 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
}
result := make([]map[string]any, 0, len(devices))
for _, entry := range devices {
if !entry.Discovered {
continue
}
candidate := entry.Candidate
atPorts := make([]string, 0, len(candidate.Ports))
for _, port := range candidate.Ports {
@@ -0,0 +1,72 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
)
type discoverySnapshotController struct {
fakeDeviceController
entries []device.Device
discoverCalls int
}
func (controller *discoverySnapshotController) Discover(context.Context) ([]device.Device, error) {
controller.discoverCalls++
return append([]device.Device(nil), controller.entries...), nil
}
func TestDiscoveredDevicesPerformsFreshScanAndOmitsAbsentEntries(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
controller := &discoverySnapshotController{
fakeDeviceController: fakeDeviceController{entry: device.Device{
ID: "stale-device", Discovered: false,
Candidate: modem.Candidate{ID: "stale-device", USBPath: "1-1"},
}},
entries: []device.Device{
{
ID: "current-device", Discovered: true,
Candidate: modem.Candidate{ID: "current-device", USBPath: "2-1"},
},
{
ID: "absent-device", Discovered: false,
Candidate: modem.Candidate{ID: "absent-device", USBPath: "3-1"},
},
},
}
server := &Server{
store: database, logger: regionTestLogger(),
maxRequestBodyBytes: 4096, devices: controller,
}
request := httptest.NewRequest(http.MethodGet, "/api/devices/discovered", nil)
recorder := httptest.NewRecorder()
if !server.handleDiscoveredDevices(recorder, request) {
t.Fatal("handleDiscoveredDevices returned false")
}
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if controller.discoverCalls != 1 {
t.Fatalf("Discover calls = %d, want 1", controller.discoverCalls)
}
body := recorder.Body.String()
if !strings.Contains(body, "current-device") {
t.Fatalf("response omits current device: %s", body)
}
if strings.Contains(body, "stale-device") || strings.Contains(body, "absent-device") {
t.Fatalf("response contains an absent device: %s", body)
}
}
@@ -1,5 +1,5 @@
import { useEffect, type ReactNode } from "react";
import { SaveRegular } from "@fluentui/react-icons";
import { ArrowSyncRegular, SaveRegular } from "@fluentui/react-icons";
import { cx } from "../../lib/utils";
import { Button, Input, Modal, Select, Spinner, Tag } from "../ui";
import { isQmiControl } from "./shared";
@@ -17,6 +17,7 @@ export interface DeviceAddDialogProps {
addConfig: AddDeviceForm;
addSaving: boolean;
onClose: () => void;
onRefresh: () => void;
onSelectDevice: (d: DiscoveredDevice) => void;
onConfigChange: (next: AddDeviceForm) => void;
onSave: () => void;
@@ -85,7 +86,12 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
</div>
}
>
<div className="mb-3 text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</div>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</div>
<Button size="small" loading={props.discovering} onClick={props.onRefresh} icon={<ArrowSyncRegular />}>
{t("刷新设备")}
</Button>
</div>
<div className="max-h-[260px] space-y-2 overflow-auto pr-1">
{props.discovering ? (
<div className="flex flex-col items-center justify-center py-10 text-gray-400">
+1
View File
@@ -993,6 +993,7 @@ export const EN_DICT: Record<string, string> = {
: "Latest",
: "Load More",
: "Refresh",
"刷新设备": "Refresh Devices",
// ---- 代理 / 国家规则(i18n 补充) ----
"绑定:": "Bound:",
+11 -4
View File
@@ -136,11 +136,17 @@ export default function DevicesPage() {
const loadDiscovered = useCallback(async () => {
setDiscovering(true);
// Never leave a previous physical scan visible while a new scan is in
// progress or after it fails.
setDiscovered([]);
setAddSelected(null);
setAddConfig(EMPTY_ADD);
try {
const res = await api<{ devices?: DiscoveredDevice[] }>("/devices/discovered?with_imei=1");
setDiscovered(Array.isArray(res?.devices) ? res!.devices! : []);
const devices = Array.isArray(res?.devices) ? res!.devices! : [];
setDiscovered(devices);
} catch {
/* ignore */
setDiscovered([]);
} finally {
setDiscovering(false);
}
@@ -296,13 +302,13 @@ export default function DevicesPage() {
try {
await api("/devices/actions/rescan", { method: "POST" });
message.success(t("设备重新扫描完成"));
await loadDevices(true);
await Promise.all([loadDevices(true), loadDiscovered()]);
} catch (e) {
message.error(apiMessage(e) || t("重新扫描失败"));
} finally {
setRescanning(false);
}
}, [loadDevices]);
}, [loadDevices, loadDiscovered]);
const handleOpenSms = useCallback(() => {
const id = selectedIdRef.current;
@@ -736,6 +742,7 @@ export default function DevicesPage() {
addConfig={addConfig}
addSaving={addSaving}
onClose={() => setAddOpen(false)}
onRefresh={() => void loadDiscovered()}
onSelectDevice={selectDiscovered}
onConfigChange={setAddConfig}
onSave={saveAdd}