② 适配T5骑行台,做了相关页面。内测还不够充分,产品也不着急上市,所以此版更新中,应该需要隐藏掉。 ③ 修复了之前 盘爪设备信息 读取时,有概率出现乱码的情况。读取时做了排序和延迟,修复了此问题。 ④ 多语言我们这边做更新时,拉取的是仅有中英文的版本,当时你们那好像有更新更多语言?所以需要适配一下。 ⑤ 蓝牙名搜索时,适配最新的固件名称: 盘爪为:PF-PM5-前缀,桨频器为PF-STK-前缀,骑行台为PF-T5-前缀。同时允许前缀POWERFUN-也能显示并连接。
1228 lines
34 KiB
TypeScript
1228 lines
34 KiB
TypeScript
// InfoScreen2.tsx 此页面为桨频器信息页面
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
Alert,
|
|
} from "react-native";
|
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
|
import { decode as atob } from "base-64";
|
|
import {
|
|
Central,
|
|
ScannedPeripheral,
|
|
ConnectionStatus,
|
|
} from "@systemic-games/react-native-bluetooth-le";
|
|
import { RootStackParamList } from "../App";
|
|
import { useTranslation } from "react-i18next";
|
|
import MyStatusbar from "./component/MyStatusbar";
|
|
import MyHeader from "./component/MyHeader";
|
|
|
|
type Props = NativeStackScreenProps<RootStackParamList, "Info2">;
|
|
|
|
const CSC_SERVICE_UUID = "1816";
|
|
const CSC_MEASUREMENT_UUID = "2a5b";
|
|
|
|
const CUSTOM_SERVICE_UUID = "fff1";
|
|
const CUSTOM_WRITE_UUID = "fff2";
|
|
const CUSTOM_NOTIFY_UUID = "fff3";
|
|
|
|
const DFU_MANIFEST_URL =
|
|
"https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
|
|
|
|
type BoatType = 0x00 | 0x01 | 0x02;
|
|
|
|
type ParsedFirmware = {
|
|
hardware: number;
|
|
iteration: number;
|
|
build: string;
|
|
raw: string;
|
|
};
|
|
|
|
type DeviceInfo = {
|
|
hardware: number;
|
|
latestFirmware: string;
|
|
download: string;
|
|
};
|
|
|
|
const fullUUID = (uuid: string) =>
|
|
uuid.length === 4
|
|
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
|
|
: uuid;
|
|
|
|
const sleep = (ms: number) =>
|
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
|
|
const base64ToBytes = (base64: string): number[] => {
|
|
const binary = atob(base64);
|
|
const bytes: number[] = [];
|
|
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes.push(binary.charCodeAt(i) & 0xff);
|
|
}
|
|
|
|
return bytes;
|
|
};
|
|
|
|
const bytesToArrayBuffer = (bytes: number[]): ArrayBuffer => {
|
|
return new Uint8Array(bytes).buffer;
|
|
};
|
|
|
|
const normalizeBytes = (arr: number[]): number[] => {
|
|
return arr.map((v) => v & 0xff);
|
|
};
|
|
|
|
const toHexByte = (value: number): string =>
|
|
(value & 0xff).toString(16).toUpperCase().padStart(2, "0");
|
|
|
|
const bytesToHexString = (bytes: number[]): string =>
|
|
normalizeBytes(bytes)
|
|
.map((v) => toHexByte(v))
|
|
.join(" ");
|
|
|
|
const parseFirmwareVersion = (text: string): ParsedFirmware => {
|
|
const raw = String(text ?? "").trim();
|
|
const parts = raw.split(".");
|
|
|
|
if (parts.length < 2) {
|
|
throw new Error(`固件版本格式不正确: ${raw}`);
|
|
}
|
|
|
|
const hardware = parseInt(parts[0], 10);
|
|
const iteration = parseInt(parts[1], 10);
|
|
const build = parts.length >= 3 ? parts[2] : "";
|
|
|
|
if (Number.isNaN(hardware) || Number.isNaN(iteration)) {
|
|
throw new Error(`固件版本无法解析: ${raw}`);
|
|
}
|
|
|
|
return {
|
|
hardware,
|
|
iteration,
|
|
build,
|
|
raw,
|
|
};
|
|
};
|
|
|
|
export default function InfoScreen2({ route, navigation }: Props) {
|
|
const { t } = useTranslation();
|
|
const { peripheral } = route.params;
|
|
const getBoatNameByValue = (value?: number | null): string => {
|
|
if (value === 0x00) return t("info2.boatKayak");
|
|
if (value === 0x01) return t("info2.boatRowing");
|
|
if (value === 0x02) return t("info2.boatRacing");
|
|
return t("common.unknown");
|
|
};
|
|
|
|
const deviceKey =
|
|
typeof peripheral.address === "string" && peripheral.address.length > 0
|
|
? peripheral.address
|
|
: String(peripheral.systemId ?? "");
|
|
|
|
const [cadence, setCadence] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [selectedBoat, setSelectedBoat] = useState<BoatType | null>(0x00);
|
|
const [boatBusy, setBoatBusy] = useState(false);
|
|
const [boatStatus, setBoatStatus] = useState(t("info2.waitingAction"));
|
|
const [boatNotifyHex, setBoatNotifyHex] = useState("--");
|
|
const [boatVerified, setBoatVerified] = useState("--");
|
|
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [serial, setSerial] = useState(t("info.reading"));
|
|
const [firmware, setFirmware] = useState(t("info.reading"));
|
|
const [battery, setBattery] = useState(t("info.reading"));
|
|
|
|
// 新增:固件升级状态
|
|
const [latestFirmware, setLatestFirmware] = useState(t("info.reading"));
|
|
const [upgradeAvailable, setUpgradeAvailable] = useState(false);
|
|
const [upgradeCheckDone, setUpgradeCheckDone] = useState(false);
|
|
|
|
const lastCrankRef = useRef<number | null>(null);
|
|
const lastEventTimeRef = useRef<number | null>(null);
|
|
const lastCadenceUpdateAppTsRef = useRef<number | null>(null);
|
|
|
|
const boatReadResolverRef = useRef<((bytes: number[]) => void) | null>(null);
|
|
const mountedRef = useRef(true);
|
|
const disconnectingRef = useRef(false);
|
|
|
|
const hasConnectedOnceRef = useRef(false);
|
|
const hasNavigatedBackRef = useRef(false);
|
|
const skipDisconnectOnLeaveRef = useRef(false);
|
|
|
|
const checkUpgradeNeeded = async (firmwareText: string) => {
|
|
try {
|
|
const text = String(firmwareText ?? "").trim();
|
|
|
|
if (!text) {
|
|
if (mountedRef.current) {
|
|
setLatestFirmware(t("common.unknown"));
|
|
setUpgradeAvailable(false);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentFw = parseFirmwareVersion(text);
|
|
console.log("🔥 InfoScreen currentFw =", currentFw);
|
|
|
|
const manifestResp = await fetch(DFU_MANIFEST_URL);
|
|
console.log("🔥 InfoScreen manifest status =", manifestResp.status);
|
|
|
|
if (!manifestResp.ok) {
|
|
throw new Error(`manifest 下载失败,HTTP ${manifestResp.status}`);
|
|
}
|
|
|
|
const manifestText = await manifestResp.text();
|
|
console.log("🔥 InfoScreen manifest text =", manifestText);
|
|
|
|
let manifest: { devices: DeviceInfo[] };
|
|
|
|
try {
|
|
manifest = JSON.parse(manifestText) as { devices: DeviceInfo[] };
|
|
} catch (e) {
|
|
throw new Error("manifest 不是合法 JSON");
|
|
}
|
|
|
|
const deviceInfo = manifest.devices.find(
|
|
(d) => d.hardware === currentFw.hardware
|
|
);
|
|
|
|
console.log("🔥 InfoScreen matched deviceInfo =", deviceInfo);
|
|
|
|
if (!deviceInfo) {
|
|
if (mountedRef.current) {
|
|
setLatestFirmware(t("info2.notMatched"));
|
|
setUpgradeAvailable(false);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
|
|
console.log("🔥 InfoScreen latestFw =", latestFw);
|
|
|
|
if (latestFw.hardware !== currentFw.hardware) {
|
|
throw new Error(
|
|
`服务器固件硬件号不匹配:当前 ${currentFw.hardware},服务器 ${latestFw.hardware}`
|
|
);
|
|
}
|
|
|
|
const needUpgrade = latestFw.iteration > currentFw.iteration;
|
|
|
|
if (mountedRef.current) {
|
|
setLatestFirmware(deviceInfo.latestFirmware);
|
|
setUpgradeAvailable(needUpgrade);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
} catch (err: any) {
|
|
console.warn("⚠️ checkUpgradeNeeded failed:", err);
|
|
if (mountedRef.current) {
|
|
setLatestFirmware(t("info2.checkFailed"));
|
|
setUpgradeAvailable(false);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
const readDeviceInfo = async () => {
|
|
console.log("📘 start read device info...");
|
|
|
|
try {
|
|
try {
|
|
const v = await Central.readCharacteristic(
|
|
peripheral,
|
|
fullUUID("180a"),
|
|
fullUUID("2a25")
|
|
);
|
|
|
|
if (v && (v as any).length) {
|
|
const idText = String.fromCharCode(...(v as any));
|
|
console.log("📘 serial raw:", v, "=>", idText);
|
|
if (mountedRef.current) {
|
|
setSerial(idText);
|
|
}
|
|
} else if (mountedRef.current) {
|
|
setSerial(
|
|
(peripheral.address ||
|
|
peripheral.systemId ||
|
|
peripheral.name ||
|
|
t("common.unknown")) as string
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ read serial failed:", err);
|
|
if (mountedRef.current) {
|
|
setSerial(
|
|
(peripheral.address ||
|
|
peripheral.systemId ||
|
|
peripheral.name ||
|
|
t("common.unknown")) as string
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const v = await Central.readCharacteristic(
|
|
peripheral,
|
|
fullUUID("180a"),
|
|
fullUUID("2a28")
|
|
);
|
|
|
|
if (v && (v as any).length) {
|
|
const fwText = String.fromCharCode(...(v as any));
|
|
console.log("📘 firmware raw:", v, "=>", fwText);
|
|
if (mountedRef.current) {
|
|
setFirmware(fwText);
|
|
}
|
|
|
|
// 新增:在 Info 页提前检查是否需要升级
|
|
await checkUpgradeNeeded(fwText);
|
|
} else if (mountedRef.current) {
|
|
setFirmware(t("common.unknown"));
|
|
setLatestFirmware(t("common.unknown"));
|
|
setUpgradeAvailable(false);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ read firmware failed:", err);
|
|
if (mountedRef.current) {
|
|
setFirmware(t("common.unknown"));
|
|
setLatestFirmware(t("common.unknown"));
|
|
setUpgradeAvailable(false);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const v = await Central.readCharacteristic(
|
|
peripheral,
|
|
fullUUID("180f"),
|
|
fullUUID("2a19")
|
|
);
|
|
|
|
if (v && (v as any).length) {
|
|
const batteryValue = (v as any)[0];
|
|
console.log("📘 battery raw:", v, "=>", batteryValue);
|
|
if (mountedRef.current) {
|
|
setBattery(`${batteryValue}%`);
|
|
}
|
|
} else if (mountedRef.current) {
|
|
setBattery(t("common.unknown"));
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ read battery failed:", err);
|
|
if (mountedRef.current) {
|
|
setBattery(t("common.unknown"));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ readDeviceInfo failed:", err);
|
|
if (mountedRef.current) {
|
|
setSerial(
|
|
(peripheral.address ||
|
|
peripheral.systemId ||
|
|
peripheral.name ||
|
|
t("common.unknown")) as string
|
|
);
|
|
setFirmware(t("common.unknown"));
|
|
setBattery(t("common.unknown"));
|
|
setLatestFirmware(t("common.unknown"));
|
|
setUpgradeAvailable(false);
|
|
setUpgradeCheckDone(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
const writeBytesToFff2 = async (bytes: number[]) => {
|
|
const normalized = normalizeBytes(bytes);
|
|
const buffer = bytesToArrayBuffer(normalized);
|
|
|
|
console.log("✍️ write FFF2 hex:", bytesToHexString(normalized));
|
|
|
|
await Central.writeCharacteristic(
|
|
peripheral,
|
|
fullUUID(CUSTOM_SERVICE_UUID),
|
|
fullUUID(CUSTOM_WRITE_UUID),
|
|
buffer,
|
|
{ withoutResponse: false }
|
|
);
|
|
};
|
|
|
|
const waitBoatNotifyOnce = (timeoutMs = 3000): Promise<number[]> => {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
if (boatReadResolverRef.current) {
|
|
boatReadResolverRef.current = null;
|
|
}
|
|
reject(new Error(t("info2.waitFff3Timeout")));
|
|
}, timeoutMs);
|
|
|
|
boatReadResolverRef.current = (bytes: number[]) => {
|
|
const normalized = normalizeBytes(bytes);
|
|
|
|
if (normalized.length < 1) {
|
|
console.log("⚠️ 忽略 FFF3:长度不足 1 字节");
|
|
return;
|
|
}
|
|
|
|
clearTimeout(timer);
|
|
boatReadResolverRef.current = null;
|
|
resolve(normalized);
|
|
};
|
|
});
|
|
};
|
|
|
|
const applyBoatTypeFromNotify = (notifyBytes: number[]) => {
|
|
let returnedBoatType: number | null = null;
|
|
|
|
if (notifyBytes.length === 1) {
|
|
returnedBoatType = notifyBytes[0];
|
|
} else if (notifyBytes[0] === 0x01 && notifyBytes.length >= 2) {
|
|
returnedBoatType = notifyBytes[1];
|
|
} else {
|
|
returnedBoatType = notifyBytes[0];
|
|
}
|
|
|
|
if (
|
|
returnedBoatType === 0x00 ||
|
|
returnedBoatType === 0x01 ||
|
|
returnedBoatType === 0x02
|
|
) {
|
|
setSelectedBoat(returnedBoatType as BoatType);
|
|
setBoatVerified(
|
|
t("info2.currentBoat", { boatName: getBoatNameByValue(returnedBoatType) })
|
|
);
|
|
setBoatStatus(
|
|
t("info2.readCurrentBoat", {
|
|
boatName: getBoatNameByValue(returnedBoatType),
|
|
})
|
|
);
|
|
console.log(
|
|
"🚣 apply boat type:",
|
|
returnedBoatType,
|
|
getBoatNameByValue(returnedBoatType)
|
|
);
|
|
} else {
|
|
setBoatVerified(`❌ ${t("info2.unknownBoatType")}`);
|
|
setBoatStatus(t("info2.readAbnormal", { hex: bytesToHexString(notifyBytes) }));
|
|
console.log("⚠️ unknown boat notify:", bytesToHexString(notifyBytes));
|
|
}
|
|
};
|
|
|
|
const handleSetBoatType = async (boatType: BoatType) => {
|
|
if (boatBusy) return;
|
|
|
|
try {
|
|
setBoatBusy(true);
|
|
setBoatVerified("--");
|
|
setBoatNotifyHex("--");
|
|
|
|
const boatName = getBoatNameByValue(boatType);
|
|
|
|
setBoatStatus(t("info2.writingBoat", { boatName }));
|
|
await writeBytesToFff2([0x01, boatType]);
|
|
|
|
setBoatStatus(t("info2.writtenWaitRead", { boatName }));
|
|
await sleep(200);
|
|
|
|
setBoatStatus(t("info2.requestingBoatRead"));
|
|
const waitPromise = waitBoatNotifyOnce(3000);
|
|
|
|
await writeBytesToFff2([0x02]);
|
|
|
|
const notifyBytes = await waitPromise;
|
|
const hex = bytesToHexString(notifyBytes);
|
|
setBoatNotifyHex(hex);
|
|
|
|
let returnedBoatType: number | null = null;
|
|
|
|
if (notifyBytes.length === 1) {
|
|
returnedBoatType = notifyBytes[0];
|
|
} else if (notifyBytes[0] === 0x01) {
|
|
returnedBoatType = notifyBytes[1];
|
|
} else {
|
|
returnedBoatType = notifyBytes[0];
|
|
}
|
|
|
|
const ok = returnedBoatType === boatType;
|
|
|
|
if (ok) {
|
|
applyBoatTypeFromNotify(notifyBytes);
|
|
setBoatStatus(t("info2.setSuccess", { boatName }));
|
|
} else {
|
|
setBoatVerified(
|
|
`❌ ${t("info2.notMatch", {
|
|
expected: boatName,
|
|
actual: getBoatNameByValue(returnedBoatType),
|
|
})}`
|
|
);
|
|
setBoatStatus(t("info2.readResult", { hex }));
|
|
}
|
|
} catch (error: any) {
|
|
console.warn("❌ set boat type failed:", error);
|
|
setBoatStatus(
|
|
t("info2.actionFailed", { message: error?.message ?? "unknown error" })
|
|
);
|
|
setBoatVerified(`❌ ${t("info2.failed")}`);
|
|
} finally {
|
|
setBoatBusy(false);
|
|
}
|
|
};
|
|
|
|
const goToDfu = () => {
|
|
if (!isConnected) {
|
|
Alert.alert(t("common.notice"), t("info2.disconnectedNeedReconnect"), [
|
|
{
|
|
text: t("info.confirm"),
|
|
onPress: () => {
|
|
navigation.goBack();
|
|
},
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
skipDisconnectOnLeaveRef.current = true;
|
|
console.log("🔥 peripheral =", JSON.stringify(peripheral, null, 2));
|
|
console.log("🔥 peripheral.address =", peripheral.address);
|
|
console.log("🔥 peripheral.systemId =", peripheral.systemId);
|
|
console.log("🔥 peripheral.id =", peripheral.id);
|
|
console.log("🔥 peripheral.identifier =", peripheral.identifier);
|
|
console.log("🔥 peripheral.uuid =", peripheral.uuid);
|
|
console.log("🔥 current deviceKey =", deviceKey);
|
|
|
|
navigation.navigate("Dfu", {
|
|
deviceId: deviceKey,
|
|
systemId: peripheral.systemId,
|
|
address: peripheral.address,
|
|
name: peripheral.name ?? "",
|
|
firmware: firmware ?? "",
|
|
});
|
|
};
|
|
|
|
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);
|
|
|
|
const connected =
|
|
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
|
|
|
|
setIsConnected(connected);
|
|
|
|
if (connected) {
|
|
hasConnectedOnceRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
hasConnectedOnceRef.current &&
|
|
mountedRef.current &&
|
|
!disconnectingRef.current &&
|
|
!hasNavigatedBackRef.current
|
|
) {
|
|
hasNavigatedBackRef.current = true;
|
|
|
|
console.log("↩️ 检测到设备断开,自动返回上一级");
|
|
|
|
if (navigation.canGoBack()) {
|
|
navigation.goBack();
|
|
}
|
|
}
|
|
};
|
|
|
|
Central.addListener("peripheralConnectionStatus", connectionHandler);
|
|
return () => {
|
|
Central.removeListener("peripheralConnectionStatus", connectionHandler);
|
|
};
|
|
}, [deviceKey, navigation]);
|
|
|
|
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;
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
|
|
let cscSubscription: any = null;
|
|
let customSubscription: any = null;
|
|
let cadenceZeroTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const handleMeasurement = (ev: any) => {
|
|
const rawData: number[] = Array.isArray(ev?.value)
|
|
? ev.value
|
|
: typeof ev?.value === "string"
|
|
? base64ToBytes(ev.value)
|
|
: ev?.value instanceof ArrayBuffer
|
|
? Array.from(new Uint8Array(ev.value))
|
|
: [];
|
|
|
|
const data = normalizeBytes(rawData);
|
|
|
|
if (data.length === 0) {
|
|
console.log("📥 CSC raw data: <empty>");
|
|
return;
|
|
}
|
|
|
|
console.log("📥 CSC raw data hex:", bytesToHexString(data));
|
|
|
|
if (data.length < 5) {
|
|
console.log("⚠️ invalid CSC packet length:", data.length);
|
|
return;
|
|
}
|
|
|
|
const flags = data[0];
|
|
|
|
if (flags !== 0x02) {
|
|
console.log("⚠️ unexpected CSC flags:", toHexByte(flags));
|
|
return;
|
|
}
|
|
|
|
const crankRevolutions = data[1] | (data[2] << 8);
|
|
const crankEventTime = data[3] | (data[4] << 8);
|
|
|
|
const lastCrank = lastCrankRef.current;
|
|
const lastEventTime = lastEventTimeRef.current;
|
|
|
|
console.log(
|
|
"📊 CSC parsed:",
|
|
"crankRevolutions=",
|
|
crankRevolutions,
|
|
"crankEventTime=",
|
|
crankEventTime,
|
|
"lastCrank=",
|
|
lastCrank,
|
|
"lastEventTime=",
|
|
lastEventTime
|
|
);
|
|
|
|
if (lastCrank !== null && lastEventTime !== null) {
|
|
let deltaCrank = crankRevolutions - lastCrank;
|
|
let deltaTime = crankEventTime - lastEventTime;
|
|
|
|
if (deltaCrank < 0) deltaCrank += 0x10000;
|
|
if (deltaTime < 0) deltaTime += 0x10000;
|
|
|
|
console.log(
|
|
"📊 CSC delta:",
|
|
"deltaCrank=",
|
|
deltaCrank,
|
|
"deltaTime=",
|
|
deltaTime
|
|
);
|
|
|
|
if (deltaCrank > 0 && deltaTime > 0) {
|
|
if (deltaCrank <= 4) {
|
|
const rpm = (60 * 1024 * deltaCrank) / deltaTime;
|
|
|
|
console.log("📊 CSC rpm raw:", rpm);
|
|
|
|
if (rpm >= 1 && rpm <= 220) {
|
|
const roundedCadence = Math.round(rpm);
|
|
const appNow = Date.now();
|
|
|
|
if (mountedRef.current) {
|
|
setCadence(roundedCadence);
|
|
}
|
|
|
|
lastCadenceUpdateAppTsRef.current = appNow;
|
|
|
|
console.log(
|
|
"✅ cadence updated:",
|
|
"cadence=",
|
|
roundedCadence,
|
|
"appTs=",
|
|
appNow,
|
|
"bleCrank=",
|
|
crankRevolutions,
|
|
"bleEventTime=",
|
|
crankEventTime
|
|
);
|
|
} else {
|
|
console.log("⚠️ rpm out of range, ignored:", rpm);
|
|
}
|
|
} else {
|
|
console.log("⚠️ deltaCrank too large, ignored:", deltaCrank);
|
|
}
|
|
} else {
|
|
console.log(
|
|
"⚠️ delta invalid, ignored:",
|
|
"deltaCrank=",
|
|
deltaCrank,
|
|
"deltaTime=",
|
|
deltaTime
|
|
);
|
|
}
|
|
} else {
|
|
console.log(
|
|
"🟡 first valid CSC packet, baseline saved only:",
|
|
"crankRevolutions=",
|
|
crankRevolutions,
|
|
"crankEventTime=",
|
|
crankEventTime
|
|
);
|
|
}
|
|
|
|
lastCrankRef.current = crankRevolutions;
|
|
lastEventTimeRef.current = crankEventTime;
|
|
};
|
|
|
|
const handleBoatNotify = (ev: any) => {
|
|
const rawData: number[] = Array.isArray(ev?.value)
|
|
? ev.value
|
|
: typeof ev?.value === "string"
|
|
? base64ToBytes(ev.value)
|
|
: ev?.value instanceof ArrayBuffer
|
|
? Array.from(new Uint8Array(ev.value))
|
|
: [];
|
|
|
|
const data = normalizeBytes(rawData);
|
|
|
|
if (data.length === 0) {
|
|
console.log("📥 FFF3 raw data: <empty>");
|
|
return;
|
|
}
|
|
|
|
const hex = bytesToHexString(data);
|
|
console.log("📥 FFF3 raw data hex:", hex);
|
|
|
|
if (mountedRef.current) {
|
|
setBoatNotifyHex(hex);
|
|
}
|
|
|
|
if (boatReadResolverRef.current) {
|
|
boatReadResolverRef.current(data);
|
|
}
|
|
};
|
|
|
|
(async () => {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await Central.connectPeripheral(peripheral);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
setIsConnected(true);
|
|
hasConnectedOnceRef.current = true;
|
|
hasNavigatedBackRef.current = false;
|
|
|
|
console.log("📡 subscribe CSC:", CSC_MEASUREMENT_UUID);
|
|
cscSubscription = await Central.subscribeCharacteristic(
|
|
peripheral,
|
|
fullUUID(CSC_SERVICE_UUID),
|
|
fullUUID(CSC_MEASUREMENT_UUID),
|
|
handleMeasurement
|
|
);
|
|
|
|
console.log("📡 subscribe FFF3:", CUSTOM_NOTIFY_UUID);
|
|
customSubscription = await Central.subscribeCharacteristic(
|
|
peripheral,
|
|
fullUUID(CUSTOM_SERVICE_UUID),
|
|
fullUUID(CUSTOM_NOTIFY_UUID),
|
|
handleBoatNotify
|
|
);
|
|
|
|
await readDeviceInfo();
|
|
|
|
cadenceZeroTimer = setInterval(() => {
|
|
const lastUpdateTs = lastCadenceUpdateAppTsRef.current;
|
|
const now = Date.now();
|
|
|
|
if (lastUpdateTs === null) {
|
|
return;
|
|
}
|
|
|
|
const elapsed = now - lastUpdateTs;
|
|
|
|
if (elapsed > 3000) {
|
|
if (mountedRef.current) {
|
|
setCadence((prev) => {
|
|
if (prev !== 0) {
|
|
console.log(
|
|
"⏱️ cadence reset to 0 and baseline cleared:",
|
|
"now=",
|
|
now,
|
|
"lastCadenceUpdateAppTs=",
|
|
lastUpdateTs,
|
|
"elapsedMs=",
|
|
elapsed
|
|
);
|
|
return 0;
|
|
}
|
|
return prev;
|
|
});
|
|
}
|
|
|
|
lastCrankRef.current = null;
|
|
lastEventTimeRef.current = null;
|
|
lastCadenceUpdateAppTsRef.current = null;
|
|
}
|
|
}, 500);
|
|
|
|
console.log("✅ all subscribe success");
|
|
|
|
try {
|
|
console.log("🔎 default read boat type: write FFF2 = 02");
|
|
const waitPromise = waitBoatNotifyOnce(3000);
|
|
await writeBytesToFff2([0x02]);
|
|
const notifyBytes = await waitPromise;
|
|
|
|
if (mountedRef.current) {
|
|
setBoatNotifyHex(bytesToHexString(notifyBytes));
|
|
applyBoatTypeFromNotify(notifyBytes);
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ default read boat type failed:", err);
|
|
if (mountedRef.current) {
|
|
setBoatStatus(t("info2.defaultReadFailed"));
|
|
}
|
|
}
|
|
|
|
if (mountedRef.current) {
|
|
setIsLoading(false);
|
|
}
|
|
} catch (e) {
|
|
console.warn("❌ 读取失败", e);
|
|
if (mountedRef.current) {
|
|
setIsLoading(false);
|
|
setIsConnected(false);
|
|
setBoatStatus(t("info2.connectOrReadFailed"));
|
|
Alert.alert(t("common.notice"), t("info2.connectOrReadFailedAlert"));
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
hasConnectedOnceRef.current = false;
|
|
hasNavigatedBackRef.current = false;
|
|
setIsConnected(false);
|
|
|
|
try {
|
|
if (cscSubscription?.remove) {
|
|
cscSubscription.remove();
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ csc subscription remove failed:", err);
|
|
}
|
|
|
|
try {
|
|
if (customSubscription?.remove) {
|
|
customSubscription.remove();
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ custom subscription remove failed:", err);
|
|
}
|
|
|
|
if (cadenceZeroTimer) {
|
|
clearInterval(cadenceZeroTimer);
|
|
cadenceZeroTimer = null;
|
|
}
|
|
|
|
boatReadResolverRef.current = null;
|
|
lastCrankRef.current = null;
|
|
lastEventTimeRef.current = null;
|
|
lastCadenceUpdateAppTsRef.current = null;
|
|
|
|
try {
|
|
if (!skipDisconnectOnLeaveRef.current) {
|
|
Central.disconnectPeripheral(peripheral);
|
|
} else {
|
|
console.log("➡️ cleanup: 跳转到 DFU,不断开蓝牙");
|
|
}
|
|
} catch {}
|
|
};
|
|
}, [peripheral]);
|
|
|
|
return (
|
|
<>
|
|
<MyStatusbar backgroundColor="#f2f3f7" dark />
|
|
<MyHeader
|
|
title={t("info.title")}
|
|
textColor="#333"
|
|
backgroundColor="#f2f3f7"
|
|
navigation={navigation}
|
|
/>
|
|
<ScrollView
|
|
style={styles.container}
|
|
contentContainerStyle={styles.contentContainer}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{isLoading ? (
|
|
<ActivityIndicator
|
|
size="large"
|
|
color="#E7141E"
|
|
style={{ marginTop: 40 }}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* 1. 桨频数据框 */}
|
|
<View style={styles.cadenceCard}>
|
|
<Text style={styles.cadenceLabel}>{t("info2.cadence")}</Text>
|
|
<Text style={styles.cadenceValue}>{cadence}</Text>
|
|
<Text style={styles.cadenceUnit}>{t("info2.cadenceUnit")}</Text>
|
|
</View>
|
|
|
|
{/* 2. 船型选择 */}
|
|
<View style={styles.sectionCard}>
|
|
<View style={styles.sectionHeader}>
|
|
<Text style={styles.sectionTitle}>{t("info2.boatSelect")}</Text>
|
|
<View style={styles.statusContainer}>
|
|
{boatBusy ? (
|
|
<Text style={styles.statusTextBusy}>{t("info2.boatSwitching")}</Text>
|
|
) : boatVerified.startsWith("✅") ? (
|
|
<Text style={styles.statusTextSuccess}>✅</Text>
|
|
) : boatVerified.startsWith("❌") ? (
|
|
<Text style={styles.statusTextError}>❌{t("info2.retry")}</Text>
|
|
) : (
|
|
<Text style={styles.statusText}>-</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.buttonRow}>
|
|
<Pressable
|
|
style={[
|
|
styles.boatButton,
|
|
selectedBoat === 0x00 && styles.boatButtonActive,
|
|
]}
|
|
onPress={() => handleSetBoatType(0x00)}
|
|
disabled={boatBusy}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.boatButtonText,
|
|
selectedBoat === 0x00 && styles.boatButtonTextActive,
|
|
]}
|
|
>
|
|
{t("info2.boatKayak")}
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
style={[
|
|
styles.boatButton,
|
|
selectedBoat === 0x01 && styles.boatButtonActive,
|
|
]}
|
|
onPress={() => handleSetBoatType(0x01)}
|
|
disabled={boatBusy}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.boatButtonText,
|
|
selectedBoat === 0x01 && styles.boatButtonTextActive,
|
|
]}
|
|
>
|
|
{t("info2.boatRowing")}
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
style={[
|
|
styles.boatButton,
|
|
selectedBoat === 0x02 && styles.boatButtonActive,
|
|
]}
|
|
onPress={() => handleSetBoatType(0x02)}
|
|
disabled={boatBusy}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.boatButtonText,
|
|
selectedBoat === 0x02 && styles.boatButtonTextActive,
|
|
]}
|
|
>
|
|
{t("info2.boatRacing")}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 3. 设备信息(内含固件升级按钮) */}
|
|
<View style={styles.infoCard}>
|
|
<View style={styles.infoHeader}>
|
|
<Text style={styles.infoTitle}>{t("info.title")}</Text>
|
|
</View>
|
|
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t("info.bluetoothName")}</Text>
|
|
<Text style={styles.infoValue}>{peripheral?.name || t("common.unknown")}</Text>
|
|
</View>
|
|
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t("info.idNumber")}</Text>
|
|
<Text style={styles.infoValue}>{serial}</Text>
|
|
</View>
|
|
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t("info.firmwareVersion")}</Text>
|
|
<View style={styles.firmwareRightWrap}>
|
|
<Text style={styles.infoValue}>{firmware}</Text>
|
|
<Pressable
|
|
style={[
|
|
styles.inlineDfuButton,
|
|
upgradeCheckDone &&
|
|
upgradeAvailable &&
|
|
styles.inlineDfuButtonHot,
|
|
(!isConnected || isLoading || boatBusy) &&
|
|
styles.inlineDfuButtonDisabled,
|
|
]}
|
|
onPress={goToDfu}
|
|
disabled={!isConnected || isLoading || boatBusy}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.inlineDfuButtonText,
|
|
upgradeCheckDone &&
|
|
upgradeAvailable &&
|
|
styles.inlineDfuButtonTextHot,
|
|
]}
|
|
>
|
|
{t("info.firmwareUpgrade")}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t("info.battery")}</Text>
|
|
<Text style={styles.infoValue}>{battery}</Text>
|
|
</View>
|
|
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{t("info2.latestFirmware")}</Text>
|
|
<Text style={styles.infoValue}>
|
|
{upgradeCheckDone ? latestFirmware : t("info2.checking")}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={[styles.infoRow, styles.infoRowLast]}>
|
|
<Text style={styles.infoLabel}>{t("info.connectionStatus")}</Text>
|
|
<Text
|
|
style={[
|
|
styles.infoValue,
|
|
isConnected ? styles.connectedText : styles.disconnectedText,
|
|
]}
|
|
>
|
|
{isConnected ? t("info.connected") : t("info.disconnected")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: "#f2f3f7",
|
|
},
|
|
contentContainer: {
|
|
padding: 16,
|
|
gap: 12,
|
|
backgroundColor: "#f2f3f7",
|
|
},
|
|
|
|
cadenceCard: {
|
|
backgroundColor: "#FFFFFF",
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: "#E7141E",
|
|
paddingVertical: 58,
|
|
paddingHorizontal: 20,
|
|
alignItems: "center",
|
|
marginBottom: 4,
|
|
},
|
|
cadenceLabel: {
|
|
color: "#666666",
|
|
fontSize: 20,
|
|
marginBottom: 12,
|
|
fontWeight: "500",
|
|
},
|
|
cadenceValue: {
|
|
color: "#E7141E",
|
|
fontSize: 100,
|
|
fontWeight: "700",
|
|
lineHeight: 108,
|
|
},
|
|
cadenceUnit: {
|
|
color: "#666666",
|
|
fontSize: 18,
|
|
marginTop: 10,
|
|
},
|
|
|
|
sectionCard: {
|
|
backgroundColor: "#FFFFFF",
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: "#E7141E",
|
|
padding: 16,
|
|
marginBottom: 4,
|
|
},
|
|
sectionHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 16,
|
|
},
|
|
sectionTitle: {
|
|
color: "#111111",
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
statusContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
statusTextBusy: {
|
|
color: "#FF9800",
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
},
|
|
statusTextSuccess: {
|
|
color: "#4CAF50",
|
|
fontSize: 20,
|
|
fontWeight: "bold",
|
|
},
|
|
statusTextError: {
|
|
color: "#F44336",
|
|
fontSize: 16,
|
|
fontWeight: "bold",
|
|
},
|
|
statusText: {
|
|
color: "#444444",
|
|
fontSize: 14,
|
|
},
|
|
buttonRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
gap: 10,
|
|
},
|
|
boatButton: {
|
|
flex: 1,
|
|
backgroundColor: "#FFFFFF",
|
|
borderWidth: 1,
|
|
borderColor: "#E7141E",
|
|
borderRadius: 10,
|
|
paddingVertical: 10,
|
|
alignItems: "center",
|
|
},
|
|
boatButtonActive: {
|
|
backgroundColor: "#E7141E",
|
|
},
|
|
boatButtonText: {
|
|
color: "#E7141E",
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
},
|
|
boatButtonTextActive: {
|
|
color: "#FFFFFF",
|
|
},
|
|
|
|
infoCard: {
|
|
backgroundColor: "#FFFFFF",
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: "#E7141E",
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 4,
|
|
},
|
|
infoHeader: {
|
|
paddingBottom: 4,
|
|
},
|
|
infoTitle: {
|
|
color: "#111111",
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
infoRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingVertical: 5,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#F0F0F0",
|
|
},
|
|
infoRowLast: {
|
|
borderBottomWidth: 0,
|
|
},
|
|
infoLabel: {
|
|
color: "#666666",
|
|
fontSize: 13,
|
|
flexShrink: 0,
|
|
marginRight: 12,
|
|
},
|
|
infoValue: {
|
|
color: "#111111",
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
},
|
|
firmwareRightWrap: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "flex-end",
|
|
flexShrink: 1,
|
|
},
|
|
inlineDfuButton: {
|
|
marginLeft: 10,
|
|
backgroundColor: "#D9D9D9",
|
|
borderRadius: 8,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 5,
|
|
},
|
|
inlineDfuButtonHot: {
|
|
backgroundColor: "#E7141E",
|
|
},
|
|
inlineDfuButtonDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
inlineDfuButtonText: {
|
|
color: "#333333",
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
inlineDfuButtonTextHot: {
|
|
color: "#FFFFFF",
|
|
},
|
|
connectedText: {
|
|
color: "#0A9F4B",
|
|
},
|
|
disconnectedText: {
|
|
color: "#D43C3C",
|
|
},
|
|
});
|