418 lines
12 KiB
TypeScript
418 lines
12 KiB
TypeScript
|
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
|||
|
|
import {
|
|||
|
|
View,
|
|||
|
|
Text as RNText,
|
|||
|
|
TextInput,
|
|||
|
|
ScrollView,
|
|||
|
|
StyleSheet,
|
|||
|
|
ActivityIndicator,
|
|||
|
|
useColorScheme,
|
|||
|
|
Alert,
|
|||
|
|
Pressable,
|
|||
|
|
} from "react-native";
|
|||
|
|
import { useFocusEffect } from "@react-navigation/native";
|
|||
|
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
|||
|
|
import { RootStackParamList } from "../App";
|
|||
|
|
import {
|
|||
|
|
Central,
|
|||
|
|
ScannedPeripheral,
|
|||
|
|
ConnectionStatus,
|
|||
|
|
} from "@systemic-games/react-native-bluetooth-le";
|
|||
|
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
type Props = NativeStackScreenProps<RootStackParamList, "Info">;
|
|||
|
|
|
|||
|
|
const powerServiceUuid = "fff1";
|
|||
|
|
const powerWriteUuid = "fff2";
|
|||
|
|
const powerNotifyUuid = "fff3";
|
|||
|
|
|
|||
|
|
const fullUUID = (uuid: string) =>
|
|||
|
|
uuid.length === 4
|
|||
|
|
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
|
|||
|
|
: uuid;
|
|||
|
|
|
|||
|
|
const Text = ({ children, style, ...props }: any) => {
|
|||
|
|
const isDark = useColorScheme() === "dark";
|
|||
|
|
return (
|
|||
|
|
<RNText
|
|||
|
|
style={[{ color: isDark ? "white" : "black", fontSize: 16 }, style]}
|
|||
|
|
{...props}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</RNText>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function InfoScreen({ route, navigation }: Props) {
|
|||
|
|
const { peripheral } = route.params;
|
|||
|
|
const deviceKey = peripheral.address || peripheral.systemId;
|
|||
|
|
|
|||
|
|
const [isConnected, setIsConnected] = useState(false);
|
|||
|
|
const [isLoading, setIsLoading] = useState(true);
|
|||
|
|
const [serial, setSerial] = useState("读取中...");
|
|||
|
|
const [firmware, setFirmware] = useState("读取中...");
|
|||
|
|
const [hardware, setHardware] = useState("读取中...");
|
|||
|
|
const [battery, setBattery] = useState("读取中...");
|
|||
|
|
const [powerTrim, setPowerTrim] = useState("读取中");
|
|||
|
|
const [inputTrim, setInputTrim] = useState("100");
|
|||
|
|
|
|||
|
|
const [readSuccessToast, setReadSuccessToast] = useState(false); // ✅ 读取成功提示
|
|||
|
|
const [powerTrimLoading, setPowerTrimLoading] = useState(false); // 正在写入功率微调
|
|||
|
|
const [powerTrimSuccessToast, setPowerTrimSuccessToast] = useState(false); // 功率微调写入成功
|
|||
|
|
|
|||
|
|
|
|||
|
|
const notifySubscribedRef = useRef(false);
|
|||
|
|
const disconnectingRef = useRef(false);
|
|||
|
|
|
|||
|
|
const prevConnectedRef = useRef(isConnected);
|
|||
|
|
const isActiveRef = useRef(true);
|
|||
|
|
|
|||
|
|
useFocusEffect(
|
|||
|
|
React.useCallback(() => {
|
|||
|
|
// 页面获得焦点
|
|||
|
|
isActiveRef.current = true;
|
|||
|
|
return () => {
|
|||
|
|
// 页面失去焦点
|
|||
|
|
isActiveRef.current = false;
|
|||
|
|
};
|
|||
|
|
}, [])
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
|
|||
|
|
// ========== 监听连接状态变化,断开时提示重新连接 ==========
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (prevConnectedRef.current && !isConnected && isActiveRef.current) {
|
|||
|
|
Alert.alert("提示", "请重新连接设备", [
|
|||
|
|
{ text: "确定", onPress: () => navigation.goBack() },
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
prevConnectedRef.current = isConnected;
|
|||
|
|
}, [isConnected]);
|
|||
|
|
|
|||
|
|
// ========== 页面即将返回时,强制断开蓝牙 ==========
|
|||
|
|
useEffect(() => {
|
|||
|
|
const unsubscribe = navigation.addListener("beforeRemove", async (e) => {
|
|||
|
|
if (disconnectingRef.current) return;
|
|||
|
|
disconnectingRef.current = true;
|
|||
|
|
|
|||
|
|
const next = e.data.action?.type;
|
|||
|
|
const action = e.data.action as any;
|
|||
|
|
|
|||
|
|
// ✅ 判断是否跳转到 Dfu 页面
|
|||
|
|
if (next === "NAVIGATE" && action?.payload?.name === "Dfu") {
|
|||
|
|
console.log("➡️ 跳转到 DFU,不断开蓝牙");
|
|||
|
|
disconnectingRef.current = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 🔌 其他页面离开则断开蓝牙
|
|||
|
|
console.log("📴 页面关闭,断开蓝牙...");
|
|||
|
|
try {
|
|||
|
|
await Central.disconnectPeripheral(peripheral);
|
|||
|
|
console.log("✅ 已断开蓝牙");
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn("❌ 断开失败:", err);
|
|||
|
|
} finally {
|
|||
|
|
disconnectingRef.current = false;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
return unsubscribe;
|
|||
|
|
}, [navigation, peripheral]);
|
|||
|
|
|
|||
|
|
// ========== BLE notify 订阅 ==========
|
|||
|
|
useEffect(() => {
|
|||
|
|
const connectionHandler = async (ev: {
|
|||
|
|
peripheral: ScannedPeripheral;
|
|||
|
|
connectionStatus: ConnectionStatus;
|
|||
|
|
}) => {
|
|||
|
|
const addr = ev.peripheral.address || ev.peripheral.systemId;
|
|||
|
|
if (addr !== deviceKey) return;
|
|||
|
|
|
|||
|
|
console.log("🔌 connection event:", ev.connectionStatus);
|
|||
|
|
setIsConnected(
|
|||
|
|
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
|||
|
|
console.log("✅ device ready - subscribe notify FFF3");
|
|||
|
|
try {
|
|||
|
|
await Central.unsubscribeCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID(powerServiceUuid),
|
|||
|
|
fullUUID(powerNotifyUuid)
|
|||
|
|
).catch(() => {});
|
|||
|
|
await Central.subscribeCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID(powerServiceUuid),
|
|||
|
|
fullUUID(powerNotifyUuid),
|
|||
|
|
(notifyEv) => {
|
|||
|
|
try {
|
|||
|
|
const raw = (notifyEv as any).value;
|
|||
|
|
if (!raw) return;
|
|||
|
|
const arr =
|
|||
|
|
raw instanceof Uint8Array
|
|||
|
|
? Array.from(raw)
|
|||
|
|
: Array.from(new Uint8Array(raw));
|
|||
|
|
if (arr[0] === 0x02 && arr.length >= 2) {
|
|||
|
|
setPowerTrim(arr[1].toString());
|
|||
|
|
setInputTrim(arr[1].toString());
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn("notify parse error", err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
notifySubscribedRef.current = true;
|
|||
|
|
console.log("✅ notify 已订阅");
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn("❌ notify 订阅失败:", err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
Central.addListener("peripheralConnectionStatus", connectionHandler);
|
|||
|
|
return () => {
|
|||
|
|
Central.removeListener("peripheralConnectionStatus", connectionHandler);
|
|||
|
|
notifySubscribedRef.current = false;
|
|||
|
|
};
|
|||
|
|
}, [deviceKey, peripheral]);
|
|||
|
|
|
|||
|
|
// ========== 首次连接并读取信息 ==========
|
|||
|
|
useEffect(() => {
|
|||
|
|
(async () => {
|
|||
|
|
setIsLoading(true);
|
|||
|
|
try {
|
|||
|
|
await Central.connectPeripheral(peripheral);
|
|||
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|||
|
|
|
|||
|
|
const readStr = async (srv: string, char: string) => {
|
|||
|
|
try {
|
|||
|
|
const v = await Central.readCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID(srv),
|
|||
|
|
fullUUID(char)
|
|||
|
|
);
|
|||
|
|
return v ? String.fromCharCode(...(v as any)) : "未知";
|
|||
|
|
} catch {
|
|||
|
|
return "未知";
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
setSerial(await readStr("180a", "2a25"));
|
|||
|
|
setFirmware(await readStr("180a", "2a28"));
|
|||
|
|
setHardware(await readStr("180a", "2a27"));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const v = await Central.readCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID("180f"),
|
|||
|
|
fullUUID("2a19")
|
|||
|
|
);
|
|||
|
|
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
|
|||
|
|
} catch {
|
|||
|
|
setBattery("未知");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log("✅ info 读取完成");
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await Central.writeCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID(powerServiceUuid),
|
|||
|
|
fullUUID(powerWriteUuid),
|
|||
|
|
new Uint8Array([0x04]).buffer,
|
|||
|
|
{ withoutResponse: false }
|
|||
|
|
);
|
|||
|
|
console.log("✅ 已发送 0x04,等待 notify 更新功率微调");
|
|||
|
|
|
|||
|
|
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
|||
|
|
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
|||
|
|
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn("❌ 首次读取功率微调失败", err);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn("❌ 读取失败", e);
|
|||
|
|
} finally {
|
|||
|
|
setIsLoading(false);
|
|||
|
|
|
|||
|
|
// ✅ 显示读取成功提示 2 秒
|
|||
|
|
setReadSuccessToast(true);
|
|||
|
|
setTimeout(() => setReadSuccessToast(false), 2000);
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
}, [peripheral]);
|
|||
|
|
|
|||
|
|
// ========== 写入功率微调 ==========
|
|||
|
|
const updatePowerTrim = async () => {
|
|||
|
|
const val = parseInt(inputTrim);
|
|||
|
|
if (isNaN(val) || val < 50 || val > 200) {
|
|||
|
|
Alert.alert("提示", "请输入有效数字(50~200)");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (val === Number(powerTrim)) {
|
|||
|
|
console.log("⚙️ 相同数值,跳过发送");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!isConnected) {
|
|||
|
|
Alert.alert("提示", "设备未连接");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setPowerTrimLoading(true);
|
|||
|
|
|
|||
|
|
console.log("🚀 写入功率微调", val);
|
|||
|
|
await Central.writeCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID(powerServiceUuid),
|
|||
|
|
fullUUID(powerWriteUuid),
|
|||
|
|
new Uint8Array([0x02, val]).buffer,
|
|||
|
|
{ withoutResponse: false }
|
|||
|
|
);
|
|||
|
|
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
|||
|
|
await Central.writeCharacteristic(
|
|||
|
|
peripheral,
|
|||
|
|
fullUUID(powerServiceUuid),
|
|||
|
|
fullUUID(powerWriteUuid),
|
|||
|
|
new Uint8Array([0x04]).buffer,
|
|||
|
|
{ withoutResponse: false }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
setPowerTrimLoading(false);
|
|||
|
|
setPowerTrimSuccessToast(true);
|
|||
|
|
setTimeout(() => setPowerTrimSuccessToast(false), 2000);
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn("❌ 写入失败", err);
|
|||
|
|
Alert.alert("写入失败");
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ========== UI ==========
|
|||
|
|
return (
|
|||
|
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 8 }}>
|
|||
|
|
<View style={styles.row}>
|
|||
|
|
<Text>蓝牙名称: {peripheral.name}</Text>
|
|||
|
|
</View>
|
|||
|
|
<View style={styles.row}>
|
|||
|
|
<Text>ID号: {serial}</Text>
|
|||
|
|
</View>
|
|||
|
|
<View style={styles.row}>
|
|||
|
|
<Text>固件版本: {firmware}</Text>
|
|||
|
|
</View>
|
|||
|
|
<View style={styles.row}>
|
|||
|
|
<Text>电量: {battery}</Text>
|
|||
|
|
</View>
|
|||
|
|
<View style={styles.row}>
|
|||
|
|
<Text>连接状态: {isConnected ? "已连接" : "未连接"}</Text>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<View style={{ marginTop: 20 }}>
|
|||
|
|
<Text>功率微调: {powerTrim}%</Text>
|
|||
|
|
<TextInput
|
|||
|
|
style={styles.input}
|
|||
|
|
value={inputTrim}
|
|||
|
|
onChangeText={setInputTrim}
|
|||
|
|
keyboardType="numeric"
|
|||
|
|
onEndEditing={updatePowerTrim}
|
|||
|
|
/>
|
|||
|
|
<Pressable onPress={updatePowerTrim} style={styles.pressable} disabled={isLoading || powerTrimLoading}>
|
|||
|
|
<Text style={{ color: "white" }}>更新功率微调</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<View style={{ marginTop: 30 }}>
|
|||
|
|
<Pressable
|
|||
|
|
onPress={() => {
|
|||
|
|
if (!isConnected) {
|
|||
|
|
Alert.alert("提示", "请重新连接设备", [
|
|||
|
|
{
|
|||
|
|
text: "确定",
|
|||
|
|
onPress: () => {
|
|||
|
|
// 返回 ScanScreen
|
|||
|
|
navigation.goBack();
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 蓝牙已连接,正常跳转 DFU
|
|||
|
|
navigation.navigate("Dfu", {
|
|||
|
|
deviceId: deviceKey,
|
|||
|
|
name: peripheral.name,
|
|||
|
|
firmware,
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
style={styles.pressable}
|
|||
|
|
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "white" }}>升级固件</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
{isLoading && (
|
|||
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|||
|
|
<ActivityIndicator size={80} color="#E7141E" />
|
|||
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>正在读取信息...</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ✅ 读取成功提示,固定在页面底部 */}
|
|||
|
|
{readSuccessToast && (
|
|||
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|||
|
|
<Icon name="check-circle" size={60} color="#E7141E" />
|
|||
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>读取成功!</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 正在写入功率微调 */}
|
|||
|
|
{powerTrimLoading && (
|
|||
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|||
|
|
<ActivityIndicator size={60} color="#E7141E" />
|
|||
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>正在写入功率微调...</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 功率微调写入成功 */}
|
|||
|
|
{powerTrimSuccessToast && (
|
|||
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|||
|
|
<Icon name="check-circle" size={60} color="#E7141E" />
|
|||
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>功率微调更新成功!</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
</ScrollView>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const styles = StyleSheet.create({
|
|||
|
|
row: {
|
|||
|
|
borderBottomWidth: 1,
|
|||
|
|
borderBottomColor: "#E7141E",
|
|||
|
|
paddingBottom: 4, // 横线和文字保持一定间距
|
|||
|
|
marginBottom: 8, // 行间距
|
|||
|
|
},
|
|||
|
|
input: {
|
|||
|
|
borderWidth: 1,
|
|||
|
|
borderColor: "#ccc",
|
|||
|
|
padding: 6,
|
|||
|
|
marginTop: 5,
|
|||
|
|
borderRadius: 4,
|
|||
|
|
},
|
|||
|
|
pressable: {
|
|||
|
|
padding: 10,
|
|||
|
|
backgroundColor: "#E7141E",
|
|||
|
|
marginTop: 10,
|
|||
|
|
borderRadius: 6,
|
|||
|
|
alignItems: "center",
|
|||
|
|
},
|
|||
|
|
});
|