2026-06-03 14:35:08 +08:00
|
|
|
|
// src/ScanScreen.tsx此页面为功率计搜索页面
|
2025-11-05 15:18:15 +08:00
|
|
|
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
FlatList,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
RefreshControl,
|
|
|
|
|
|
} from "react-native";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Central,
|
|
|
|
|
|
CentralEventMap,
|
|
|
|
|
|
ScannedPeripheral,
|
|
|
|
|
|
} from "@systemic-games/react-native-bluetooth-le";
|
|
|
|
|
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
|
|
|
|
|
import { RootStackParamList } from "../App";
|
|
|
|
|
|
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
|
|
|
|
|
|
|
2025-12-25 16:16:01 +08:00
|
|
|
|
type Props = NativeStackScreenProps<RootStackParamList>;
|
2025-11-05 15:18:15 +08:00
|
|
|
|
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
|
2025-12-25 16:16:01 +08:00
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
import MyStatusbar from "./component/MyStatusbar";
|
|
|
|
|
|
import MyHeader from "./component/MyHeader";
|
2025-11-05 15:18:15 +08:00
|
|
|
|
|
|
|
|
|
|
export default function ScanScreen({ navigation }: Props) {
|
|
|
|
|
|
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
|
|
|
|
|
|
const [scanning, setScanning] = useState(false);
|
|
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
|
2025-12-25 16:16:01 +08:00
|
|
|
|
const { t } = useTranslation();
|
2025-11-05 15:18:15 +08:00
|
|
|
|
// 将 RSSI 转换为信号格数(0-4)
|
|
|
|
|
|
const getSignalLevel = (rssi?: number): number => {
|
|
|
|
|
|
if (rssi === undefined) return 0;
|
|
|
|
|
|
if (rssi >= -50) return 4; // 很强
|
|
|
|
|
|
if (rssi >= -65) return 3; // 强
|
|
|
|
|
|
if (rssi >= -80) return 2; // 一般
|
|
|
|
|
|
if (rssi >= -90) return 1; // 弱
|
|
|
|
|
|
return 0; // 无信号
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染信号图标(格数+颜色)
|
|
|
|
|
|
const renderSignalIcon = (rssi?: number) => {
|
|
|
|
|
|
const level = getSignalLevel(rssi);
|
|
|
|
|
|
|
|
|
|
|
|
let iconColor = "#aaa"; // 默认灰色
|
|
|
|
|
|
switch (level) {
|
|
|
|
|
|
case 4:
|
|
|
|
|
|
iconColor = "#8BC34A"; // 亮绿
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
iconColor = "#4CAF50"; // 深绿
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
iconColor = "#FF9800"; // 橙色
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
iconColor = "#F44336"; // 红色
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const iconName =
|
|
|
|
|
|
level === 4
|
|
|
|
|
|
? "wifi-strength-4"
|
|
|
|
|
|
: level === 3
|
|
|
|
|
|
? "wifi-strength-3"
|
|
|
|
|
|
: level === 2
|
|
|
|
|
|
? "wifi-strength-2"
|
|
|
|
|
|
: level === 1
|
|
|
|
|
|
? "wifi-strength-1"
|
|
|
|
|
|
: "wifi-strength-outline";
|
|
|
|
|
|
|
|
|
|
|
|
return <Icon name={iconName} size={22} color={iconColor} />;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 更新或添加设备,并记录最后扫描时间
|
|
|
|
|
|
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
2026-06-03 14:35:08 +08:00
|
|
|
|
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
|
2025-11-05 15:18:15 +08:00
|
|
|
|
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
|
|
|
|
|
|
|
|
|
|
|
setDevices((prev) => {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const exists = prev.find((d) => d.systemId === p.systemId);
|
|
|
|
|
|
if (exists) {
|
|
|
|
|
|
return prev.map((d) =>
|
|
|
|
|
|
d.systemId === p.systemId ? { ...p, lastSeen: now } : d
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return [...prev, { ...p, lastSeen: now }];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 清理消失或信号低的设备
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
setDevices((prev) =>
|
|
|
|
|
|
prev.filter(
|
|
|
|
|
|
(d) =>
|
|
|
|
|
|
(d.advertisementData?.rssi ?? -999) >= -90 &&
|
|
|
|
|
|
now - d.lastSeen < 3000
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const stopScan = useCallback(() => {
|
|
|
|
|
|
setScanning(false);
|
|
|
|
|
|
try { Central.stopScan(); } catch {}
|
|
|
|
|
|
if (handlerRef.current) {
|
|
|
|
|
|
try { Central.removeListener("scannedPeripheral", handlerRef.current); } catch {}
|
|
|
|
|
|
handlerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
try { Central.shutdown(); } catch {}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const startScan = useCallback(() => {
|
|
|
|
|
|
stopScan();
|
|
|
|
|
|
setDevices([]);
|
|
|
|
|
|
setScanning(true);
|
|
|
|
|
|
|
|
|
|
|
|
const onScanned = (payload: CentralEventMap["scannedPeripheral"]) => {
|
|
|
|
|
|
const p = payload?.peripheral;
|
|
|
|
|
|
if (p?.name && p.advertisementData?.isConnectable) {
|
|
|
|
|
|
updatePeripherals(p);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
handlerRef.current = onScanned;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
Central.initialize();
|
|
|
|
|
|
Central.addListener("scannedPeripheral", onScanned);
|
|
|
|
|
|
Central.startScan([]);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("Central scan start error:", e);
|
|
|
|
|
|
setScanning(false);
|
|
|
|
|
|
handlerRef.current = null;
|
|
|
|
|
|
try { Central.shutdown(); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [stopScan, updatePeripherals]);
|
|
|
|
|
|
|
|
|
|
|
|
const onRefresh = useCallback(async () => {
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
|
stopScan();
|
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 200));
|
|
|
|
|
|
startScan();
|
|
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
}, [stopScan, startScan]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
startScan();
|
|
|
|
|
|
return () => { stopScan(); };
|
|
|
|
|
|
}, [startScan, stopScan]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.container}>
|
2025-12-25 16:16:01 +08:00
|
|
|
|
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
|
|
|
|
|
<MyHeader
|
|
|
|
|
|
title={t('scan.title')}
|
|
|
|
|
|
textColor="#333"
|
|
|
|
|
|
backgroundColor="#f2f3f7"
|
|
|
|
|
|
navigation={navigation}
|
|
|
|
|
|
></MyHeader>
|
2025-11-05 15:18:15 +08:00
|
|
|
|
<FlatList
|
|
|
|
|
|
data={devices}
|
|
|
|
|
|
keyExtractor={(item) => item.systemId}
|
|
|
|
|
|
renderItem={({ item }) => (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
onPress={() => navigation.navigate("Info", { peripheral: item })}
|
|
|
|
|
|
style={styles.deviceRow}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.deviceInfo}>
|
2025-12-25 16:16:01 +08:00
|
|
|
|
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
|
2025-11-05 15:18:15 +08:00
|
|
|
|
<View style={styles.rssiRow}>
|
|
|
|
|
|
{renderSignalIcon(item.advertisementData?.rssi)}
|
|
|
|
|
|
<Text style={styles.rssiText}>
|
|
|
|
|
|
{item.advertisementData?.rssi ?? "--"} dBm
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
ListEmptyComponent={() => (
|
|
|
|
|
|
<View style={styles.emptyBox}>
|
|
|
|
|
|
{scanning ? (
|
|
|
|
|
|
<>
|
2025-12-25 16:16:01 +08:00
|
|
|
|
<Text style={styles.emptyText}>{t("scan.scanning")}</Text>
|
|
|
|
|
|
<Text style={styles.tips}>{t("scan.tipScanning")}</Text>
|
2025-11-05 15:18:15 +08:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2025-12-25 16:16:01 +08:00
|
|
|
|
<Text style={styles.emptyText}>{t("scan.noDevice")}</Text>
|
|
|
|
|
|
<Text style={styles.tips}>{t("scan.tipBluetooth")}</Text>
|
2025-11-05 15:18:15 +08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
|
container: { flex: 1, backgroundColor: "#fff" },
|
|
|
|
|
|
deviceRow: {
|
|
|
|
|
|
flexDirection: "row",
|
|
|
|
|
|
justifyContent: "space-between",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
paddingVertical: 15,
|
|
|
|
|
|
paddingHorizontal: 20,
|
|
|
|
|
|
borderBottomWidth: 1,
|
|
|
|
|
|
borderColor: "#eee",
|
|
|
|
|
|
},
|
|
|
|
|
|
deviceInfo: { flexDirection: "column" },
|
|
|
|
|
|
deviceName: { fontSize: 16, fontWeight: "500" },
|
|
|
|
|
|
rssiRow: { flexDirection: "row", alignItems: "center", marginTop: 4 },
|
|
|
|
|
|
rssiText: { fontSize: 14, color: "#666", marginLeft: 6 },
|
|
|
|
|
|
emptyBox: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
paddingHorizontal: 20,
|
|
|
|
|
|
marginTop: 50,
|
|
|
|
|
|
},
|
|
|
|
|
|
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
|
|
|
|
|
|
tips: { fontSize: 14, color: "#888", textAlign: "center" },
|
2026-06-03 14:35:08 +08:00
|
|
|
|
});
|