Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd0edd2ce |
81
App.tsx
81
App.tsx
@ -1,23 +1,55 @@
|
|||||||
|
console.log("🔥 当前 App.tsx 已加载");
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { NavigationContainer } from "@react-navigation/native";
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
|
||||||
import HomeScreen from "./src/HomeScreen";
|
import HomeScreen from "./src/HomeScreen";
|
||||||
import ScanScreen from "./src/ScanScreen";
|
import ScanScreen from "./src/ScanScreen";
|
||||||
|
import ScanScreen2 from "./src/ScanScreen2";
|
||||||
|
import ScanScreen3 from "./src/ScanScreen3";
|
||||||
import InfoScreen from "./src/InfoScreen";
|
import InfoScreen from "./src/InfoScreen";
|
||||||
import DfuScreen from "./src/DfuScreen";
|
import DfuScreen from "./src/DfuScreen";
|
||||||
import PrivacyScreen from "./src/PrivacyScreen";
|
import PrivacyScreen from "./src/PrivacyScreen";
|
||||||
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
|
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
|
||||||
import pxToDp from "./src/helper/pxToDp";
|
|
||||||
import SettingScreen from "./src/SettingScreen";
|
import SettingScreen from "./src/SettingScreen";
|
||||||
import './src/i18n'
|
import './src/i18n'
|
||||||
|
import InfoScreen2 from "./src/InfoScreen2";
|
||||||
|
import InfoScreen3 from "./src/InfoScreen3";
|
||||||
|
import SpindownScreen from "./src/SpindownScreen";
|
||||||
|
import { decode } from "base-64";
|
||||||
|
|
||||||
|
|
||||||
|
// 不要 global.atob
|
||||||
|
// 不要 atob
|
||||||
|
|
||||||
|
const base64ToBytes = (base64: string): number[] => {
|
||||||
|
const binary = decode(base64);
|
||||||
|
const bytes: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes.push(binary.charCodeAt(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Splash: undefined;
|
Splash: undefined;
|
||||||
Home: undefined;
|
Home: undefined;
|
||||||
Scan: undefined;
|
Scan: undefined;
|
||||||
|
ScanScreen2: undefined;
|
||||||
|
ScanScreen3: undefined;
|
||||||
Info: { peripheral: any };
|
Info: { peripheral: any };
|
||||||
Dfu: { deviceId: string; name: string; firmware: string };
|
Info2: { peripheral: any };
|
||||||
|
Info3: { peripheral: any };
|
||||||
|
Spindown: { peripheral: any };
|
||||||
|
Dfu: {
|
||||||
|
deviceId: string;
|
||||||
|
systemId?: string;
|
||||||
|
address?: string | number;
|
||||||
|
name: string;
|
||||||
|
firmware: string;
|
||||||
|
};
|
||||||
Privacy: undefined;
|
Privacy: undefined;
|
||||||
Setting: undefined;
|
Setting: undefined;
|
||||||
};
|
};
|
||||||
@ -25,6 +57,23 @@ export type RootStackParamList = {
|
|||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
||||||
|
//linshi
|
||||||
|
React.useEffect(() => {
|
||||||
|
const test = async () => {
|
||||||
|
try {
|
||||||
|
console.log("🔥 App.tsx fetch test start");
|
||||||
|
const resp = await fetch("https://www.baidu.com");
|
||||||
|
console.log("🔥 App.tsx fetch status =", resp.status);
|
||||||
|
const text = await resp.text();
|
||||||
|
console.log("🔥 App.tsx fetch text length =", text.length);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("❌ App.tsx fetch error =", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
@ -32,6 +81,11 @@ export default function App() {
|
|||||||
screenOptions={{
|
screenOptions={{
|
||||||
animation : 'slide_from_right'
|
animation : 'slide_from_right'
|
||||||
}}>
|
}}>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Info2"
|
||||||
|
component={InfoScreen2}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
{/* 启动页(无标题) */}
|
{/* 启动页(无标题) */}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Splash"
|
name="Splash"
|
||||||
@ -53,12 +107,35 @@ export default function App() {
|
|||||||
// options={{ title: "搜索设备" }}
|
// options={{ title: "搜索设备" }}
|
||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ScanScreen2"
|
||||||
|
component={ScanScreen2}
|
||||||
|
// options={{ title: "搜索设备" }}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ScanScreen3"
|
||||||
|
component={ScanScreen3}
|
||||||
|
// options={{ title: "搜索设备" }}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Info"
|
name="Info"
|
||||||
component={InfoScreen}
|
component={InfoScreen}
|
||||||
// options={{ title: "设备详情" }}
|
// options={{ title: "设备详情" }}
|
||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Info3"
|
||||||
|
component={InfoScreen3}
|
||||||
|
// options={{ title: "设备详情" }}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Spindown"
|
||||||
|
component={SpindownScreen}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Dfu"
|
name="Dfu"
|
||||||
component={DfuScreen}
|
component={DfuScreen}
|
||||||
|
|||||||
@ -2316,7 +2316,7 @@ PODS:
|
|||||||
- SocketRocket
|
- SocketRocket
|
||||||
- RNCAsyncStorage (2.2.0):
|
- RNCAsyncStorage (2.2.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNDeviceInfo (15.0.1):
|
- RNDeviceInfo (15.0.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNFS (2.20.0):
|
- RNFS (2.20.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
@ -2707,7 +2707,7 @@ SPEC CHECKSUMS:
|
|||||||
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
|
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
|
||||||
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
|
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
|
||||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||||
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
|
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
|
||||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||||
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
|
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
|
||||||
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e
|
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e
|
||||||
|
|||||||
@ -207,10 +207,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
|
||||||
@ -246,10 +250,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
|
||||||
@ -288,7 +296,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B7ZA544T59;
|
DEVELOPMENT_TEAM = PXHWD6972V;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = dfuapp/Info.plist;
|
INFOPLIST_FILE = dfuapp/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
|
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
|
||||||
@ -303,7 +311,7 @@
|
|||||||
"-ObjC",
|
"-ObjC",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
|
||||||
PRODUCT_NAME = dfuapp;
|
PRODUCT_NAME = dfuapp;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -318,7 +326,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B7ZA544T59;
|
DEVELOPMENT_TEAM = PXHWD6972V;
|
||||||
INFOPLIST_FILE = dfuapp/Info.plist;
|
INFOPLIST_FILE = dfuapp/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
|
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
@ -332,7 +340,7 @@
|
|||||||
"-ObjC",
|
"-ObjC",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
|
||||||
PRODUCT_NAME = dfuapp;
|
PRODUCT_NAME = dfuapp;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
|||||||
@ -41,6 +41,10 @@
|
|||||||
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
|
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
|
||||||
<key>RCTNewArchEnabled</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIAppFonts</key>
|
||||||
|
<array>
|
||||||
|
<string>MaterialCommunityIcons.ttf</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
@ -53,9 +57,5 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>UIAppFonts</key>
|
|
||||||
<array>
|
|
||||||
<string>MaterialCommunityIcons.ttf</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
741
package-lock.json
generated
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/core": "^7.25.2",
|
||||||
"@babel/preset-env": "^7.25.3",
|
"@babel/preset-env": "^7.25.3",
|
||||||
"@babel/runtime": "^7.25.0",
|
"@babel/runtime": "^7.25.0",
|
||||||
"@react-native-community/cli": "20.0.0",
|
"@react-native-community/cli": "15.0.0",
|
||||||
"@react-native-community/cli-platform-android": "20.0.0",
|
"@react-native-community/cli-platform-android": "15.0.0",
|
||||||
"@react-native-community/cli-platform-ios": "20.0.0",
|
"@react-native-community/cli-platform-ios": "15.0.0",
|
||||||
"@react-native/babel-preset": "0.81.4",
|
"@react-native/babel-preset": "0.81.4",
|
||||||
"@react-native/eslint-config": "0.81.4",
|
"@react-native/eslint-config": "0.81.4",
|
||||||
"@react-native/metro-config": "0.81.4",
|
"@react-native/metro-config": "0.81.4",
|
||||||
@ -53,7 +53,10 @@
|
|||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"react-test-renderer": "19.1.0",
|
"react-test-renderer": "19.1.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { View, Text, StyleSheet, Alert, BackHandler } from "react-native";
|
import { View, Text, StyleSheet, Alert, Platform } from "react-native";
|
||||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
import { RootStackParamList } from "../App";
|
import { RootStackParamList } from "../App";
|
||||||
import RNFS from "react-native-fs";
|
import RNFS from "react-native-fs";
|
||||||
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu";
|
import {
|
||||||
import { useTranslation } from 'react-i18next';
|
startDfu,
|
||||||
|
getDfuTargetId,
|
||||||
|
DfuProgressEvent,
|
||||||
|
DfuStateEvent,
|
||||||
|
} from "@systemic-games/react-native-nordic-nrf5-dfu";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { encode as btoa } from "base-64";
|
||||||
import MyStatusbar from "./component/MyStatusbar";
|
import MyStatusbar from "./component/MyStatusbar";
|
||||||
import MyHeader from "./component/MyHeader";
|
import MyHeader from "./component/MyHeader";
|
||||||
|
|
||||||
@ -16,162 +22,337 @@ interface DeviceInfo {
|
|||||||
download: string;
|
download: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedFirmware {
|
||||||
|
hardware: number;
|
||||||
|
iteration: number;
|
||||||
|
build: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesToBase64 = (bytes: Uint8Array): string => {
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize);
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseFirmwareVersion = (text: string): ParsedFirmware => {
|
||||||
|
const raw = String(text ?? "").trim();
|
||||||
|
const parts = raw.split(".");
|
||||||
|
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new Error(`固件版本格式不正确: ${raw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardware = parseInt(parts[0], 10);
|
||||||
|
const iteration = parseInt(parts[1], 10);
|
||||||
|
const build = parts.length >= 3 ? parts[2] : "";
|
||||||
|
|
||||||
|
if (Number.isNaN(hardware) || Number.isNaN(iteration)) {
|
||||||
|
throw new Error(`固件版本无法解析: ${raw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hardware,
|
||||||
|
iteration,
|
||||||
|
build,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function DfuScreen({ route, navigation }: Props) {
|
export default function DfuScreen({ route, navigation }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { deviceId, name, firmware: deviceFirmware } = route.params;
|
const {
|
||||||
|
deviceId,
|
||||||
|
systemId,
|
||||||
|
address,
|
||||||
|
name,
|
||||||
|
firmware: deviceFirmware,
|
||||||
|
} = route.params;
|
||||||
|
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [state, setState] = useState(t('dfu.preparing'));
|
const [state, setState] = useState("准备中...");
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [latestVersion, setLatestVersion] = useState<string>(t('dfu.reading'));
|
const [latestVersion, setLatestVersion] = useState("读取中...");
|
||||||
const [isDfuRunning, setIsDfuRunning] = useState(false);
|
const [isDfuRunning, setIsDfuRunning] = useState(false);
|
||||||
|
|
||||||
const mapDfuStateToChinese = (state: string): string => {
|
const mapDfuStateToChinese = (s: string): string => {
|
||||||
switch (state) {
|
switch (s) {
|
||||||
case "connecting": return t('dfu.stateConnecting');
|
case "connecting":
|
||||||
case "starting": return t('dfu.stateStarting');
|
return "连接中…";
|
||||||
case "enablingDfuMode": return t('dfu.stateEnablingDfuMode');
|
case "starting":
|
||||||
case "uploading": return t('dfu.stateUploading');
|
return "初始化中…";
|
||||||
case "validating": return t('dfu.stateValidating');
|
case "enablingDfuMode":
|
||||||
case "disconnecting": return t('dfu.stateDisconnecting');
|
return "启用 DFU 模式…";
|
||||||
case "completed": return t('dfu.stateCompleted');
|
case "uploading":
|
||||||
case "aborted": return t('dfu.stateAborted');
|
return "上传固件中…";
|
||||||
|
case "validating":
|
||||||
|
return "校验固件…";
|
||||||
|
case "disconnecting":
|
||||||
|
return "断开连接…";
|
||||||
|
case "completed":
|
||||||
|
return "升级完成";
|
||||||
|
case "aborted":
|
||||||
|
return "已取消";
|
||||||
case "failed":
|
case "failed":
|
||||||
case "dfu_failed": return t('dfu.stateFailed');
|
case "dfu_failed":
|
||||||
case "initializing": return t('dfu.stateInitializing');
|
return "升级失败";
|
||||||
case "errored": return t('dfu.stateErrored');
|
case "initializing":
|
||||||
default: return state;
|
return "启动中…";
|
||||||
|
case "errored":
|
||||||
|
return "升级出错!";
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// ✅ 拦截所有导航返回(iOS + Android)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = navigation.addListener("beforeRemove", (e) => {
|
const unsubscribe = navigation.addListener("beforeRemove", (e) => {
|
||||||
if (!isDfuRunning) return;
|
if (!isDfuRunning) return;
|
||||||
|
|
||||||
// 阻止返回
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
Alert.alert(t('dfu.pleaseWait'), t('dfu.doNotReturn'));
|
Alert.alert("请稍候", "正在升级,请勿返回或关闭应用!");
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [navigation, isDfuRunning]);
|
}, [navigation, isDfuRunning]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runDfu = async () => {
|
const runDfu = async () => {
|
||||||
try {
|
try {
|
||||||
setIsDfuRunning(true);
|
setIsDfuRunning(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
const manifestUrl = "https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
|
const rawDeviceId = String(deviceId ?? "").trim();
|
||||||
|
const rawSystemId = String(systemId ?? rawDeviceId).trim();
|
||||||
|
const rawAddress =
|
||||||
|
typeof address === "number"
|
||||||
|
? address
|
||||||
|
: address !== undefined && address !== null
|
||||||
|
? Number(address)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const safeDeviceId = getDfuTargetId({
|
||||||
|
systemId: rawSystemId,
|
||||||
|
address: rawAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firmwareText = String(deviceFirmware ?? "").trim();
|
||||||
|
|
||||||
|
console.log("🔥 rawDeviceId =", rawDeviceId);
|
||||||
|
console.log("🔥 rawSystemId =", rawSystemId);
|
||||||
|
console.log("🔥 rawAddress =", rawAddress);
|
||||||
|
console.log("🔥 safeDeviceId =", safeDeviceId);
|
||||||
|
console.log("🔥 firmwareText =", JSON.stringify(firmwareText));
|
||||||
|
|
||||||
|
if (!safeDeviceId) {
|
||||||
|
throw new Error("无法生成 DFU 目标设备 ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFw = parseFirmwareVersion(firmwareText);
|
||||||
|
console.log("🔥 currentFw =", currentFw);
|
||||||
|
|
||||||
|
const manifestUrl =
|
||||||
|
"https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
|
||||||
const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
|
const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
|
||||||
|
|
||||||
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise;
|
console.log("🔥 before fetch manifest");
|
||||||
const manifestContent = await RNFS.readFile(manifestPath);
|
const manifestResp = await fetch(manifestUrl);
|
||||||
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] };
|
console.log("🔥 manifest status =", manifestResp.status);
|
||||||
|
|
||||||
const [deviceHWStr, deviceFWStr] = deviceFirmware.split(".");
|
if (!manifestResp.ok) {
|
||||||
const deviceHW = parseInt(deviceHWStr);
|
throw new Error(`manifest 下载失败,HTTP ${manifestResp.status}`);
|
||||||
const deviceFW = parseInt(deviceFWStr);
|
}
|
||||||
|
|
||||||
|
const manifestText = await manifestResp.text();
|
||||||
|
console.log("🔥 manifest text =", manifestText);
|
||||||
|
|
||||||
|
await RNFS.writeFile(manifestPath, manifestText, "utf8");
|
||||||
|
console.log("🔥 manifest saved =", manifestPath);
|
||||||
|
|
||||||
|
let manifest: { devices: DeviceInfo[] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(manifestText) as { devices: DeviceInfo[] };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("manifest 不是合法 JSON: " + manifestText.slice(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceInfo = manifest.devices.find(
|
||||||
|
(d) => d.hardware === currentFw.hardware
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("🔥 matched deviceInfo =", deviceInfo);
|
||||||
|
|
||||||
const deviceInfo = manifest.devices.find(d => d.hardware === deviceHW);
|
|
||||||
if (!deviceInfo) {
|
if (!deviceInfo) {
|
||||||
setIsDfuRunning(false);
|
setIsDfuRunning(false);
|
||||||
Alert.alert(t('dfu.cannotUpgrade'), t('dfu.hardwareNotFound', { hardware: deviceHW }), [
|
Alert.alert(
|
||||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
"无法升级",
|
||||||
]);
|
`未找到 hardware=${currentFw.hardware} 的固件`,
|
||||||
|
[{ text: "确认", onPress: () => navigation.goBack() }]
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLatestVersion(deviceInfo.latestFirmware);
|
setLatestVersion(deviceInfo.latestFirmware);
|
||||||
|
|
||||||
const [, latestFWStr] = deviceInfo.latestFirmware.split(".");
|
const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
|
||||||
const latestFW = parseInt(latestFWStr);
|
console.log("🔥 latestFw =", latestFw);
|
||||||
|
|
||||||
if (latestFW <= deviceFW) {
|
if (latestFw.hardware !== currentFw.hardware) {
|
||||||
|
throw new Error(
|
||||||
|
`服务器固件硬件号不匹配:当前 ${currentFw.hardware},服务器 ${latestFw.hardware}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestFw.iteration <= currentFw.iteration) {
|
||||||
setIsDfuRunning(false);
|
setIsDfuRunning(false);
|
||||||
Alert.alert(t('dfu.noNeedUpgrade'), t('dfu.alreadyLatest'), [
|
Alert.alert("无需升级", "已是最新固件,无需升级", [
|
||||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
{ text: "确认", onPress: () => navigation.goBack() },
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
|
console.log("🔥 upgrade allowed", {
|
||||||
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise;
|
currentIteration: currentFw.iteration,
|
||||||
|
latestIteration: latestFw.iteration,
|
||||||
|
});
|
||||||
|
|
||||||
await startDfu(deviceId, "file://" + localPath, {
|
const zipResp = await fetch(deviceInfo.download);
|
||||||
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state),
|
console.log("🔥 zip status =", zipResp.status);
|
||||||
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
|
|
||||||
|
if (!zipResp.ok) {
|
||||||
|
throw new Error(`固件包下载失败,HTTP ${zipResp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipArrayBuffer = await zipResp.arrayBuffer();
|
||||||
|
const zipBytes = new Uint8Array(zipArrayBuffer);
|
||||||
|
console.log("🔥 zip bytes =", zipBytes.length);
|
||||||
|
|
||||||
|
const zipBase64 = bytesToBase64(zipBytes);
|
||||||
|
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
|
||||||
|
|
||||||
|
await RNFS.writeFile(localPath, zipBase64, "base64");
|
||||||
|
console.log("🔥 zip saved =", localPath);
|
||||||
|
|
||||||
|
const dfuFilePath =
|
||||||
|
Platform.OS === "android" ? "file://" + localPath : localPath;
|
||||||
|
|
||||||
|
console.log("🔥 before startDfu =", {
|
||||||
|
safeDeviceId,
|
||||||
|
dfuFilePath,
|
||||||
|
currentFirmware: currentFw.raw,
|
||||||
|
latestFirmware: latestFw.raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
await startDfu(safeDeviceId, dfuFilePath, {
|
||||||
|
dfuStateListener: (ev: DfuStateEvent) => {
|
||||||
|
console.log("🔥 dfu state =", ev.state);
|
||||||
|
setState(ev.state);
|
||||||
|
},
|
||||||
|
dfuProgressListener: (ev: DfuProgressEvent) => {
|
||||||
|
console.log("🔥 dfu progress =", ev.percent);
|
||||||
|
setProgress(ev.percent);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsDfuRunning(false);
|
setIsDfuRunning(false);
|
||||||
Alert.alert(t('dfu.upgradeSuccess'), t('dfu.upgradeSuccessMessage'), [
|
Alert.alert("升级成功", "升级成功,请重连设备", [
|
||||||
{ text: t('dfu.confirm'), onPress: () => navigation.navigate("Home") },
|
{
|
||||||
|
text: "确认",
|
||||||
|
onPress: () => {
|
||||||
|
navigation.reset({
|
||||||
|
index: 0,
|
||||||
|
routes: [{ name: "Home" }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.log("❌ runDfu error =", err);
|
||||||
setIsDfuRunning(false);
|
setIsDfuRunning(false);
|
||||||
setError(err.message || t('dfu.dfuFailed'));
|
setError(err?.message || "DFU失败");
|
||||||
Alert.alert(t('dfu.upgradeFailed'), err.message || t('dfu.dfuFailed'), [
|
Alert.alert("升级失败", err?.message || "DFU失败", [
|
||||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
{ text: "确认", onPress: () => navigation.goBack() },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runDfu();
|
runDfu();
|
||||||
}, [deviceId, deviceFirmware, navigation]);
|
}, [deviceId, systemId, address, deviceFirmware, navigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{flex:1,backgroundColor:'#f2f3f7'}}>
|
<View style={styles.container}>
|
||||||
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
<MyStatusbar backgroundColor="#FFFFFF" dark />
|
||||||
<MyHeader title={t("dfu.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader>
|
<MyHeader
|
||||||
<View style={{ flex: 1, padding: 20 }}>
|
title="固件升级"
|
||||||
|
textColor="#333"
|
||||||
|
backgroundColor="#FFFFFF"
|
||||||
|
navigation={navigation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.titleText}>蓝牙名称: {name || "--"}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
<Text style={styles.normalText}>最新版本: {latestVersion}</Text>
|
||||||
{t('dfu.bluetoothName')}: {name}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
<Text style={styles.normalText}>当前版本: {deviceFirmware || "--"}</Text>
|
||||||
{t('dfu.latestVersion')}: {latestVersion}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
<Text style={styles.normalText}>
|
||||||
{t('dfu.currentVersion')}: {deviceFirmware}
|
升级状态: {mapDfuStateToChinese(state)}
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.row}>
|
|
||||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
|
||||||
{t('dfu.upgradeStatus')}: {mapDfuStateToChinese(state)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 横向进度条 */}
|
|
||||||
<View style={styles.progressContainer}>
|
<View style={styles.progressContainer}>
|
||||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||||
<Text style={styles.progressText}>{progress}%</Text>
|
<Text style={styles.progressText}>{progress}%</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>}
|
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
},
|
||||||
row: {
|
row: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: "red",
|
borderBottomColor: "#E7141E",
|
||||||
paddingBottom: 4,
|
paddingBottom: 6,
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#111111",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
normalText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#222222",
|
||||||
},
|
},
|
||||||
progressContainer: {
|
progressContainer: {
|
||||||
height: 30,
|
height: 30,
|
||||||
backgroundColor: "#eee",
|
backgroundColor: "#EEEEEE",
|
||||||
borderRadius: 15,
|
borderRadius: 15,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
marginTop: 40,
|
marginTop: 40,
|
||||||
@ -185,6 +366,11 @@ const styles = StyleSheet.create({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000",
|
color: "#000000",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "red",
|
||||||
|
marginTop: 20,
|
||||||
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -28,7 +28,19 @@ export default function HomeScreen({ navigation }: Props) {
|
|||||||
style={styles.button}
|
style={styles.button}
|
||||||
onPress={() => navigation.navigate("Scan")}
|
onPress={() => navigation.navigate("Scan")}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonText}>{t('home.scan')}</Text>
|
<Text style={styles.buttonText}>{t("home.powerMeter")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, { marginTop: pxToDp(20) }]}
|
||||||
|
onPress={() => navigation.navigate("ScanScreen2")}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>{t("home.paddle")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, { marginTop: pxToDp(20) }]}
|
||||||
|
onPress={() => navigation.navigate("ScanScreen3")}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>{t("home.T5trainer")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
// InfoScreen.tsx此页面为功率计信息页面
|
||||||
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text as RNText,
|
Text as RNText,
|
||||||
@ -6,7 +7,6 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
useColorScheme,
|
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@ -57,6 +57,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
const [rightbalance, setRightBalance] = useState<number>(0);
|
const [rightbalance, setRightBalance] = useState<number>(0);
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isDeviceReady, setIsDeviceReady] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [serial, setSerial] = useState(t('info.reading'));
|
const [serial, setSerial] = useState(t('info.reading'));
|
||||||
const [firmware, setFirmware] = useState(t('info.reading'));
|
const [firmware, setFirmware] = useState(t('info.reading'));
|
||||||
@ -72,6 +73,12 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
const notifySubscribedRef = useRef(false);
|
const notifySubscribedRef = useRef(false);
|
||||||
const disconnectingRef = useRef(false);
|
const disconnectingRef = useRef(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const deviceReadyRef = useRef(false);
|
||||||
|
const readyResolverRef = useRef<(() => void) | null>(null);
|
||||||
|
const readSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const powerDataSubscribedRef = useRef(false);
|
||||||
|
const initialInfoLoadedRef = useRef(false);
|
||||||
|
|
||||||
const prevConnectedRef = useRef(isConnected);
|
const prevConnectedRef = useRef(isConnected);
|
||||||
const isActiveRef = useRef(true);
|
const isActiveRef = useRef(true);
|
||||||
@ -83,6 +90,227 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
const calibrationTimeoutRef = useRef<number | null>(null);
|
const calibrationTimeoutRef = useRef<number | null>(null);
|
||||||
const isCalibratingRef = useRef<boolean>(false);
|
const isCalibratingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const sleep = (ms: number) =>
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const toUint8Array = (raw: unknown): Uint8Array | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw instanceof Uint8Array) return raw;
|
||||||
|
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
||||||
|
if (Array.isArray(raw)) return new Uint8Array(raw);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof raw === "object" &&
|
||||||
|
raw !== null &&
|
||||||
|
"buffer" in raw &&
|
||||||
|
(raw as { buffer?: unknown }).buffer instanceof ArrayBuffer
|
||||||
|
) {
|
||||||
|
const view = raw as {
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
byteOffset?: number;
|
||||||
|
byteLength?: number;
|
||||||
|
};
|
||||||
|
return new Uint8Array(
|
||||||
|
view.buffer,
|
||||||
|
view.byteOffset || 0,
|
||||||
|
view.byteLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeTextValue = (raw: unknown) => {
|
||||||
|
const bytes = toUint8Array(raw);
|
||||||
|
if (!bytes || bytes.length === 0) return null;
|
||||||
|
|
||||||
|
const text = String.fromCharCode(...Array.from(bytes))
|
||||||
|
.replace(/\0/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return text || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForDeviceReady = (timeoutMs = 6000) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
if (deviceReadyRef.current) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (readyResolverRef.current === onReady) {
|
||||||
|
readyResolverRef.current = null;
|
||||||
|
}
|
||||||
|
reject(new Error("device ready timeout"));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const onReady = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (readyResolverRef.current === onReady) {
|
||||||
|
readyResolverRef.current = null;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
readyResolverRef.current = onReady;
|
||||||
|
});
|
||||||
|
|
||||||
|
const readCharacteristicWithRetry = async (
|
||||||
|
serviceUuid: string,
|
||||||
|
characteristicUuid: string,
|
||||||
|
attempts = 3,
|
||||||
|
delayMs = 180
|
||||||
|
) => {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const value = await Central.readCharacteristic(
|
||||||
|
peripheral,
|
||||||
|
fullUUID(serviceUuid),
|
||||||
|
fullUUID(characteristicUuid)
|
||||||
|
);
|
||||||
|
const bytes = toUint8Array(value);
|
||||||
|
|
||||||
|
if (bytes && bytes.length > 0) {
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new Error("empty characteristic value");
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < attempts - 1) {
|
||||||
|
await sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("read characteristic failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTextCharacteristic = async (serviceUuid: string, characteristicUuid: string) => {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const bytes = await readCharacteristicWithRetry(
|
||||||
|
serviceUuid,
|
||||||
|
characteristicUuid,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const text = decodeTextValue(bytes);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue retrying with a short gap below.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < 2) {
|
||||||
|
await sleep(180);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "未知";
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBatteryCharacteristic = async () => {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const bytes = await readCharacteristicWithRetry("180f", "2a19", 1);
|
||||||
|
const batteryValue = bytes[0];
|
||||||
|
|
||||||
|
if (Number.isInteger(batteryValue) && batteryValue >= 0 && batteryValue <= 100) {
|
||||||
|
return `${batteryValue}%`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue retrying with a short gap below.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < 2) {
|
||||||
|
await sleep(180);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "未知";
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribePowerDataIfNeeded = async () => {
|
||||||
|
if (!deviceReadyRef.current || !initialInfoLoadedRef.current || powerDataSubscribedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 订阅 2A63 特性通知...");
|
||||||
|
|
||||||
|
await Central.unsubscribeCharacteristic(
|
||||||
|
peripheral,
|
||||||
|
fullUUID("1818"),
|
||||||
|
fullUUID("2a63")
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
await Central.subscribeCharacteristic(
|
||||||
|
peripheral,
|
||||||
|
fullUUID("1818"),
|
||||||
|
fullUUID("2a63"),
|
||||||
|
(notifyEv) => {
|
||||||
|
try {
|
||||||
|
const byteArray = toUint8Array(notifyEv.value);
|
||||||
|
if (!byteArray) return;
|
||||||
|
parseData(byteArray);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("❌ 处理通知数据失败", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
powerDataSubscribedRef.current = true;
|
||||||
|
console.log("✅ 已订阅 2A63 特性通知");
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeFff3IfNeeded = async () => {
|
||||||
|
if (notifySubscribedRef.current) return;
|
||||||
|
|
||||||
|
console.log("✅ device ready - subscribe notify FFF3");
|
||||||
|
await Central.unsubscribeCharacteristic(
|
||||||
|
peripheral,
|
||||||
|
fullUUID(powerServiceUuid),
|
||||||
|
fullUUID(powerNotifyUuid)
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
await Central.subscribeCharacteristic(
|
||||||
|
peripheral,
|
||||||
|
fullUUID(powerServiceUuid),
|
||||||
|
fullUUID(powerNotifyUuid),
|
||||||
|
(notifyEv) => {
|
||||||
|
try {
|
||||||
|
const raw = (notifyEv as any).value;
|
||||||
|
const byteArray = toUint8Array(raw);
|
||||||
|
if (!byteArray) return;
|
||||||
|
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
||||||
|
const rawTrim =
|
||||||
|
byteArray.length >= 3
|
||||||
|
? byteArray[1] | (byteArray[2] << 8)
|
||||||
|
: byteArray[1];
|
||||||
|
const displayTrim =
|
||||||
|
byteArray.length >= 3
|
||||||
|
? (rawTrim / 100).toFixed(2).replace(/\.?0+$/, "")
|
||||||
|
: rawTrim.toString();
|
||||||
|
|
||||||
|
setPowerTrim(displayTrim);
|
||||||
|
} else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
||||||
|
handleFFF3Response(byteArray);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("notify parse error", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
notifySubscribedRef.current = true;
|
||||||
|
console.log("✅ notify 已订阅");
|
||||||
|
};
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
// 页面获得焦点
|
// 页面获得焦点
|
||||||
@ -145,49 +373,40 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
if (addr !== deviceKey) return;
|
if (addr !== deviceKey) return;
|
||||||
|
|
||||||
console.log("🔌 connection event:", ev.connectionStatus);
|
console.log("🔌 connection event:", ev.connectionStatus);
|
||||||
setIsConnected(
|
const connected =
|
||||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
|
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
|
||||||
);
|
const isReady = ev.connectionStatus === "ready";
|
||||||
|
|
||||||
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
setIsConnected(connected);
|
||||||
console.log("✅ device ready - subscribe notify FFF3");
|
setIsDeviceReady(isReady);
|
||||||
|
deviceReadyRef.current = isReady;
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
notifySubscribedRef.current = false;
|
||||||
|
powerDataSubscribedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReady && readyResolverRef.current) {
|
||||||
|
const resolveReady = readyResolverRef.current;
|
||||||
|
readyResolverRef.current = null;
|
||||||
|
resolveReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReady && !notifySubscribedRef.current) {
|
||||||
try {
|
try {
|
||||||
await Central.unsubscribeCharacteristic(
|
await subscribeFff3IfNeeded();
|
||||||
peripheral,
|
|
||||||
fullUUID(powerServiceUuid),
|
|
||||||
fullUUID(powerNotifyUuid)
|
|
||||||
).catch(() => { });
|
|
||||||
await Central.subscribeCharacteristic(
|
|
||||||
peripheral,
|
|
||||||
fullUUID(powerServiceUuid),
|
|
||||||
fullUUID(powerNotifyUuid),
|
|
||||||
(notifyEv) => {
|
|
||||||
try {
|
|
||||||
const raw = (notifyEv as any).value;
|
|
||||||
if (!raw) return;
|
|
||||||
const byteArray = raw instanceof Uint8Array
|
|
||||||
? raw
|
|
||||||
: new Uint8Array(raw);
|
|
||||||
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
|
||||||
// 功率微调数据
|
|
||||||
setPowerTrim(byteArray[1].toString());
|
|
||||||
//setInputTrim(byteArray[1].toString());
|
|
||||||
}
|
|
||||||
else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
|
||||||
// 校准响应数据 (05XXXX格式)
|
|
||||||
handleFFF3Response(byteArray);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("notify parse error", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
notifySubscribedRef.current = true;
|
|
||||||
console.log("✅ notify 已订阅");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("❌ notify 订阅失败:", err);
|
console.warn("❌ notify 订阅失败:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReady && initialInfoLoadedRef.current && !powerDataSubscribedRef.current) {
|
||||||
|
try {
|
||||||
|
await subscribePowerDataIfNeeded();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("❌ 实时数据订阅失败:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -201,45 +420,44 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
// ========== 首次连接并读取信息 ==========
|
// ========== 首次连接并读取信息 ==========
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
let shouldShowReadSuccess = false;
|
||||||
try {
|
try {
|
||||||
await Central.connectPeripheral(peripheral);
|
await Central.connectPeripheral(peripheral);
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
await waitForDeviceReady(6000);
|
||||||
|
await sleep(150);
|
||||||
|
await subscribeFff3IfNeeded();
|
||||||
|
await sleep(120);
|
||||||
|
|
||||||
const readStr = async (srv: string, char: string) => {
|
const serialValue = await readTextCharacteristic("180a", "2a25");
|
||||||
try {
|
const firmwareValue = await readTextCharacteristic("180a", "2a28");
|
||||||
const v = await Central.readCharacteristic(
|
const hardwareValue = await readTextCharacteristic("180a", "2a27");
|
||||||
peripheral,
|
const batteryValue = await readBatteryCharacteristic();
|
||||||
fullUUID(srv),
|
|
||||||
fullUUID(char)
|
if (!cancelled && mountedRef.current) {
|
||||||
);
|
setSerial(serialValue);
|
||||||
return v ? String.fromCharCode(...(v as any)) : "未知";
|
setFirmware(firmwareValue);
|
||||||
} catch {
|
setHardware(hardwareValue);
|
||||||
return "未知";
|
setBattery(batteryValue);
|
||||||
|
initialInfoLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
setSerial(await readStr("180a", "2a25"));
|
await sleep(120);
|
||||||
setFirmware(await readStr("180a", "2a28"));
|
await subscribePowerDataIfNeeded();
|
||||||
setHardware(await readStr("180a", "2a27"));
|
|
||||||
|
|
||||||
try {
|
shouldShowReadSuccess =
|
||||||
const v = await Central.readCharacteristic(
|
serialValue !== "未知" ||
|
||||||
peripheral,
|
firmwareValue !== "未知" ||
|
||||||
fullUUID("180f"),
|
hardwareValue !== "未知" ||
|
||||||
fullUUID("2a19")
|
batteryValue !== "未知";
|
||||||
);
|
|
||||||
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
|
|
||||||
} catch {
|
|
||||||
setBattery("未知");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ info 读取完成");
|
console.log("✅ info 读取完成");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await sleep(150);
|
||||||
await Central.writeCharacteristic(
|
await Central.writeCharacteristic(
|
||||||
peripheral,
|
peripheral,
|
||||||
fullUUID(powerServiceUuid),
|
fullUUID(powerServiceUuid),
|
||||||
@ -251,22 +469,35 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
||||||
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
||||||
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
|
await sleep(300);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("❌ 首次读取功率微调失败", err);
|
console.warn("❌ 首次读取功率微调失败", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("❌ 读取失败", e);
|
console.warn("❌ 读取失败", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!cancelled && mountedRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 显示读取成功提示 2 秒
|
if (!cancelled && mountedRef.current && shouldShowReadSuccess) {
|
||||||
setReadSuccessToast(true);
|
setReadSuccessToast(true);
|
||||||
setTimeout(() => setReadSuccessToast(false), 2000);
|
if (readSuccessTimeoutRef.current) {
|
||||||
|
clearTimeout(readSuccessTimeoutRef.current);
|
||||||
|
}
|
||||||
|
readSuccessTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setReadSuccessToast(false);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
initialInfoLoadedRef.current = false;
|
||||||
|
};
|
||||||
}, [peripheral]);
|
}, [peripheral]);
|
||||||
|
|
||||||
|
|
||||||
@ -274,59 +505,19 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
|
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscribeToPowerData = async () => {
|
if (peripheral && deviceReadyRef.current && initialInfoLoadedRef.current) {
|
||||||
try {
|
subscribePowerDataIfNeeded().catch((err) => {
|
||||||
// 确保设备已经连接
|
|
||||||
if (!isConnected) return;
|
|
||||||
|
|
||||||
console.log("✅ 订阅 2A63 特性通知...");
|
|
||||||
|
|
||||||
// 先取消之前的订阅(防止重复订阅)
|
|
||||||
await Central.unsubscribeCharacteristic(
|
|
||||||
peripheral,
|
|
||||||
fullUUID("1818"),
|
|
||||||
fullUUID("2a63")
|
|
||||||
).catch(() => { });
|
|
||||||
|
|
||||||
// 订阅 2A63 特性通知
|
|
||||||
await Central.subscribeCharacteristic(
|
|
||||||
peripheral,
|
|
||||||
fullUUID("1818"),
|
|
||||||
fullUUID("2a63"),
|
|
||||||
(notifyEv) => {
|
|
||||||
try {
|
|
||||||
const raw = notifyEv.value;
|
|
||||||
if (raw) {
|
|
||||||
// 将 ArrayBuffer 转换为字节数组
|
|
||||||
const byteArray = new Uint8Array(raw);
|
|
||||||
|
|
||||||
// 解析数据
|
|
||||||
parseData(byteArray);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("❌ 处理通知数据失败", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ 已订阅 2A63 特性通知");
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("❌ 订阅 2A63 特性失败", err);
|
console.warn("❌ 订阅 2A63 特性失败", err);
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// 只在设备已连接时进行订阅
|
|
||||||
if (peripheral && isConnected) {
|
|
||||||
subscribeToPowerData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理订阅(当组件卸载或者连接状态变化时)
|
|
||||||
return () => {
|
return () => {
|
||||||
if (peripheral && isConnected) {
|
if (peripheral && powerDataSubscribedRef.current) {
|
||||||
|
powerDataSubscribedRef.current = false;
|
||||||
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
|
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [peripheral, isConnected]);
|
}, [peripheral, isDeviceReady]);
|
||||||
|
|
||||||
const cadenceStateRef = useRef({
|
const cadenceStateRef = useRef({
|
||||||
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
|
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
|
||||||
@ -442,7 +633,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
// ========== 写入功率微调 ==========
|
// ========== 写入功率微调 ==========
|
||||||
const updatePowerTrim = async () => {
|
const updatePowerTrim = async () => {
|
||||||
const val = parseInt(inputTrim);
|
const val = Number(inputTrim);
|
||||||
if (isNaN(val) || val < 50 || val > 200) {
|
if (isNaN(val) || val < 50 || val > 200) {
|
||||||
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
|
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
|
||||||
return;
|
return;
|
||||||
@ -458,13 +649,16 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setPowerTrimLoading(true);
|
setPowerTrimLoading(true);
|
||||||
|
const scaledVal = Math.round(val * 100);
|
||||||
|
const low = scaledVal & 0xff;
|
||||||
|
const high = (scaledVal >> 8) & 0xff;
|
||||||
|
|
||||||
console.log("🚀 写入功率微调", val);
|
console.log("🚀 写入功率微调", val, "=>", scaledVal);
|
||||||
await Central.writeCharacteristic(
|
await Central.writeCharacteristic(
|
||||||
peripheral,
|
peripheral,
|
||||||
fullUUID(powerServiceUuid),
|
fullUUID(powerServiceUuid),
|
||||||
fullUUID(powerWriteUuid),
|
fullUUID(powerWriteUuid),
|
||||||
new Uint8Array([0x02, val]).buffer,
|
new Uint8Array([0x02, low, high]).buffer,
|
||||||
{ withoutResponse: false }
|
{ withoutResponse: false }
|
||||||
);
|
);
|
||||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
||||||
@ -483,6 +677,8 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("❌ 写入失败", err);
|
console.warn("❌ 写入失败", err);
|
||||||
Alert.alert(t('info.writeFailed'));
|
Alert.alert(t('info.writeFailed'));
|
||||||
|
} finally {
|
||||||
|
setPowerTrimLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -584,12 +780,22 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
deviceReadyRef.current = false;
|
||||||
|
initialInfoLoadedRef.current = false;
|
||||||
|
powerDataSubscribedRef.current = false;
|
||||||
|
readyResolverRef.current = null;
|
||||||
// 清理校准超时定时器
|
// 清理校准超时定时器
|
||||||
if (calibrationTimeoutRef.current) {
|
if (calibrationTimeoutRef.current) {
|
||||||
clearTimeout(calibrationTimeoutRef.current);
|
clearTimeout(calibrationTimeoutRef.current);
|
||||||
calibrationTimeoutRef.current = null;
|
calibrationTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (readSuccessTimeoutRef.current) {
|
||||||
|
clearTimeout(readSuccessTimeoutRef.current);
|
||||||
|
readSuccessTimeoutRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -715,8 +921,10 @@ export default function InfoScreen({ route, navigation }: Props) {
|
|||||||
// 蓝牙已连接,正常跳转 DFU
|
// 蓝牙已连接,正常跳转 DFU
|
||||||
navigation.navigate("Dfu", {
|
navigation.navigate("Dfu", {
|
||||||
deviceId: deviceKey,
|
deviceId: deviceKey,
|
||||||
name: peripheral.name,
|
systemId: peripheral.systemId,
|
||||||
firmware,
|
address: peripheral.address,
|
||||||
|
name: peripheral.name ?? "",
|
||||||
|
firmware: firmware ?? "",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
style={styles.pressable}
|
style={styles.pressable}
|
||||||
|
|||||||
1227
src/InfoScreen2.tsx
Normal file
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 React, { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@ -76,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
|
|||||||
|
|
||||||
// 更新或添加设备,并记录最后扫描时间
|
// 更新或添加设备,并记录最后扫描时间
|
||||||
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
||||||
if (!p?.name || !p.name.startsWith("POWERFUN")) return;
|
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
|
||||||
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
||||||
|
|
||||||
setDevices((prev) => {
|
setDevices((prev) => {
|
||||||
|
|||||||
238
src/ScanScreen2.tsx
Normal file
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
|
i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
compatibilityJSON: 'v3',
|
compatibilityJSON: 'v4',
|
||||||
resources: {
|
resources: {
|
||||||
zh: { translation: zh },
|
zh: { translation: zh },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
|
|||||||
@ -9,7 +9,10 @@
|
|||||||
"title": "POWERFUN Settings",
|
"title": "POWERFUN Settings",
|
||||||
"scan": "Scan Devices",
|
"scan": "Scan Devices",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
"version": "Version v0.0.1"
|
"version": "Version v0.0.1",
|
||||||
|
"powerMeter": "Power Meter",
|
||||||
|
"paddle": "Paddle",
|
||||||
|
"T5trainer": "T5 Trainer"
|
||||||
},
|
},
|
||||||
"scan": {
|
"scan": {
|
||||||
"title": "Scan Devices",
|
"title": "Scan Devices",
|
||||||
@ -19,6 +22,141 @@
|
|||||||
"tipBluetooth": "(Please enable Bluetooth in settings)",
|
"tipBluetooth": "(Please enable Bluetooth in settings)",
|
||||||
"noName": "[No Name]",
|
"noName": "[No Name]",
|
||||||
"rssiUnit": "dBm"
|
"rssiUnit": "dBm"
|
||||||
|
|
||||||
|
},
|
||||||
|
"t5Scan": {
|
||||||
|
"title": "Scan T5 Trainer",
|
||||||
|
"scanning": "Scanning...",
|
||||||
|
"noDevice": "No T5 trainers found",
|
||||||
|
"tipScanning": "(Make sure the trainer is powered on and awake)",
|
||||||
|
"tipBluetooth": "(Please enable Bluetooth in settings)"
|
||||||
|
},
|
||||||
|
"paddleScan": {
|
||||||
|
"title": "Scan Paddle",
|
||||||
|
"scanning": "Scanning...",
|
||||||
|
"noDevice": "No paddle devices found",
|
||||||
|
"tipScanning": "(Make sure the paddle device is powered on and awake)",
|
||||||
|
"tipBluetooth": "(Please enable Bluetooth in settings)",
|
||||||
|
"noName": "[No Name]",
|
||||||
|
"rssiUnit": "dBm"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"notice": "Notice",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"unknownDevice": "Unknown Device",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"info2": {
|
||||||
|
"waitingAction": "Waiting",
|
||||||
|
"notMatched": "Not Matched",
|
||||||
|
"checkFailed": "Check Failed",
|
||||||
|
"unknownBoatType": "Unknown boat type",
|
||||||
|
"readAbnormal": "Unexpected response: {{hex}}",
|
||||||
|
"writingBoat": "Writing {{boatName}}...",
|
||||||
|
"writtenWaitRead": "Written {{boatName}}, reading back in 200ms...",
|
||||||
|
"requestingBoatRead": "Requesting boat type...",
|
||||||
|
"setSuccess": "Set success: {{boatName}}",
|
||||||
|
"notMatch": "Mismatch: expected {{expected}}, actual {{actual}}",
|
||||||
|
"readResult": "Read result: {{hex}}",
|
||||||
|
"actionFailed": "Action failed: {{message}}",
|
||||||
|
"failed": "Failed",
|
||||||
|
"waitFff3Timeout": "Timeout waiting for FFF3 response",
|
||||||
|
"currentBoat": "✅ Current boat type: {{boatName}}",
|
||||||
|
"readCurrentBoat": "Current boat type read: {{boatName}}",
|
||||||
|
"disconnectedNeedReconnect": "Device disconnected. Reconnect before upgrading.",
|
||||||
|
"defaultReadFailed": "Connected, but initial boat type read failed",
|
||||||
|
"connectOrReadFailed": "Connection or read failed",
|
||||||
|
"connectOrReadFailedAlert": "Bluetooth connection failed or device info read failed",
|
||||||
|
"cadence": "Cadence",
|
||||||
|
"cadenceUnit": "RPM (strokes/min)",
|
||||||
|
"boatSelect": "Boat Type",
|
||||||
|
"boatSwitching": "Switching boat type...",
|
||||||
|
"retry": "Please retry",
|
||||||
|
"boatKayak": "Kayak",
|
||||||
|
"boatRowing": "Rowing",
|
||||||
|
"boatRacing": "Racing",
|
||||||
|
"latestFirmware": "Latest Firmware",
|
||||||
|
"checking": "Checking..."
|
||||||
|
},
|
||||||
|
"info3": {
|
||||||
|
"bikeTypeFollow": "Follow Software",
|
||||||
|
"bikeTypeRoad": "Road Bike",
|
||||||
|
"bikeTypeMtb26": "Mountain Bike 26\"",
|
||||||
|
"bikeTypeMtb275": "Mountain Bike 27.5\"",
|
||||||
|
"bikeTypeMtb29": "Mountain Bike 29\"",
|
||||||
|
"bikeTypeSmallWheel": "Small Wheel Bike",
|
||||||
|
"ergOn": "On",
|
||||||
|
"ergOff": "Off",
|
||||||
|
"readUsedHoursTimeout": "Timeout reading usage hours",
|
||||||
|
"readUsedMileageTimeout": "Timeout reading usage mileage",
|
||||||
|
"powerTrimRangeError": "Please enter a value between 50.00 and 200.00 with up to 2 decimals",
|
||||||
|
"deviceNotReady": "Device is not ready yet. Please try again later",
|
||||||
|
"invalidWeight": "Please enter a valid weight",
|
||||||
|
"waitFff3Timeout": "Timeout waiting for FFF3 response",
|
||||||
|
"readPowerTrimTimeout": "Timeout reading current power trim",
|
||||||
|
"waitWeightAckTimeout": "Timeout waiting for weight setting confirmation",
|
||||||
|
"readWeightTimeout": "Timeout reading current weight",
|
||||||
|
"readBikeTypeTimeout": "Timeout reading bike type",
|
||||||
|
"waitBikeTypeAckTimeout": "Timeout waiting for bike type confirmation",
|
||||||
|
"readErgTimeout": "Timeout reading ERG smoothing",
|
||||||
|
"waitErgAckTimeout": "Timeout waiting for ERG smoothing confirmation",
|
||||||
|
"connectReadFailed": "Device connection or read failed",
|
||||||
|
"readFailed": "Read failed",
|
||||||
|
"connecting": "Connecting",
|
||||||
|
"pendingTag": "Pending",
|
||||||
|
"settingTag": "Applying",
|
||||||
|
"currentWeightValue": "Current: {{value}} kg",
|
||||||
|
"currentPowerTrimValue": "Current: {{value}} %",
|
||||||
|
"currentTextValue": "Current: {{value}}",
|
||||||
|
"usedMileage": "Usage Mileage",
|
||||||
|
"speedKph": "Speed/km/h",
|
||||||
|
"weightSetting": "Weight Setting",
|
||||||
|
"powerTrim": "Power Trim",
|
||||||
|
"bikeType": "Bike Type",
|
||||||
|
"ergSmooth": "ERG Smoothing",
|
||||||
|
"settingWeight": "Setting weight...",
|
||||||
|
"weightSetDone": "Weight set successfully",
|
||||||
|
"weightSetFailed": "Weight setting failed",
|
||||||
|
"settingPowerTrim": "Setting power trim...",
|
||||||
|
"powerTrimSetDone": "Power trim set successfully",
|
||||||
|
"powerTrimSetFailed": "Power trim setting failed",
|
||||||
|
"settingBikeType": "Setting bike type...",
|
||||||
|
"bikeTypeSetDone": "Bike type set successfully",
|
||||||
|
"bikeTypeSetFailed": "Bike type setting failed",
|
||||||
|
"settingErgSmooth": "Setting ERG smoothing...",
|
||||||
|
"ergSmoothSetDone": "ERG smoothing set successfully",
|
||||||
|
"ergSmoothSetFailed": "ERG smoothing setting failed",
|
||||||
|
"confirmWeightChange": "Set weight to {{value}}kg?",
|
||||||
|
"confirmPowerTrimChange": "Set power trim to {{value}}%?",
|
||||||
|
"helpText": "Hold to view help"
|
||||||
|
},
|
||||||
|
"spindown": {
|
||||||
|
"title": "Spindown",
|
||||||
|
"headerTitle": "Trainer Spindown",
|
||||||
|
"connecting": "Connecting",
|
||||||
|
"connected": "Connected",
|
||||||
|
"targetLabel": "Target Speed",
|
||||||
|
"targetHint": "Ride to reach the target speed first",
|
||||||
|
"currentSpeed": "Current Speed",
|
||||||
|
"statusReach36": "Ride to reach {{speed}} km/h",
|
||||||
|
"statusReached36": "Reached {{high}} km/h. Stop pedaling and wait until speed drops to {{low}} km/h",
|
||||||
|
"statusCalibrating": "Spindown in progress, please wait",
|
||||||
|
"statusCompleted": "Spindown completed",
|
||||||
|
"deviceNotReady": "Device not ready, please try again",
|
||||||
|
"connectFailed": "Failed to connect. Please go back and try again",
|
||||||
|
"timeout": "Timed out waiting for spindown time",
|
||||||
|
"failedRetry": "Spindown failed, please try again",
|
||||||
|
"step1Title": "Ride to {{speed}} km/h",
|
||||||
|
"step1Desc": "Automatically proceeds after reaching the target speed",
|
||||||
|
"step2Title": "Stop pedaling and coast",
|
||||||
|
"step2Desc": "Starts spindown automatically when speed drops to {{speed}} km/h",
|
||||||
|
"step3Title": "Spindown Completed",
|
||||||
|
"step3Loading": "Reading spindown time...",
|
||||||
|
"step3Pending": "Will appear here after completion",
|
||||||
|
"loading": "Connecting and preparing spindown...",
|
||||||
|
"result": "Spindown completed, time {{seconds}}s",
|
||||||
|
"retry": "Restart Spindown"
|
||||||
},
|
},
|
||||||
"dfu": {
|
"dfu": {
|
||||||
"title": "Firmware Update",
|
"title": "Firmware Update",
|
||||||
|
|||||||
@ -9,7 +9,10 @@
|
|||||||
"title": "POWERFUN设置",
|
"title": "POWERFUN设置",
|
||||||
"scan": "搜索设备",
|
"scan": "搜索设备",
|
||||||
"privacy": "隐私协议",
|
"privacy": "隐私协议",
|
||||||
"version": "版本号 v0.0.1"
|
"version": "版本号 v0.0.1",
|
||||||
|
"powerMeter": "功率计",
|
||||||
|
"paddle": "桨频器",
|
||||||
|
"T5trainer": "T5骑行台"
|
||||||
},
|
},
|
||||||
"scan": {
|
"scan": {
|
||||||
"title": "搜索设备",
|
"title": "搜索设备",
|
||||||
@ -20,6 +23,140 @@
|
|||||||
"noName": "[无名称]",
|
"noName": "[无名称]",
|
||||||
"rssiUnit": "dBm"
|
"rssiUnit": "dBm"
|
||||||
},
|
},
|
||||||
|
"t5Scan": {
|
||||||
|
"title": "搜索T5骑行台",
|
||||||
|
"scanning": "搜索中...",
|
||||||
|
"noDevice": "暂无T5骑行台设备",
|
||||||
|
"tipScanning": "(请确保骑行台有电且被唤醒)",
|
||||||
|
"tipBluetooth": "(请在设置中打开蓝牙)"
|
||||||
|
},
|
||||||
|
"paddleScan": {
|
||||||
|
"title": "搜索桨频器",
|
||||||
|
"scanning": "搜索中...",
|
||||||
|
"noDevice": "暂无桨频器设备",
|
||||||
|
"tipScanning": "(请确保桨频器设备有电且被唤醒)",
|
||||||
|
"tipBluetooth": "(请在设置中打开蓝牙)",
|
||||||
|
"noName": "[无名称]",
|
||||||
|
"rssiUnit": "dBm"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"notice": "提示",
|
||||||
|
"unknown": "未知",
|
||||||
|
"unknownDevice": "未知设备",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否"
|
||||||
|
},
|
||||||
|
"info2": {
|
||||||
|
"waitingAction": "等待操作",
|
||||||
|
"notMatched": "未匹配",
|
||||||
|
"checkFailed": "检查失败",
|
||||||
|
"unknownBoatType": "读取到未知船型",
|
||||||
|
"readAbnormal": "读取返回异常:{{hex}}",
|
||||||
|
"writingBoat": "正在写入{{boatName}}...",
|
||||||
|
"writtenWaitRead": "已写入{{boatName}},200ms后读取确认...",
|
||||||
|
"requestingBoatRead": "正在请求读取船型...",
|
||||||
|
"setSuccess": "设置成功:{{boatName}}",
|
||||||
|
"notMatch": "不一致:期望 {{expected}},实际 {{actual}}",
|
||||||
|
"readResult": "读取返回:{{hex}}",
|
||||||
|
"actionFailed": "操作失败:{{message}}",
|
||||||
|
"failed": "失败",
|
||||||
|
"waitFff3Timeout": "等待 FFF3 返回超时",
|
||||||
|
"currentBoat": "✅ 当前船型:{{boatName}}",
|
||||||
|
"readCurrentBoat": "已读取当前船型:{{boatName}}",
|
||||||
|
"disconnectedNeedReconnect": "设备已断开,请重新连接",
|
||||||
|
"defaultReadFailed": "已连接,但默认读取船型失败",
|
||||||
|
"connectOrReadFailed": "连接或读取失败",
|
||||||
|
"connectOrReadFailedAlert": "蓝牙连接失败或设备信息读取失败",
|
||||||
|
"cadence": "桨频",
|
||||||
|
"cadenceUnit": "RPM(次/分钟)",
|
||||||
|
"boatSelect": "船型选择",
|
||||||
|
"boatSwitching": "船型切换中...",
|
||||||
|
"retry": "请重试",
|
||||||
|
"boatKayak": "皮艇",
|
||||||
|
"boatRowing": "划艇",
|
||||||
|
"boatRacing": "赛艇",
|
||||||
|
"latestFirmware": "最新固件",
|
||||||
|
"checking": "检查中..."
|
||||||
|
},
|
||||||
|
"info3": {
|
||||||
|
"bikeTypeFollow": "跟随软件",
|
||||||
|
"bikeTypeRoad": "公路车",
|
||||||
|
"bikeTypeMtb26": "山地车26寸",
|
||||||
|
"bikeTypeMtb275": "山地车27.5寸",
|
||||||
|
"bikeTypeMtb29": "山地车29寸",
|
||||||
|
"bikeTypeSmallWheel": "小轮车",
|
||||||
|
"ergOn": "开启",
|
||||||
|
"ergOff": "关闭",
|
||||||
|
"readUsedMileageTimeout": "读取使用里程超时",
|
||||||
|
"powerTrimRangeError": "请输入50.00-200.00之间的数,最多保留两位小数",
|
||||||
|
"deviceNotReady": "设备尚未准备完成,请稍后再试",
|
||||||
|
"invalidWeight": "请输入正确的体重",
|
||||||
|
"waitFff3Timeout": "等待FFF3返回超时",
|
||||||
|
"readPowerTrimTimeout": "读取当前功率微调超时",
|
||||||
|
"waitWeightAckTimeout": "等待体重设定确认超时",
|
||||||
|
"readWeightTimeout": "读取当前体重超时",
|
||||||
|
"readBikeTypeTimeout": "读取当前车型超时",
|
||||||
|
"waitBikeTypeAckTimeout": "等待车型设定确认超时",
|
||||||
|
"readErgTimeout": "读取ERG功率平滑超时",
|
||||||
|
"waitErgAckTimeout": "等待ERG功率平滑确认超时",
|
||||||
|
"connectReadFailed": "设备连接或读取失败",
|
||||||
|
"readFailed": "读取失败",
|
||||||
|
"connecting": "连接中",
|
||||||
|
"pendingTag": "待保存",
|
||||||
|
"settingTag": "设置中",
|
||||||
|
"currentWeightValue": "当前:{{value}} kg",
|
||||||
|
"currentPowerTrimValue": "当前:{{value}} %",
|
||||||
|
"currentTextValue": "当前:{{value}}",
|
||||||
|
"usedMileage": "使用里程",
|
||||||
|
"speedKph": "速度/km/h",
|
||||||
|
"weightSetting": "体重设定",
|
||||||
|
"powerTrim": "功率微调",
|
||||||
|
"bikeType": "车型选择",
|
||||||
|
"ergSmooth": "ERG功率平滑",
|
||||||
|
"settingWeight": "正在设置体重…",
|
||||||
|
"weightSetDone": "体重设定完成",
|
||||||
|
"weightSetFailed": "体重设定失败",
|
||||||
|
"settingPowerTrim": "正在设置功率微调…",
|
||||||
|
"powerTrimSetDone": "功率微调设定完成",
|
||||||
|
"powerTrimSetFailed": "功率微调设定失败",
|
||||||
|
"settingBikeType": "车型设定中",
|
||||||
|
"bikeTypeSetDone": "车型设定完成",
|
||||||
|
"bikeTypeSetFailed": "车型设定失败",
|
||||||
|
"settingErgSmooth": "ERG功率平滑设定中",
|
||||||
|
"ergSmoothSetDone": "ERG功率平滑设定完成",
|
||||||
|
"ergSmoothSetFailed": "ERG功率平滑设定失败",
|
||||||
|
"confirmWeightChange": "是否将体重改为{{value}}kg",
|
||||||
|
"confirmPowerTrimChange": "是否将功率微调改为{{value}}%",
|
||||||
|
"helpText": "按住查看说明"
|
||||||
|
},
|
||||||
|
"spindown": {
|
||||||
|
"title": "消旋",
|
||||||
|
"headerTitle": "骑行台消旋",
|
||||||
|
"connecting": "设备连接中",
|
||||||
|
"connected": "设备已连接",
|
||||||
|
"targetLabel": "目标速度",
|
||||||
|
"targetHint": "请先骑行加速到目标速度",
|
||||||
|
"currentSpeed": "当前速度",
|
||||||
|
"statusReach36": "请骑行加速到 {{speed}} km/h",
|
||||||
|
"statusReached36": "已达到 {{high}} km/h,请停止踩踏并等待速度下降到 {{low}} km/h",
|
||||||
|
"statusCalibrating": "正在消旋,请等待",
|
||||||
|
"statusCompleted": "消旋完成",
|
||||||
|
"deviceNotReady": "设备未准备好,请稍后重试",
|
||||||
|
"connectFailed": "设备连接失败,请返回重试",
|
||||||
|
"timeout": "等待消旋时间返回超时",
|
||||||
|
"failedRetry": "消旋失败,请重试",
|
||||||
|
"step1Title": "请骑行到 {{speed}} km/h",
|
||||||
|
"step1Desc": "达到目标速度后自动进入下一步",
|
||||||
|
"step2Title": "停止踩踏并等待减速",
|
||||||
|
"step2Desc": "速度下降到 {{speed}} km/h 后自动开始消旋",
|
||||||
|
"step3Title": "消旋完成",
|
||||||
|
"step3Loading": "正在读取消旋时间...",
|
||||||
|
"step3Pending": "完成前会显示在这里",
|
||||||
|
"loading": "正在连接设备并准备消旋...",
|
||||||
|
"result": "消旋完成,时间 {{seconds}}s",
|
||||||
|
"retry": "重新开始消旋"
|
||||||
|
},
|
||||||
|
|
||||||
"dfu": {
|
"dfu": {
|
||||||
"title": "固件升级",
|
"title": "固件升级",
|
||||||
"preparing": "准备中...",
|
"preparing": "准备中...",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user