Compare commits
No commits in common. "main" and "1.0.1" have entirely different histories.
@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -74,6 +74,3 @@ yarn-error.log
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Local SDK workspace
|
||||
sdk/
|
||||
|
||||
81
App.tsx
81
App.tsx
@ -1,55 +1,23 @@
|
||||
console.log("🔥 当前 App.tsx 已加载");
|
||||
import * as React from "react";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
|
||||
import HomeScreen from "./src/HomeScreen";
|
||||
import ScanScreen from "./src/ScanScreen";
|
||||
import ScanScreen2 from "./src/ScanScreen2";
|
||||
import ScanScreen3 from "./src/ScanScreen3";
|
||||
import InfoScreen from "./src/InfoScreen";
|
||||
import DfuScreen from "./src/DfuScreen";
|
||||
import PrivacyScreen from "./src/PrivacyScreen";
|
||||
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
|
||||
import pxToDp from "./src/helper/pxToDp";
|
||||
import SettingScreen from "./src/SettingScreen";
|
||||
import './src/i18n'
|
||||
import InfoScreen2 from "./src/InfoScreen2";
|
||||
import InfoScreen3 from "./src/InfoScreen3";
|
||||
import SpindownScreen from "./src/SpindownScreen";
|
||||
import { decode } from "base-64";
|
||||
|
||||
|
||||
// 不要 global.atob
|
||||
// 不要 atob
|
||||
|
||||
const base64ToBytes = (base64: string): number[] => {
|
||||
const binary = decode(base64);
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes.push(binary.charCodeAt(i));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export type RootStackParamList = {
|
||||
Splash: undefined;
|
||||
Home: undefined;
|
||||
Scan: undefined;
|
||||
ScanScreen2: undefined;
|
||||
ScanScreen3: undefined;
|
||||
Info: { peripheral: any };
|
||||
Info2: { peripheral: any };
|
||||
Info3: { peripheral: any };
|
||||
Spindown: { peripheral: any };
|
||||
Dfu: {
|
||||
deviceId: string;
|
||||
systemId?: string;
|
||||
address?: string | number;
|
||||
name: string;
|
||||
firmware: string;
|
||||
};
|
||||
Dfu: { deviceId: string; name: string; firmware: string };
|
||||
Privacy: undefined;
|
||||
Setting: undefined;
|
||||
};
|
||||
@ -57,23 +25,6 @@ export type RootStackParamList = {
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export default function App() {
|
||||
|
||||
//linshi
|
||||
React.useEffect(() => {
|
||||
const test = async () => {
|
||||
try {
|
||||
console.log("🔥 App.tsx fetch test start");
|
||||
const resp = await fetch("https://www.baidu.com");
|
||||
console.log("🔥 App.tsx fetch status =", resp.status);
|
||||
const text = await resp.text();
|
||||
console.log("🔥 App.tsx fetch text length =", text.length);
|
||||
} catch (e) {
|
||||
console.log("❌ App.tsx fetch error =", e);
|
||||
}
|
||||
};
|
||||
|
||||
test();
|
||||
}, []);
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
@ -81,11 +32,6 @@ export default function App() {
|
||||
screenOptions={{
|
||||
animation : 'slide_from_right'
|
||||
}}>
|
||||
<Stack.Screen
|
||||
name="Info2"
|
||||
component={InfoScreen2}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
{/* 启动页(无标题) */}
|
||||
<Stack.Screen
|
||||
name="Splash"
|
||||
@ -107,35 +53,12 @@ export default function App() {
|
||||
// options={{ title: "搜索设备" }}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ScanScreen2"
|
||||
component={ScanScreen2}
|
||||
// options={{ title: "搜索设备" }}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ScanScreen3"
|
||||
component={ScanScreen3}
|
||||
// options={{ title: "搜索设备" }}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Info"
|
||||
component={InfoScreen}
|
||||
// options={{ title: "设备详情" }}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Info3"
|
||||
component={InfoScreen3}
|
||||
// options={{ title: "设备详情" }}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Spindown"
|
||||
component={SpindownScreen}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Dfu"
|
||||
component={DfuScreen}
|
||||
|
||||
109
CLAUDE.md
109
CLAUDE.md
@ -1,109 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
POWERFUN Settings App - A React Native application for managing POWERFUN fitness devices (power meters, paddle sensors, T5 trainers) via Bluetooth LE. Supports device scanning, information reading, calibration, firmware updates (DFU), and spindown calibration.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```sh
|
||||
yarn start # Start Metro bundler
|
||||
yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
yarn build-a # Android release build (set NODE_OPTIONS=--openssl-legacy-provider)
|
||||
yarn lint # Run ESLint
|
||||
yarn test # Run Jest tests
|
||||
yarn clean-a # Clean Android build (cd android && gradlew clean)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Navigation Structure
|
||||
- React Navigation Native Stack Navigator
|
||||
- Entry: SplashScreen → HomeScreen
|
||||
- Three device paths from HomeScreen: Power Meter, Paddle, T5 Trainer
|
||||
- Each path: ScanScreen → InfoScreen → DfuScreen
|
||||
- T5 has additional: SpindownScreen
|
||||
- Global: SettingScreen, PrivacyScreen
|
||||
|
||||
### Screen to File Mapping
|
||||
| Screen | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| HomeScreen | src/HomeScreen.tsx | Device type selection (3 buttons) |
|
||||
| ScanScreen | src/ScanScreen.tsx | Power meter BLE scanning |
|
||||
| ScanScreen2 | src/ScanScreen2.tsx | Paddle BLE scanning |
|
||||
| ScanScreen3 | src/ScanScreen3.tsx | T5 trainer BLE scanning |
|
||||
| InfoScreen | src/InfoScreen.tsx | Power meter info, real-time data, calibration, power trim |
|
||||
| InfoScreen2 | src/InfoScreen2.tsx | Paddle boat type settings |
|
||||
| InfoScreen3 | src/InfoScreen3.tsx | T5 trainer weight, bike type, ERG smoothing |
|
||||
| DfuScreen | src/DfuScreen.tsx | Nordic DFU firmware upgrade |
|
||||
| SpindownScreen | src/SpindownScreen.tsx | T5 trainer spindown calibration |
|
||||
| SettingScreen | src/SettingScreen.tsx | Language, privacy policy |
|
||||
| PrivacyScreen | src/PrivacyScreen.tsx | Privacy policy content |
|
||||
|
||||
### Bluetooth LE Architecture
|
||||
- Library: `@systemic-games/react-native-bluetooth-le`
|
||||
- Central API for scanning, connecting, reading/writing characteristics, subscribing to notifications
|
||||
- Device name prefixes for filtering:
|
||||
- Power meters: `PF-PM5-` or `POWERFUN-`
|
||||
- Paddles: `PF-STK-` or `POWERFUN-`
|
||||
- T5 trainers: `PF-T5-` or `POWERFUN-`
|
||||
- RSSI filtering: devices below -90 dBm are filtered out
|
||||
|
||||
### BLE Service/Characteristic UUIDs
|
||||
| Service | Characteristic | Purpose |
|
||||
|---------|----------------|---------|
|
||||
| fff1 | fff2 (write) | Custom power meter commands |
|
||||
| fff1 | fff3 (notify) | Custom power meter responses |
|
||||
| 180a | 2a25 | Serial number |
|
||||
| 180a | 2a28 | Firmware version |
|
||||
| 180a | 2a27 | Hardware version |
|
||||
| 180f | 2a19 | Battery level |
|
||||
| 1818 | 2a63 | Cycling power measurement (real-time) |
|
||||
|
||||
### DFU Architecture
|
||||
- Library: `@systemic-games/react-native-nordic-nrf5-dfu`
|
||||
- Firmware manifest: `https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json`
|
||||
- Firmware versions parsed as `hardware.iteration.build` (e.g., "1.23.456")
|
||||
- Upgrade only allowed when server iteration > device iteration
|
||||
- Downloads firmware zip to cache, then initiates DFU
|
||||
|
||||
### i18n Architecture
|
||||
- Library: i18next with react-i18next
|
||||
- Languages: Chinese (zh) and English (en)
|
||||
- Storage key: `@app_language` in AsyncStorage
|
||||
- Translation files: `src/i18n/locales/zh.json`, `src/i18n/locales/en.json`
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
### Bluetooth Connection Lifecycle
|
||||
- InfoScreens connect to device on mount, disconnect on navigation away (except when going to DfuScreen)
|
||||
- Connection status listener: `Central.addListener("peripheralConnectionStatus", handler)`
|
||||
- Device ready state: `connectionStatus === "ready"`
|
||||
|
||||
### Real-time Data Parsing (Power Meter - 2A63)
|
||||
- Byte 2-3: Power value (little-endian)
|
||||
- Byte 4: Left balance (0.5% units)
|
||||
- Byte 5-6: Cadence revolution count (little-endian)
|
||||
- Byte 7-8: Cadence timestamp (1/1024 second units)
|
||||
- Cadence RPM = (revolution_diff / time_in_seconds) * 60
|
||||
|
||||
### Power Trim
|
||||
- Range: 50-200 (represents 50%-200%)
|
||||
- Stored as: value * 100, sent as 2-byte little-endian
|
||||
- Protocol: Write [0x02, low, high] to FFF2, then write [0x04] to trigger update
|
||||
|
||||
### Calibration
|
||||
- Command: Write 0x05 to FFF2
|
||||
- Response: Read from FFF3 notify (0x05 + 2-byte value in 0.1 units)
|
||||
- Timeout: 5 seconds
|
||||
|
||||
### Firmware Version Comparison
|
||||
```typescript
|
||||
// Version format: "hardware.iteration.build" (e.g., "1.23.456")
|
||||
// Only iteration is compared for upgrade decision
|
||||
// hardware mismatch → cannot upgrade
|
||||
// server iteration <= device iteration → no upgrade needed
|
||||
```
|
||||
@ -83,7 +83,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionName "1.0.2"
|
||||
versionName "1.0.1"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
||||
@ -42,3 +42,7 @@ hermesEnabled=true
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=false
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -2316,7 +2316,7 @@ PODS:
|
||||
- SocketRocket
|
||||
- RNCAsyncStorage (2.2.0):
|
||||
- React-Core
|
||||
- RNDeviceInfo (15.0.2):
|
||||
- RNDeviceInfo (15.0.1):
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
@ -2707,7 +2707,7 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
|
||||
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
|
||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
|
||||
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
|
||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
|
||||
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e
|
||||
|
||||
@ -207,14 +207,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
|
||||
@ -250,14 +246,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
|
||||
@ -305,7 +297,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@ -334,7 +326,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
||||
@ -41,10 +41,6 @@
|
||||
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<false/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@ -57,5 +53,9 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
737
package-lock.json
generated
737
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,7 @@
|
||||
"base-64": "^1.0.0",
|
||||
"i18next": "^25.7.3",
|
||||
"react": "19.1.0",
|
||||
"react-i18next": "16.5.0",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-device-info": "^15.0.1",
|
||||
"react-native-fs": "^2.20.0",
|
||||
@ -37,9 +37,9 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "15.0.0",
|
||||
"@react-native-community/cli-platform-android": "15.0.0",
|
||||
"@react-native-community/cli-platform-ios": "15.0.0",
|
||||
"@react-native-community/cli": "20.0.0",
|
||||
"@react-native-community/cli-platform-android": "20.0.0",
|
||||
"@react-native-community/cli-platform-ios": "20.0.0",
|
||||
"@react-native/babel-preset": "0.81.4",
|
||||
"@react-native/eslint-config": "0.81.4",
|
||||
"@react-native/metro-config": "0.81.4",
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { View, Text, StyleSheet, Alert, Platform } from "react-native";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, StyleSheet, Alert, BackHandler } from "react-native";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
import RNFS from "react-native-fs";
|
||||
import {
|
||||
startDfu,
|
||||
getDfuTargetId,
|
||||
addDfuEventListener,
|
||||
DfuProgressEvent,
|
||||
DfuStateEvent,
|
||||
} from "@systemic-games/react-native-nordic-nrf5-dfu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MyStatusbar from "./component/MyStatusbar";
|
||||
import MyHeader from "./component/MyHeader";
|
||||
|
||||
@ -22,429 +16,162 @@ interface DeviceInfo {
|
||||
download: string;
|
||||
}
|
||||
|
||||
interface ParsedFirmware {
|
||||
hardware: number;
|
||||
iteration: number;
|
||||
build: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
const trace = (step: string, payload?: unknown) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
if (payload === undefined) {
|
||||
console.log(`[DFU-TRACE ${timestamp}] ${step}`);
|
||||
} else {
|
||||
console.log(`[DFU-TRACE ${timestamp}] ${step}`, payload);
|
||||
}
|
||||
};
|
||||
|
||||
export default function DfuScreen({ route, navigation }: Props) {
|
||||
const {
|
||||
deviceId,
|
||||
systemId,
|
||||
address,
|
||||
name,
|
||||
firmware: deviceFirmware,
|
||||
} = route.params;
|
||||
const { t } = useTranslation();
|
||||
const { deviceId, name, firmware: deviceFirmware } = route.params;
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [state, setState] = useState(t("dfu.preparing"));
|
||||
const [state, setState] = useState(t('dfu.preparing'));
|
||||
const [error, setError] = useState<string>();
|
||||
const [latestVersion, setLatestVersion] = useState(t("dfu.reading"));
|
||||
const [latestVersion, setLatestVersion] = useState<string>(t('dfu.reading'));
|
||||
const [isDfuRunning, setIsDfuRunning] = useState(false);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const mapDfuStateToLabel = (s: string): string => {
|
||||
switch (s) {
|
||||
case "connecting":
|
||||
return t("dfu.stateConnecting");
|
||||
case "starting":
|
||||
return t("dfu.stateStarting");
|
||||
case "enablingDfuMode":
|
||||
return t("dfu.stateEnablingDfuMode");
|
||||
case "uploading":
|
||||
return t("dfu.stateUploading");
|
||||
case "validating":
|
||||
return t("dfu.stateValidating");
|
||||
case "disconnecting":
|
||||
return t("dfu.stateDisconnecting");
|
||||
case "completed":
|
||||
return t("dfu.stateCompleted");
|
||||
case "aborted":
|
||||
return t("dfu.stateAborted");
|
||||
const mapDfuStateToChinese = (state: string): string => {
|
||||
switch (state) {
|
||||
case "connecting": return t('dfu.stateConnecting');
|
||||
case "starting": return t('dfu.stateStarting');
|
||||
case "enablingDfuMode": return t('dfu.stateEnablingDfuMode');
|
||||
case "uploading": return t('dfu.stateUploading');
|
||||
case "validating": return t('dfu.stateValidating');
|
||||
case "disconnecting": return t('dfu.stateDisconnecting');
|
||||
case "completed": return t('dfu.stateCompleted');
|
||||
case "aborted": return t('dfu.stateAborted');
|
||||
case "failed":
|
||||
case "dfu_failed":
|
||||
return t("dfu.stateFailed");
|
||||
case "initializing":
|
||||
return t("dfu.stateInitializing");
|
||||
case "errored":
|
||||
return t("dfu.stateErrored");
|
||||
default:
|
||||
return s;
|
||||
case "dfu_failed": return t('dfu.stateFailed');
|
||||
case "initializing": return t('dfu.stateInitializing');
|
||||
case "errored": return t('dfu.stateErrored');
|
||||
default: return state;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ✅ 拦截所有导航返回(iOS + Android)
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener("beforeRemove", (e) => {
|
||||
if (!isDfuRunning) return;
|
||||
|
||||
// 阻止返回
|
||||
e.preventDefault();
|
||||
Alert.alert(t("dfu.pleaseWait"), t("dfu.doNotReturn"));
|
||||
Alert.alert(t('dfu.pleaseWait'), t('dfu.doNotReturn'));
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, isDfuRunning, t]);
|
||||
}, [navigation, isDfuRunning]);
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
trace("screen mounted", {
|
||||
routeParams: {
|
||||
deviceId,
|
||||
name,
|
||||
deviceFirmware,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
trace("screen unmounted");
|
||||
};
|
||||
}, [deviceId, name, deviceFirmware]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = addDfuEventListener("log", (ev) => {
|
||||
trace("dfu native log", ev);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) {
|
||||
trace("runDfu duplicate effect skipped");
|
||||
return;
|
||||
}
|
||||
startedRef.current = true;
|
||||
|
||||
const runDfu = async () => {
|
||||
try {
|
||||
trace("runDfu begin");
|
||||
setIsDfuRunning(true);
|
||||
setError(undefined);
|
||||
|
||||
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: Number.isFinite(rawAddress) ? rawAddress : undefined,
|
||||
});
|
||||
|
||||
const firmwareText = String(deviceFirmware ?? "").trim();
|
||||
|
||||
trace("resolved input", {
|
||||
rawDeviceId,
|
||||
rawSystemId,
|
||||
rawAddress,
|
||||
safeDeviceId,
|
||||
firmwareText,
|
||||
});
|
||||
|
||||
if (!safeDeviceId) {
|
||||
throw new Error(t("dfu.invalidTargetDeviceId"));
|
||||
}
|
||||
|
||||
const currentFw = parseFirmwareVersion(firmwareText);
|
||||
trace("parsed current firmware", currentFw);
|
||||
|
||||
const manifestUrl =
|
||||
"https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
|
||||
const manifestUrl = "https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
|
||||
const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
|
||||
|
||||
trace("manifest fetch start", { manifestUrl, manifestPath });
|
||||
const manifestResp = await fetch(manifestUrl);
|
||||
trace("manifest fetch response", { status: manifestResp.status });
|
||||
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise;
|
||||
const manifestContent = await RNFS.readFile(manifestPath);
|
||||
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] };
|
||||
|
||||
if (!manifestResp.ok) {
|
||||
throw new Error(
|
||||
t("dfu.manifestDownloadFailed", { status: manifestResp.status })
|
||||
);
|
||||
}
|
||||
|
||||
const manifestText = await manifestResp.text();
|
||||
trace("manifest text loaded", { length: manifestText.length });
|
||||
|
||||
await RNFS.writeFile(manifestPath, manifestText, "utf8");
|
||||
trace("manifest saved", { manifestPath });
|
||||
|
||||
let manifest: { devices: DeviceInfo[] };
|
||||
|
||||
try {
|
||||
manifest = JSON.parse(manifestText) as { devices: DeviceInfo[] };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
t("dfu.manifestInvalidJson", {
|
||||
preview: manifestText.slice(0, 200),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const deviceInfo = manifest.devices.find(
|
||||
(d) => d.hardware === currentFw.hardware
|
||||
);
|
||||
|
||||
trace("manifest device matched", { deviceInfo });
|
||||
const [deviceHWStr, deviceFWStr] = deviceFirmware.split(".");
|
||||
const deviceHW = parseInt(deviceHWStr);
|
||||
const deviceFW = parseInt(deviceFWStr);
|
||||
|
||||
const deviceInfo = manifest.devices.find(d => d.hardware === deviceHW);
|
||||
if (!deviceInfo) {
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert(
|
||||
t("dfu.cannotUpgrade"),
|
||||
t("dfu.hardwareNotFound", { hardware: currentFw.hardware }),
|
||||
[{ text: t("dfu.confirm"), onPress: () => navigation.goBack() }]
|
||||
);
|
||||
Alert.alert(t('dfu.cannotUpgrade'), t('dfu.hardwareNotFound', { hardware: deviceHW }), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLatestVersion(deviceInfo.latestFirmware);
|
||||
|
||||
const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
|
||||
trace("parsed latest firmware", latestFw);
|
||||
const [, latestFWStr] = deviceInfo.latestFirmware.split(".");
|
||||
const latestFW = parseInt(latestFWStr);
|
||||
|
||||
if (latestFw.hardware !== currentFw.hardware) {
|
||||
throw new Error(
|
||||
t("dfu.hardwareMismatch", {
|
||||
current: currentFw.hardware,
|
||||
latest: latestFw.hardware,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (latestFw.iteration <= currentFw.iteration) {
|
||||
if (latestFW <= deviceFW) {
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert(t("dfu.noNeedUpgrade"), t("dfu.alreadyLatest"), [
|
||||
{ text: t("dfu.confirm"), onPress: () => navigation.goBack() },
|
||||
Alert.alert(t('dfu.noNeedUpgrade'), t('dfu.alreadyLatest'), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
trace("upgrade allowed", {
|
||||
currentIteration: currentFw.iteration,
|
||||
latestIteration: latestFw.iteration,
|
||||
});
|
||||
|
||||
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
|
||||
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise;
|
||||
|
||||
const oldFileExists = await RNFS.exists(localPath);
|
||||
if (oldFileExists) {
|
||||
await RNFS.unlink(localPath);
|
||||
trace("removed stale firmware zip", { localPath });
|
||||
}
|
||||
|
||||
trace("firmware download start", {
|
||||
downloadUrl: deviceInfo.download,
|
||||
localPath,
|
||||
await startDfu(deviceId, "file://" + localPath, {
|
||||
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state),
|
||||
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
|
||||
});
|
||||
|
||||
const downloadResult = await RNFS.downloadFile({
|
||||
fromUrl: deviceInfo.download,
|
||||
toFile: localPath,
|
||||
background: false,
|
||||
discretionary: false,
|
||||
}).promise;
|
||||
|
||||
trace("firmware download result", downloadResult);
|
||||
|
||||
if (downloadResult.statusCode !== 200) {
|
||||
throw new Error(
|
||||
t("dfu.firmwareDownloadFailed", {
|
||||
status: downloadResult.statusCode,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const fileExists = await RNFS.exists(localPath);
|
||||
trace("firmware exists check", { fileExists });
|
||||
|
||||
if (!fileExists) {
|
||||
throw new Error(t("dfu.firmwareFileMissing", { path: localPath }));
|
||||
}
|
||||
|
||||
const fileStat = await RNFS.stat(localPath);
|
||||
const fileSize = Number(fileStat.size ?? 0);
|
||||
trace("firmware stat", fileStat);
|
||||
|
||||
if (!Number.isFinite(fileSize) || fileSize <= 0) {
|
||||
throw new Error(
|
||||
t("dfu.firmwareFileInvalid", { size: String(fileStat.size) })
|
||||
);
|
||||
}
|
||||
|
||||
const dfuFilePath = "file://" + localPath;
|
||||
let iosBootloaderName: string | undefined;
|
||||
if (Platform.OS === "ios") {
|
||||
if (name?.startsWith("PF-PM5-")) {
|
||||
iosBootloaderName = "PF-PM5-DFU";
|
||||
} else if (name?.startsWith("PF-STK-")) {
|
||||
iosBootloaderName = "PF-STK-DFU";
|
||||
}
|
||||
}
|
||||
|
||||
trace("startDfu call", {
|
||||
safeDeviceId,
|
||||
dfuFilePath,
|
||||
localPath,
|
||||
fileSize,
|
||||
currentFirmware: currentFw.raw,
|
||||
latestFirmware: latestFw.raw,
|
||||
iosBootloaderName,
|
||||
});
|
||||
|
||||
await startDfu(safeDeviceId, dfuFilePath, {
|
||||
alternativeAdvertisingName: iosBootloaderName,
|
||||
dfuStateListener: (ev: DfuStateEvent) => {
|
||||
trace("dfu state", ev);
|
||||
setState(ev.state);
|
||||
},
|
||||
dfuProgressListener: (ev: DfuProgressEvent) => {
|
||||
trace("dfu progress", ev);
|
||||
setProgress(ev.percent);
|
||||
},
|
||||
});
|
||||
|
||||
trace("startDfu resolved successfully");
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert(t("dfu.upgradeSuccess"), t("dfu.upgradeSuccessMessage"), [
|
||||
{
|
||||
text: t("dfu.confirm"),
|
||||
onPress: () => {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: "Home" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
Alert.alert(t('dfu.upgradeSuccess'), t('dfu.upgradeSuccessMessage'), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.navigate("Home") },
|
||||
]);
|
||||
} catch (err: any) {
|
||||
trace("runDfu error", {
|
||||
message: err?.message,
|
||||
code: err?.code,
|
||||
name: err?.name,
|
||||
error: err,
|
||||
});
|
||||
setIsDfuRunning(false);
|
||||
setError(err?.message || t("dfu.dfuFailed"));
|
||||
Alert.alert(t("dfu.upgradeFailed"), err?.message || t("dfu.dfuFailed"), [
|
||||
{ text: t("dfu.confirm"), onPress: () => navigation.goBack() },
|
||||
setError(err.message || t('dfu.dfuFailed'));
|
||||
Alert.alert(t('dfu.upgradeFailed'), err.message || t('dfu.dfuFailed'), [
|
||||
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
runDfu();
|
||||
}, [deviceId, name, deviceFirmware, navigation, t]);
|
||||
}, [deviceId, deviceFirmware, navigation]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MyStatusbar backgroundColor="#FFFFFF" dark />
|
||||
<MyHeader
|
||||
title={t("dfu.title")}
|
||||
textColor="#333"
|
||||
backgroundColor="#FFFFFF"
|
||||
navigation={navigation}
|
||||
/>
|
||||
<View style={{flex:1,backgroundColor:'#f2f3f7'}}>
|
||||
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
||||
<MyHeader title={t("dfu.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader>
|
||||
<View style={{ flex: 1, padding: 20 }}>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.titleText}>
|
||||
{t("dfu.bluetoothName")}: {name || "--"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.normalText}>
|
||||
{t("dfu.latestVersion")}: {latestVersion}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.normalText}>
|
||||
{t("dfu.currentVersion")}: {deviceFirmware || "--"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.normalText}>
|
||||
{t("dfu.upgradeStatus")}: {mapDfuStateToLabel(state)}
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.bluetoothName')}: {name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.latestVersion')}: {latestVersion}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.currentVersion')}: {deviceFirmware}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16, color: "#333" }}>
|
||||
{t('dfu.upgradeStatus')}: {mapDfuStateToChinese(state)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 横向进度条 */}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||
<Text style={styles.progressText}>{progress}%</Text>
|
||||
</View>
|
||||
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
row: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E7141E",
|
||||
paddingBottom: 6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 18,
|
||||
color: "#111111",
|
||||
fontWeight: "600",
|
||||
},
|
||||
normalText: {
|
||||
fontSize: 16,
|
||||
color: "#222222",
|
||||
borderBottomColor: "red",
|
||||
paddingBottom: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressContainer: {
|
||||
height: 30,
|
||||
backgroundColor: "#EEEEEE",
|
||||
backgroundColor: "#eee",
|
||||
borderRadius: 15,
|
||||
overflow: "hidden",
|
||||
marginTop: 40,
|
||||
@ -458,11 +185,6 @@ const styles = StyleSheet.create({
|
||||
position: "absolute",
|
||||
alignSelf: "center",
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
},
|
||||
errorText: {
|
||||
color: "red",
|
||||
marginTop: 20,
|
||||
fontSize: 14,
|
||||
color: "#000",
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,111 +1,36 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Image, Platform } from "react-native";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet, StatusBar, Image } from "react-native";
|
||||
import { NativeStackScreenProps,NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
import MyStatusbar from "./component/MyStatusbar";
|
||||
import MyHeader from "./component/MyHeader";
|
||||
import UpdateModal from "./component/UpdateModal";
|
||||
import pxToDp from "./helper/pxToDp";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList>;
|
||||
|
||||
interface UpdateInfo {
|
||||
latestVersion: string;
|
||||
downloadUrl: string;
|
||||
updateLogs: string[];
|
||||
}
|
||||
|
||||
const UPDATE_JSON_URL = `https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/apks/update.json?t=${Date.now()}`;
|
||||
|
||||
const compareVersions = (latest: string, current: string): boolean => {
|
||||
const latestParts = latest.split(".").map(Number);
|
||||
const currentParts = current.split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
|
||||
const latestNum = latestParts[i] || 0;
|
||||
const currentNum = currentParts[i] || 0;
|
||||
if (latestNum > currentNum) return true;
|
||||
if (latestNum < currentNum) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export default function HomeScreen({ navigation }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [updateModalVisible, setUpdateModalVisible] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [currentVersion, setCurrentVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const version = DeviceInfo.getVersion();
|
||||
setCurrentVersion(version);
|
||||
|
||||
const resp = await fetch(UPDATE_JSON_URL);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const data = await resp.json();
|
||||
const platform = Platform.OS === "ios" ? "ios" : "android";
|
||||
const platformUpdate = data[platform];
|
||||
if (!platformUpdate) return;
|
||||
|
||||
if (compareVersions(platformUpdate.latestVersion, version)) {
|
||||
setUpdateInfo({
|
||||
latestVersion: platformUpdate.latestVersion,
|
||||
downloadUrl: platformUpdate.downloadUrl,
|
||||
updateLogs: platformUpdate.updateLogs || [],
|
||||
});
|
||||
setUpdateModalVisible(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("检查更新失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
checkUpdate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 设置状态栏颜色 */}
|
||||
<MyStatusbar backgroundColor="#E7141E" dark={false}></MyStatusbar>
|
||||
<MyHeader
|
||||
title={t("home.title")}
|
||||
textColor="#fff"
|
||||
backgroundColor="#E7141E"
|
||||
hideBack
|
||||
navigation={navigation}
|
||||
rightView={
|
||||
<TouchableOpacity
|
||||
style={{ width: pxToDp(56), height: pxToDp(56), alignItems: "center", justifyContent: "center" }}
|
||||
onPress={() => navigation.navigate("Setting")}
|
||||
>
|
||||
<Image source={require("./img/Settings.png")} style={{ width: pxToDp(48), height: pxToDp(44) }}></Image>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
></MyHeader>
|
||||
|
||||
<MyHeader title={t('home.title')} textColor="#fff" backgroundColor="#E7141E" hideBack navigation={navigation}
|
||||
rightView={<TouchableOpacity style={{width:pxToDp(56),height:pxToDp(56),alignItems:'center',justifyContent:'center'}}
|
||||
onPress={() => navigation.navigate("Setting")}>
|
||||
<Image source={require('./img/Settings.png')} style={{width:pxToDp(48),height:pxToDp(44)}}></Image>
|
||||
</TouchableOpacity>}></MyHeader>
|
||||
{/* 中间按钮 */}
|
||||
<View style={styles.centerBox}>
|
||||
<Image source={require("./img/Search.png")} style={{width:pxToDp(568),height:pxToDp(344),marginBottom:pxToDp(100)}}></Image>
|
||||
<TouchableOpacity style={styles.button} onPress={() => navigation.navigate("Scan")}>
|
||||
<Text style={styles.buttonText}>{t("home.powerMeter")}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => navigation.navigate("Scan")}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t('home.scan')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.button, { marginTop: pxToDp(20) }]} onPress={() => navigation.navigate("ScanScreen2")}>
|
||||
<Text style={styles.buttonText}>{t("home.paddle")}</Text>
|
||||
</TouchableOpacity>
|
||||
{/* <TouchableOpacity style={[styles.button, { marginTop: pxToDp(20) }]} onPress={() => navigation.navigate("ScanScreen3")}>
|
||||
<Text style={styles.buttonText}>{t("home.T5trainer")}</Text>
|
||||
</TouchableOpacity> */}
|
||||
</View>
|
||||
|
||||
<UpdateModal
|
||||
visible={updateModalVisible}
|
||||
updateInfo={updateInfo}
|
||||
currentVersion={currentVersion}
|
||||
onClose={() => setUpdateModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -117,17 +42,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
centerBox: {
|
||||
flex: 1,
|
||||
// justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
paddingTop: pxToDp(250),
|
||||
backgroundColor:'#fff',
|
||||
paddingTop:pxToDp(250)
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#E7141E",
|
||||
borderRadius: pxToDp(24),
|
||||
width:pxToDp(300),
|
||||
height:pxToDp(96),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems:'center',
|
||||
justifyContent:'center'
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
@ -140,7 +66,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
privacyText: {
|
||||
fontSize: 16,
|
||||
color: "#E7141E",
|
||||
color: "#E7141E", // 红色字体
|
||||
marginBottom: 8,
|
||||
},
|
||||
version: {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// InfoScreen.tsx此页面为功率计信息页面
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
@ -7,6 +6,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
useColorScheme,
|
||||
Alert,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
@ -57,7 +57,6 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
const [rightbalance, setRightBalance] = useState<number>(0);
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isDeviceReady, setIsDeviceReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [serial, setSerial] = useState(t('info.reading'));
|
||||
const [firmware, setFirmware] = useState(t('info.reading'));
|
||||
@ -73,13 +72,6 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
const notifySubscribedRef = useRef(false);
|
||||
const disconnectingRef = useRef(false);
|
||||
const skipDisconnectOnLeaveRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const deviceReadyRef = useRef(false);
|
||||
const readyResolverRef = useRef<(() => void) | null>(null);
|
||||
const readSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const powerDataSubscribedRef = useRef(false);
|
||||
const initialInfoLoadedRef = useRef(false);
|
||||
|
||||
const prevConnectedRef = useRef(isConnected);
|
||||
const isActiveRef = useRef(true);
|
||||
@ -91,237 +83,6 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
const calibrationTimeoutRef = useRef<number | null>(null);
|
||||
const isCalibratingRef = useRef<boolean>(false);
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const toUint8Array = (raw: unknown): Uint8Array | null => {
|
||||
if (!raw) return null;
|
||||
if (raw instanceof Uint8Array) return raw;
|
||||
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
||||
if (Array.isArray(raw)) return new Uint8Array(raw);
|
||||
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
"buffer" in raw &&
|
||||
(raw as { buffer?: unknown }).buffer instanceof ArrayBuffer
|
||||
) {
|
||||
const view = raw as {
|
||||
buffer: ArrayBuffer;
|
||||
byteOffset?: number;
|
||||
byteLength?: number;
|
||||
};
|
||||
return new Uint8Array(
|
||||
view.buffer,
|
||||
view.byteOffset || 0,
|
||||
view.byteLength
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const decodeTextValue = (raw: unknown) => {
|
||||
const bytes = toUint8Array(raw);
|
||||
if (!bytes || bytes.length === 0) return null;
|
||||
|
||||
const text = String.fromCharCode(...Array.from(bytes))
|
||||
.replace(/\0/g, "")
|
||||
.trim();
|
||||
|
||||
return text || null;
|
||||
};
|
||||
|
||||
const waitForDeviceReady = (timeoutMs = 6000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (deviceReadyRef.current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (readyResolverRef.current === onReady) {
|
||||
readyResolverRef.current = null;
|
||||
}
|
||||
reject(new Error("device ready timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
const onReady = () => {
|
||||
clearTimeout(timer);
|
||||
if (readyResolverRef.current === onReady) {
|
||||
readyResolverRef.current = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
readyResolverRef.current = onReady;
|
||||
});
|
||||
|
||||
const readCharacteristicWithRetry = async (
|
||||
serviceUuid: string,
|
||||
characteristicUuid: string,
|
||||
attempts = 3,
|
||||
delayMs = 180
|
||||
) => {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const value = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(serviceUuid),
|
||||
fullUUID(characteristicUuid)
|
||||
);
|
||||
const bytes = toUint8Array(value);
|
||||
|
||||
if (bytes && bytes.length > 0) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
lastError = new Error("empty characteristic value");
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("read characteristic failed");
|
||||
};
|
||||
|
||||
const readTextCharacteristic = async (serviceUuid: string, characteristicUuid: string) => {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const bytes = await readCharacteristicWithRetry(
|
||||
serviceUuid,
|
||||
characteristicUuid,
|
||||
1
|
||||
);
|
||||
const text = decodeTextValue(bytes);
|
||||
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
} catch {
|
||||
// Continue retrying with a short gap below.
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
await sleep(180);
|
||||
}
|
||||
}
|
||||
|
||||
return "未知";
|
||||
};
|
||||
|
||||
const readBatteryCharacteristic = async () => {
|
||||
let lastValidBattery: number | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
try {
|
||||
const bytes = await readCharacteristicWithRetry("180f", "2a19", 1);
|
||||
const batteryValue = bytes[0];
|
||||
|
||||
if (
|
||||
Number.isInteger(batteryValue) &&
|
||||
batteryValue >= 0 &&
|
||||
batteryValue <= 100
|
||||
) {
|
||||
if (lastValidBattery !== null && lastValidBattery === batteryValue) {
|
||||
return `${batteryValue}%`;
|
||||
}
|
||||
|
||||
lastValidBattery = batteryValue;
|
||||
}
|
||||
} catch {
|
||||
// Continue retrying with a short gap below.
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
await sleep(220);
|
||||
}
|
||||
}
|
||||
|
||||
return lastValidBattery !== null ? `${lastValidBattery}%` : "未知";
|
||||
};
|
||||
|
||||
const subscribePowerDataIfNeeded = async () => {
|
||||
if (!deviceReadyRef.current || !initialInfoLoadedRef.current || powerDataSubscribedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("✅ 订阅 2A63 特性通知...");
|
||||
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63")
|
||||
).catch(() => {});
|
||||
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63"),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const byteArray = toUint8Array(notifyEv.value);
|
||||
if (!byteArray) return;
|
||||
parseData(byteArray);
|
||||
} catch (err) {
|
||||
console.warn("❌ 处理通知数据失败", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
powerDataSubscribedRef.current = true;
|
||||
console.log("✅ 已订阅 2A63 特性通知");
|
||||
};
|
||||
|
||||
const subscribeFff3IfNeeded = async () => {
|
||||
if (notifySubscribedRef.current) return;
|
||||
|
||||
console.log("✅ device ready - subscribe notify FFF3");
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid)
|
||||
).catch(() => {});
|
||||
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = (notifyEv as any).value;
|
||||
const byteArray = toUint8Array(raw);
|
||||
if (!byteArray) return;
|
||||
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
||||
const rawTrim =
|
||||
byteArray.length >= 3
|
||||
? byteArray[1] | (byteArray[2] << 8)
|
||||
: byteArray[1];
|
||||
const displayTrim =
|
||||
byteArray.length >= 3
|
||||
? (rawTrim / 100).toFixed(2).replace(/\.?0+$/, "")
|
||||
: rawTrim.toString();
|
||||
|
||||
setPowerTrim(displayTrim);
|
||||
} else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
||||
handleFFF3Response(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("notify parse error", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
notifySubscribedRef.current = true;
|
||||
console.log("✅ notify 已订阅");
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 页面获得焦点
|
||||
@ -337,10 +98,6 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
// ========== 监听连接状态变化,断开时提示重新连接 ==========
|
||||
useEffect(() => {
|
||||
if (prevConnectedRef.current && !isConnected && isActiveRef.current) {
|
||||
if (skipDisconnectOnLeaveRef.current) {
|
||||
prevConnectedRef.current = isConnected;
|
||||
return;
|
||||
}
|
||||
Alert.alert(t('info.disconnectTitle'), t('info.disconnectMessage'), [
|
||||
{ text: t('info.confirm'), onPress: () => navigation.goBack() },
|
||||
]);
|
||||
@ -351,7 +108,6 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
// ========== 页面即将返回时,强制断开蓝牙 ==========
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener("beforeRemove", async (e) => {
|
||||
if (skipDisconnectOnLeaveRef.current) return;
|
||||
if (disconnectingRef.current) return;
|
||||
disconnectingRef.current = true;
|
||||
|
||||
@ -389,50 +145,49 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
if (addr !== deviceKey) return;
|
||||
|
||||
console.log("🔌 connection event:", ev.connectionStatus);
|
||||
const connected =
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
|
||||
const isReady = ev.connectionStatus === "ready";
|
||||
setIsConnected(
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
|
||||
);
|
||||
|
||||
if (skipDisconnectOnLeaveRef.current && !connected) {
|
||||
console.log("⏭️ DFU flow disconnect ignored on InfoScreen");
|
||||
setIsConnected(false);
|
||||
setIsDeviceReady(false);
|
||||
deviceReadyRef.current = false;
|
||||
notifySubscribedRef.current = false;
|
||||
powerDataSubscribedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnected(connected);
|
||||
setIsDeviceReady(isReady);
|
||||
deviceReadyRef.current = isReady;
|
||||
|
||||
if (!isReady) {
|
||||
notifySubscribedRef.current = false;
|
||||
powerDataSubscribedRef.current = false;
|
||||
}
|
||||
|
||||
if (isReady && readyResolverRef.current) {
|
||||
const resolveReady = readyResolverRef.current;
|
||||
readyResolverRef.current = null;
|
||||
resolveReady();
|
||||
}
|
||||
|
||||
if (isReady && !notifySubscribedRef.current) {
|
||||
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
||||
console.log("✅ device ready - subscribe notify FFF3");
|
||||
try {
|
||||
await subscribeFff3IfNeeded();
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid)
|
||||
).catch(() => { });
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = (notifyEv as any).value;
|
||||
if (!raw) return;
|
||||
const byteArray = raw instanceof Uint8Array
|
||||
? raw
|
||||
: new Uint8Array(raw);
|
||||
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
||||
// 功率微调数据
|
||||
setPowerTrim(byteArray[1].toString());
|
||||
//setInputTrim(byteArray[1].toString());
|
||||
}
|
||||
else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
||||
// 校准响应数据 (05XXXX格式)
|
||||
handleFFF3Response(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("notify parse error", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
notifySubscribedRef.current = true;
|
||||
console.log("✅ notify 已订阅");
|
||||
} catch (err) {
|
||||
console.warn("❌ notify 订阅失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isReady && initialInfoLoadedRef.current && !powerDataSubscribedRef.current) {
|
||||
try {
|
||||
await subscribePowerDataIfNeeded();
|
||||
} catch (err) {
|
||||
console.warn("❌ 实时数据订阅失败:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -446,44 +201,45 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 首次连接并读取信息 ==========
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
let shouldShowReadSuccess = false;
|
||||
try {
|
||||
await Central.connectPeripheral(peripheral);
|
||||
await waitForDeviceReady(6000);
|
||||
await sleep(150);
|
||||
await subscribeFff3IfNeeded();
|
||||
await sleep(120);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const serialValue = await readTextCharacteristic("180a", "2a25");
|
||||
const firmwareValue = await readTextCharacteristic("180a", "2a28");
|
||||
const hardwareValue = await readTextCharacteristic("180a", "2a27");
|
||||
const batteryValue = await readBatteryCharacteristic();
|
||||
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setSerial(serialValue);
|
||||
setFirmware(firmwareValue);
|
||||
setHardware(hardwareValue);
|
||||
setBattery(batteryValue);
|
||||
initialInfoLoadedRef.current = true;
|
||||
const readStr = async (srv: string, char: string) => {
|
||||
try {
|
||||
const v = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(srv),
|
||||
fullUUID(char)
|
||||
);
|
||||
return v ? String.fromCharCode(...(v as any)) : "未知";
|
||||
} catch {
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
await sleep(120);
|
||||
await subscribePowerDataIfNeeded();
|
||||
setSerial(await readStr("180a", "2a25"));
|
||||
setFirmware(await readStr("180a", "2a28"));
|
||||
setHardware(await readStr("180a", "2a27"));
|
||||
|
||||
shouldShowReadSuccess =
|
||||
serialValue !== "未知" ||
|
||||
firmwareValue !== "未知" ||
|
||||
hardwareValue !== "未知" ||
|
||||
batteryValue !== "未知";
|
||||
try {
|
||||
const v = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("180f"),
|
||||
fullUUID("2a19")
|
||||
);
|
||||
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
|
||||
} catch {
|
||||
setBattery("未知");
|
||||
}
|
||||
|
||||
console.log("✅ info 读取完成");
|
||||
|
||||
|
||||
|
||||
try {
|
||||
await sleep(150);
|
||||
await Central.writeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
@ -495,35 +251,22 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
||||
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
||||
await sleep(300);
|
||||
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
|
||||
} catch (err) {
|
||||
console.warn("❌ 首次读取功率微调失败", err);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.warn("❌ 读取失败", e);
|
||||
} finally {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (!cancelled && mountedRef.current && shouldShowReadSuccess) {
|
||||
// ✅ 显示读取成功提示 2 秒
|
||||
setReadSuccessToast(true);
|
||||
if (readSuccessTimeoutRef.current) {
|
||||
clearTimeout(readSuccessTimeoutRef.current);
|
||||
}
|
||||
readSuccessTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setReadSuccessToast(false);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
setTimeout(() => setReadSuccessToast(false), 2000);
|
||||
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
initialInfoLoadedRef.current = false;
|
||||
};
|
||||
}, [peripheral]);
|
||||
|
||||
|
||||
@ -531,19 +274,59 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
|
||||
useEffect(() => {
|
||||
if (peripheral && deviceReadyRef.current && initialInfoLoadedRef.current) {
|
||||
subscribePowerDataIfNeeded().catch((err) => {
|
||||
const subscribeToPowerData = async () => {
|
||||
try {
|
||||
// 确保设备已经连接
|
||||
if (!isConnected) return;
|
||||
|
||||
console.log("✅ 订阅 2A63 特性通知...");
|
||||
|
||||
// 先取消之前的订阅(防止重复订阅)
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63")
|
||||
).catch(() => { });
|
||||
|
||||
// 订阅 2A63 特性通知
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63"),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = notifyEv.value;
|
||||
if (raw) {
|
||||
// 将 ArrayBuffer 转换为字节数组
|
||||
const byteArray = new Uint8Array(raw);
|
||||
|
||||
// 解析数据
|
||||
parseData(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("❌ 处理通知数据失败", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ 已订阅 2A63 特性通知");
|
||||
} catch (err) {
|
||||
console.warn("❌ 订阅 2A63 特性失败", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 只在设备已连接时进行订阅
|
||||
if (peripheral && isConnected) {
|
||||
subscribeToPowerData();
|
||||
}
|
||||
|
||||
// 清理订阅(当组件卸载或者连接状态变化时)
|
||||
return () => {
|
||||
if (peripheral && powerDataSubscribedRef.current) {
|
||||
powerDataSubscribedRef.current = false;
|
||||
if (peripheral && isConnected) {
|
||||
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
|
||||
}
|
||||
};
|
||||
}, [peripheral, isDeviceReady]);
|
||||
}, [peripheral, isConnected]);
|
||||
|
||||
const cadenceStateRef = useRef({
|
||||
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
|
||||
@ -659,7 +442,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 写入功率微调 ==========
|
||||
const updatePowerTrim = async () => {
|
||||
const val = Number(inputTrim);
|
||||
const val = parseInt(inputTrim);
|
||||
if (isNaN(val) || val < 50 || val > 200) {
|
||||
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
|
||||
return;
|
||||
@ -675,16 +458,13 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
try {
|
||||
setPowerTrimLoading(true);
|
||||
const scaledVal = Math.round(val * 100);
|
||||
const low = scaledVal & 0xff;
|
||||
const high = (scaledVal >> 8) & 0xff;
|
||||
|
||||
console.log("🚀 写入功率微调", val, "=>", scaledVal);
|
||||
console.log("🚀 写入功率微调", val);
|
||||
await Central.writeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerWriteUuid),
|
||||
new Uint8Array([0x02, low, high]).buffer,
|
||||
new Uint8Array([0x02, val]).buffer,
|
||||
{ withoutResponse: false }
|
||||
);
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
||||
@ -703,8 +483,6 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
} catch (err) {
|
||||
console.warn("❌ 写入失败", err);
|
||||
Alert.alert(t('info.writeFailed'));
|
||||
} finally {
|
||||
setPowerTrimLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -806,22 +584,12 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
deviceReadyRef.current = false;
|
||||
initialInfoLoadedRef.current = false;
|
||||
powerDataSubscribedRef.current = false;
|
||||
readyResolverRef.current = null;
|
||||
// 清理校准超时定时器
|
||||
if (calibrationTimeoutRef.current) {
|
||||
clearTimeout(calibrationTimeoutRef.current);
|
||||
calibrationTimeoutRef.current = null;
|
||||
}
|
||||
if (readSuccessTimeoutRef.current) {
|
||||
clearTimeout(readSuccessTimeoutRef.current);
|
||||
readSuccessTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -944,23 +712,12 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dfuRouteParams = {
|
||||
// 蓝牙已连接,正常跳转 DFU
|
||||
navigation.navigate("Dfu", {
|
||||
deviceId: deviceKey,
|
||||
systemId: peripheral.systemId,
|
||||
address: peripheral.address,
|
||||
name: peripheral.name ?? "",
|
||||
firmware: firmware ?? "",
|
||||
};
|
||||
|
||||
console.log("[DFU-TRACE] navigate to Dfu", {
|
||||
...dfuRouteParams,
|
||||
isConnected,
|
||||
isDeviceReady,
|
||||
name: peripheral.name,
|
||||
firmware,
|
||||
});
|
||||
|
||||
skipDisconnectOnLeaveRef.current = true;
|
||||
navigation.navigate("Dfu", dfuRouteParams);
|
||||
}}
|
||||
style={styles.pressable}
|
||||
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击
|
||||
|
||||
1227
src/InfoScreen2.tsx
1227
src/InfoScreen2.tsx
File diff suppressed because it is too large
Load Diff
2049
src/InfoScreen3.tsx
2049
src/InfoScreen3.tsx
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
// src/ScanScreen.tsx此页面为功率计搜索页面
|
||||
// src/ScanScreen.tsx
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
@ -76,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
|
||||
|
||||
// 更新或添加设备,并记录最后扫描时间
|
||||
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
||||
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
|
||||
if (!p?.name || !p.name.startsWith("POWERFUN")) return;
|
||||
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
||||
|
||||
setDevices((prev) => {
|
||||
|
||||
@ -1,238 +0,0 @@
|
||||
// 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" },
|
||||
});
|
||||
@ -1,231 +0,0 @@
|
||||
// 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" },
|
||||
});
|
||||
@ -1,796 +0,0 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
@ -33,7 +33,6 @@ const LanguageModal: React.FC<LanguageModalProps> = ({ visible, onClose }) => {
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Pressable
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Modal, Linking } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import pxToDp from "../helper/pxToDp";
|
||||
|
||||
interface UpdateModalProps {
|
||||
visible: boolean;
|
||||
updateInfo: {
|
||||
latestVersion: string;
|
||||
downloadUrl: string;
|
||||
updateLogs: string[];
|
||||
} | null;
|
||||
currentVersion: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function UpdateModal({ visible, updateInfo, currentVersion, onClose }: UpdateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleUpdatePress = () => {
|
||||
if (updateInfo?.downloadUrl) {
|
||||
Linking.openURL(updateInfo.downloadUrl);
|
||||
}
|
||||
};
|
||||
|
||||
if (!updateInfo) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>{t("home.updateTitle")}</Text>
|
||||
<Text style={styles.modalVersion}>
|
||||
{t("home.latestVersion")}: {updateInfo.latestVersion}
|
||||
</Text>
|
||||
<Text style={styles.modalVersion}>
|
||||
{t("home.currentVersion")}: {currentVersion}
|
||||
</Text>
|
||||
|
||||
<View style={styles.updateLogsContainer}>
|
||||
<Text style={styles.updateLogsTitle}>{t("home.updateLogs")}</Text>
|
||||
{updateInfo.updateLogs.map((log, index) => (
|
||||
<Text key={index} style={styles.updateLogItem}>• {log}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.updateButton}
|
||||
onPress={handleUpdatePress}
|
||||
>
|
||||
<Text style={styles.updateButtonText}>{t("home.updateNow")}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* <TouchableOpacity
|
||||
style={styles.laterButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Text style={styles.laterButtonText}>{t("home.updateLater")}</Text>
|
||||
</TouchableOpacity> */}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
modalContent: {
|
||||
width: pxToDp(600),
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: pxToDp(20),
|
||||
padding: pxToDp(40),
|
||||
alignItems: "center",
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: pxToDp(36),
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginBottom: pxToDp(20),
|
||||
},
|
||||
modalVersion: {
|
||||
fontSize: pxToDp(28),
|
||||
color: "#666",
|
||||
marginBottom: pxToDp(10),
|
||||
},
|
||||
updateLogsContainer: {
|
||||
width: "100%",
|
||||
marginTop: pxToDp(20),
|
||||
marginBottom: pxToDp(20),
|
||||
paddingHorizontal: pxToDp(10),
|
||||
maxHeight: pxToDp(300),
|
||||
},
|
||||
updateLogsTitle: {
|
||||
fontSize: pxToDp(28),
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginBottom: pxToDp(10),
|
||||
},
|
||||
updateLogItem: {
|
||||
fontSize: pxToDp(24),
|
||||
color: "#666",
|
||||
lineHeight: pxToDp(40),
|
||||
},
|
||||
updateButton: {
|
||||
width: "100%",
|
||||
backgroundColor: "#E7141E",
|
||||
borderRadius: pxToDp(12),
|
||||
padding: pxToDp(24),
|
||||
alignItems: "center",
|
||||
// marginBottom: pxToDp(20),
|
||||
},
|
||||
updateButtonText: {
|
||||
fontSize: pxToDp(32),
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
},
|
||||
laterButton: {
|
||||
padding: pxToDp(20),
|
||||
},
|
||||
laterButtonText: {
|
||||
fontSize: pxToDp(28),
|
||||
color: "#999",
|
||||
},
|
||||
});
|
||||
@ -1,315 +0,0 @@
|
||||
import {
|
||||
Central,
|
||||
ScannedPeripheral,
|
||||
ConnectionStatus,
|
||||
} from "@systemic-games/react-native-bluetooth-le";
|
||||
|
||||
const ftmsServiceUuid = "1826";
|
||||
const ftmsIndoorBikeDataUuid = "2ad2";
|
||||
|
||||
const fullUUID = (uuid: string) =>
|
||||
uuid.length === 4
|
||||
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
|
||||
: uuid;
|
||||
|
||||
export type FtmsIndoorBikeData = {
|
||||
speedKph: number;
|
||||
cadence: number;
|
||||
power: number;
|
||||
raw: Uint8Array;
|
||||
};
|
||||
|
||||
type Listener = (data: FtmsIndoorBikeData) => void;
|
||||
|
||||
type SubscriptionEntry = {
|
||||
listeners: Set<Listener>;
|
||||
lastData: FtmsIndoorBikeData | null;
|
||||
subscribePromise: Promise<void> | null;
|
||||
subscribed: boolean;
|
||||
cadenceState: {
|
||||
lastCadenceValue: number;
|
||||
lastCadenceChangedTime: number;
|
||||
};
|
||||
lastEmitTime: number; // ✅ throttle 用
|
||||
};
|
||||
|
||||
const entries = new Map<string, SubscriptionEntry>();
|
||||
let connectionListenerInstalled = false;
|
||||
|
||||
const getDeviceKey = (peripheral: ScannedPeripheral) =>
|
||||
String(peripheral.address || peripheral.systemId || peripheral.name || "unknown");
|
||||
|
||||
const resetEntrySubscriptionState = (
|
||||
key: string,
|
||||
connectionStatus?: ConnectionStatus
|
||||
) => {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return;
|
||||
|
||||
entry.subscribed = false;
|
||||
entry.subscribePromise = null;
|
||||
entry.lastEmitTime = 0;
|
||||
entry.cadenceState.lastCadenceValue = 0;
|
||||
entry.cadenceState.lastCadenceChangedTime = 0;
|
||||
|
||||
if (connectionStatus !== "connected" && connectionStatus !== "ready") {
|
||||
entry.lastData = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureConnectionListenerInstalled = () => {
|
||||
if (connectionListenerInstalled) return;
|
||||
|
||||
Central.addListener("peripheralConnectionStatus", (ev) => {
|
||||
const key = getDeviceKey(ev.peripheral);
|
||||
|
||||
if (
|
||||
ev.connectionStatus === "disconnected" ||
|
||||
ev.connectionStatus === "disconnecting" ||
|
||||
ev.connectionStatus === "connecting"
|
||||
) {
|
||||
resetEntrySubscriptionState(key, ev.connectionStatus);
|
||||
}
|
||||
});
|
||||
|
||||
connectionListenerInstalled = true;
|
||||
};
|
||||
|
||||
// ✅ 默认关闭调试(避免性能问题)
|
||||
const DEBUG = false;
|
||||
|
||||
const bytesToHex = (bytes: Uint8Array) =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
||||
.join("");
|
||||
|
||||
const decodeBase64 = (value: string): Uint8Array | null => {
|
||||
try {
|
||||
const atobFn = (globalThis as { atob?: (data: string) => string }).atob;
|
||||
|
||||
if (typeof atobFn === "function") {
|
||||
const binary = atobFn(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
const output: number[] = [];
|
||||
|
||||
for (const char of value.replace(/=+$/, "")) {
|
||||
const index = chars.indexOf(char);
|
||||
if (index < 0) continue;
|
||||
|
||||
buffer = (buffer << 6) | index;
|
||||
bits += 6;
|
||||
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.push((buffer >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(output);
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn("[FTMS] decodeBase64 failed", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const toUint8Array = (raw: any): Uint8Array | null => {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw instanceof Uint8Array) return raw;
|
||||
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
||||
if (typeof raw === "string") return decodeBase64(raw);
|
||||
|
||||
if (Array.isArray(raw)) return new Uint8Array(raw);
|
||||
|
||||
if (raw?.buffer instanceof ArrayBuffer) {
|
||||
return new Uint8Array(raw.buffer, raw.byteOffset || 0, raw.byteLength);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn("[FTMS] toUint8Array failed", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseIndoorBikeData = (
|
||||
byteArray: Uint8Array,
|
||||
cadenceState: SubscriptionEntry["cadenceState"]
|
||||
): FtmsIndoorBikeData | null => {
|
||||
if (byteArray.length < 8) return null;
|
||||
|
||||
const currentTime = Date.now();
|
||||
const rawSpeed = byteArray[2] | (byteArray[3] << 8);
|
||||
const rawCadence = byteArray[4] | (byteArray[5] << 8);
|
||||
let rawPower = byteArray[6] | (byteArray[7] << 8);
|
||||
|
||||
const speedKph = Number((rawSpeed / 100.0).toFixed(1));
|
||||
const cadenceValue = rawCadence / 2.0;
|
||||
|
||||
if (cadenceValue !== cadenceState.lastCadenceValue) {
|
||||
cadenceState.lastCadenceChangedTime = currentTime;
|
||||
cadenceState.lastCadenceValue = cadenceValue;
|
||||
}
|
||||
|
||||
const cadence =
|
||||
cadenceState.lastCadenceChangedTime > 0 &&
|
||||
currentTime - cadenceState.lastCadenceChangedTime > 3000
|
||||
? 0
|
||||
: Math.round(cadenceValue);
|
||||
|
||||
if (rawPower & 0x8000) {
|
||||
rawPower -= 0x10000;
|
||||
}
|
||||
|
||||
const power = rawPower;
|
||||
|
||||
return {
|
||||
speedKph,
|
||||
cadence,
|
||||
power,
|
||||
raw: byteArray,
|
||||
};
|
||||
};
|
||||
|
||||
const ensureSubscribed = async (
|
||||
peripheral: ScannedPeripheral,
|
||||
entry: SubscriptionEntry
|
||||
) => {
|
||||
if (entry.subscribed) return;
|
||||
|
||||
if (entry.subscribePromise) {
|
||||
await entry.subscribePromise;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.subscribePromise = Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(ftmsServiceUuid),
|
||||
fullUUID(ftmsIndoorBikeDataUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const byteArray = toUint8Array(notifyEv.value);
|
||||
if (!byteArray || byteArray.length === 0) return;
|
||||
|
||||
if (DEBUG) {
|
||||
const flags =
|
||||
byteArray.length >= 2
|
||||
? byteArray[0] | (byteArray[1] << 8)
|
||||
: 0;
|
||||
|
||||
console.log("[FTMS]", bytesToHex(byteArray));
|
||||
console.log(
|
||||
"[FTMS] flags =",
|
||||
"0x" + flags.toString(16).padStart(4, "0")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseIndoorBikeData(
|
||||
byteArray,
|
||||
entry.cadenceState
|
||||
);
|
||||
if (!parsed) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ✅ throttle: 限制为 10Hz(100ms 一次)
|
||||
if (now - entry.lastEmitTime < 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.lastEmitTime = now;
|
||||
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[FTMS] parsed =>",
|
||||
parsed.speedKph,
|
||||
parsed.cadence,
|
||||
parsed.power
|
||||
);
|
||||
}
|
||||
|
||||
entry.lastData = parsed;
|
||||
entry.listeners.forEach((listener) => listener(parsed));
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn("处理 2AD2 通知失败", err);
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
entry.subscribed = true;
|
||||
if (DEBUG) console.log("[FTMS] subscribed");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (DEBUG) console.warn("[FTMS] subscribe failed", err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.subscribePromise = null;
|
||||
});
|
||||
|
||||
await entry.subscribePromise;
|
||||
};
|
||||
|
||||
export const observeFtmsIndoorBikeData = async (
|
||||
peripheral: ScannedPeripheral,
|
||||
listener: Listener
|
||||
) => {
|
||||
ensureConnectionListenerInstalled();
|
||||
|
||||
const key = getDeviceKey(peripheral);
|
||||
let entry = entries.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
listeners: new Set(),
|
||||
lastData: null,
|
||||
subscribePromise: null,
|
||||
subscribed: false,
|
||||
cadenceState: {
|
||||
lastCadenceValue: 0,
|
||||
lastCadenceChangedTime: 0,
|
||||
},
|
||||
lastEmitTime: 0, // ✅ 初始化
|
||||
};
|
||||
entries.set(key, entry);
|
||||
}
|
||||
|
||||
if (entry.listeners.size === 0) {
|
||||
resetEntrySubscriptionState(key, "disconnected");
|
||||
}
|
||||
|
||||
entry.listeners.add(listener);
|
||||
await ensureSubscribed(peripheral, entry);
|
||||
|
||||
if (entry.lastData) {
|
||||
listener(entry.lastData);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const currentEntry = entries.get(key);
|
||||
if (!currentEntry) return;
|
||||
currentEntry.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const debugFtmsIndoorBikeDataEntries = () => {
|
||||
return Array.from(entries.entries()).map(([key, entry]) => ({
|
||||
key,
|
||||
listeners: entry.listeners.size,
|
||||
subscribed: entry.subscribed,
|
||||
lastDataHex: entry.lastData ? bytesToHex(entry.lastData.raw) : null,
|
||||
}));
|
||||
};
|
||||
@ -51,7 +51,7 @@ export const saveLanguage = async (language: string): Promise<void> => {
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
compatibilityJSON: 'v4',
|
||||
compatibilityJSON: 'v3',
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en },
|
||||
|
||||
@ -9,16 +9,7 @@
|
||||
"title": "POWERFUN Settings",
|
||||
"scan": "Scan Devices",
|
||||
"privacy": "Privacy Policy",
|
||||
"version": "Version v0.0.1",
|
||||
"powerMeter": "Power Meter",
|
||||
"paddle": "Paddle",
|
||||
"T5trainer": "T5 Trainer",
|
||||
"updateTitle": "Update Available",
|
||||
"latestVersion": "Latest Version",
|
||||
"currentVersion": "Current Version",
|
||||
"updateLogs": "Update Logs",
|
||||
"updateNow": "Update Now",
|
||||
"updateLater": "Later"
|
||||
"version": "Version v0.0.1"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scan Devices",
|
||||
@ -29,140 +20,6 @@
|
||||
"noName": "[No Name]",
|
||||
"rssiUnit": "dBm"
|
||||
},
|
||||
"t5Scan": {
|
||||
"title": "Scan T5 Trainer",
|
||||
"scanning": "Scanning...",
|
||||
"noDevice": "No T5 trainers found",
|
||||
"tipScanning": "(Make sure the trainer is powered on and awake)",
|
||||
"tipBluetooth": "(Please enable Bluetooth in settings)"
|
||||
},
|
||||
"paddleScan": {
|
||||
"title": "Scan Paddle",
|
||||
"scanning": "Scanning...",
|
||||
"noDevice": "No paddle devices found",
|
||||
"tipScanning": "(Make sure the paddle device is powered on and awake)",
|
||||
"tipBluetooth": "(Please enable Bluetooth in settings)",
|
||||
"noName": "[No Name]",
|
||||
"rssiUnit": "dBm"
|
||||
},
|
||||
"common": {
|
||||
"notice": "Notice",
|
||||
"unknown": "Unknown",
|
||||
"unknownDevice": "Unknown Device",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"info2": {
|
||||
"waitingAction": "Waiting",
|
||||
"notMatched": "Not Matched",
|
||||
"checkFailed": "Check Failed",
|
||||
"unknownBoatType": "Unknown boat type",
|
||||
"readAbnormal": "Unexpected response: {{hex}}",
|
||||
"writingBoat": "Writing {{boatName}}...",
|
||||
"writtenWaitRead": "Written {{boatName}}, reading back in 200ms...",
|
||||
"requestingBoatRead": "Requesting boat type...",
|
||||
"setSuccess": "Set success: {{boatName}}",
|
||||
"notMatch": "Mismatch: expected {{expected}}, actual {{actual}}",
|
||||
"readResult": "Read result: {{hex}}",
|
||||
"actionFailed": "Action failed: {{message}}",
|
||||
"failed": "Failed",
|
||||
"waitFff3Timeout": "Timeout waiting for FFF3 response",
|
||||
"currentBoat": "✅ Current boat type: {{boatName}}",
|
||||
"readCurrentBoat": "Current boat type read: {{boatName}}",
|
||||
"disconnectedNeedReconnect": "Device disconnected. Reconnect before upgrading.",
|
||||
"defaultReadFailed": "Connected, but initial boat type read failed",
|
||||
"connectOrReadFailed": "Connection or read failed",
|
||||
"connectOrReadFailedAlert": "Bluetooth connection failed or device info read failed",
|
||||
"cadence": "Cadence",
|
||||
"cadenceUnit": "RPM (strokes/min)",
|
||||
"boatSelect": "Boat Type",
|
||||
"boatSwitching": "Switching boat type...",
|
||||
"retry": "Please retry",
|
||||
"boatKayak": "Kayak",
|
||||
"boatRowing": "Rowing",
|
||||
"boatRacing": "Racing",
|
||||
"latestFirmware": "Latest Firmware",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"info3": {
|
||||
"bikeTypeFollow": "Follow Software",
|
||||
"bikeTypeRoad": "Road Bike",
|
||||
"bikeTypeMtb26": "Mountain Bike 26\"",
|
||||
"bikeTypeMtb275": "Mountain Bike 27.5\"",
|
||||
"bikeTypeMtb29": "Mountain Bike 29\"",
|
||||
"bikeTypeSmallWheel": "Small Wheel Bike",
|
||||
"ergOn": "On",
|
||||
"ergOff": "Off",
|
||||
"readUsedHoursTimeout": "Timeout reading usage hours",
|
||||
"readUsedMileageTimeout": "Timeout reading usage mileage",
|
||||
"powerTrimRangeError": "Please enter a value between 50.00 and 200.00 with up to 2 decimals",
|
||||
"deviceNotReady": "Device is not ready yet. Please try again later",
|
||||
"invalidWeight": "Please enter a valid weight",
|
||||
"waitFff3Timeout": "Timeout waiting for FFF3 response",
|
||||
"readPowerTrimTimeout": "Timeout reading current power trim",
|
||||
"waitWeightAckTimeout": "Timeout waiting for weight setting confirmation",
|
||||
"readWeightTimeout": "Timeout reading current weight",
|
||||
"readBikeTypeTimeout": "Timeout reading bike type",
|
||||
"waitBikeTypeAckTimeout": "Timeout waiting for bike type confirmation",
|
||||
"readErgTimeout": "Timeout reading ERG smoothing",
|
||||
"waitErgAckTimeout": "Timeout waiting for ERG smoothing confirmation",
|
||||
"connectReadFailed": "Device connection or read failed",
|
||||
"readFailed": "Read failed",
|
||||
"connecting": "Connecting",
|
||||
"pendingTag": "Pending",
|
||||
"settingTag": "Applying",
|
||||
"currentWeightValue": "Current: {{value}} kg",
|
||||
"currentPowerTrimValue": "Current: {{value}} %",
|
||||
"currentTextValue": "Current: {{value}}",
|
||||
"usedMileage": "Usage Mileage",
|
||||
"speedKph": "Speed/km/h",
|
||||
"weightSetting": "Weight Setting",
|
||||
"powerTrim": "Power Trim",
|
||||
"bikeType": "Bike Type",
|
||||
"ergSmooth": "ERG Smoothing",
|
||||
"settingWeight": "Setting weight...",
|
||||
"weightSetDone": "Weight set successfully",
|
||||
"weightSetFailed": "Weight setting failed",
|
||||
"settingPowerTrim": "Setting power trim...",
|
||||
"powerTrimSetDone": "Power trim set successfully",
|
||||
"powerTrimSetFailed": "Power trim setting failed",
|
||||
"settingBikeType": "Setting bike type...",
|
||||
"bikeTypeSetDone": "Bike type set successfully",
|
||||
"bikeTypeSetFailed": "Bike type setting failed",
|
||||
"settingErgSmooth": "Setting ERG smoothing...",
|
||||
"ergSmoothSetDone": "ERG smoothing set successfully",
|
||||
"ergSmoothSetFailed": "ERG smoothing setting failed",
|
||||
"confirmWeightChange": "Set weight to {{value}}kg?",
|
||||
"confirmPowerTrimChange": "Set power trim to {{value}}%?",
|
||||
"helpText": "Hold to view help"
|
||||
},
|
||||
"spindown": {
|
||||
"title": "Spindown",
|
||||
"headerTitle": "Trainer Spindown",
|
||||
"connecting": "Connecting",
|
||||
"connected": "Connected",
|
||||
"targetLabel": "Target Speed",
|
||||
"targetHint": "Ride to reach the target speed first",
|
||||
"currentSpeed": "Current Speed",
|
||||
"statusReach36": "Ride to reach {{speed}} km/h",
|
||||
"statusReached36": "Reached {{high}} km/h. Stop pedaling and wait until speed drops to {{low}} km/h",
|
||||
"statusCalibrating": "Spindown in progress, please wait",
|
||||
"statusCompleted": "Spindown completed",
|
||||
"deviceNotReady": "Device not ready, please try again",
|
||||
"connectFailed": "Failed to connect. Please go back and try again",
|
||||
"timeout": "Timed out waiting for spindown time",
|
||||
"failedRetry": "Spindown failed, please try again",
|
||||
"step1Title": "Ride to {{speed}} km/h",
|
||||
"step1Desc": "Automatically proceeds after reaching the target speed",
|
||||
"step2Title": "Stop pedaling and coast",
|
||||
"step2Desc": "Starts spindown automatically when speed drops to {{speed}} km/h",
|
||||
"step3Title": "Spindown Completed",
|
||||
"step3Loading": "Reading spindown time...",
|
||||
"step3Pending": "Will appear here after completion",
|
||||
"loading": "Connecting and preparing spindown...",
|
||||
"result": "Spindown completed, time {{seconds}}s",
|
||||
"retry": "Restart Spindown"
|
||||
},
|
||||
"dfu": {
|
||||
"title": "Firmware Update",
|
||||
"preparing": "Preparing...",
|
||||
@ -189,16 +46,9 @@
|
||||
"doNotReturn": "Updating in progress, do not go back or close the app!",
|
||||
|
||||
"cannotUpgrade": "Cannot Update",
|
||||
"hardwareNotFound": "Firmware not found for hardware version {{hardware}}",
|
||||
"hardwareNotFound": "Firmware not found for hardware version {hardware}",
|
||||
"noNeedUpgrade": "No Update Needed",
|
||||
"alreadyLatest": "Already the latest firmware, no update needed",
|
||||
"invalidTargetDeviceId": "Unable to generate DFU target device ID",
|
||||
"manifestDownloadFailed": "Manifest download failed, HTTP {{status}}",
|
||||
"manifestInvalidJson": "Manifest is not valid JSON: {{preview}}",
|
||||
"hardwareMismatch": "Firmware hardware mismatch: current {{current}}, server {{latest}}",
|
||||
"firmwareDownloadFailed": "Firmware package download failed, HTTP {{status}}",
|
||||
"firmwareFileMissing": "Firmware package file not found after download: {{path}}",
|
||||
"firmwareFileInvalid": "Firmware package file is invalid, size {{size}}",
|
||||
|
||||
"upgradeSuccess": "Update Successful",
|
||||
"upgradeSuccessMessage": "Update successful, please reconnect the device",
|
||||
|
||||
@ -9,16 +9,7 @@
|
||||
"title": "POWERFUN设置",
|
||||
"scan": "搜索设备",
|
||||
"privacy": "隐私协议",
|
||||
"version": "版本号 v0.0.1",
|
||||
"powerMeter": "功率计",
|
||||
"paddle": "桨频器",
|
||||
"T5trainer": "T5骑行台",
|
||||
"updateTitle": "发现新版本",
|
||||
"latestVersion": "最新版本",
|
||||
"currentVersion": "当前版本",
|
||||
"updateLogs": "更新日志",
|
||||
"updateNow": "立即更新",
|
||||
"updateLater": "稍后再说"
|
||||
"version": "版本号 v0.0.1"
|
||||
},
|
||||
"scan": {
|
||||
"title": "搜索设备",
|
||||
@ -29,140 +20,6 @@
|
||||
"noName": "[无名称]",
|
||||
"rssiUnit": "dBm"
|
||||
},
|
||||
"t5Scan": {
|
||||
"title": "搜索T5骑行台",
|
||||
"scanning": "搜索中...",
|
||||
"noDevice": "暂无T5骑行台设备",
|
||||
"tipScanning": "(请确保骑行台有电且被唤醒)",
|
||||
"tipBluetooth": "(请在设置中打开蓝牙)"
|
||||
},
|
||||
"paddleScan": {
|
||||
"title": "搜索桨频器",
|
||||
"scanning": "搜索中...",
|
||||
"noDevice": "暂无桨频器设备",
|
||||
"tipScanning": "(请确保桨频器设备有电且被唤醒)",
|
||||
"tipBluetooth": "(请在设置中打开蓝牙)",
|
||||
"noName": "[无名称]",
|
||||
"rssiUnit": "dBm"
|
||||
},
|
||||
"common": {
|
||||
"notice": "提示",
|
||||
"unknown": "未知",
|
||||
"unknownDevice": "未知设备",
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
},
|
||||
"info2": {
|
||||
"waitingAction": "等待操作",
|
||||
"notMatched": "未匹配",
|
||||
"checkFailed": "检查失败",
|
||||
"unknownBoatType": "读取到未知船型",
|
||||
"readAbnormal": "读取返回异常:{{hex}}",
|
||||
"writingBoat": "正在写入{{boatName}}...",
|
||||
"writtenWaitRead": "已写入{{boatName}},200ms后读取确认...",
|
||||
"requestingBoatRead": "正在请求读取船型...",
|
||||
"setSuccess": "设置成功:{{boatName}}",
|
||||
"notMatch": "不一致:期望 {{expected}},实际 {{actual}}",
|
||||
"readResult": "读取返回:{{hex}}",
|
||||
"actionFailed": "操作失败:{{message}}",
|
||||
"failed": "失败",
|
||||
"waitFff3Timeout": "等待 FFF3 返回超时",
|
||||
"currentBoat": "✅ 当前船型:{{boatName}}",
|
||||
"readCurrentBoat": "已读取当前船型:{{boatName}}",
|
||||
"disconnectedNeedReconnect": "设备已断开,请重新连接",
|
||||
"defaultReadFailed": "已连接,但默认读取船型失败",
|
||||
"connectOrReadFailed": "连接或读取失败",
|
||||
"connectOrReadFailedAlert": "蓝牙连接失败或设备信息读取失败",
|
||||
"cadence": "桨频",
|
||||
"cadenceUnit": "RPM(次/分钟)",
|
||||
"boatSelect": "船型选择",
|
||||
"boatSwitching": "船型切换中...",
|
||||
"retry": "请重试",
|
||||
"boatKayak": "皮艇",
|
||||
"boatRowing": "划艇",
|
||||
"boatRacing": "赛艇",
|
||||
"latestFirmware": "最新固件",
|
||||
"checking": "检查中..."
|
||||
},
|
||||
"info3": {
|
||||
"bikeTypeFollow": "跟随软件",
|
||||
"bikeTypeRoad": "公路车",
|
||||
"bikeTypeMtb26": "山地车26寸",
|
||||
"bikeTypeMtb275": "山地车27.5寸",
|
||||
"bikeTypeMtb29": "山地车29寸",
|
||||
"bikeTypeSmallWheel": "小轮车",
|
||||
"ergOn": "开启",
|
||||
"ergOff": "关闭",
|
||||
"readUsedMileageTimeout": "读取使用里程超时",
|
||||
"powerTrimRangeError": "请输入50.00-200.00之间的数,最多保留两位小数",
|
||||
"deviceNotReady": "设备尚未准备完成,请稍后再试",
|
||||
"invalidWeight": "请输入正确的体重",
|
||||
"waitFff3Timeout": "等待FFF3返回超时",
|
||||
"readPowerTrimTimeout": "读取当前功率微调超时",
|
||||
"waitWeightAckTimeout": "等待体重设定确认超时",
|
||||
"readWeightTimeout": "读取当前体重超时",
|
||||
"readBikeTypeTimeout": "读取当前车型超时",
|
||||
"waitBikeTypeAckTimeout": "等待车型设定确认超时",
|
||||
"readErgTimeout": "读取ERG功率平滑超时",
|
||||
"waitErgAckTimeout": "等待ERG功率平滑确认超时",
|
||||
"connectReadFailed": "设备连接或读取失败",
|
||||
"readFailed": "读取失败",
|
||||
"connecting": "连接中",
|
||||
"pendingTag": "待保存",
|
||||
"settingTag": "设置中",
|
||||
"currentWeightValue": "当前:{{value}} kg",
|
||||
"currentPowerTrimValue": "当前:{{value}} %",
|
||||
"currentTextValue": "当前:{{value}}",
|
||||
"usedMileage": "使用里程",
|
||||
"speedKph": "速度/km/h",
|
||||
"weightSetting": "体重设定",
|
||||
"powerTrim": "功率微调",
|
||||
"bikeType": "车型选择",
|
||||
"ergSmooth": "ERG功率平滑",
|
||||
"settingWeight": "正在设置体重…",
|
||||
"weightSetDone": "体重设定完成",
|
||||
"weightSetFailed": "体重设定失败",
|
||||
"settingPowerTrim": "正在设置功率微调…",
|
||||
"powerTrimSetDone": "功率微调设定完成",
|
||||
"powerTrimSetFailed": "功率微调设定失败",
|
||||
"settingBikeType": "车型设定中",
|
||||
"bikeTypeSetDone": "车型设定完成",
|
||||
"bikeTypeSetFailed": "车型设定失败",
|
||||
"settingErgSmooth": "ERG功率平滑设定中",
|
||||
"ergSmoothSetDone": "ERG功率平滑设定完成",
|
||||
"ergSmoothSetFailed": "ERG功率平滑设定失败",
|
||||
"confirmWeightChange": "是否将体重改为{{value}}kg",
|
||||
"confirmPowerTrimChange": "是否将功率微调改为{{value}}%",
|
||||
"helpText": "按住查看说明"
|
||||
},
|
||||
"spindown": {
|
||||
"title": "消旋",
|
||||
"headerTitle": "骑行台消旋",
|
||||
"connecting": "设备连接中",
|
||||
"connected": "设备已连接",
|
||||
"targetLabel": "目标速度",
|
||||
"targetHint": "请先骑行加速到目标速度",
|
||||
"currentSpeed": "当前速度",
|
||||
"statusReach36": "请骑行加速到 {{speed}} km/h",
|
||||
"statusReached36": "已达到 {{high}} km/h,请停止踩踏并等待速度下降到 {{low}} km/h",
|
||||
"statusCalibrating": "正在消旋,请等待",
|
||||
"statusCompleted": "消旋完成",
|
||||
"deviceNotReady": "设备未准备好,请稍后重试",
|
||||
"connectFailed": "设备连接失败,请返回重试",
|
||||
"timeout": "等待消旋时间返回超时",
|
||||
"failedRetry": "消旋失败,请重试",
|
||||
"step1Title": "请骑行到 {{speed}} km/h",
|
||||
"step1Desc": "达到目标速度后自动进入下一步",
|
||||
"step2Title": "停止踩踏并等待减速",
|
||||
"step2Desc": "速度下降到 {{speed}} km/h 后自动开始消旋",
|
||||
"step3Title": "消旋完成",
|
||||
"step3Loading": "正在读取消旋时间...",
|
||||
"step3Pending": "完成前会显示在这里",
|
||||
"loading": "正在连接设备并准备消旋...",
|
||||
"result": "消旋完成,时间 {{seconds}}s",
|
||||
"retry": "重新开始消旋"
|
||||
},
|
||||
|
||||
"dfu": {
|
||||
"title": "固件升级",
|
||||
"preparing": "准备中...",
|
||||
@ -189,16 +46,9 @@
|
||||
"doNotReturn": "正在升级,请勿返回或关闭应用!",
|
||||
|
||||
"cannotUpgrade": "无法升级",
|
||||
"hardwareNotFound": "未找到硬件版本 {{hardware}} 的固件",
|
||||
"hardwareNotFound": "未找到硬件版本 {hardware} 的固件",
|
||||
"noNeedUpgrade": "无需升级",
|
||||
"alreadyLatest": "已是最新固件,无需升级",
|
||||
"invalidTargetDeviceId": "无法生成 DFU 目标设备 ID",
|
||||
"manifestDownloadFailed": "manifest 下载失败,HTTP {{status}}",
|
||||
"manifestInvalidJson": "manifest 不是合法 JSON: {{preview}}",
|
||||
"hardwareMismatch": "服务器固件硬件号不匹配:当前 {{current}},服务器 {{latest}}",
|
||||
"firmwareDownloadFailed": "固件包下载失败,HTTP {{status}}",
|
||||
"firmwareFileMissing": "固件包下载后文件不存在: {{path}}",
|
||||
"firmwareFileInvalid": "固件包文件无效,大小为 {{size}}",
|
||||
|
||||
"upgradeSuccess": "升级成功",
|
||||
"upgradeSuccessMessage": "升级成功,请重连设备",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user