diff --git a/App.tsx b/App.tsx
index c7fb293..24207aa 100644
--- a/App.tsx
+++ b/App.tsx
@@ -55,7 +55,7 @@ export default function App() {
- 蓝牙名称: {name}
+ 蓝牙名称: {name}
最新版本: {latestVersion}
diff --git a/src/InfoScreen.tsx b/src/InfoScreen.tsx
index d8197a7..fead836 100644
--- a/src/InfoScreen.tsx
+++ b/src/InfoScreen.tsx
@@ -49,6 +49,11 @@ export default function InfoScreen({ route, navigation }: Props) {
const { peripheral } = route.params;
const deviceKey = peripheral.address || peripheral.systemId;
+ const [power, setPower] = useState(0);
+ const [cadence, setCadence] = useState(0);
+ const [leftbalance, setLeftBalance] = useState(0);
+ const [rightbalance, setRightBalance] = useState(0);
+
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [serial, setSerial] = useState("读取中...");
@@ -56,7 +61,7 @@ export default function InfoScreen({ route, navigation }: Props) {
const [hardware, setHardware] = useState("读取中...");
const [battery, setBattery] = useState("读取中...");
const [powerTrim, setPowerTrim] = useState("读取中");
- const [inputTrim, setInputTrim] = useState("100");
+ const [inputTrim, setInputTrim] = useState("");
const [readSuccessToast, setReadSuccessToast] = useState(false); // ✅ 读取成功提示
const [powerTrimLoading, setPowerTrimLoading] = useState(false); // 正在写入功率微调
@@ -69,6 +74,13 @@ export default function InfoScreen({ route, navigation }: Props) {
const prevConnectedRef = useRef(isConnected);
const isActiveRef = useRef(true);
+ //增加校准按钮相关状态
+ const [calibrating, setCalibrating] = useState(false);
+ const [calibrationSuccess, setCalibrationSuccess] = useState(false);
+ const [calibrationValue, setCalibrationValue] = useState("");
+ const calibrationTimeoutRef = useRef(null);
+ const isCalibratingRef = useRef(false);
+
useFocusEffect(
React.useCallback(() => {
// 页面获得焦点
@@ -84,7 +96,7 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 监听连接状态变化,断开时提示重新连接 ==========
useEffect(() => {
if (prevConnectedRef.current && !isConnected && isActiveRef.current) {
- Alert.alert("提示", "请重新连接设备", [
+ Alert.alert("提示", "设备已断开,请重新连接设备", [
{ text: "确定", onPress: () => navigation.goBack() },
]);
}
@@ -121,7 +133,7 @@ export default function InfoScreen({ route, navigation }: Props) {
return unsubscribe;
}, [navigation, peripheral]);
- // ========== BLE notify 订阅 ==========
+ // ========== BLE notify 订阅FFF3 ==========
useEffect(() => {
const connectionHandler = async (ev: {
peripheral: ScannedPeripheral;
@@ -151,14 +163,18 @@ export default function InfoScreen({ route, navigation }: Props) {
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());
- }
+ const byteArray = raw instanceof Uint8Array
+ ? raw
+ : new Uint8Array(raw);
+ if (byteArray[0] === 0x02 && byteArray.length >= 2) {
+ // 功率微调数据
+ setPowerTrim(byteArray[1].toString());
+ //setInputTrim(byteArray[1].toString());
+ }
+ else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
+ // 校准响应数据 (05XXXX格式)
+ handleFFF3Response(byteArray);
+ }
} catch (err) {
console.warn("notify parse error", err);
}
@@ -172,6 +188,8 @@ export default function InfoScreen({ route, navigation }: Props) {
}
};
+
+
Central.addListener("peripheralConnectionStatus", connectionHandler);
return () => {
Central.removeListener("peripheralConnectionStatus", connectionHandler);
@@ -249,11 +267,182 @@ export default function InfoScreen({ route, navigation }: Props) {
})();
}, [peripheral]);
+
+
+
+// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
+useEffect(() => {
+ const subscribeToPowerData = async () => {
+ try {
+ // 确保设备已经连接
+ if (!isConnected) return;
+
+ console.log("✅ 订阅 2A63 特性通知...");
+
+ // 先取消之前的订阅(防止重复订阅)
+ await Central.unsubscribeCharacteristic(
+ peripheral,
+ fullUUID("1818"),
+ fullUUID("2a63")
+ ).catch(() => {});
+
+ // 订阅 2A63 特性通知
+ await Central.subscribeCharacteristic(
+ peripheral,
+ fullUUID("1818"),
+ fullUUID("2a63"),
+ (notifyEv) => {
+ try {
+ const raw = notifyEv.value;
+ if (raw) {
+ // 将 ArrayBuffer 转换为字节数组
+ const byteArray = new Uint8Array(raw);
+
+ // 解析数据
+ parseData(byteArray);
+ }
+ } catch (err) {
+ console.warn("❌ 处理通知数据失败", err);
+ }
+ }
+ );
+
+ console.log("✅ 已订阅 2A63 特性通知");
+ } catch (err) {
+ console.warn("❌ 订阅 2A63 特性失败", err);
+ }
+ };
+
+ // 只在设备已连接时进行订阅
+ if (peripheral && isConnected) {
+ subscribeToPowerData();
+ }
+
+ // 清理订阅(当组件卸载或者连接状态变化时)
+ return () => {
+ if (peripheral && isConnected) {
+ Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => {});
+ }
+ };
+}, [peripheral, isConnected]);
+
+const cadenceStateRef = useRef({
+ lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
+ lastCadenceTimestamp: 0, // 上一次的踏频时间戳(设备时间),用于计算差值
+ lastCadenceChangedTime: 0, // 最后一次踏频变化的时间(本地时间),用于检测超时
+ });
+
+const parseData = (byteArray: Uint8Array) => {
+ // 当前时间(用于超时检测)
+ const currentTime = Date.now();
+ // ========== 1. 解析功率 ==========
+ // 字节2-3:功率值(小端序)
+ const powerValue = (byteArray[3] << 8) | byteArray[2];
+ setPower(powerValue);
+
+ // ========== 3. 解析踏频 ==========
+ // 字节5-6:踏频翻转次数(小端序)
+ const cadenceCount = (byteArray[6] << 8) | byteArray[5];
+
+ // 字节7-8:踏频时间戳(小端序)
+ const timestamp = (byteArray[8] << 8) | byteArray[7];
+
+ // 获取上一次的状态
+ const lastState = cadenceStateRef.current;
+
+ // 检测踏频翻转次数是否有变化
+ const hasCadenceChanged = cadenceCount !== lastState.lastCadenceCount;
+
+ if (hasCadenceChanged) {
+ // 踏频有变化,更新变化时间戳
+ cadenceStateRef.current.lastCadenceChangedTime = currentTime;
+ }
+
+ // 检查是否超时(3秒无变化)
+ if (lastState.lastCadenceChangedTime > 0 &&
+ currentTime - lastState.lastCadenceChangedTime > 3000) {
+ //setPower("0 W");
+ setCadence(0);
+ setLeftBalance(0);
+ setRightBalance(0);
+ cadenceStateRef.current = {
+ lastCadenceCount: 0,
+ lastCadenceTimestamp: 0,
+ lastCadenceChangedTime: 0,
+ };
+ return; // 超时后直接返回,不再计算踏频
+ }
+
+ // 如果是第一次收到踏频数据,只记录不计算
+ if (lastState.lastCadenceTimestamp === 0) {
+ cadenceStateRef.current = {
+ lastCadenceCount: cadenceCount,
+ lastCadenceTimestamp: timestamp,
+ lastCadenceChangedTime: hasCadenceChanged ? currentTime : 0,
+ };
+ setCadence(0);
+ return;
+ }
+
+ // 计算踏频RPM(只在踏频有变化时计算)
+ if (hasCadenceChanged) {
+ // 计算翻转次数差(处理16位计数器翻转)
+ let revDiff = cadenceCount - lastState.lastCadenceCount;
+ if (revDiff < 0) {
+ revDiff += 65536; // 16位计数器最大值65535
+ }
+
+ // 计算时间差(处理16位计数器翻转)
+ let timeDiff = timestamp - lastState.lastCadenceTimestamp;
+ if (timeDiff < 0) {
+ timeDiff += 65536; // 16位计数器最大值65535
+ }
+
+ // 计算踏频
+ if (timeDiff > 0 && revDiff > 0) {
+ // 假设时间单位是1/1024秒(蓝牙设备常用)
+ const timeInSeconds = timeDiff / 1024.0;
+
+ // RPM = (圈数 / 时间(秒)) * 60
+ const cadenceRPM = (revDiff / timeInSeconds) * 60;
+
+ // 限制合理范围
+ if (cadenceRPM > 10 && cadenceRPM < 250) {
+ setCadence(Math.round(cadenceRPM));
+ } else {
+ setCadence(0);
+ }
+ } else {
+ // 如果没有翻转,踏频为0
+ setCadence(0);
+ }
+
+ // ========== 解析左平衡 ==========
+ // 字节4:左平衡值,单位0.5%
+ const leftBalanceValue = byteArray[4];
+ const leftBalancePercent = Math.round(leftBalanceValue * 0.5 * 10) / 10;
+ setLeftBalance(leftBalancePercent);
+ // 计算右平衡:100% - 左平衡百分比
+ const rightBalancePercent = Math.round((100 - leftBalancePercent) * 10) / 10;
+ setRightBalance(rightBalancePercent);
+
+ // 更新踏频计算状态
+ cadenceStateRef.current = {
+ ...cadenceStateRef.current,
+ lastCadenceCount: cadenceCount,
+ lastCadenceTimestamp: timestamp,
+ };
+ }
+ };
+
+
+
+
// ========== 写入功率微调 ==========
const updatePowerTrim = async () => {
const val = parseInt(inputTrim);
if (isNaN(val) || val < 50 || val > 200) {
- Alert.alert("提示", "请输入有效数字(50~200)");
+ Alert.alert("提示", "功率微调可调整功率计的高低偏差,默认值100%。可调整的范围是50%-200%。请输入50至200的纯数字,不需要包含%符号。输入后点击下方按钮更新进功率计设备。");
return;
}
if (val === Number(powerTrim)) {
@@ -295,40 +484,215 @@ export default function InfoScreen({ route, navigation }: Props) {
}
};
+// ========== 校准按钮逻辑 ==========
+const handleCalibration = async () => {
+ if (!isConnected) {
+ Alert.alert("提示", "设备未连接");
+ return;
+ }
+
+ if (calibrating) {
+ return; // 正在校准中,不重复点击
+ }
+
+ try {
+ setCalibrating(true);
+ isCalibratingRef.current = true;
+ setCalibrationSuccess(false);
+ setCalibrationValue("");
+
+ console.log("🔧 开始校准,向FFF2写入05");
+
+ // 向FFF2写入05
+ await Central.writeCharacteristic(
+ peripheral,
+ fullUUID("FFF1"), // 服务UUID
+ fullUUID("FFF2"), // 写入特征UUID
+ new Uint8Array([0x05]).buffer,
+ { withoutResponse: false }
+ );
+
+ console.log("✅ 已发送校准命令,等待响应...");
+
+ // 设置5秒超时
+ calibrationTimeoutRef.current = setTimeout(() => {
+ if (isCalibratingRef.current) {
+ console.log("❌ 校准超时,未收到响应");
+ Alert.alert("校准错误", "设备未响应,请重试");
+ setCalibrating(false);
+ isCalibratingRef.current = false;
+ }
+ }, 5000);
+
+ } catch (err) {
+ console.warn("❌ 发送校准命令失败", err);
+ Alert.alert("错误", "发送校准命令失败");
+ setCalibrating(false);
+ isCalibratingRef.current = false;
+
+ if (calibrationTimeoutRef.current) {
+ clearTimeout(calibrationTimeoutRef.current);
+ }
+ }
+};
+
+const handleFFF3Response = (byteArray: Uint8Array) => {
+ // 如果不是正在校准中,不处理
+ if (!isCalibratingRef.current) return;
+
+ // 清除超时定时器
+ if (calibrationTimeoutRef.current) {
+ clearTimeout(calibrationTimeoutRef.current);
+ calibrationTimeoutRef.current = null;
+ }
+
+ // 检查响应数据格式:05XXXX (05开头,后面至少2个字节)
+ // 假设响应是05 + 2字节的小端序数值
+ if (byteArray[0] === 0x05 && byteArray.length >= 3) {
+ // 解析XXXX(小端序):低位在前,高位在后
+ // byteArray[1] 是低位字节,byteArray[2] 是高位字节
+ const value = Math.round (((byteArray[2] << 8) | byteArray[1])/10 ) ; //除以10发送出来 不然数据容易乱跳 和码表端处理保持一致
+ console.log(`✅ 收到校准响应,值: ${value}`);
+
+ // 更新状态
+ setCalibrationValue(value.toString());
+ setCalibrationSuccess(true);
+
+ // 显示成功弹框
+ Alert.alert(
+ "校准成功",
+ `校准值: ${value}`,
+ [
+ {
+ text: "确定",
+ onPress: () => {
+ setCalibrating(false);
+ isCalibratingRef.current = false;
+ setCalibrationSuccess(false);
+ }
+ }
+ ]
+ );
+ } else {
+ console.warn("❌ 校准响应格式错误", byteArray);
+ Alert.alert("校准错误", "设备返回数据格式错误");
+ setCalibrating(false);
+ isCalibratingRef.current = false;
+ }
+};
+
+useEffect(() => {
+ return () => {
+ // 清理校准超时定时器
+ if (calibrationTimeoutRef.current) {
+ clearTimeout(calibrationTimeoutRef.current);
+ calibrationTimeoutRef.current = null;
+ }
+ };
+}, []);
+
+
// ========== UI ==========
return (
-
+
+
蓝牙名称: {peripheral.name}
+
ID号: {serial}
+
固件版本: {firmware}
+
电量: {battery}
+
连接状态: {isConnected ? "已连接" : "未连接"}
+
+{/* ====== 实时数据三卡片 ====== */}
+
+ {/* 功率 */}
+
+
+ 功率/W
+ {power}
+
-
- 功率微调: {powerTrim}%
-
-
- 更新功率微调
-
-
+ {/* 踏频 */}
+
+
+ 踏频/RPM
+ {cadence}
+
-
+ {/* 左右平衡 */}
+
+ L / R
+ 左右平衡/%
+
+ {leftbalance}/{rightbalance}
+
+
+
+
+
+
+ {/* ========== 功率微调卡片部分 ========== */}
+
+
+ 功率微调设置
+
+
+
+ 当前微调: {powerTrim}%
+
+
+
+
+ 更新数值
+
+
+
+ {/* ========== 新增校准按钮 ========== */}
+
+
+
+ {calibrating ? "校准中...等待设备反应" : "校准归零"}
+
+
+
+
+
+
+
{
if (!isConnected) {
@@ -343,7 +707,6 @@ export default function InfoScreen({ route, navigation }: Props) {
]);
return;
}
-
// 蓝牙已连接,正常跳转 DFU
navigation.navigate("Dfu", {
deviceId: deviceKey,
@@ -354,13 +717,15 @@ export default function InfoScreen({ route, navigation }: Props) {
style={styles.pressable}
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击
>
- 升级固件
+ 固件升级
+
+
{isLoading && (
-
+
正在读取信息...
)}
@@ -368,7 +733,7 @@ export default function InfoScreen({ route, navigation }: Props) {
{/* ✅ 读取成功提示,固定在页面底部 */}
{readSuccessToast && (
-
+
读取成功!
)}
@@ -376,7 +741,7 @@ export default function InfoScreen({ route, navigation }: Props) {
{/* 正在写入功率微调 */}
{powerTrimLoading && (
-
+
正在写入功率微调...
)}
@@ -384,7 +749,7 @@ export default function InfoScreen({ route, navigation }: Props) {
{/* 功率微调写入成功 */}
{powerTrimSuccessToast && (
-
+
功率微调更新成功!
)}
@@ -397,7 +762,7 @@ const styles = StyleSheet.create({
row: {
borderBottomWidth: 1,
borderBottomColor: "#E7141E",
- paddingBottom: 4, // 横线和文字保持一定间距
+ paddingBottom: 3, // 横线和文字保持一定间距
marginBottom: 8, // 行间距
},
input: {
@@ -414,4 +779,79 @@ const styles = StyleSheet.create({
borderRadius: 6,
alignItems: "center",
},
+ disabledButton: {
+ backgroundColor: "#999", // 禁用时的颜色
+ opacity: 0.6,
+ },
+
+ cardContainer: {
+ borderWidth: 1,
+ borderColor: "#ddd",
+ borderRadius: 10,
+ marginTop: 10,
+ backgroundColor: "#fff",
+ overflow: 'hidden', // 确保圆角效果完整
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.1,
+ shadowRadius: 2,
+ elevation: 2, // Android阴影
+ },
+ cardTitle: {
+ backgroundColor: "#f5f5f5",
+ paddingVertical: 8,
+ paddingHorizontal: 15,
+ borderBottomWidth: 1,
+ borderBottomColor: "#eee",
+ },
+ cardTitleText: {
+ fontWeight: 'bold',
+ color: "#333",
+ },
+
+ realtimeContainer: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ marginVertical: 10,
+},
+
+realtimeCard: {
+ flex: 1,
+ marginHorizontal: 4,
+ paddingVertical: 12,
+ borderRadius: 10,
+ backgroundColor: "#fff",
+ alignItems: "center",
+
+ borderWidth: 1,
+ borderColor: "#E7141E",
+
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.1,
+ shadowRadius: 2,
+ elevation: 2,
+},
+
+realtimeLabel: {
+ marginTop: 6,
+ fontSize: 14,
+ color: "#666",
+},
+
+realtimeValue: {
+ marginTop: 4,
+ fontSize: 20,
+ fontWeight: "bold",
+ color: "#000",
+},
+
+balanceHeader: {
+ fontSize: 16,
+ fontWeight: "bold",
+ color: "#E7141E",
+},
+
+
+
});