Compare commits

..

1 Commits
1.0.1 ... main

Author SHA1 Message Date
8dd0edd2ce 20260603-① 适配桨频器,做了连接和设置页面,已经内测ok。
② 适配T5骑行台,做了相关页面。内测还不够充分,产品也不着急上市,所以此版更新中,应该需要隐藏掉。
③ 修复了之前 盘爪设备信息 读取时,有概率出现乱码的情况。读取时做了排序和延迟,修复了此问题。
④ 多语言我们这边做更新时,拉取的是仅有中英文的版本,当时你们那好像有更新更多语言?所以需要适配一下。
⑤ 蓝牙名搜索时,适配最新的固件名称:
盘爪为:PF-PM5-前缀,桨频器为PF-STK-前缀,骑行台为PF-T5-前缀。同时允许前缀POWERFUN-也能显示并连接。
2026-06-03 14:35:08 +08:00
20 changed files with 6690 additions and 939 deletions

81
App.tsx
View File

@ -1,23 +1,55 @@
console.log("🔥 当前 App.tsx 已加载");
import * as React from "react"; import * as React from "react";
import { NavigationContainer } from "@react-navigation/native"; import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { createNativeStackNavigator } from "@react-navigation/native-stack";
import HomeScreen from "./src/HomeScreen"; import HomeScreen from "./src/HomeScreen";
import ScanScreen from "./src/ScanScreen"; import ScanScreen from "./src/ScanScreen";
import ScanScreen2 from "./src/ScanScreen2";
import ScanScreen3 from "./src/ScanScreen3";
import InfoScreen from "./src/InfoScreen"; import InfoScreen from "./src/InfoScreen";
import DfuScreen from "./src/DfuScreen"; import DfuScreen from "./src/DfuScreen";
import PrivacyScreen from "./src/PrivacyScreen"; import PrivacyScreen from "./src/PrivacyScreen";
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页 import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
import pxToDp from "./src/helper/pxToDp";
import SettingScreen from "./src/SettingScreen"; import SettingScreen from "./src/SettingScreen";
import './src/i18n' import './src/i18n'
import InfoScreen2 from "./src/InfoScreen2";
import InfoScreen3 from "./src/InfoScreen3";
import SpindownScreen from "./src/SpindownScreen";
import { decode } from "base-64";
// 不要 global.atob
// 不要 atob
const base64ToBytes = (base64: string): number[] => {
const binary = decode(base64);
const bytes: number[] = [];
for (let i = 0; i < binary.length; i++) {
bytes.push(binary.charCodeAt(i));
}
return bytes;
};
export type RootStackParamList = { export type RootStackParamList = {
Splash: undefined; Splash: undefined;
Home: undefined; Home: undefined;
Scan: undefined; Scan: undefined;
ScanScreen2: undefined;
ScanScreen3: undefined;
Info: { peripheral: any }; Info: { peripheral: any };
Dfu: { deviceId: string; name: string; firmware: string }; Info2: { peripheral: any };
Info3: { peripheral: any };
Spindown: { peripheral: any };
Dfu: {
deviceId: string;
systemId?: string;
address?: string | number;
name: string;
firmware: string;
};
Privacy: undefined; Privacy: undefined;
Setting: undefined; Setting: undefined;
}; };
@ -25,6 +57,23 @@ export type RootStackParamList = {
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() { export default function App() {
//linshi
React.useEffect(() => {
const test = async () => {
try {
console.log("🔥 App.tsx fetch test start");
const resp = await fetch("https://www.baidu.com");
console.log("🔥 App.tsx fetch status =", resp.status);
const text = await resp.text();
console.log("🔥 App.tsx fetch text length =", text.length);
} catch (e) {
console.log("❌ App.tsx fetch error =", e);
}
};
test();
}, []);
return ( return (
<NavigationContainer> <NavigationContainer>
<Stack.Navigator <Stack.Navigator
@ -32,6 +81,11 @@ export default function App() {
screenOptions={{ screenOptions={{
animation : 'slide_from_right' animation : 'slide_from_right'
}}> }}>
<Stack.Screen
name="Info2"
component={InfoScreen2}
options={{ headerShown: false }}
/>
{/* 启动页(无标题) */} {/* 启动页(无标题) */}
<Stack.Screen <Stack.Screen
name="Splash" name="Splash"
@ -53,12 +107,35 @@ export default function App() {
// options={{ title: "搜索设备" }} // options={{ title: "搜索设备" }}
options={{ headerShown: false }} options={{ headerShown: false }}
/> />
<Stack.Screen
name="ScanScreen2"
component={ScanScreen2}
// options={{ title: "搜索设备" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="ScanScreen3"
component={ScanScreen3}
// options={{ title: "搜索设备" }}
options={{ headerShown: false }}
/>
<Stack.Screen <Stack.Screen
name="Info" name="Info"
component={InfoScreen} component={InfoScreen}
// options={{ title: "设备详情" }} // options={{ title: "设备详情" }}
options={{ headerShown: false }} options={{ headerShown: false }}
/> />
<Stack.Screen
name="Info3"
component={InfoScreen3}
// options={{ title: "设备详情" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Spindown"
component={SpindownScreen}
options={{ headerShown: false }}
/>
<Stack.Screen <Stack.Screen
name="Dfu" name="Dfu"
component={DfuScreen} component={DfuScreen}

View File

@ -2316,7 +2316,7 @@ PODS:
- SocketRocket - SocketRocket
- RNCAsyncStorage (2.2.0): - RNCAsyncStorage (2.2.0):
- React-Core - React-Core
- RNDeviceInfo (15.0.1): - RNDeviceInfo (15.0.2):
- React-Core - React-Core
- RNFS (2.20.0): - RNFS (2.20.0):
- React-Core - React-Core
@ -2707,7 +2707,7 @@ SPEC CHECKSUMS:
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00 ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6 ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388 RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e

View File

@ -207,10 +207,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
@ -246,10 +250,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
@ -288,7 +296,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B7ZA544T59; DEVELOPMENT_TEAM = PXHWD6972V;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = dfuapp/Info.plist; INFOPLIST_FILE = dfuapp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置"; INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
@ -303,7 +311,7 @@
"-ObjC", "-ObjC",
"-lc++", "-lc++",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp; PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
PRODUCT_NAME = dfuapp; PRODUCT_NAME = dfuapp;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -318,7 +326,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B7ZA544T59; DEVELOPMENT_TEAM = PXHWD6972V;
INFOPLIST_FILE = dfuapp/Info.plist; INFOPLIST_FILE = dfuapp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置"; INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -332,7 +340,7 @@
"-ObjC", "-ObjC",
"-lc++", "-lc++",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp; PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
PRODUCT_NAME = dfuapp; PRODUCT_NAME = dfuapp;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";

View File

@ -41,6 +41,10 @@
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string> <string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
<key>RCTNewArchEnabled</key> <key>RCTNewArchEnabled</key>
<false/> <false/>
<key>UIAppFonts</key>
<array>
<string>MaterialCommunityIcons.ttf</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
@ -53,9 +57,5 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>UIAppFonts</key>
<array>
<string>MaterialCommunityIcons.ttf</string>
</array>
</dict> </dict>
</plist> </plist>

741
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,9 +37,9 @@
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3", "@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0", "@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.0.0", "@react-native-community/cli": "15.0.0",
"@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-android": "15.0.0",
"@react-native-community/cli-platform-ios": "20.0.0", "@react-native-community/cli-platform-ios": "15.0.0",
"@react-native/babel-preset": "0.81.4", "@react-native/babel-preset": "0.81.4",
"@react-native/eslint-config": "0.81.4", "@react-native/eslint-config": "0.81.4",
"@react-native/metro-config": "0.81.4", "@react-native/metro-config": "0.81.4",
@ -53,7 +53,10 @@
"prettier": "2.8.8", "prettier": "2.8.8",
"react-test-renderer": "19.1.0", "react-test-renderer": "19.1.0",
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }

View File

@ -1,10 +1,16 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { View, Text, StyleSheet, Alert, BackHandler } from "react-native"; import { View, Text, StyleSheet, Alert, Platform } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import RNFS from "react-native-fs"; import RNFS from "react-native-fs";
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu"; import {
import { useTranslation } from 'react-i18next'; startDfu,
getDfuTargetId,
DfuProgressEvent,
DfuStateEvent,
} from "@systemic-games/react-native-nordic-nrf5-dfu";
import { useTranslation } from "react-i18next";
import { encode as btoa } from "base-64";
import MyStatusbar from "./component/MyStatusbar"; import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader"; import MyHeader from "./component/MyHeader";
@ -16,162 +22,337 @@ interface DeviceInfo {
download: string; download: string;
} }
interface ParsedFirmware {
hardware: number;
iteration: number;
build: string;
raw: string;
}
const bytesToBase64 = (bytes: Uint8Array): string => {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
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 DfuScreen({ route, navigation }: Props) { export default function DfuScreen({ route, navigation }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { deviceId, name, firmware: deviceFirmware } = route.params; const {
deviceId,
systemId,
address,
name,
firmware: deviceFirmware,
} = route.params;
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [state, setState] = useState(t('dfu.preparing')); const [state, setState] = useState("准备中...");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [latestVersion, setLatestVersion] = useState<string>(t('dfu.reading')); const [latestVersion, setLatestVersion] = useState("读取中...");
const [isDfuRunning, setIsDfuRunning] = useState(false); const [isDfuRunning, setIsDfuRunning] = useState(false);
const mapDfuStateToChinese = (state: string): string => { const mapDfuStateToChinese = (s: string): string => {
switch (state) { switch (s) {
case "connecting": return t('dfu.stateConnecting'); case "connecting":
case "starting": return t('dfu.stateStarting'); return "连接中…";
case "enablingDfuMode": return t('dfu.stateEnablingDfuMode'); case "starting":
case "uploading": return t('dfu.stateUploading'); return "初始化中…";
case "validating": return t('dfu.stateValidating'); case "enablingDfuMode":
case "disconnecting": return t('dfu.stateDisconnecting'); return "启用 DFU 模式…";
case "completed": return t('dfu.stateCompleted'); case "uploading":
case "aborted": return t('dfu.stateAborted'); return "上传固件中…";
case "validating":
return "校验固件…";
case "disconnecting":
return "断开连接…";
case "completed":
return "升级完成";
case "aborted":
return "已取消";
case "failed": case "failed":
case "dfu_failed": return t('dfu.stateFailed'); case "dfu_failed":
case "initializing": return t('dfu.stateInitializing'); return "升级失败";
case "errored": return t('dfu.stateErrored'); case "initializing":
default: return state; return "启动中…";
case "errored":
return "升级出错!";
default:
return s;
} }
}; };
// ✅ 拦截所有导航返回iOS + Android
useEffect(() => { useEffect(() => {
const unsubscribe = navigation.addListener("beforeRemove", (e) => { const unsubscribe = navigation.addListener("beforeRemove", (e) => {
if (!isDfuRunning) return; if (!isDfuRunning) return;
// 阻止返回
e.preventDefault(); e.preventDefault();
Alert.alert(t('dfu.pleaseWait'), t('dfu.doNotReturn')); Alert.alert("请稍候", "正在升级,请勿返回或关闭应用!");
}); });
return unsubscribe; return unsubscribe;
}, [navigation, isDfuRunning]); }, [navigation, isDfuRunning]);
useEffect(() => { useEffect(() => {
const runDfu = async () => { const runDfu = async () => {
try { try {
setIsDfuRunning(true); setIsDfuRunning(true);
setError(undefined);
const manifestUrl = "https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json"; const rawDeviceId = String(deviceId ?? "").trim();
const rawSystemId = String(systemId ?? rawDeviceId).trim();
const rawAddress =
typeof address === "number"
? address
: address !== undefined && address !== null
? Number(address)
: undefined;
const safeDeviceId = getDfuTargetId({
systemId: rawSystemId,
address: rawAddress,
});
const firmwareText = String(deviceFirmware ?? "").trim();
console.log("🔥 rawDeviceId =", rawDeviceId);
console.log("🔥 rawSystemId =", rawSystemId);
console.log("🔥 rawAddress =", rawAddress);
console.log("🔥 safeDeviceId =", safeDeviceId);
console.log("🔥 firmwareText =", JSON.stringify(firmwareText));
if (!safeDeviceId) {
throw new Error("无法生成 DFU 目标设备 ID");
}
const currentFw = parseFirmwareVersion(firmwareText);
console.log("🔥 currentFw =", currentFw);
const manifestUrl =
"https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
const manifestPath = RNFS.CachesDirectoryPath + "/latest.json"; const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise; console.log("🔥 before fetch manifest");
const manifestContent = await RNFS.readFile(manifestPath); const manifestResp = await fetch(manifestUrl);
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] }; console.log("🔥 manifest status =", manifestResp.status);
const [deviceHWStr, deviceFWStr] = deviceFirmware.split("."); if (!manifestResp.ok) {
const deviceHW = parseInt(deviceHWStr); throw new Error(`manifest 下载失败HTTP ${manifestResp.status}`);
const deviceFW = parseInt(deviceFWStr); }
const manifestText = await manifestResp.text();
console.log("🔥 manifest text =", manifestText);
await RNFS.writeFile(manifestPath, manifestText, "utf8");
console.log("🔥 manifest saved =", manifestPath);
let manifest: { devices: DeviceInfo[] };
try {
manifest = JSON.parse(manifestText) as { devices: DeviceInfo[] };
} catch (e) {
throw new Error("manifest 不是合法 JSON: " + manifestText.slice(0, 200));
}
const deviceInfo = manifest.devices.find(
(d) => d.hardware === currentFw.hardware
);
console.log("🔥 matched deviceInfo =", deviceInfo);
const deviceInfo = manifest.devices.find(d => d.hardware === deviceHW);
if (!deviceInfo) { if (!deviceInfo) {
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert(t('dfu.cannotUpgrade'), t('dfu.hardwareNotFound', { hardware: deviceHW }), [ Alert.alert(
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() }, "无法升级",
]); `未找到 hardware=${currentFw.hardware} 的固件`,
[{ text: "确认", onPress: () => navigation.goBack() }]
);
return; return;
} }
setLatestVersion(deviceInfo.latestFirmware); setLatestVersion(deviceInfo.latestFirmware);
const [, latestFWStr] = deviceInfo.latestFirmware.split("."); const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
const latestFW = parseInt(latestFWStr); console.log("🔥 latestFw =", latestFw);
if (latestFW <= deviceFW) { if (latestFw.hardware !== currentFw.hardware) {
throw new Error(
`服务器固件硬件号不匹配:当前 ${currentFw.hardware},服务器 ${latestFw.hardware}`
);
}
if (latestFw.iteration <= currentFw.iteration) {
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert(t('dfu.noNeedUpgrade'), t('dfu.alreadyLatest'), [ Alert.alert("无需升级", "已是最新固件,无需升级", [
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() }, { text: "确认", onPress: () => navigation.goBack() },
]); ]);
return; return;
} }
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip"; console.log("🔥 upgrade allowed", {
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise; currentIteration: currentFw.iteration,
latestIteration: latestFw.iteration,
});
await startDfu(deviceId, "file://" + localPath, { const zipResp = await fetch(deviceInfo.download);
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state), console.log("🔥 zip status =", zipResp.status);
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
if (!zipResp.ok) {
throw new Error(`固件包下载失败HTTP ${zipResp.status}`);
}
const zipArrayBuffer = await zipResp.arrayBuffer();
const zipBytes = new Uint8Array(zipArrayBuffer);
console.log("🔥 zip bytes =", zipBytes.length);
const zipBase64 = bytesToBase64(zipBytes);
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
await RNFS.writeFile(localPath, zipBase64, "base64");
console.log("🔥 zip saved =", localPath);
const dfuFilePath =
Platform.OS === "android" ? "file://" + localPath : localPath;
console.log("🔥 before startDfu =", {
safeDeviceId,
dfuFilePath,
currentFirmware: currentFw.raw,
latestFirmware: latestFw.raw,
});
await startDfu(safeDeviceId, dfuFilePath, {
dfuStateListener: (ev: DfuStateEvent) => {
console.log("🔥 dfu state =", ev.state);
setState(ev.state);
},
dfuProgressListener: (ev: DfuProgressEvent) => {
console.log("🔥 dfu progress =", ev.percent);
setProgress(ev.percent);
},
}); });
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert(t('dfu.upgradeSuccess'), t('dfu.upgradeSuccessMessage'), [ Alert.alert("升级成功", "升级成功,请重连设备", [
{ text: t('dfu.confirm'), onPress: () => navigation.navigate("Home") }, {
text: "确认",
onPress: () => {
navigation.reset({
index: 0,
routes: [{ name: "Home" }],
});
},
},
]); ]);
} catch (err: any) { } catch (err: any) {
console.log("❌ runDfu error =", err);
setIsDfuRunning(false); setIsDfuRunning(false);
setError(err.message || t('dfu.dfuFailed')); setError(err?.message || "DFU失败");
Alert.alert(t('dfu.upgradeFailed'), err.message || t('dfu.dfuFailed'), [ Alert.alert("升级失败", err?.message || "DFU失败", [
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() }, { text: "确认", onPress: () => navigation.goBack() },
]); ]);
} }
}; };
runDfu(); runDfu();
}, [deviceId, deviceFirmware, navigation]); }, [deviceId, systemId, address, deviceFirmware, navigation]);
return ( return (
<View style={{flex:1,backgroundColor:'#f2f3f7'}}> <View style={styles.container}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar> <MyStatusbar backgroundColor="#FFFFFF" dark />
<MyHeader title={t("dfu.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader> <MyHeader
<View style={{ flex: 1, padding: 20 }}> title="固件升级"
textColor="#333"
backgroundColor="#FFFFFF"
navigation={navigation}
/>
<View style={styles.content}>
<View style={styles.row}>
<Text style={styles.titleText}>: {name || "--"}</Text>
</View>
<View style={styles.row}> <View style={styles.row}>
<Text style={{ fontSize: 16, color: "#333" }}> <Text style={styles.normalText}>: {latestVersion}</Text>
{t('dfu.bluetoothName')}: {name}
</Text>
</View> </View>
<View style={styles.row}> <View style={styles.row}>
<Text style={{ fontSize: 16, color: "#333" }}> <Text style={styles.normalText}>: {deviceFirmware || "--"}</Text>
{t('dfu.latestVersion')}: {latestVersion}
</Text>
</View> </View>
<View style={styles.row}> <View style={styles.row}>
<Text style={{ fontSize: 16, color: "#333" }}> <Text style={styles.normalText}>
{t('dfu.currentVersion')}: {deviceFirmware} : {mapDfuStateToChinese(state)}
</Text>
</View>
<View style={styles.row}>
<Text style={{ fontSize: 16, color: "#333" }}>
{t('dfu.upgradeStatus')}: {mapDfuStateToChinese(state)}
</Text> </Text>
</View> </View>
{/* 横向进度条 */}
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${progress}%` }]} /> <View style={[styles.progressBar, { width: `${progress}%` }]} />
<Text style={styles.progressText}>{progress}%</Text> <Text style={styles.progressText}>{progress}%</Text>
</View> </View>
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>} {!!error && <Text style={styles.errorText}>{error}</Text>}
</View> </View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#FFFFFF",
},
content: {
flex: 1,
padding: 20,
backgroundColor: "#FFFFFF",
},
row: { row: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "red", borderBottomColor: "#E7141E",
paddingBottom: 4, paddingBottom: 6,
marginBottom: 8, marginBottom: 12,
},
titleText: {
fontSize: 18,
color: "#111111",
fontWeight: "600",
},
normalText: {
fontSize: 16,
color: "#222222",
}, },
progressContainer: { progressContainer: {
height: 30, height: 30,
backgroundColor: "#eee", backgroundColor: "#EEEEEE",
borderRadius: 15, borderRadius: 15,
overflow: "hidden", overflow: "hidden",
marginTop: 40, marginTop: 40,
@ -185,6 +366,11 @@ const styles = StyleSheet.create({
position: "absolute", position: "absolute",
alignSelf: "center", alignSelf: "center",
fontWeight: "bold", fontWeight: "bold",
color: "#000", color: "#000000",
},
errorText: {
color: "red",
marginTop: 20,
fontSize: 14,
}, },
}); });

View File

@ -28,7 +28,19 @@ export default function HomeScreen({ navigation }: Props) {
style={styles.button} style={styles.button}
onPress={() => navigation.navigate("Scan")} onPress={() => navigation.navigate("Scan")}
> >
<Text style={styles.buttonText}>{t('home.scan')}</Text> <Text style={styles.buttonText}>{t("home.powerMeter")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, { marginTop: pxToDp(20) }]}
onPress={() => navigation.navigate("ScanScreen2")}
>
<Text style={styles.buttonText}>{t("home.paddle")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, { marginTop: pxToDp(20) }]}
onPress={() => navigation.navigate("ScanScreen3")}
>
<Text style={styles.buttonText}>{t("home.T5trainer")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from "react"; // InfoScreen.tsx此页面为功率计信息页面
import React, { useEffect, useState, useRef } from "react";
import { import {
View, View,
Text as RNText, Text as RNText,
@ -6,7 +7,6 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
ActivityIndicator, ActivityIndicator,
useColorScheme,
Alert, Alert,
Pressable, Pressable,
} from "react-native"; } from "react-native";
@ -57,6 +57,7 @@ export default function InfoScreen({ route, navigation }: Props) {
const [rightbalance, setRightBalance] = useState<number>(0); const [rightbalance, setRightBalance] = useState<number>(0);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isDeviceReady, setIsDeviceReady] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [serial, setSerial] = useState(t('info.reading')); const [serial, setSerial] = useState(t('info.reading'));
const [firmware, setFirmware] = useState(t('info.reading')); const [firmware, setFirmware] = useState(t('info.reading'));
@ -72,6 +73,12 @@ export default function InfoScreen({ route, navigation }: Props) {
const notifySubscribedRef = useRef(false); const notifySubscribedRef = useRef(false);
const disconnectingRef = useRef(false); const disconnectingRef = useRef(false);
const mountedRef = useRef(true);
const deviceReadyRef = useRef(false);
const readyResolverRef = useRef<(() => void) | null>(null);
const readSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const powerDataSubscribedRef = useRef(false);
const initialInfoLoadedRef = useRef(false);
const prevConnectedRef = useRef(isConnected); const prevConnectedRef = useRef(isConnected);
const isActiveRef = useRef(true); const isActiveRef = useRef(true);
@ -83,6 +90,227 @@ export default function InfoScreen({ route, navigation }: Props) {
const calibrationTimeoutRef = useRef<number | null>(null); const calibrationTimeoutRef = useRef<number | null>(null);
const isCalibratingRef = useRef<boolean>(false); const isCalibratingRef = useRef<boolean>(false);
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
const toUint8Array = (raw: unknown): Uint8Array | null => {
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
if (Array.isArray(raw)) return new Uint8Array(raw);
if (
typeof raw === "object" &&
raw !== null &&
"buffer" in raw &&
(raw as { buffer?: unknown }).buffer instanceof ArrayBuffer
) {
const view = raw as {
buffer: ArrayBuffer;
byteOffset?: number;
byteLength?: number;
};
return new Uint8Array(
view.buffer,
view.byteOffset || 0,
view.byteLength
);
}
return null;
};
const decodeTextValue = (raw: unknown) => {
const bytes = toUint8Array(raw);
if (!bytes || bytes.length === 0) return null;
const text = String.fromCharCode(...Array.from(bytes))
.replace(/\0/g, "")
.trim();
return text || null;
};
const waitForDeviceReady = (timeoutMs = 6000) =>
new Promise<void>((resolve, reject) => {
if (deviceReadyRef.current) {
resolve();
return;
}
const timer = setTimeout(() => {
if (readyResolverRef.current === onReady) {
readyResolverRef.current = null;
}
reject(new Error("device ready timeout"));
}, timeoutMs);
const onReady = () => {
clearTimeout(timer);
if (readyResolverRef.current === onReady) {
readyResolverRef.current = null;
}
resolve();
};
readyResolverRef.current = onReady;
});
const readCharacteristicWithRetry = async (
serviceUuid: string,
characteristicUuid: string,
attempts = 3,
delayMs = 180
) => {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const value = await Central.readCharacteristic(
peripheral,
fullUUID(serviceUuid),
fullUUID(characteristicUuid)
);
const bytes = toUint8Array(value);
if (bytes && bytes.length > 0) {
return bytes;
}
lastError = new Error("empty characteristic value");
} catch (err) {
lastError = err;
}
if (attempt < attempts - 1) {
await sleep(delayMs);
}
}
throw lastError ?? new Error("read characteristic failed");
};
const readTextCharacteristic = async (serviceUuid: string, characteristicUuid: string) => {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const bytes = await readCharacteristicWithRetry(
serviceUuid,
characteristicUuid,
1
);
const text = decodeTextValue(bytes);
if (text) {
return text;
}
} catch {
// Continue retrying with a short gap below.
}
if (attempt < 2) {
await sleep(180);
}
}
return "未知";
};
const readBatteryCharacteristic = async () => {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const bytes = await readCharacteristicWithRetry("180f", "2a19", 1);
const batteryValue = bytes[0];
if (Number.isInteger(batteryValue) && batteryValue >= 0 && batteryValue <= 100) {
return `${batteryValue}%`;
}
} catch {
// Continue retrying with a short gap below.
}
if (attempt < 2) {
await sleep(180);
}
}
return "未知";
};
const subscribePowerDataIfNeeded = async () => {
if (!deviceReadyRef.current || !initialInfoLoadedRef.current || powerDataSubscribedRef.current) {
return;
}
console.log("✅ 订阅 2A63 特性通知...");
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID("1818"),
fullUUID("2a63")
).catch(() => {});
await Central.subscribeCharacteristic(
peripheral,
fullUUID("1818"),
fullUUID("2a63"),
(notifyEv) => {
try {
const byteArray = toUint8Array(notifyEv.value);
if (!byteArray) return;
parseData(byteArray);
} catch (err) {
console.warn("❌ 处理通知数据失败", err);
}
}
);
powerDataSubscribedRef.current = true;
console.log("✅ 已订阅 2A63 特性通知");
};
const subscribeFff3IfNeeded = async () => {
if (notifySubscribedRef.current) return;
console.log("✅ device ready - subscribe notify FFF3");
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;
const byteArray = toUint8Array(raw);
if (!byteArray) return;
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
const rawTrim =
byteArray.length >= 3
? byteArray[1] | (byteArray[2] << 8)
: byteArray[1];
const displayTrim =
byteArray.length >= 3
? (rawTrim / 100).toFixed(2).replace(/\.?0+$/, "")
: rawTrim.toString();
setPowerTrim(displayTrim);
} else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
handleFFF3Response(byteArray);
}
} catch (err) {
console.warn("notify parse error", err);
}
}
);
notifySubscribedRef.current = true;
console.log("✅ notify 已订阅");
};
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
// 页面获得焦点 // 页面获得焦点
@ -145,49 +373,40 @@ export default function InfoScreen({ route, navigation }: Props) {
if (addr !== deviceKey) return; if (addr !== deviceKey) return;
console.log("🔌 connection event:", ev.connectionStatus); console.log("🔌 connection event:", ev.connectionStatus);
setIsConnected( const connected =
ev.connectionStatus === "connected" || ev.connectionStatus === "ready" ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
); const isReady = ev.connectionStatus === "ready";
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) { setIsConnected(connected);
console.log("✅ device ready - subscribe notify FFF3"); setIsDeviceReady(isReady);
deviceReadyRef.current = isReady;
if (!isReady) {
notifySubscribedRef.current = false;
powerDataSubscribedRef.current = false;
}
if (isReady && readyResolverRef.current) {
const resolveReady = readyResolverRef.current;
readyResolverRef.current = null;
resolveReady();
}
if (isReady && !notifySubscribedRef.current) {
try { try {
await Central.unsubscribeCharacteristic( await subscribeFff3IfNeeded();
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 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);
}
}
);
notifySubscribedRef.current = true;
console.log("✅ notify 已订阅");
} catch (err) { } catch (err) {
console.warn("❌ notify 订阅失败:", err); console.warn("❌ notify 订阅失败:", err);
} }
} }
if (isReady && initialInfoLoadedRef.current && !powerDataSubscribedRef.current) {
try {
await subscribePowerDataIfNeeded();
} catch (err) {
console.warn("❌ 实时数据订阅失败:", err);
}
}
}; };
@ -201,45 +420,44 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 首次连接并读取信息 ========== // ========== 首次连接并读取信息 ==========
useEffect(() => { useEffect(() => {
let cancelled = false;
(async () => { (async () => {
setIsLoading(true); setIsLoading(true);
let shouldShowReadSuccess = false;
try { try {
await Central.connectPeripheral(peripheral); await Central.connectPeripheral(peripheral);
await new Promise<void>((resolve) => setTimeout(resolve, 500)); await waitForDeviceReady(6000);
await sleep(150);
await subscribeFff3IfNeeded();
await sleep(120);
const readStr = async (srv: string, char: string) => { const serialValue = await readTextCharacteristic("180a", "2a25");
try { const firmwareValue = await readTextCharacteristic("180a", "2a28");
const v = await Central.readCharacteristic( const hardwareValue = await readTextCharacteristic("180a", "2a27");
peripheral, const batteryValue = await readBatteryCharacteristic();
fullUUID(srv),
fullUUID(char) if (!cancelled && mountedRef.current) {
); setSerial(serialValue);
return v ? String.fromCharCode(...(v as any)) : "未知"; setFirmware(firmwareValue);
} catch { setHardware(hardwareValue);
return "未知"; setBattery(batteryValue);
initialInfoLoadedRef.current = true;
} }
};
setSerial(await readStr("180a", "2a25")); await sleep(120);
setFirmware(await readStr("180a", "2a28")); await subscribePowerDataIfNeeded();
setHardware(await readStr("180a", "2a27"));
try { shouldShowReadSuccess =
const v = await Central.readCharacteristic( serialValue !== "未知" ||
peripheral, firmwareValue !== "未知" ||
fullUUID("180f"), hardwareValue !== "未知" ||
fullUUID("2a19") batteryValue !== "未知";
);
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
} catch {
setBattery("未知");
}
console.log("✅ info 读取完成"); console.log("✅ info 读取完成");
try { try {
await sleep(150);
await Central.writeCharacteristic( await Central.writeCharacteristic(
peripheral, peripheral,
fullUUID(powerServiceUuid), fullUUID(powerServiceUuid),
@ -251,22 +469,35 @@ export default function InfoScreen({ route, navigation }: Props) {
// notify 回调里已经订阅了,所以这里不用再重复订阅 // notify 回调里已经订阅了,所以这里不用再重复订阅
// 可以稍等 300ms确保 notify 回来后 UI 会更新 // 可以稍等 300ms确保 notify 回来后 UI 会更新
await new Promise<void>(resolve => setTimeout(() => resolve(), 300)); await sleep(300);
} catch (err) { } catch (err) {
console.warn("❌ 首次读取功率微调失败", err); console.warn("❌ 首次读取功率微调失败", err);
} }
} catch (e) { } catch (e) {
console.warn("❌ 读取失败", e); console.warn("❌ 读取失败", e);
} finally { } finally {
if (!cancelled && mountedRef.current) {
setIsLoading(false); setIsLoading(false);
}
// ✅ 显示读取成功提示 2 秒 if (!cancelled && mountedRef.current && shouldShowReadSuccess) {
setReadSuccessToast(true); setReadSuccessToast(true);
setTimeout(() => setReadSuccessToast(false), 2000); if (readSuccessTimeoutRef.current) {
clearTimeout(readSuccessTimeoutRef.current);
}
readSuccessTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
setReadSuccessToast(false);
}
}, 2000);
}
} }
})(); })();
return () => {
cancelled = true;
initialInfoLoadedRef.current = false;
};
}, [peripheral]); }, [peripheral]);
@ -274,59 +505,19 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 订阅 1818 服务的 2A63 特性 (通知) ========== // ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
useEffect(() => { useEffect(() => {
const subscribeToPowerData = async () => { if (peripheral && deviceReadyRef.current && initialInfoLoadedRef.current) {
try { subscribePowerDataIfNeeded().catch((err) => {
// 确保设备已经连接
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); console.warn("❌ 订阅 2A63 特性失败", err);
} });
};
// 只在设备已连接时进行订阅
if (peripheral && isConnected) {
subscribeToPowerData();
} }
// 清理订阅(当组件卸载或者连接状态变化时)
return () => { return () => {
if (peripheral && isConnected) { if (peripheral && powerDataSubscribedRef.current) {
powerDataSubscribedRef.current = false;
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { }); Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
} }
}; };
}, [peripheral, isConnected]); }, [peripheral, isDeviceReady]);
const cadenceStateRef = useRef({ const cadenceStateRef = useRef({
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值 lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
@ -442,7 +633,7 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 写入功率微调 ========== // ========== 写入功率微调 ==========
const updatePowerTrim = async () => { const updatePowerTrim = async () => {
const val = parseInt(inputTrim); const val = Number(inputTrim);
if (isNaN(val) || val < 50 || val > 200) { if (isNaN(val) || val < 50 || val > 200) {
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert')); Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
return; return;
@ -458,13 +649,16 @@ export default function InfoScreen({ route, navigation }: Props) {
try { try {
setPowerTrimLoading(true); setPowerTrimLoading(true);
const scaledVal = Math.round(val * 100);
const low = scaledVal & 0xff;
const high = (scaledVal >> 8) & 0xff;
console.log("🚀 写入功率微调", val); console.log("🚀 写入功率微调", val, "=>", scaledVal);
await Central.writeCharacteristic( await Central.writeCharacteristic(
peripheral, peripheral,
fullUUID(powerServiceUuid), fullUUID(powerServiceUuid),
fullUUID(powerWriteUuid), fullUUID(powerWriteUuid),
new Uint8Array([0x02, val]).buffer, new Uint8Array([0x02, low, high]).buffer,
{ withoutResponse: false } { withoutResponse: false }
); );
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150)); await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
@ -483,6 +677,8 @@ export default function InfoScreen({ route, navigation }: Props) {
} catch (err) { } catch (err) {
console.warn("❌ 写入失败", err); console.warn("❌ 写入失败", err);
Alert.alert(t('info.writeFailed')); Alert.alert(t('info.writeFailed'));
} finally {
setPowerTrimLoading(false);
} }
}; };
@ -584,12 +780,22 @@ export default function InfoScreen({ route, navigation }: Props) {
}; };
useEffect(() => { useEffect(() => {
mountedRef.current = true;
return () => { return () => {
mountedRef.current = false;
deviceReadyRef.current = false;
initialInfoLoadedRef.current = false;
powerDataSubscribedRef.current = false;
readyResolverRef.current = null;
// 清理校准超时定时器 // 清理校准超时定时器
if (calibrationTimeoutRef.current) { if (calibrationTimeoutRef.current) {
clearTimeout(calibrationTimeoutRef.current); clearTimeout(calibrationTimeoutRef.current);
calibrationTimeoutRef.current = null; calibrationTimeoutRef.current = null;
} }
if (readSuccessTimeoutRef.current) {
clearTimeout(readSuccessTimeoutRef.current);
readSuccessTimeoutRef.current = null;
}
}; };
}, []); }, []);
@ -715,8 +921,10 @@ export default function InfoScreen({ route, navigation }: Props) {
// 蓝牙已连接,正常跳转 DFU // 蓝牙已连接,正常跳转 DFU
navigation.navigate("Dfu", { navigation.navigate("Dfu", {
deviceId: deviceKey, deviceId: deviceKey,
name: peripheral.name, systemId: peripheral.systemId,
firmware, address: peripheral.address,
name: peripheral.name ?? "",
firmware: firmware ?? "",
}); });
}} }}
style={styles.pressable} style={styles.pressable}

1227
src/InfoScreen2.tsx Normal file

File diff suppressed because it is too large Load Diff

2049
src/InfoScreen3.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
// src/ScanScreen.tsx // src/ScanScreen.tsx此页面为功率计搜索页面
import React, { useEffect, useState, useCallback, useRef } from "react"; import React, { useEffect, useState, useCallback, useRef } from "react";
import { import {
View, View,
@ -76,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
// 更新或添加设备,并记录最后扫描时间 // 更新或添加设备,并记录最后扫描时间
const updatePeripherals = useCallback((p: ScannedPeripheral) => { const updatePeripherals = useCallback((p: ScannedPeripheral) => {
if (!p?.name || !p.name.startsWith("POWERFUN")) return; if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号 if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
setDevices((prev) => { setDevices((prev) => {

238
src/ScanScreen2.tsx Normal file
View File

@ -0,0 +1,238 @@
// src/ScanScreen.tsx此页面为桨频器搜索页面
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"; // 信号图标集
type Props = NativeStackScreenProps<RootStackParamList>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen2({ 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);
const { t } = useTranslation();
// 将 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) => {
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-STK"))) return;
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;
console.log("🔥 scanned raw peripheral =", JSON.stringify(p, null, 2));
console.log("🔥 scanned raw peripheral keys =", p ? Object.keys(p) : []);
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}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('paddleScan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList
data={devices}
keyExtractor={(item) => item.systemId}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => {
console.log("🔥 navigate item =", JSON.stringify(item, null, 2));
console.log("🔥 navigate item keys =", Object.keys(item));
navigation.navigate("Info2", { peripheral: item });
}}
style={styles.deviceRow}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
<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 ? (
<>
<Text style={styles.emptyText}>{t("paddleScan.scanning")}</Text>
<Text style={styles.tips}>{t("paddleScan.tipScanning")}</Text>
</>
) : (
<>
<Text style={styles.emptyText}>{t("paddleScan.noDevice")}</Text>
<Text style={styles.tips}>{t("paddleScan.tipBluetooth")}</Text>
</>
)}
</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" },
});

231
src/ScanScreen3.tsx Normal file
View File

@ -0,0 +1,231 @@
// src/ScanScreen3.tsx此页面为T5骑行台搜索页面
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"; // 信号图标集
type Props = NativeStackScreenProps<RootStackParamList>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen3({ 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);
const { t } = useTranslation();
// 将 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) => {
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-T5"))) return;
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}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('t5Scan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList
data={devices}
keyExtractor={(item) => item.systemId}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => navigation.navigate("Info3", { peripheral: item })}
style={styles.deviceRow}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
<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 ? (
<>
<Text style={styles.emptyText}>{t("t5Scan.scanning")}</Text>
<Text style={styles.tips}>{t("t5Scan.tipScanning")}</Text>
</>
) : (
<>
<Text style={styles.emptyText}>{t("t5Scan.noDevice")}</Text>
<Text style={styles.tips}>{t("t5Scan.tipBluetooth")}</Text>
</>
)}
</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" },
});

796
src/SpindownScreen.tsx Normal file
View File

@ -0,0 +1,796 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
Text as RNText,
} from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
Central,
ConnectionStatus,
ScannedPeripheral,
} from "@systemic-games/react-native-bluetooth-le";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { RootStackParamList } from "../App";
import MyHeader from "./component/MyHeader";
import MyStatusbar from "./component/MyStatusbar";
import { useTranslation } from "react-i18next";
import { observeFtmsIndoorBikeData } from "./helper/ftmsIndoorBikeDataBus";
type Props = NativeStackScreenProps<RootStackParamList, "Spindown">;
type Phase = "reach36" | "wait18" | "calibrating" | "completed" | "error";
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) => {
return (
<RNText style={[{ color: "black", fontSize: 16 }, style]} {...props}>
{children}
</RNText>
);
};
export default function SpindownScreen({ route, navigation }: Props) {
const { peripheral } = route.params;
const { t } = useTranslation();
const deviceKey = useMemo(
() => peripheral.address || peripheral.systemId,
[peripheral.address, peripheral.systemId]
);
const [speedKph, setSpeedKph] = useState<number>(0);
const [isConnected, setIsConnected] = useState(false);
const [isFff3Ready, setIsFff3Ready] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [phase, setPhase] = useState<Phase>("reach36");
const [step1Done, setStep1Done] = useState(false);
const [step2Done, setStep2Done] = useState(false);
const [statusText, setStatusText] = useState(
t("spindown.statusReach36", { speed: 36 })
);
const [spindownSeconds, setSpindownSeconds] = useState<number | null>(null);
const notifySubscribedRef = useRef(false);
const spindownStartedRef = useRef(false);
const spindownModeEnabledRef = useRef(false);
const resolverRef = useRef<((value: Uint8Array) => void) | null>(null);
const rejecterRef = useRef<((reason?: any) => void) | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const hasConnectedOnceRef = useRef(false);
const hasNavigatedBackRef = useRef(false);
const skipDisconnectOnLeaveRef = useRef(false);
const isConnectedRef = useRef(false);
const isFff3ReadyRef = useRef(false);
const spindownResultPromiseRef = useRef<Promise<number> | null>(null);
const DEBUG = false;
const phaseRef = useRef<Phase>("reach36");
const lastUiUpdateRef = useRef(0);
useEffect(() => {
phaseRef.current = phase;
}, [phase]);
useEffect(() => {
isConnectedRef.current = isConnected;
}, [isConnected]);
useEffect(() => {
isFff3ReadyRef.current = isFff3Ready;
}, [isFff3Ready]);
// 新增:缓存最近收到的 0x09 消旋结果避免结果包先到、waiter 后挂导致丢包
const lastSpindownPacketRef = useRef<Uint8Array | null>(null);
const bytesToHex = (bytes: Uint8Array) =>
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("");
const clearWaiter = () => {
resolverRef.current = null;
rejecterRef.current = null;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const formatSpindownSeconds = (seconds: number) => {
return seconds.toFixed(2).replace(/\.?0+$/, "");
};
const subscribeFff3IfNeeded = async () => {
if (notifySubscribedRef.current) return;
try {
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid)
).catch(() => {});
await Central.subscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid),
(notifyEv) => {
try {
const raw = notifyEv.value;
if (!raw) return;
const bytes = new Uint8Array(raw);
if (DEBUG) {
console.log("[BLE] FFF3 =", bytes.length);
}
if (bytes.length >= 3 && bytes[0] === 0x09) {
lastSpindownPacketRef.current = bytes;
}
resolverRef.current?.(bytes);
} catch (err) {
rejecterRef.current?.(err);
clearWaiter();
}
}
);
notifySubscribedRef.current = true;
isFff3ReadyRef.current = true;
setIsFff3Ready(true);
} catch {
isFff3ReadyRef.current = false;
setIsFff3Ready(false);
}
};
const waitForSpindownTime = (timeout = 5000) => {
return new Promise<number>((resolve, reject) => {
clearWaiter();
// 先吃缓存,防止 0x09 结果包在 runSpindown 前就已经到了
const cached = lastSpindownPacketRef.current;
if (cached && cached.length >= 3 && cached[0] === 0x09) {
console.log("[BLE] use cached spindown packet =", bytesToHex(cached));
const rawSeconds = cached[1] | (cached[2] << 8);
lastSpindownPacketRef.current = null;
if (rawSeconds === 0xffff) {
reject(new Error(t("spindown.failedRetry")));
return;
}
resolve(rawSeconds / 100);
return;
}
resolverRef.current = (value: Uint8Array) => {
if (value.length < 3 || value[0] !== 0x09) {
console.log("[BLE] ignore unrelated FFF3(spindown):", bytesToHex(value));
return;
}
lastSpindownPacketRef.current = null;
const rawSeconds = value[1] | (value[2] << 8);
if (rawSeconds === 0xffff) {
clearWaiter();
reject(new Error(t("spindown.failedRetry")));
return;
}
const displaySeconds = rawSeconds / 100;
clearWaiter();
resolve(displaySeconds);
};
rejecterRef.current = reject;
timeoutRef.current = setTimeout(() => {
clearWaiter();
reject(new Error(t("spindown.timeout")));
}, timeout);
});
};
const prepareSpindownResultWait = (timeout = 45000) => {
if (spindownResultPromiseRef.current) {
return spindownResultPromiseRef.current;
}
const promise = waitForSpindownTime(timeout);
spindownResultPromiseRef.current = promise;
void promise.catch(() => {});
return promise;
};
const writeFff2 = async (bytes: number[]) => {
await Central.writeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerWriteUuid),
new Uint8Array(bytes).buffer,
{ withoutResponse: false }
);
};
const enableSpindownMode = async () => {
if (spindownModeEnabledRef.current) return;
if (!isConnectedRef.current || !isFff3ReadyRef.current) return;
// 开始一轮新的消旋前清掉旧缓存,避免上一次结果污染
lastSpindownPacketRef.current = null;
console.log("[BLE] write FFF2 1001");
await writeFff2([0x10, 0x01]);
spindownModeEnabledRef.current = true;
};
const disableSpindownMode = async () => {
if (!spindownModeEnabledRef.current) return;
try {
console.log("[BLE] write FFF2 1000");
await writeFff2([0x10, 0x00]);
} finally {
spindownModeEnabledRef.current = false;
}
};
const runSpindown = async () => {
if (spindownStartedRef.current) return;
spindownStartedRef.current = true;
try {
if (!isConnectedRef.current || !isFff3ReadyRef.current) {
throw new Error(t("spindown.deviceNotReady"));
}
setStatusText(t("spindown.statusCalibrating"));
const seconds = await prepareSpindownResultWait(5000);
setSpindownSeconds(seconds);
setPhase("completed");
phaseRef.current = "completed";
setStatusText(t("spindown.statusCompleted"));
} catch (err: any) {
console.warn("消旋失败:", err);
setPhase("error");
phaseRef.current = "error";
setStatusText("消旋失败,请重新消旋");
Alert.alert(
"消旋失败",
err?.message || "本次消旋未完成,请检查速度是否符合要求后重新尝试。",
[{ text: "确定" }]
);
} finally {
spindownResultPromiseRef.current = null;
}
};
useEffect(() => {
mountedRef.current = true;
const connectionHandler = async (ev: {
peripheral: ScannedPeripheral;
connectionStatus: ConnectionStatus;
}) => {
const addr = ev.peripheral.address || ev.peripheral.systemId;
if (addr !== deviceKey) return;
const connected =
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
isConnectedRef.current = connected;
setIsConnected(connected);
if (connected) {
hasConnectedOnceRef.current = true;
}
if (ev.connectionStatus !== "ready") {
isFff3ReadyRef.current = false;
setIsFff3Ready(false);
notifySubscribedRef.current = false;
}
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
await subscribeFff3IfNeeded();
}
if (
!connected &&
hasConnectedOnceRef.current &&
mountedRef.current &&
!hasNavigatedBackRef.current
) {
hasNavigatedBackRef.current = true;
skipDisconnectOnLeaveRef.current = true;
Alert.alert(t("common.notice"), t("info2.disconnectedNeedReconnect"), [
{
text: t("info.confirm"),
onPress: () => {
navigation.reset({
index: 0,
routes: [{ name: "ScanScreen3" }],
});
},
},
]);
}
};
Central.addListener("peripheralConnectionStatus", connectionHandler);
return () => {
mountedRef.current = false;
Central.removeListener("peripheralConnectionStatus", connectionHandler);
notifySubscribedRef.current = false;
setIsFff3Ready(false);
clearWaiter();
};
}, [deviceKey, navigation, peripheral, t]);
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
// 进入页面时清一次缓存,避免旧结果残留
lastSpindownPacketRef.current = null;
spindownResultPromiseRef.current = null;
spindownStartedRef.current = false;
phaseRef.current = "reach36";
// Reuse existing connection from Info3 instead of reconnecting here.
isConnectedRef.current = true;
setIsConnected(true);
await subscribeFff3IfNeeded();
} catch (err) {
console.warn("连接设备失败:", err);
isConnectedRef.current = false;
setIsConnected(false);
if (!cancelled) {
Alert.alert(t("common.notice"), t("spindown.connectFailed"));
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
if (skipDisconnectOnLeaveRef.current) return;
disableSpindownMode().catch((err) => {
console.warn("关闭消旋模式失败:", err);
});
};
}, [peripheral, t]);
useEffect(() => {
if (!isConnected || !isFff3Ready) return;
enableSpindownMode().catch((err) => {
console.warn("启用消旋模式失败:", err);
spindownModeEnabledRef.current = false;
});
}, [isConnected, isFff3Ready]);
useEffect(() => {
if (!peripheral) return;
let cancelled = false;
let cleanup: (() => void) | null = null;
observeFtmsIndoorBikeData(peripheral, (data) => {
if (cancelled) return;
const speed = data.speedKph;
const now = Date.now();
// ✅ UI 限速 10Hz
if (now - lastUiUpdateRef.current > 100) {
lastUiUpdateRef.current = now;
setSpeedKph(speed);
}
// ===== ⭐ 实时状态机(不再依赖 React state =====
const currentPhase = phaseRef.current;
// step1到 36
if (currentPhase === "reach36" && speed >= 36) {
phaseRef.current = "wait18";
setPhase("wait18");
setStep1Done(true);
setStatusText(t("spindown.statusReached36", { high: 36, low: 18 }));
prepareSpindownResultWait();
return;
}
// step2降到 18
if (currentPhase === "wait18" && speed <= 18) {
phaseRef.current = "calibrating";
setPhase("calibrating");
setStep2Done(true);
setStatusText(t("spindown.statusCalibrating"));
runSpindown();
return;
}
})
.then((unsubscribe) => {
if (cancelled) {
unsubscribe();
return;
}
cleanup = unsubscribe;
})
.catch(() => {});
return () => {
cancelled = true;
cleanup?.();
};
}, [peripheral, t]);
const renderStepState = (done: boolean, active: boolean) => {
if (done) {
return <Icon name="check-circle" size={28} color="#19a15f" />;
}
if (active) {
return <ActivityIndicator size="small" color="#eb3b3b" />;
}
return <Icon name="circle-outline" size={26} color="#c6c6c6" />;
};
const isDeviceReady = isConnected || isFff3Ready;
const connectionLabel = isDeviceReady
? t("spindown.connected")
: t("spindown.connecting");
const connectionColor = isDeviceReady ? "#19a15f" : "#eb3b3b";
return (
<View style={styles.screen}>
<MyStatusbar backgroundColor="#ffffff" dark={true} />
<MyHeader
backgroundColor="#ffffff"
title={t("spindown.title")}
textColor="#eb3b3b"
navigation={navigation}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.heroCard}>
<View style={styles.connectionRow}>
<View
style={[
styles.connectionBadge,
{ backgroundColor: `${connectionColor}16` },
]}
>
<View
style={[styles.connectionDot, { backgroundColor: connectionColor }]}
/>
<Text style={[styles.connectionText, { color: connectionColor }]}>
{connectionLabel}
</Text>
</View>
</View>
<Text style={styles.heroTitle}>{t("spindown.headerTitle")}</Text>
<Text style={styles.heroHint}>{statusText}</Text>
<View style={styles.targetCard}>
<Text style={styles.targetLabel}>{t("spindown.targetLabel")}</Text>
<Text style={styles.targetValue}>36 km/h</Text>
<Text style={styles.targetHint}>{t("spindown.targetHint")}</Text>
</View>
<View style={styles.speedCard}>
<Text style={styles.speedLabel}>{t("spindown.currentSpeed")}</Text>
<Text style={styles.speedValue}>{speedKph.toFixed(1)} km/h</Text>
</View>
</View>
<View style={styles.stepCard}>
<View style={styles.stepRow}>
<View style={styles.stepIndexWrap}>
<Text style={styles.stepIndex}>1</Text>
</View>
<View style={styles.stepTextWrap}>
<Text style={styles.stepTitle}>
{t("spindown.step1Title", { speed: 36 })}
</Text>
<Text style={styles.stepDesc}>{t("spindown.step1Desc")}</Text>
</View>
{renderStepState(step1Done, phase === "reach36")}
</View>
<View style={styles.divider} />
<View style={styles.stepRow}>
<View style={styles.stepIndexWrap}>
<Text style={styles.stepIndex}>2</Text>
</View>
<View style={styles.stepTextWrap}>
<Text style={styles.stepTitle}>{t("spindown.step2Title")}</Text>
<Text style={styles.stepDesc}>
{t("spindown.step2Desc", { speed: 18 })}
</Text>
</View>
{renderStepState(step2Done, phase === "wait18" || phase === "calibrating")}
</View>
<View style={styles.divider} />
<View style={styles.stepRow}>
<View style={styles.stepIndexWrap}>
<Text style={styles.stepIndex}>3</Text>
</View>
<View style={styles.stepTextWrap}>
<Text style={styles.stepTitle}>{t("spindown.step3Title")}</Text>
<Text style={styles.stepDesc}>
{phase === "error"
? "消旋失败,请重新消旋"
: spindownSeconds === null
? phase === "calibrating"
? t("spindown.step3Loading")
: t("spindown.step3Pending")
: `${formatSpindownSeconds(spindownSeconds)}s`}
</Text>
</View>
{phase === "error" ? (
<Icon name="close-circle" size={28} color="#eb3b3b" />
) : (
renderStepState(phase === "completed", phase === "calibrating")
)}
</View>
</View>
{isLoading && (
<View style={styles.loadingWrap}>
<ActivityIndicator size={28} color="#eb3b3b" />
<Text style={styles.loadingText}>{t("spindown.loading")}</Text>
</View>
)}
{phase === "completed" && (
<View style={styles.resultCard}>
<Icon name="check-decagram" size={24} color="#19a15f" />
<Text style={styles.resultText}>
{t("spindown.result", {
seconds: formatSpindownSeconds(spindownSeconds ?? 0),
})}
</Text>
</View>
)}
{phase === "error" && (
<TouchableOpacity
activeOpacity={0.85}
style={styles.retryButton}
onPress={() => navigation.replace("Spindown", { peripheral })}
>
<Text style={styles.retryButtonText}>{t("spindown.retry")}</Text>
</TouchableOpacity>
)}
</ScrollView>
</View>
);
}
const RED = "#eb3b3b";
const BG = "#f6f7fb";
const DARK = "#242424";
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: BG,
},
scrollContent: {
paddingHorizontal: 18,
paddingTop: 10,
paddingBottom: 28,
},
heroCard: {
backgroundColor: "#ffffff",
borderRadius: 24,
paddingHorizontal: 18,
paddingVertical: 18,
shadowColor: "#000",
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
connectionRow: {
flexDirection: "row",
justifyContent: "flex-end",
},
connectionBadge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
connectionDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
connectionText: {
fontSize: 13,
fontWeight: "600",
},
heroTitle: {
fontSize: 18,
fontWeight: "800",
color: DARK,
marginTop: 10,
},
heroHint: {
fontSize: 15,
color: "#666666",
lineHeight: 22,
marginTop: 8,
minHeight: 26,
},
targetCard: {
marginTop: 12,
borderRadius: 18,
backgroundColor: "#fff0f0",
borderWidth: 1,
borderColor: "#ffd4d4",
paddingVertical: 14,
paddingHorizontal: 14,
alignItems: "center",
},
targetLabel: {
fontSize: 14,
color: "#8a5555",
fontWeight: "600",
},
targetValue: {
marginTop: 4,
fontSize: 36,
color: RED,
fontWeight: "900",
lineHeight: 40,
},
targetHint: {
marginTop: 4,
fontSize: 13,
color: "#9b6c6c",
},
speedCard: {
marginTop: 18,
borderRadius: 22,
backgroundColor: "#fff5f5",
borderWidth: 1,
borderColor: "#ffdede",
paddingVertical: 20,
alignItems: "center",
},
speedLabel: {
fontSize: 15,
color: "#666666",
},
speedValue: {
fontSize: 34,
fontWeight: "800",
color: RED,
marginTop: 8,
},
stepCard: {
marginTop: 16,
backgroundColor: "#ffffff",
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
stepRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 18,
},
stepIndexWrap: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: "#fff1f1",
alignItems: "center",
justifyContent: "center",
marginRight: 12,
},
stepIndex: {
fontSize: 17,
color: RED,
fontWeight: "800",
},
stepTextWrap: {
flex: 1,
paddingRight: 12,
},
stepTitle: {
fontSize: 17,
color: DARK,
fontWeight: "700",
lineHeight: 24,
},
stepDesc: {
fontSize: 14,
color: "#777777",
lineHeight: 20,
marginTop: 4,
},
divider: {
height: 1,
backgroundColor: "#f0f0f0",
},
loadingWrap: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: 18,
},
loadingText: {
marginLeft: 10,
fontSize: 14,
color: "#666666",
},
resultCard: {
marginTop: 16,
backgroundColor: "#edf9f2",
borderRadius: 18,
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
},
resultText: {
marginLeft: 10,
color: "#156d45",
fontSize: 15,
fontWeight: "700",
},
retryButton: {
marginTop: 18,
height: 46,
borderRadius: 14,
backgroundColor: RED,
alignItems: "center",
justifyContent: "center",
},
retryButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "700",
},
});

View File

@ -0,0 +1,315 @@
import {
Central,
ScannedPeripheral,
ConnectionStatus,
} from "@systemic-games/react-native-bluetooth-le";
const ftmsServiceUuid = "1826";
const ftmsIndoorBikeDataUuid = "2ad2";
const fullUUID = (uuid: string) =>
uuid.length === 4
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
: uuid;
export type FtmsIndoorBikeData = {
speedKph: number;
cadence: number;
power: number;
raw: Uint8Array;
};
type Listener = (data: FtmsIndoorBikeData) => void;
type SubscriptionEntry = {
listeners: Set<Listener>;
lastData: FtmsIndoorBikeData | null;
subscribePromise: Promise<void> | null;
subscribed: boolean;
cadenceState: {
lastCadenceValue: number;
lastCadenceChangedTime: number;
};
lastEmitTime: number; // ✅ throttle 用
};
const entries = new Map<string, SubscriptionEntry>();
let connectionListenerInstalled = false;
const getDeviceKey = (peripheral: ScannedPeripheral) =>
String(peripheral.address || peripheral.systemId || peripheral.name || "unknown");
const resetEntrySubscriptionState = (
key: string,
connectionStatus?: ConnectionStatus
) => {
const entry = entries.get(key);
if (!entry) return;
entry.subscribed = false;
entry.subscribePromise = null;
entry.lastEmitTime = 0;
entry.cadenceState.lastCadenceValue = 0;
entry.cadenceState.lastCadenceChangedTime = 0;
if (connectionStatus !== "connected" && connectionStatus !== "ready") {
entry.lastData = null;
}
};
const ensureConnectionListenerInstalled = () => {
if (connectionListenerInstalled) return;
Central.addListener("peripheralConnectionStatus", (ev) => {
const key = getDeviceKey(ev.peripheral);
if (
ev.connectionStatus === "disconnected" ||
ev.connectionStatus === "disconnecting" ||
ev.connectionStatus === "connecting"
) {
resetEntrySubscriptionState(key, ev.connectionStatus);
}
});
connectionListenerInstalled = true;
};
// ✅ 默认关闭调试(避免性能问题)
const DEBUG = false;
const bytesToHex = (bytes: Uint8Array) =>
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("");
const decodeBase64 = (value: string): Uint8Array | null => {
try {
const atobFn = (globalThis as { atob?: (data: string) => string }).atob;
if (typeof atobFn === "function") {
const binary = atobFn(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let buffer = 0;
let bits = 0;
const output: number[] = [];
for (const char of value.replace(/=+$/, "")) {
const index = chars.indexOf(char);
if (index < 0) continue;
buffer = (buffer << 6) | index;
bits += 6;
if (bits >= 8) {
bits -= 8;
output.push((buffer >> bits) & 0xff);
}
}
return new Uint8Array(output);
} catch (err) {
if (DEBUG) console.warn("[FTMS] decodeBase64 failed", err);
return null;
}
};
const toUint8Array = (raw: any): Uint8Array | null => {
try {
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
if (typeof raw === "string") return decodeBase64(raw);
if (Array.isArray(raw)) return new Uint8Array(raw);
if (raw?.buffer instanceof ArrayBuffer) {
return new Uint8Array(raw.buffer, raw.byteOffset || 0, raw.byteLength);
}
return null;
} catch (err) {
if (DEBUG) console.warn("[FTMS] toUint8Array failed", err);
return null;
}
};
const parseIndoorBikeData = (
byteArray: Uint8Array,
cadenceState: SubscriptionEntry["cadenceState"]
): FtmsIndoorBikeData | null => {
if (byteArray.length < 8) return null;
const currentTime = Date.now();
const rawSpeed = byteArray[2] | (byteArray[3] << 8);
const rawCadence = byteArray[4] | (byteArray[5] << 8);
let rawPower = byteArray[6] | (byteArray[7] << 8);
const speedKph = Number((rawSpeed / 100.0).toFixed(1));
const cadenceValue = rawCadence / 2.0;
if (cadenceValue !== cadenceState.lastCadenceValue) {
cadenceState.lastCadenceChangedTime = currentTime;
cadenceState.lastCadenceValue = cadenceValue;
}
const cadence =
cadenceState.lastCadenceChangedTime > 0 &&
currentTime - cadenceState.lastCadenceChangedTime > 3000
? 0
: Math.round(cadenceValue);
if (rawPower & 0x8000) {
rawPower -= 0x10000;
}
const power = rawPower;
return {
speedKph,
cadence,
power,
raw: byteArray,
};
};
const ensureSubscribed = async (
peripheral: ScannedPeripheral,
entry: SubscriptionEntry
) => {
if (entry.subscribed) return;
if (entry.subscribePromise) {
await entry.subscribePromise;
return;
}
entry.subscribePromise = Central.subscribeCharacteristic(
peripheral,
fullUUID(ftmsServiceUuid),
fullUUID(ftmsIndoorBikeDataUuid),
(notifyEv) => {
try {
const byteArray = toUint8Array(notifyEv.value);
if (!byteArray || byteArray.length === 0) return;
if (DEBUG) {
const flags =
byteArray.length >= 2
? byteArray[0] | (byteArray[1] << 8)
: 0;
console.log("[FTMS]", bytesToHex(byteArray));
console.log(
"[FTMS] flags =",
"0x" + flags.toString(16).padStart(4, "0")
);
}
const parsed = parseIndoorBikeData(
byteArray,
entry.cadenceState
);
if (!parsed) return;
const now = Date.now();
// ✅ throttle: 限制为 10Hz100ms 一次)
if (now - entry.lastEmitTime < 100) {
return;
}
entry.lastEmitTime = now;
if (DEBUG) {
console.log(
"[FTMS] parsed =>",
parsed.speedKph,
parsed.cadence,
parsed.power
);
}
entry.lastData = parsed;
entry.listeners.forEach((listener) => listener(parsed));
} catch (err) {
if (DEBUG) console.warn("处理 2AD2 通知失败", err);
}
}
)
.then(() => {
entry.subscribed = true;
if (DEBUG) console.log("[FTMS] subscribed");
})
.catch((err) => {
if (DEBUG) console.warn("[FTMS] subscribe failed", err);
throw err;
})
.finally(() => {
entry.subscribePromise = null;
});
await entry.subscribePromise;
};
export const observeFtmsIndoorBikeData = async (
peripheral: ScannedPeripheral,
listener: Listener
) => {
ensureConnectionListenerInstalled();
const key = getDeviceKey(peripheral);
let entry = entries.get(key);
if (!entry) {
entry = {
listeners: new Set(),
lastData: null,
subscribePromise: null,
subscribed: false,
cadenceState: {
lastCadenceValue: 0,
lastCadenceChangedTime: 0,
},
lastEmitTime: 0, // ✅ 初始化
};
entries.set(key, entry);
}
if (entry.listeners.size === 0) {
resetEntrySubscriptionState(key, "disconnected");
}
entry.listeners.add(listener);
await ensureSubscribed(peripheral, entry);
if (entry.lastData) {
listener(entry.lastData);
}
return () => {
const currentEntry = entries.get(key);
if (!currentEntry) return;
currentEntry.listeners.delete(listener);
};
};
export const debugFtmsIndoorBikeDataEntries = () => {
return Array.from(entries.entries()).map(([key, entry]) => ({
key,
listeners: entry.listeners.size,
subscribed: entry.subscribed,
lastDataHex: entry.lastData ? bytesToHex(entry.lastData.raw) : null,
}));
};

View File

@ -51,7 +51,7 @@ export const saveLanguage = async (language: string): Promise<void> => {
i18n i18n
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
compatibilityJSON: 'v3', compatibilityJSON: 'v4',
resources: { resources: {
zh: { translation: zh }, zh: { translation: zh },
en: { translation: en }, en: { translation: en },

View File

@ -9,7 +9,10 @@
"title": "POWERFUN Settings", "title": "POWERFUN Settings",
"scan": "Scan Devices", "scan": "Scan Devices",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"version": "Version v0.0.1" "version": "Version v0.0.1",
"powerMeter": "Power Meter",
"paddle": "Paddle",
"T5trainer": "T5 Trainer"
}, },
"scan": { "scan": {
"title": "Scan Devices", "title": "Scan Devices",
@ -19,6 +22,141 @@
"tipBluetooth": "(Please enable Bluetooth in settings)", "tipBluetooth": "(Please enable Bluetooth in settings)",
"noName": "[No Name]", "noName": "[No Name]",
"rssiUnit": "dBm" "rssiUnit": "dBm"
},
"t5Scan": {
"title": "Scan T5 Trainer",
"scanning": "Scanning...",
"noDevice": "No T5 trainers found",
"tipScanning": "(Make sure the trainer is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)"
},
"paddleScan": {
"title": "Scan Paddle",
"scanning": "Scanning...",
"noDevice": "No paddle devices found",
"tipScanning": "(Make sure the paddle device is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)",
"noName": "[No Name]",
"rssiUnit": "dBm"
},
"common": {
"notice": "Notice",
"unknown": "Unknown",
"unknownDevice": "Unknown Device",
"yes": "Yes",
"no": "No"
},
"info2": {
"waitingAction": "Waiting",
"notMatched": "Not Matched",
"checkFailed": "Check Failed",
"unknownBoatType": "Unknown boat type",
"readAbnormal": "Unexpected response: {{hex}}",
"writingBoat": "Writing {{boatName}}...",
"writtenWaitRead": "Written {{boatName}}, reading back in 200ms...",
"requestingBoatRead": "Requesting boat type...",
"setSuccess": "Set success: {{boatName}}",
"notMatch": "Mismatch: expected {{expected}}, actual {{actual}}",
"readResult": "Read result: {{hex}}",
"actionFailed": "Action failed: {{message}}",
"failed": "Failed",
"waitFff3Timeout": "Timeout waiting for FFF3 response",
"currentBoat": "✅ Current boat type: {{boatName}}",
"readCurrentBoat": "Current boat type read: {{boatName}}",
"disconnectedNeedReconnect": "Device disconnected. Reconnect before upgrading.",
"defaultReadFailed": "Connected, but initial boat type read failed",
"connectOrReadFailed": "Connection or read failed",
"connectOrReadFailedAlert": "Bluetooth connection failed or device info read failed",
"cadence": "Cadence",
"cadenceUnit": "RPM (strokes/min)",
"boatSelect": "Boat Type",
"boatSwitching": "Switching boat type...",
"retry": "Please retry",
"boatKayak": "Kayak",
"boatRowing": "Rowing",
"boatRacing": "Racing",
"latestFirmware": "Latest Firmware",
"checking": "Checking..."
},
"info3": {
"bikeTypeFollow": "Follow Software",
"bikeTypeRoad": "Road Bike",
"bikeTypeMtb26": "Mountain Bike 26\"",
"bikeTypeMtb275": "Mountain Bike 27.5\"",
"bikeTypeMtb29": "Mountain Bike 29\"",
"bikeTypeSmallWheel": "Small Wheel Bike",
"ergOn": "On",
"ergOff": "Off",
"readUsedHoursTimeout": "Timeout reading usage hours",
"readUsedMileageTimeout": "Timeout reading usage mileage",
"powerTrimRangeError": "Please enter a value between 50.00 and 200.00 with up to 2 decimals",
"deviceNotReady": "Device is not ready yet. Please try again later",
"invalidWeight": "Please enter a valid weight",
"waitFff3Timeout": "Timeout waiting for FFF3 response",
"readPowerTrimTimeout": "Timeout reading current power trim",
"waitWeightAckTimeout": "Timeout waiting for weight setting confirmation",
"readWeightTimeout": "Timeout reading current weight",
"readBikeTypeTimeout": "Timeout reading bike type",
"waitBikeTypeAckTimeout": "Timeout waiting for bike type confirmation",
"readErgTimeout": "Timeout reading ERG smoothing",
"waitErgAckTimeout": "Timeout waiting for ERG smoothing confirmation",
"connectReadFailed": "Device connection or read failed",
"readFailed": "Read failed",
"connecting": "Connecting",
"pendingTag": "Pending",
"settingTag": "Applying",
"currentWeightValue": "Current: {{value}} kg",
"currentPowerTrimValue": "Current: {{value}} %",
"currentTextValue": "Current: {{value}}",
"usedMileage": "Usage Mileage",
"speedKph": "Speed/km/h",
"weightSetting": "Weight Setting",
"powerTrim": "Power Trim",
"bikeType": "Bike Type",
"ergSmooth": "ERG Smoothing",
"settingWeight": "Setting weight...",
"weightSetDone": "Weight set successfully",
"weightSetFailed": "Weight setting failed",
"settingPowerTrim": "Setting power trim...",
"powerTrimSetDone": "Power trim set successfully",
"powerTrimSetFailed": "Power trim setting failed",
"settingBikeType": "Setting bike type...",
"bikeTypeSetDone": "Bike type set successfully",
"bikeTypeSetFailed": "Bike type setting failed",
"settingErgSmooth": "Setting ERG smoothing...",
"ergSmoothSetDone": "ERG smoothing set successfully",
"ergSmoothSetFailed": "ERG smoothing setting failed",
"confirmWeightChange": "Set weight to {{value}}kg?",
"confirmPowerTrimChange": "Set power trim to {{value}}%?",
"helpText": "Hold to view help"
},
"spindown": {
"title": "Spindown",
"headerTitle": "Trainer Spindown",
"connecting": "Connecting",
"connected": "Connected",
"targetLabel": "Target Speed",
"targetHint": "Ride to reach the target speed first",
"currentSpeed": "Current Speed",
"statusReach36": "Ride to reach {{speed}} km/h",
"statusReached36": "Reached {{high}} km/h. Stop pedaling and wait until speed drops to {{low}} km/h",
"statusCalibrating": "Spindown in progress, please wait",
"statusCompleted": "Spindown completed",
"deviceNotReady": "Device not ready, please try again",
"connectFailed": "Failed to connect. Please go back and try again",
"timeout": "Timed out waiting for spindown time",
"failedRetry": "Spindown failed, please try again",
"step1Title": "Ride to {{speed}} km/h",
"step1Desc": "Automatically proceeds after reaching the target speed",
"step2Title": "Stop pedaling and coast",
"step2Desc": "Starts spindown automatically when speed drops to {{speed}} km/h",
"step3Title": "Spindown Completed",
"step3Loading": "Reading spindown time...",
"step3Pending": "Will appear here after completion",
"loading": "Connecting and preparing spindown...",
"result": "Spindown completed, time {{seconds}}s",
"retry": "Restart Spindown"
}, },
"dfu": { "dfu": {
"title": "Firmware Update", "title": "Firmware Update",

View File

@ -9,7 +9,10 @@
"title": "POWERFUN设置", "title": "POWERFUN设置",
"scan": "搜索设备", "scan": "搜索设备",
"privacy": "隐私协议", "privacy": "隐私协议",
"version": "版本号 v0.0.1" "version": "版本号 v0.0.1",
"powerMeter": "功率计",
"paddle": "桨频器",
"T5trainer": "T5骑行台"
}, },
"scan": { "scan": {
"title": "搜索设备", "title": "搜索设备",
@ -20,6 +23,140 @@
"noName": "[无名称]", "noName": "[无名称]",
"rssiUnit": "dBm" "rssiUnit": "dBm"
}, },
"t5Scan": {
"title": "搜索T5骑行台",
"scanning": "搜索中...",
"noDevice": "暂无T5骑行台设备",
"tipScanning": "(请确保骑行台有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)"
},
"paddleScan": {
"title": "搜索桨频器",
"scanning": "搜索中...",
"noDevice": "暂无桨频器设备",
"tipScanning": "(请确保桨频器设备有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)",
"noName": "[无名称]",
"rssiUnit": "dBm"
},
"common": {
"notice": "提示",
"unknown": "未知",
"unknownDevice": "未知设备",
"yes": "是",
"no": "否"
},
"info2": {
"waitingAction": "等待操作",
"notMatched": "未匹配",
"checkFailed": "检查失败",
"unknownBoatType": "读取到未知船型",
"readAbnormal": "读取返回异常:{{hex}}",
"writingBoat": "正在写入{{boatName}}...",
"writtenWaitRead": "已写入{{boatName}}200ms后读取确认...",
"requestingBoatRead": "正在请求读取船型...",
"setSuccess": "设置成功:{{boatName}}",
"notMatch": "不一致:期望 {{expected}},实际 {{actual}}",
"readResult": "读取返回:{{hex}}",
"actionFailed": "操作失败:{{message}}",
"failed": "失败",
"waitFff3Timeout": "等待 FFF3 返回超时",
"currentBoat": "✅ 当前船型:{{boatName}}",
"readCurrentBoat": "已读取当前船型:{{boatName}}",
"disconnectedNeedReconnect": "设备已断开,请重新连接",
"defaultReadFailed": "已连接,但默认读取船型失败",
"connectOrReadFailed": "连接或读取失败",
"connectOrReadFailedAlert": "蓝牙连接失败或设备信息读取失败",
"cadence": "桨频",
"cadenceUnit": "RPM(次/分钟)",
"boatSelect": "船型选择",
"boatSwitching": "船型切换中...",
"retry": "请重试",
"boatKayak": "皮艇",
"boatRowing": "划艇",
"boatRacing": "赛艇",
"latestFirmware": "最新固件",
"checking": "检查中..."
},
"info3": {
"bikeTypeFollow": "跟随软件",
"bikeTypeRoad": "公路车",
"bikeTypeMtb26": "山地车26寸",
"bikeTypeMtb275": "山地车27.5寸",
"bikeTypeMtb29": "山地车29寸",
"bikeTypeSmallWheel": "小轮车",
"ergOn": "开启",
"ergOff": "关闭",
"readUsedMileageTimeout": "读取使用里程超时",
"powerTrimRangeError": "请输入50.00-200.00之间的数,最多保留两位小数",
"deviceNotReady": "设备尚未准备完成,请稍后再试",
"invalidWeight": "请输入正确的体重",
"waitFff3Timeout": "等待FFF3返回超时",
"readPowerTrimTimeout": "读取当前功率微调超时",
"waitWeightAckTimeout": "等待体重设定确认超时",
"readWeightTimeout": "读取当前体重超时",
"readBikeTypeTimeout": "读取当前车型超时",
"waitBikeTypeAckTimeout": "等待车型设定确认超时",
"readErgTimeout": "读取ERG功率平滑超时",
"waitErgAckTimeout": "等待ERG功率平滑确认超时",
"connectReadFailed": "设备连接或读取失败",
"readFailed": "读取失败",
"connecting": "连接中",
"pendingTag": "待保存",
"settingTag": "设置中",
"currentWeightValue": "当前:{{value}} kg",
"currentPowerTrimValue": "当前:{{value}} %",
"currentTextValue": "当前:{{value}}",
"usedMileage": "使用里程",
"speedKph": "速度/km/h",
"weightSetting": "体重设定",
"powerTrim": "功率微调",
"bikeType": "车型选择",
"ergSmooth": "ERG功率平滑",
"settingWeight": "正在设置体重…",
"weightSetDone": "体重设定完成",
"weightSetFailed": "体重设定失败",
"settingPowerTrim": "正在设置功率微调…",
"powerTrimSetDone": "功率微调设定完成",
"powerTrimSetFailed": "功率微调设定失败",
"settingBikeType": "车型设定中",
"bikeTypeSetDone": "车型设定完成",
"bikeTypeSetFailed": "车型设定失败",
"settingErgSmooth": "ERG功率平滑设定中",
"ergSmoothSetDone": "ERG功率平滑设定完成",
"ergSmoothSetFailed": "ERG功率平滑设定失败",
"confirmWeightChange": "是否将体重改为{{value}}kg",
"confirmPowerTrimChange": "是否将功率微调改为{{value}}%",
"helpText": "按住查看说明"
},
"spindown": {
"title": "消旋",
"headerTitle": "骑行台消旋",
"connecting": "设备连接中",
"connected": "设备已连接",
"targetLabel": "目标速度",
"targetHint": "请先骑行加速到目标速度",
"currentSpeed": "当前速度",
"statusReach36": "请骑行加速到 {{speed}} km/h",
"statusReached36": "已达到 {{high}} km/h请停止踩踏并等待速度下降到 {{low}} km/h",
"statusCalibrating": "正在消旋,请等待",
"statusCompleted": "消旋完成",
"deviceNotReady": "设备未准备好,请稍后重试",
"connectFailed": "设备连接失败,请返回重试",
"timeout": "等待消旋时间返回超时",
"failedRetry": "消旋失败,请重试",
"step1Title": "请骑行到 {{speed}} km/h",
"step1Desc": "达到目标速度后自动进入下一步",
"step2Title": "停止踩踏并等待减速",
"step2Desc": "速度下降到 {{speed}} km/h 后自动开始消旋",
"step3Title": "消旋完成",
"step3Loading": "正在读取消旋时间...",
"step3Pending": "完成前会显示在这里",
"loading": "正在连接设备并准备消旋...",
"result": "消旋完成,时间 {{seconds}}s",
"retry": "重新开始消旋"
},
"dfu": { "dfu": {
"title": "固件升级", "title": "固件升级",
"preparing": "准备中...", "preparing": "准备中...",

781
yarn.lock

File diff suppressed because it is too large Load Diff