mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-19 06:13:42 +08:00
feat: implement fresh device discovery and update UI for device refresh
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -993,6 +993,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
最新: "Latest",
|
||||
加载更多: "Load More",
|
||||
刷新: "Refresh",
|
||||
"刷新设备": "Refresh Devices",
|
||||
|
||||
// ---- 代理 / 国家规则(i18n 补充) ----
|
||||
"绑定:": "Bound:",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user