20260603-① 适配桨频器,做了连接和设置页面,已经内测ok。
② 适配T5骑行台,做了相关页面。内测还不够充分,产品也不着急上市,所以此版更新中,应该需要隐藏掉。 ③ 修复了之前 盘爪设备信息 读取时,有概率出现乱码的情况。读取时做了排序和延迟,修复了此问题。 ④ 多语言我们这边做更新时,拉取的是仅有中英文的版本,当时你们那好像有更新更多语言?所以需要适配一下。 ⑤ 蓝牙名搜索时,适配最新的固件名称: 盘爪为:PF-PM5-前缀,桨频器为PF-STK-前缀,骑行台为PF-T5-前缀。同时允许前缀POWERFUN-也能显示并连接。
This commit is contained in:
parent
d3d6774448
commit
8dd0edd2ce
83
App.tsx
83
App.tsx
@ -1,23 +1,55 @@
|
||||
console.log("🔥 当前 App.tsx 已加载");
|
||||
import * as React from "react";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
|
||||
import HomeScreen from "./src/HomeScreen";
|
||||
import ScanScreen from "./src/ScanScreen";
|
||||
import ScanScreen2 from "./src/ScanScreen2";
|
||||
import ScanScreen3 from "./src/ScanScreen3";
|
||||
import InfoScreen from "./src/InfoScreen";
|
||||
import DfuScreen from "./src/DfuScreen";
|
||||
import PrivacyScreen from "./src/PrivacyScreen";
|
||||
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
|
||||
import pxToDp from "./src/helper/pxToDp";
|
||||
import SettingScreen from "./src/SettingScreen";
|
||||
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 = {
|
||||
Splash: undefined;
|
||||
Home: undefined;
|
||||
Scan: undefined;
|
||||
ScanScreen2: undefined;
|
||||
ScanScreen3: undefined;
|
||||
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;
|
||||
Setting: undefined;
|
||||
};
|
||||
@ -25,6 +57,23 @@ export type RootStackParamList = {
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
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 (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
@ -32,13 +81,18 @@ export default function App() {
|
||||
screenOptions={{
|
||||
animation : 'slide_from_right'
|
||||
}}>
|
||||
<Stack.Screen
|
||||
name="Info2"
|
||||
component={InfoScreen2}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
{/* 启动页(无标题) */}
|
||||
<Stack.Screen
|
||||
name="Splash"
|
||||
component={SplashScreen}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
|
||||
|
||||
{/* 首页(标题栏无文字) */}
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
@ -53,12 +107,35 @@ export default function App() {
|
||||
// options={{ title: "搜索设备" }}
|
||||
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
|
||||
name="Info"
|
||||
component={InfoScreen}
|
||||
// options={{ title: "设备详情" }}
|
||||
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
|
||||
name="Dfu"
|
||||
component={DfuScreen}
|
||||
|
||||
@ -2316,7 +2316,7 @@ PODS:
|
||||
- SocketRocket
|
||||
- RNCAsyncStorage (2.2.0):
|
||||
- React-Core
|
||||
- RNDeviceInfo (15.0.1):
|
||||
- RNDeviceInfo (15.0.2):
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
@ -2707,7 +2707,7 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
|
||||
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
|
||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
|
||||
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
|
||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
|
||||
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e
|
||||
|
||||
@ -207,10 +207,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
|
||||
@ -246,10 +250,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
|
||||
@ -288,7 +296,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B7ZA544T59;
|
||||
DEVELOPMENT_TEAM = PXHWD6972V;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = dfuapp/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
|
||||
@ -303,7 +311,7 @@
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
|
||||
PRODUCT_NAME = dfuapp;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -318,7 +326,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B7ZA544T59;
|
||||
DEVELOPMENT_TEAM = PXHWD6972V;
|
||||
INFOPLIST_FILE = dfuapp/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
@ -332,7 +340,7 @@
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
|
||||
PRODUCT_NAME = dfuapp;
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@ -41,6 +41,10 @@
|
||||
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<false/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@ -53,9 +57,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
741
package-lock.json
generated
741
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -37,9 +37,9 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "20.0.0",
|
||||
"@react-native-community/cli-platform-android": "20.0.0",
|
||||
"@react-native-community/cli-platform-ios": "20.0.0",
|
||||
"@react-native-community/cli": "15.0.0",
|
||||
"@react-native-community/cli-platform-android": "15.0.0",
|
||||
"@react-native-community/cli-platform-ios": "15.0.0",
|
||||
"@react-native/babel-preset": "0.81.4",
|
||||
"@react-native/eslint-config": "0.81.4",
|
||||
"@react-native/metro-config": "0.81.4",
|
||||
@ -53,7 +53,10 @@
|
||||
"prettier": "2.8.8",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
},
|
||||
|
||||
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
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 { RootStackParamList } from "../App";
|
||||
import RNFS from "react-native-fs";
|
||||
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
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 MyHeader from "./component/MyHeader";
|
||||
|
||||
@ -16,162 +22,337 @@ interface DeviceInfo {
|
||||
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) {
|
||||
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 [state, setState] = useState(t('dfu.preparing'));
|
||||
const [state, setState] = useState("准备中...");
|
||||
const [error, setError] = useState<string>();
|
||||
const [latestVersion, setLatestVersion] = useState<string>(t('dfu.reading'));
|
||||
const [latestVersion, setLatestVersion] = useState("读取中...");
|
||||
const [isDfuRunning, setIsDfuRunning] = useState(false);
|
||||
|
||||
const mapDfuStateToChinese = (state: string): string => {
|
||||
switch (state) {
|
||||
case "connecting": return t('dfu.stateConnecting');
|
||||
case "starting": return t('dfu.stateStarting');
|
||||
case "enablingDfuMode": return t('dfu.stateEnablingDfuMode');
|
||||
case "uploading": return t('dfu.stateUploading');
|
||||
case "validating": return t('dfu.stateValidating');
|
||||
case "disconnecting": return t('dfu.stateDisconnecting');
|
||||
case "completed": return t('dfu.stateCompleted');
|
||||
case "aborted": return t('dfu.stateAborted');
|
||||
const mapDfuStateToChinese = (s: string): string => {
|
||||
switch (s) {
|
||||
case "connecting":
|
||||
return "连接中…";
|
||||
case "starting":
|
||||
return "初始化中…";
|
||||
case "enablingDfuMode":
|
||||
return "启用 DFU 模式…";
|
||||
case "uploading":
|
||||
return "上传固件中…";
|
||||
case "validating":
|
||||
return "校验固件…";
|
||||
case "disconnecting":
|
||||
return "断开连接…";
|
||||
case "completed":
|
||||
return "升级完成";
|
||||
case "aborted":
|
||||
return "已取消";
|
||||
case "failed":
|
||||
case "dfu_failed": return t('dfu.stateFailed');
|
||||
case "initializing": return t('dfu.stateInitializing');
|
||||
case "errored": return t('dfu.stateErrored');
|
||||
default: return state;
|
||||
case "dfu_failed":
|
||||
return "升级失败";
|
||||
case "initializing":
|
||||
return "启动中…";
|
||||
case "errored":
|
||||
return "升级出错!";
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ✅ 拦截所有导航返回(iOS + Android)
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener("beforeRemove", (e) => {
|
||||
if (!isDfuRunning) return;
|
||||
|
||||
// 阻止返回
|
||||
e.preventDefault();
|
||||
Alert.alert(t('dfu.pleaseWait'), t('dfu.doNotReturn'));
|
||||
Alert.alert("请稍候", "正在升级,请勿返回或关闭应用!");
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, isDfuRunning]);
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const runDfu = async () => {
|
||||
try {
|
||||
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";
|
||||
|
||||
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise;
|
||||
const manifestContent = await RNFS.readFile(manifestPath);
|
||||
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] };
|
||||
console.log("🔥 before fetch manifest");
|
||||
const manifestResp = await fetch(manifestUrl);
|
||||
console.log("🔥 manifest status =", manifestResp.status);
|
||||
|
||||
const [deviceHWStr, deviceFWStr] = deviceFirmware.split(".");
|
||||
const deviceHW = parseInt(deviceHWStr);
|
||||
const deviceFW = parseInt(deviceFWStr);
|
||||
if (!manifestResp.ok) {
|
||||
throw new Error(`manifest 下载失败,HTTP ${manifestResp.status}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert(t('dfu.cannotUpgrade'), t('dfu.hardwareNotFound', { hardware: deviceHW }), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
||||
]);
|
||||
Alert.alert(
|
||||
"无法升级",
|
||||
`未找到 hardware=${currentFw.hardware} 的固件`,
|
||||
[{ text: "确认", onPress: () => navigation.goBack() }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLatestVersion(deviceInfo.latestFirmware);
|
||||
|
||||
const [, latestFWStr] = deviceInfo.latestFirmware.split(".");
|
||||
const latestFW = parseInt(latestFWStr);
|
||||
const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
|
||||
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);
|
||||
Alert.alert(t('dfu.noNeedUpgrade'), t('dfu.alreadyLatest'), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
||||
Alert.alert("无需升级", "已是最新固件,无需升级", [
|
||||
{ text: "确认", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
|
||||
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise;
|
||||
console.log("🔥 upgrade allowed", {
|
||||
currentIteration: currentFw.iteration,
|
||||
latestIteration: latestFw.iteration,
|
||||
});
|
||||
|
||||
await startDfu(deviceId, "file://" + localPath, {
|
||||
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state),
|
||||
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
|
||||
const zipResp = await fetch(deviceInfo.download);
|
||||
console.log("🔥 zip status =", zipResp.status);
|
||||
|
||||
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);
|
||||
Alert.alert(t('dfu.upgradeSuccess'), t('dfu.upgradeSuccessMessage'), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.navigate("Home") },
|
||||
]);
|
||||
Alert.alert("升级成功", "升级成功,请重连设备", [
|
||||
{
|
||||
text: "确认",
|
||||
onPress: () => {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: "Home" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (err: any) {
|
||||
console.log("❌ runDfu error =", err);
|
||||
setIsDfuRunning(false);
|
||||
setError(err.message || t('dfu.dfuFailed'));
|
||||
Alert.alert(t('dfu.upgradeFailed'), err.message || t('dfu.dfuFailed'), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
||||
setError(err?.message || "DFU失败");
|
||||
Alert.alert("升级失败", err?.message || "DFU失败", [
|
||||
{ text: "确认", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
runDfu();
|
||||
}, [deviceId, deviceFirmware, navigation]);
|
||||
}, [deviceId, systemId, address, deviceFirmware, navigation]);
|
||||
|
||||
return (
|
||||
<View style={{flex:1,backgroundColor:'#f2f3f7'}}>
|
||||
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
||||
<MyHeader title={t("dfu.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader>
|
||||
<View style={{ flex: 1, padding: 20 }}>
|
||||
<View style={styles.container}>
|
||||
<MyStatusbar backgroundColor="#FFFFFF" dark />
|
||||
<MyHeader
|
||||
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}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.bluetoothName')}: {name}
|
||||
</Text>
|
||||
<Text style={styles.normalText}>最新版本: {latestVersion}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.latestVersion')}: {latestVersion}
|
||||
</Text>
|
||||
<Text style={styles.normalText}>当前版本: {deviceFirmware || "--"}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.currentVersion')}: {deviceFirmware}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.upgradeStatus')}: {mapDfuStateToChinese(state)}
|
||||
<Text style={styles.normalText}>
|
||||
升级状态: {mapDfuStateToChinese(state)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 横向进度条 */}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||
<Text style={styles.progressText}>{progress}%</Text>
|
||||
</View>
|
||||
|
||||
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>}
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
row: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "red",
|
||||
paddingBottom: 4,
|
||||
marginBottom: 8,
|
||||
borderBottomColor: "#E7141E",
|
||||
paddingBottom: 6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 18,
|
||||
color: "#111111",
|
||||
fontWeight: "600",
|
||||
},
|
||||
normalText: {
|
||||
fontSize: 16,
|
||||
color: "#222222",
|
||||
},
|
||||
progressContainer: {
|
||||
height: 30,
|
||||
backgroundColor: "#eee",
|
||||
backgroundColor: "#EEEEEE",
|
||||
borderRadius: 15,
|
||||
overflow: "hidden",
|
||||
marginTop: 40,
|
||||
@ -185,6 +366,11 @@ const styles = StyleSheet.create({
|
||||
position: "absolute",
|
||||
alignSelf: "center",
|
||||
fontWeight: "bold",
|
||||
color: "#000",
|
||||
color: "#000000",
|
||||
},
|
||||
});
|
||||
errorText: {
|
||||
color: "red",
|
||||
marginTop: 20,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@ -28,7 +28,19 @@ export default function HomeScreen({ navigation }: Props) {
|
||||
style={styles.button}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
// InfoScreen.tsx此页面为功率计信息页面
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
@ -6,7 +7,6 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
useColorScheme,
|
||||
Alert,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
@ -57,6 +57,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
const [rightbalance, setRightBalance] = useState<number>(0);
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isDeviceReady, setIsDeviceReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [serial, setSerial] = 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 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 isActiveRef = useRef(true);
|
||||
@ -83,6 +90,227 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
const calibrationTimeoutRef = useRef<number | null>(null);
|
||||
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(
|
||||
React.useCallback(() => {
|
||||
// 页面获得焦点
|
||||
@ -145,49 +373,40 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
if (addr !== deviceKey) return;
|
||||
|
||||
console.log("🔌 connection event:", ev.connectionStatus);
|
||||
setIsConnected(
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
|
||||
);
|
||||
const connected =
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
|
||||
const isReady = ev.connectionStatus === "ready";
|
||||
|
||||
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
||||
console.log("✅ device ready - subscribe notify FFF3");
|
||||
setIsConnected(connected);
|
||||
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 {
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid)
|
||||
).catch(() => { });
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = (notifyEv as any).value;
|
||||
if (!raw) return;
|
||||
const 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 已订阅");
|
||||
await subscribeFff3IfNeeded();
|
||||
} catch (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(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
let shouldShowReadSuccess = false;
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
const v = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(srv),
|
||||
fullUUID(char)
|
||||
);
|
||||
return v ? String.fromCharCode(...(v as any)) : "未知";
|
||||
} catch {
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
const serialValue = await readTextCharacteristic("180a", "2a25");
|
||||
const firmwareValue = await readTextCharacteristic("180a", "2a28");
|
||||
const hardwareValue = await readTextCharacteristic("180a", "2a27");
|
||||
const batteryValue = await readBatteryCharacteristic();
|
||||
|
||||
setSerial(await readStr("180a", "2a25"));
|
||||
setFirmware(await readStr("180a", "2a28"));
|
||||
setHardware(await readStr("180a", "2a27"));
|
||||
|
||||
try {
|
||||
const v = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("180f"),
|
||||
fullUUID("2a19")
|
||||
);
|
||||
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
|
||||
} catch {
|
||||
setBattery("未知");
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setSerial(serialValue);
|
||||
setFirmware(firmwareValue);
|
||||
setHardware(hardwareValue);
|
||||
setBattery(batteryValue);
|
||||
initialInfoLoadedRef.current = true;
|
||||
}
|
||||
|
||||
await sleep(120);
|
||||
await subscribePowerDataIfNeeded();
|
||||
|
||||
shouldShowReadSuccess =
|
||||
serialValue !== "未知" ||
|
||||
firmwareValue !== "未知" ||
|
||||
hardwareValue !== "未知" ||
|
||||
batteryValue !== "未知";
|
||||
|
||||
console.log("✅ info 读取完成");
|
||||
|
||||
|
||||
|
||||
try {
|
||||
await sleep(150);
|
||||
await Central.writeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
@ -251,22 +469,35 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
||||
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
||||
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
|
||||
await sleep(300);
|
||||
} catch (err) {
|
||||
console.warn("❌ 首次读取功率微调失败", err);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.warn("❌ 读取失败", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
// ✅ 显示读取成功提示 2 秒
|
||||
setReadSuccessToast(true);
|
||||
setTimeout(() => setReadSuccessToast(false), 2000);
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (!cancelled && mountedRef.current && shouldShowReadSuccess) {
|
||||
setReadSuccessToast(true);
|
||||
if (readSuccessTimeoutRef.current) {
|
||||
clearTimeout(readSuccessTimeoutRef.current);
|
||||
}
|
||||
readSuccessTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setReadSuccessToast(false);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
initialInfoLoadedRef.current = false;
|
||||
};
|
||||
}, [peripheral]);
|
||||
|
||||
|
||||
@ -274,59 +505,19 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
|
||||
useEffect(() => {
|
||||
const subscribeToPowerData = async () => {
|
||||
try {
|
||||
// 确保设备已经连接
|
||||
if (!isConnected) return;
|
||||
|
||||
console.log("✅ 订阅 2A63 特性通知...");
|
||||
|
||||
// 先取消之前的订阅(防止重复订阅)
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63")
|
||||
).catch(() => { });
|
||||
|
||||
// 订阅 2A63 特性通知
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63"),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = notifyEv.value;
|
||||
if (raw) {
|
||||
// 将 ArrayBuffer 转换为字节数组
|
||||
const byteArray = new Uint8Array(raw);
|
||||
|
||||
// 解析数据
|
||||
parseData(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("❌ 处理通知数据失败", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ 已订阅 2A63 特性通知");
|
||||
} catch (err) {
|
||||
if (peripheral && deviceReadyRef.current && initialInfoLoadedRef.current) {
|
||||
subscribePowerDataIfNeeded().catch((err) => {
|
||||
console.warn("❌ 订阅 2A63 特性失败", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 只在设备已连接时进行订阅
|
||||
if (peripheral && isConnected) {
|
||||
subscribeToPowerData();
|
||||
});
|
||||
}
|
||||
|
||||
// 清理订阅(当组件卸载或者连接状态变化时)
|
||||
return () => {
|
||||
if (peripheral && isConnected) {
|
||||
if (peripheral && powerDataSubscribedRef.current) {
|
||||
powerDataSubscribedRef.current = false;
|
||||
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
|
||||
}
|
||||
};
|
||||
}, [peripheral, isConnected]);
|
||||
}, [peripheral, isDeviceReady]);
|
||||
|
||||
const cadenceStateRef = useRef({
|
||||
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
|
||||
@ -442,7 +633,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 写入功率微调 ==========
|
||||
const updatePowerTrim = async () => {
|
||||
const val = parseInt(inputTrim);
|
||||
const val = Number(inputTrim);
|
||||
if (isNaN(val) || val < 50 || val > 200) {
|
||||
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
|
||||
return;
|
||||
@ -458,13 +649,16 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
try {
|
||||
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(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerWriteUuid),
|
||||
new Uint8Array([0x02, val]).buffer,
|
||||
new Uint8Array([0x02, low, high]).buffer,
|
||||
{ withoutResponse: false }
|
||||
);
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
||||
@ -483,6 +677,8 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
} catch (err) {
|
||||
console.warn("❌ 写入失败", err);
|
||||
Alert.alert(t('info.writeFailed'));
|
||||
} finally {
|
||||
setPowerTrimLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -584,12 +780,22 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
deviceReadyRef.current = false;
|
||||
initialInfoLoadedRef.current = false;
|
||||
powerDataSubscribedRef.current = false;
|
||||
readyResolverRef.current = null;
|
||||
// 清理校准超时定时器
|
||||
if (calibrationTimeoutRef.current) {
|
||||
clearTimeout(calibrationTimeoutRef.current);
|
||||
calibrationTimeoutRef.current = null;
|
||||
}
|
||||
if (readSuccessTimeoutRef.current) {
|
||||
clearTimeout(readSuccessTimeoutRef.current);
|
||||
readSuccessTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -714,10 +920,12 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
}
|
||||
// 蓝牙已连接,正常跳转 DFU
|
||||
navigation.navigate("Dfu", {
|
||||
deviceId: deviceKey,
|
||||
name: peripheral.name,
|
||||
firmware,
|
||||
});
|
||||
deviceId: deviceKey,
|
||||
systemId: peripheral.systemId,
|
||||
address: peripheral.address,
|
||||
name: peripheral.name ?? "",
|
||||
firmware: firmware ?? "",
|
||||
});
|
||||
}}
|
||||
style={styles.pressable}
|
||||
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击
|
||||
|
||||
1227
src/InfoScreen2.tsx
Normal file
1227
src/InfoScreen2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2049
src/InfoScreen3.tsx
Normal file
2049
src/InfoScreen3.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
// src/ScanScreen.tsx
|
||||
// src/ScanScreen.tsx此页面为功率计搜索页面
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
@ -76,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
|
||||
|
||||
// 更新或添加设备,并记录最后扫描时间
|
||||
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; // 已过滤弱信号
|
||||
|
||||
setDevices((prev) => {
|
||||
@ -228,4 +228,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
|
||||
tips: { fontSize: 14, color: "#888", textAlign: "center" },
|
||||
});
|
||||
});
|
||||
238
src/ScanScreen2.tsx
Normal file
238
src/ScanScreen2.tsx
Normal 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
231
src/ScanScreen3.tsx
Normal 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
796
src/SpindownScreen.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
315
src/helper/ftmsIndoorBikeDataBus.ts
Normal file
315
src/helper/ftmsIndoorBikeDataBus.ts
Normal 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: 限制为 10Hz(100ms 一次)
|
||||
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,
|
||||
}));
|
||||
};
|
||||
@ -51,7 +51,7 @@ export const saveLanguage = async (language: string): Promise<void> => {
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
compatibilityJSON: 'v3',
|
||||
compatibilityJSON: 'v4',
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en },
|
||||
@ -68,4 +68,4 @@ getStoredLanguage().then((language) => {
|
||||
i18n.changeLanguage(language);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
@ -9,7 +9,10 @@
|
||||
"title": "POWERFUN Settings",
|
||||
"scan": "Scan Devices",
|
||||
"privacy": "Privacy Policy",
|
||||
"version": "Version v0.0.1"
|
||||
"version": "Version v0.0.1",
|
||||
"powerMeter": "Power Meter",
|
||||
"paddle": "Paddle",
|
||||
"T5trainer": "T5 Trainer"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scan Devices",
|
||||
@ -19,6 +22,141 @@
|
||||
"tipBluetooth": "(Please enable Bluetooth in settings)",
|
||||
"noName": "[No Name]",
|
||||
"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": {
|
||||
"title": "Firmware Update",
|
||||
@ -117,4 +255,4 @@
|
||||
"calibrationSendError": "Failed to send calibration command",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,17 +9,154 @@
|
||||
"title": "POWERFUN设置",
|
||||
"scan": "搜索设备",
|
||||
"privacy": "隐私协议",
|
||||
"version": "版本号 v0.0.1"
|
||||
"version": "版本号 v0.0.1",
|
||||
"powerMeter": "功率计",
|
||||
"paddle": "桨频器",
|
||||
"T5trainer": "T5骑行台"
|
||||
},
|
||||
"scan": {
|
||||
"title": "搜索设备",
|
||||
"scanning": "搜索中...",
|
||||
"noDevice": "暂无设备",
|
||||
"tipScanning": "(请确保设备有电且被唤醒)",
|
||||
"tipBluetooth": "(请在设置中打开蓝牙)",
|
||||
"noName": "[无名称]",
|
||||
"rssiUnit": "dBm"
|
||||
"title": "搜索设备",
|
||||
"scanning": "搜索中...",
|
||||
"noDevice": "暂无设备",
|
||||
"tipScanning": "(请确保设备有电且被唤醒)",
|
||||
"tipBluetooth": "(请在设置中打开蓝牙)",
|
||||
"noName": "[无名称]",
|
||||
"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": {
|
||||
"title": "固件升级",
|
||||
"preparing": "准备中...",
|
||||
@ -117,4 +254,4 @@
|
||||
"calibrationSendError": "发送校准命令失败",
|
||||
"error": "错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user