Compare commits

...

7 Commits
1.0.1 ... main

Author SHA1 Message Date
145ec23526 版本号 2026-07-15 20:34:09 +08:00
d6f1e2f6b8 Fix Android DFU target id handling 2026-06-17 14:39:05 +08:00
5350017085 Update DFU translations and ignore local sdk workspace 2026-06-17 14:09:47 +08:00
5e387c24f5 Fix DFU flow and preserve disconnect handling 2026-06-17 11:53:20 +08:00
Caiyanpeng
1f09babe86 链接缓存问题 2026-06-11 10:29:58 +08:00
64beed9c4e 新增自动更新功能,隐藏首页T5骑行台入口
① 新增自动更新功能(UpdateModal),HomeScreen接入版本检测
② LanguageModal 添加 statusBarTranslucent 浸润样式
③ 首页T5骑行台入口注释隐藏
④ 版本号从 1.0.1 升级至 1.0.2
⑤ 多语言文件更新(en.json, zh.json)
⑥ 新增 CLAUDE.md 项目说明文档
⑦ 更新 yarn.lock 依赖

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

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git commit:*)"
]
}
}

3
.gitignore vendored
View File

@ -74,3 +74,6 @@ yarn-error.log
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
# Local SDK workspace
sdk/

81
App.tsx
View File

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

109
CLAUDE.md Normal file
View File

@ -0,0 +1,109 @@
# 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
```

View File

@ -82,8 +82,8 @@ android {
applicationId "com.zhixingpai.powerfundfuapp" applicationId "com.zhixingpai.powerfundfuapp"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2 versionCode 3
versionName "1.0.1" versionName "1.0.3"
} }
signingConfigs { signingConfigs {
debug { debug {

View File

@ -42,7 +42,3 @@ hermesEnabled=true
# This allows your app to draw behind system bars for an immersive UI. # 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. # Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false edgeToEdgeEnabled=false

View File

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

View File

@ -207,10 +207,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
@ -246,10 +250,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
@ -297,7 +305,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -326,7 +334,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",

View File

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

741
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,7 @@
"base-64": "^1.0.0", "base-64": "^1.0.0",
"i18next": "^25.7.3", "i18next": "^25.7.3",
"react": "19.1.0", "react": "19.1.0",
"react-i18next": "^16.5.0", "react-i18next": "16.5.0",
"react-native": "0.81.4", "react-native": "0.81.4",
"react-native-device-info": "^15.0.1", "react-native-device-info": "^15.0.1",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
@ -37,9 +37,9 @@
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3", "@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0", "@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.0.0", "@react-native-community/cli": "15.0.0",
"@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-android": "15.0.0",
"@react-native-community/cli-platform-ios": "20.0.0", "@react-native-community/cli-platform-ios": "15.0.0",
"@react-native/babel-preset": "0.81.4", "@react-native/babel-preset": "0.81.4",
"@react-native/eslint-config": "0.81.4", "@react-native/eslint-config": "0.81.4",
"@react-native/metro-config": "0.81.4", "@react-native/metro-config": "0.81.4",

View File

@ -1,10 +1,16 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { View, Text, StyleSheet, Alert, BackHandler } from "react-native"; import { View, Text, StyleSheet, Alert, Platform } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import RNFS from "react-native-fs"; import RNFS from "react-native-fs";
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu"; import {
import { useTranslation } from 'react-i18next'; startDfu,
getDfuTargetId,
addDfuEventListener,
DfuProgressEvent,
DfuStateEvent,
} from "@systemic-games/react-native-nordic-nrf5-dfu";
import { useTranslation } from "react-i18next";
import MyStatusbar from "./component/MyStatusbar"; import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader"; import MyHeader from "./component/MyHeader";
@ -16,162 +22,429 @@ interface DeviceInfo {
download: string; 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) { export default function DfuScreen({ route, navigation }: Props) {
const {
deviceId,
systemId,
address,
name,
firmware: deviceFirmware,
} = route.params;
const { t } = useTranslation(); const { t } = useTranslation();
const { deviceId, name, firmware: deviceFirmware } = route.params;
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [state, setState] = useState(t('dfu.preparing')); const [state, setState] = useState(t("dfu.preparing"));
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [latestVersion, setLatestVersion] = useState<string>(t('dfu.reading')); const [latestVersion, setLatestVersion] = useState(t("dfu.reading"));
const [isDfuRunning, setIsDfuRunning] = useState(false); const [isDfuRunning, setIsDfuRunning] = useState(false);
const startedRef = useRef(false);
const mapDfuStateToChinese = (state: string): string => { const mapDfuStateToLabel = (s: string): string => {
switch (state) { switch (s) {
case "connecting": return t('dfu.stateConnecting'); case "connecting":
case "starting": return t('dfu.stateStarting'); return t("dfu.stateConnecting");
case "enablingDfuMode": return t('dfu.stateEnablingDfuMode'); case "starting":
case "uploading": return t('dfu.stateUploading'); return t("dfu.stateStarting");
case "validating": return t('dfu.stateValidating'); case "enablingDfuMode":
case "disconnecting": return t('dfu.stateDisconnecting'); return t("dfu.stateEnablingDfuMode");
case "completed": return t('dfu.stateCompleted'); case "uploading":
case "aborted": return t('dfu.stateAborted'); 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 "failed":
case "dfu_failed": return t('dfu.stateFailed'); case "dfu_failed":
case "initializing": return t('dfu.stateInitializing'); return t("dfu.stateFailed");
case "errored": return t('dfu.stateErrored'); case "initializing":
default: return state; return t("dfu.stateInitializing");
case "errored":
return t("dfu.stateErrored");
default:
return s;
} }
}; };
// ✅ 拦截所有导航返回iOS + Android
useEffect(() => { useEffect(() => {
const unsubscribe = navigation.addListener("beforeRemove", (e) => { const unsubscribe = navigation.addListener("beforeRemove", (e) => {
if (!isDfuRunning) return; if (!isDfuRunning) return;
// 阻止返回
e.preventDefault(); e.preventDefault();
Alert.alert(t('dfu.pleaseWait'), t('dfu.doNotReturn')); Alert.alert(t("dfu.pleaseWait"), t("dfu.doNotReturn"));
}); });
return unsubscribe; return unsubscribe;
}, [navigation, isDfuRunning]); }, [navigation, isDfuRunning, t]);
useEffect(() => { 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 () => { const runDfu = async () => {
try { try {
trace("runDfu begin");
setIsDfuRunning(true); setIsDfuRunning(true);
setError(undefined);
const manifestUrl = "https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json"; const rawDeviceId = String(deviceId ?? "").trim();
const rawSystemId = String(systemId ?? rawDeviceId).trim();
const rawAddress =
typeof address === "number"
? address
: address !== undefined && address !== null
? Number(address)
: undefined;
const safeDeviceId = getDfuTargetId({
systemId: rawSystemId,
address: 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 manifestPath = RNFS.CachesDirectoryPath + "/latest.json"; const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise; trace("manifest fetch start", { manifestUrl, manifestPath });
const manifestContent = await RNFS.readFile(manifestPath); const manifestResp = await fetch(manifestUrl);
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] }; trace("manifest fetch response", { status: manifestResp.status });
const [deviceHWStr, deviceFWStr] = deviceFirmware.split("."); if (!manifestResp.ok) {
const deviceHW = parseInt(deviceHWStr); throw new Error(
const deviceFW = parseInt(deviceFWStr); 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 deviceInfo = manifest.devices.find(d => d.hardware === deviceHW);
if (!deviceInfo) { if (!deviceInfo) {
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert(t('dfu.cannotUpgrade'), t('dfu.hardwareNotFound', { hardware: deviceHW }), [ Alert.alert(
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() }, t("dfu.cannotUpgrade"),
]); t("dfu.hardwareNotFound", { hardware: currentFw.hardware }),
[{ text: t("dfu.confirm"), onPress: () => navigation.goBack() }]
);
return; return;
} }
setLatestVersion(deviceInfo.latestFirmware); setLatestVersion(deviceInfo.latestFirmware);
const [, latestFWStr] = deviceInfo.latestFirmware.split("."); const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
const latestFW = parseInt(latestFWStr); trace("parsed latest firmware", latestFw);
if (latestFW <= deviceFW) { if (latestFw.hardware !== currentFw.hardware) {
throw new Error(
t("dfu.hardwareMismatch", {
current: currentFw.hardware,
latest: latestFw.hardware,
})
);
}
if (latestFw.iteration <= currentFw.iteration) {
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert(t('dfu.noNeedUpgrade'), t('dfu.alreadyLatest'), [ Alert.alert(t("dfu.noNeedUpgrade"), t("dfu.alreadyLatest"), [
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() }, { text: t("dfu.confirm"), onPress: () => navigation.goBack() },
]); ]);
return; return;
} }
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip"; trace("upgrade allowed", {
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise; currentIteration: currentFw.iteration,
latestIteration: latestFw.iteration,
await startDfu(deviceId, "file://" + localPath, {
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state),
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
}); });
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
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,
});
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); setIsDfuRunning(false);
Alert.alert(t('dfu.upgradeSuccess'), t('dfu.upgradeSuccessMessage'), [ Alert.alert(t("dfu.upgradeSuccess"), t("dfu.upgradeSuccessMessage"), [
{ text: t('dfu.confirm'), onPress: () => navigation.navigate("Home") }, {
]); text: t("dfu.confirm"),
onPress: () => {
navigation.reset({
index: 0,
routes: [{ name: "Home" }],
});
},
},
]);
} catch (err: any) { } catch (err: any) {
trace("runDfu error", {
message: err?.message,
code: err?.code,
name: err?.name,
error: err,
});
setIsDfuRunning(false); setIsDfuRunning(false);
setError(err.message || t('dfu.dfuFailed')); setError(err?.message || t("dfu.dfuFailed"));
Alert.alert(t('dfu.upgradeFailed'), err.message || t('dfu.dfuFailed'), [ Alert.alert(t("dfu.upgradeFailed"), err?.message || t("dfu.dfuFailed"), [
{ text: t('dfu.confirm'), onPress: () => navigation.goBack() }, { text: t("dfu.confirm"), onPress: () => navigation.goBack() },
]); ]);
} }
}; };
runDfu(); runDfu();
}, [deviceId, deviceFirmware, navigation]); }, [deviceId, name, deviceFirmware, navigation, t]);
return ( return (
<View style={{flex:1,backgroundColor:'#f2f3f7'}}> <View style={styles.container}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar> <MyStatusbar backgroundColor="#FFFFFF" dark />
<MyHeader title={t("dfu.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader> <MyHeader
<View style={{ flex: 1, padding: 20 }}> title={t("dfu.title")}
textColor="#333"
backgroundColor="#FFFFFF"
navigation={navigation}
/>
<View style={styles.content}>
<View style={styles.row}> <View style={styles.row}>
<Text style={{ fontSize: 16, color: "#333" }}> <Text style={styles.titleText}>
{t('dfu.bluetoothName')}: {name} {t("dfu.bluetoothName")}: {name || "--"}
</Text> </Text>
</View> </View>
<View style={styles.row}>
<Text style={{ fontSize: 16, color: "#333" }}> <View style={styles.row}>
{t('dfu.latestVersion')}: {latestVersion} <Text style={styles.normalText}>
</Text> {t("dfu.latestVersion")}: {latestVersion}
</View> </Text>
<View style={styles.row}> </View>
<Text style={{ fontSize: 16, color: "#333" }}>
{t('dfu.currentVersion')}: {deviceFirmware} <View style={styles.row}>
</Text> <Text style={styles.normalText}>
</View> {t("dfu.currentVersion")}: {deviceFirmware || "--"}
<View style={styles.row}> </Text>
<Text style={{ fontSize: 16, color: "#333" }}> </View>
{t('dfu.upgradeStatus')}: {mapDfuStateToChinese(state)}
<View style={styles.row}>
<Text style={styles.normalText}>
{t("dfu.upgradeStatus")}: {mapDfuStateToLabel(state)}
</Text> </Text>
</View> </View>
{/* 横向进度条 */}
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${progress}%` }]} /> <View style={[styles.progressBar, { width: `${progress}%` }]} />
<Text style={styles.progressText}>{progress}%</Text> <Text style={styles.progressText}>{progress}%</Text>
</View> </View>
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>} {!!error && <Text style={styles.errorText}>{error}</Text>}
</View> </View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#FFFFFF",
},
content: {
flex: 1,
padding: 20,
backgroundColor: "#FFFFFF",
},
row: { row: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "red", borderBottomColor: "#E7141E",
paddingBottom: 4, paddingBottom: 6,
marginBottom: 8, marginBottom: 12,
},
titleText: {
fontSize: 18,
color: "#111111",
fontWeight: "600",
},
normalText: {
fontSize: 16,
color: "#222222",
}, },
progressContainer: { progressContainer: {
height: 30, height: 30,
backgroundColor: "#eee", backgroundColor: "#EEEEEE",
borderRadius: 15, borderRadius: 15,
overflow: "hidden", overflow: "hidden",
marginTop: 40, marginTop: 40,
@ -185,6 +458,11 @@ const styles = StyleSheet.create({
position: "absolute", position: "absolute",
alignSelf: "center", alignSelf: "center",
fontWeight: "bold", fontWeight: "bold",
color: "#000", color: "#000000",
},
errorText: {
color: "red",
marginTop: 20,
fontSize: 14,
}, },
}); });

View File

@ -1,36 +1,111 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { View, Text, TouchableOpacity, StyleSheet, StatusBar, Image } from "react-native"; import { View, Text, TouchableOpacity, StyleSheet, Image, Platform } from "react-native";
import { NativeStackScreenProps,NativeStackNavigationProp } from "@react-navigation/native-stack"; import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import MyStatusbar from "./component/MyStatusbar"; import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader"; import MyHeader from "./component/MyHeader";
import UpdateModal from "./component/UpdateModal";
import pxToDp from "./helper/pxToDp"; import pxToDp from "./helper/pxToDp";
import DeviceInfo from "react-native-device-info"; import DeviceInfo from "react-native-device-info";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
type Props = NativeStackScreenProps<RootStackParamList>; 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) { export default function HomeScreen({ navigation }: Props) {
const { t } = useTranslation(); 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 ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 设置状态栏颜色 */}
<MyStatusbar backgroundColor="#E7141E" dark={false}></MyStatusbar> <MyStatusbar backgroundColor="#E7141E" dark={false}></MyStatusbar>
<MyHeader title={t('home.title')} textColor="#fff" backgroundColor="#E7141E" hideBack navigation={navigation} <MyHeader
rightView={<TouchableOpacity style={{width:pxToDp(56),height:pxToDp(56),alignItems:'center',justifyContent:'center'}} title={t("home.title")}
onPress={() => navigation.navigate("Setting")}> textColor="#fff"
<Image source={require('./img/Settings.png')} style={{width:pxToDp(48),height:pxToDp(44)}}></Image> backgroundColor="#E7141E"
</TouchableOpacity>}></MyHeader> 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}> <View style={styles.centerBox}>
<Image source={require("./img/Search.png")} style={{width:pxToDp(568),height:pxToDp(344),marginBottom:pxToDp(100)}}></Image> <Image source={require("./img/Search.png")} style={{ width: pxToDp(568), height: pxToDp(344), marginBottom: pxToDp(100) }}></Image>
<TouchableOpacity <TouchableOpacity style={styles.button} onPress={() => navigation.navigate("Scan")}>
style={styles.button} <Text style={styles.buttonText}>{t("home.powerMeter")}</Text>
onPress={() => navigation.navigate("Scan")}
>
<Text style={styles.buttonText}>{t('home.scan')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.button, { marginTop: pxToDp(20) }]} onPress={() => navigation.navigate("ScanScreen2")}>
<Text style={styles.buttonText}>{t("home.paddle")}</Text>
</TouchableOpacity>
{/* <TouchableOpacity style={[styles.button, { marginTop: pxToDp(20) }]} onPress={() => navigation.navigate("ScanScreen3")}>
<Text style={styles.buttonText}>{t("home.T5trainer")}</Text>
</TouchableOpacity> */}
</View> </View>
<UpdateModal
visible={updateModalVisible}
updateInfo={updateInfo}
currentVersion={currentVersion}
onClose={() => setUpdateModalVisible(false)}
/>
</View> </View>
); );
} }
@ -42,18 +117,17 @@ const styles = StyleSheet.create({
}, },
centerBox: { centerBox: {
flex: 1, flex: 1,
// justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor:'#fff', backgroundColor: "#fff",
paddingTop:pxToDp(250) paddingTop: pxToDp(250),
}, },
button: { button: {
backgroundColor: "#E7141E", backgroundColor: "#E7141E",
borderRadius: pxToDp(24), borderRadius: pxToDp(24),
width:pxToDp(300), width: pxToDp(300),
height:pxToDp(96), height: pxToDp(96),
alignItems:'center', alignItems: "center",
justifyContent:'center' justifyContent: "center",
}, },
buttonText: { buttonText: {
color: "#fff", color: "#fff",
@ -66,7 +140,7 @@ const styles = StyleSheet.create({
}, },
privacyText: { privacyText: {
fontSize: 16, fontSize: 16,
color: "#E7141E", // 红色字体 color: "#E7141E",
marginBottom: 8, marginBottom: 8,
}, },
version: { version: {

View File

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

1227
src/InfoScreen2.tsx Normal file

File diff suppressed because it is too large Load Diff

2049
src/InfoScreen3.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

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

238
src/ScanScreen2.tsx Normal file
View File

@ -0,0 +1,238 @@
// src/ScanScreen.tsx此页面为桨频器搜索页面
import React, { useEffect, useState, useCallback, useRef } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from "react-native";
import {
Central,
CentralEventMap,
ScannedPeripheral,
} from "@systemic-games/react-native-bluetooth-le";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App";
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
type Props = NativeStackScreenProps<RootStackParamList>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen2({ navigation }: Props) {
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
const [scanning, setScanning] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
const { t } = useTranslation();
// 将 RSSI 转换为信号格数0-4
const getSignalLevel = (rssi?: number): number => {
if (rssi === undefined) return 0;
if (rssi >= -50) return 4; // 很强
if (rssi >= -65) return 3; // 强
if (rssi >= -80) return 2; // 一般
if (rssi >= -90) return 1; // 弱
return 0; // 无信号
};
// 渲染信号图标(格数+颜色)
const renderSignalIcon = (rssi?: number) => {
const level = getSignalLevel(rssi);
let iconColor = "#aaa"; // 默认灰色
switch (level) {
case 4:
iconColor = "#8BC34A"; // 亮绿
break;
case 3:
iconColor = "#4CAF50"; // 深绿
break;
case 2:
iconColor = "#FF9800"; // 橙色
break;
case 1:
iconColor = "#F44336"; // 红色
break;
}
const iconName =
level === 4
? "wifi-strength-4"
: level === 3
? "wifi-strength-3"
: level === 2
? "wifi-strength-2"
: level === 1
? "wifi-strength-1"
: "wifi-strength-outline";
return <Icon name={iconName} size={22} color={iconColor} />;
};
// 更新或添加设备,并记录最后扫描时间
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-STK"))) return;
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
setDevices((prev) => {
const now = Date.now();
const exists = prev.find((d) => d.systemId === p.systemId);
if (exists) {
return prev.map((d) =>
d.systemId === p.systemId ? { ...p, lastSeen: now } : d
);
} else {
return [...prev, { ...p, lastSeen: now }];
}
});
}, []);
// 清理消失或信号低的设备
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setDevices((prev) =>
prev.filter(
(d) =>
(d.advertisementData?.rssi ?? -999) >= -90 &&
now - d.lastSeen < 3000
)
);
}, 1000);
return () => clearInterval(interval);
}, []);
const stopScan = useCallback(() => {
setScanning(false);
try { Central.stopScan(); } catch {}
if (handlerRef.current) {
try { Central.removeListener("scannedPeripheral", handlerRef.current); } catch {}
handlerRef.current = null;
}
try { Central.shutdown(); } catch {}
}, []);
const startScan = useCallback(() => {
stopScan();
setDevices([]);
setScanning(true);
const onScanned = (payload: CentralEventMap["scannedPeripheral"]) => {
const p = payload?.peripheral;
console.log("🔥 scanned raw peripheral =", JSON.stringify(p, null, 2));
console.log("🔥 scanned raw peripheral keys =", p ? Object.keys(p) : []);
if (p?.name && p.advertisementData?.isConnectable) {
updatePeripherals(p);
}
};
handlerRef.current = onScanned;
try {
Central.initialize();
Central.addListener("scannedPeripheral", onScanned);
Central.startScan([]);
} catch (e) {
console.warn("Central scan start error:", e);
setScanning(false);
handlerRef.current = null;
try { Central.shutdown(); } catch {}
}
}, [stopScan, updatePeripherals]);
const onRefresh = useCallback(async () => {
setRefreshing(true);
stopScan();
await new Promise<void>((resolve) => setTimeout(resolve, 200));
startScan();
setRefreshing(false);
}, [stopScan, startScan]);
useEffect(() => {
startScan();
return () => { stopScan(); };
}, [startScan, stopScan]);
return (
<View style={styles.container}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('paddleScan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList
data={devices}
keyExtractor={(item) => item.systemId}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => {
console.log("🔥 navigate item =", JSON.stringify(item, null, 2));
console.log("🔥 navigate item keys =", Object.keys(item));
navigation.navigate("Info2", { peripheral: item });
}}
style={styles.deviceRow}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
<View style={styles.rssiRow}>
{renderSignalIcon(item.advertisementData?.rssi)}
<Text style={styles.rssiText}>
{item.advertisementData?.rssi ?? "--"} dBm
</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={() => (
<View style={styles.emptyBox}>
{scanning ? (
<>
<Text style={styles.emptyText}>{t("paddleScan.scanning")}</Text>
<Text style={styles.tips}>{t("paddleScan.tipScanning")}</Text>
</>
) : (
<>
<Text style={styles.emptyText}>{t("paddleScan.noDevice")}</Text>
<Text style={styles.tips}>{t("paddleScan.tipBluetooth")}</Text>
</>
)}
</View>
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff" },
deviceRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 15,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderColor: "#eee",
},
deviceInfo: { flexDirection: "column" },
deviceName: { fontSize: 16, fontWeight: "500" },
rssiRow: { flexDirection: "row", alignItems: "center", marginTop: 4 },
rssiText: { fontSize: 14, color: "#666", marginLeft: 6 },
emptyBox: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
marginTop: 50,
},
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
tips: { fontSize: 14, color: "#888", textAlign: "center" },
});

231
src/ScanScreen3.tsx Normal file
View File

@ -0,0 +1,231 @@
// src/ScanScreen3.tsx此页面为T5骑行台搜索页面
import React, { useEffect, useState, useCallback, useRef } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from "react-native";
import {
Central,
CentralEventMap,
ScannedPeripheral,
} from "@systemic-games/react-native-bluetooth-le";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App";
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
type Props = NativeStackScreenProps<RootStackParamList>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen3({ navigation }: Props) {
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
const [scanning, setScanning] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
const { t } = useTranslation();
// 将 RSSI 转换为信号格数0-4
const getSignalLevel = (rssi?: number): number => {
if (rssi === undefined) return 0;
if (rssi >= -50) return 4; // 很强
if (rssi >= -65) return 3; // 强
if (rssi >= -80) return 2; // 一般
if (rssi >= -90) return 1; // 弱
return 0; // 无信号
};
// 渲染信号图标(格数+颜色)
const renderSignalIcon = (rssi?: number) => {
const level = getSignalLevel(rssi);
let iconColor = "#aaa"; // 默认灰色
switch (level) {
case 4:
iconColor = "#8BC34A"; // 亮绿
break;
case 3:
iconColor = "#4CAF50"; // 深绿
break;
case 2:
iconColor = "#FF9800"; // 橙色
break;
case 1:
iconColor = "#F44336"; // 红色
break;
}
const iconName =
level === 4
? "wifi-strength-4"
: level === 3
? "wifi-strength-3"
: level === 2
? "wifi-strength-2"
: level === 1
? "wifi-strength-1"
: "wifi-strength-outline";
return <Icon name={iconName} size={22} color={iconColor} />;
};
// 更新或添加设备,并记录最后扫描时间
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-T5"))) return;
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
setDevices((prev) => {
const now = Date.now();
const exists = prev.find((d) => d.systemId === p.systemId);
if (exists) {
return prev.map((d) =>
d.systemId === p.systemId ? { ...p, lastSeen: now } : d
);
} else {
return [...prev, { ...p, lastSeen: now }];
}
});
}, []);
// 清理消失或信号低的设备
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setDevices((prev) =>
prev.filter(
(d) =>
(d.advertisementData?.rssi ?? -999) >= -90 &&
now - d.lastSeen < 3000
)
);
}, 1000);
return () => clearInterval(interval);
}, []);
const stopScan = useCallback(() => {
setScanning(false);
try { Central.stopScan(); } catch {}
if (handlerRef.current) {
try { Central.removeListener("scannedPeripheral", handlerRef.current); } catch {}
handlerRef.current = null;
}
try { Central.shutdown(); } catch {}
}, []);
const startScan = useCallback(() => {
stopScan();
setDevices([]);
setScanning(true);
const onScanned = (payload: CentralEventMap["scannedPeripheral"]) => {
const p = payload?.peripheral;
if (p?.name && p.advertisementData?.isConnectable) {
updatePeripherals(p);
}
};
handlerRef.current = onScanned;
try {
Central.initialize();
Central.addListener("scannedPeripheral", onScanned);
Central.startScan([]);
} catch (e) {
console.warn("Central scan start error:", e);
setScanning(false);
handlerRef.current = null;
try { Central.shutdown(); } catch {}
}
}, [stopScan, updatePeripherals]);
const onRefresh = useCallback(async () => {
setRefreshing(true);
stopScan();
await new Promise<void>((resolve) => setTimeout(resolve, 200));
startScan();
setRefreshing(false);
}, [stopScan, startScan]);
useEffect(() => {
startScan();
return () => { stopScan(); };
}, [startScan, stopScan]);
return (
<View style={styles.container}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('t5Scan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList
data={devices}
keyExtractor={(item) => item.systemId}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => navigation.navigate("Info3", { peripheral: item })}
style={styles.deviceRow}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
<View style={styles.rssiRow}>
{renderSignalIcon(item.advertisementData?.rssi)}
<Text style={styles.rssiText}>
{item.advertisementData?.rssi ?? "--"} dBm
</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={() => (
<View style={styles.emptyBox}>
{scanning ? (
<>
<Text style={styles.emptyText}>{t("t5Scan.scanning")}</Text>
<Text style={styles.tips}>{t("t5Scan.tipScanning")}</Text>
</>
) : (
<>
<Text style={styles.emptyText}>{t("t5Scan.noDevice")}</Text>
<Text style={styles.tips}>{t("t5Scan.tipBluetooth")}</Text>
</>
)}
</View>
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff" },
deviceRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 15,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderColor: "#eee",
},
deviceInfo: { flexDirection: "column" },
deviceName: { fontSize: 16, fontWeight: "500" },
rssiRow: { flexDirection: "row", alignItems: "center", marginTop: 4 },
rssiText: { fontSize: 14, color: "#666", marginLeft: 6 },
emptyBox: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
marginTop: 50,
},
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
tips: { fontSize: 14, color: "#888", textAlign: "center" },
});

796
src/SpindownScreen.tsx Normal file
View File

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

View File

@ -33,6 +33,7 @@ const LanguageModal: React.FC<LanguageModalProps> = ({ visible, onClose }) => {
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={onClose} onRequestClose={onClose}
statusBarTranslucent
> >
<Pressable style={styles.overlay} onPress={onClose}> <Pressable style={styles.overlay} onPress={onClose}>
<Pressable <Pressable

View File

@ -0,0 +1,135 @@
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",
},
});

View File

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

View File

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

View File

@ -1,120 +1,270 @@
{ {
"nav": { "nav": {
"scan": "Scan", "scan": "Scan",
"info": "Device Info", "info": "Device Info",
"dfu": "Firmware Update", "dfu": "Firmware Update",
"privacy": "Privacy Policy" "privacy": "Privacy Policy"
}, },
"home": { "home": {
"title": "POWERFUN Settings", "title": "POWERFUN Settings",
"scan": "Scan Devices", "scan": "Scan Devices",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"version": "Version v0.0.1" "version": "Version v0.0.1",
}, "powerMeter": "Power Meter",
"scan": { "paddle": "Paddle",
"title": "Scan Devices", "T5trainer": "T5 Trainer",
"scanning": "Scanning...", "updateTitle": "Update Available",
"noDevice": "No Devices Found", "latestVersion": "Latest Version",
"tipScanning": "(Make sure the device is powered on and awake)", "currentVersion": "Current Version",
"tipBluetooth": "(Please enable Bluetooth in settings)", "updateLogs": "Update Logs",
"noName": "[No Name]", "updateNow": "Update Now",
"rssiUnit": "dBm" "updateLater": "Later"
}, },
"dfu": { "scan": {
"title": "Firmware Update", "title": "Scan Devices",
"preparing": "Preparing...", "scanning": "Scanning...",
"reading": "Reading...", "noDevice": "No Devices Found",
"tipScanning": "(Make sure the device is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)",
"noName": "[No Name]",
"rssiUnit": "dBm"
},
"t5Scan": {
"title": "Scan T5 Trainer",
"scanning": "Scanning...",
"noDevice": "No T5 trainers found",
"tipScanning": "(Make sure the trainer is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)"
},
"paddleScan": {
"title": "Scan Paddle",
"scanning": "Scanning...",
"noDevice": "No paddle devices found",
"tipScanning": "(Make sure the paddle device is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)",
"noName": "[No Name]",
"rssiUnit": "dBm"
},
"common": {
"notice": "Notice",
"unknown": "Unknown",
"unknownDevice": "Unknown Device",
"yes": "Yes",
"no": "No"
},
"info2": {
"waitingAction": "Waiting",
"notMatched": "Not Matched",
"checkFailed": "Check Failed",
"unknownBoatType": "Unknown boat type",
"readAbnormal": "Unexpected response: {{hex}}",
"writingBoat": "Writing {{boatName}}...",
"writtenWaitRead": "Written {{boatName}}, reading back in 200ms...",
"requestingBoatRead": "Requesting boat type...",
"setSuccess": "Set success: {{boatName}}",
"notMatch": "Mismatch: expected {{expected}}, actual {{actual}}",
"readResult": "Read result: {{hex}}",
"actionFailed": "Action failed: {{message}}",
"failed": "Failed",
"waitFff3Timeout": "Timeout waiting for FFF3 response",
"currentBoat": "✅ Current boat type: {{boatName}}",
"readCurrentBoat": "Current boat type read: {{boatName}}",
"disconnectedNeedReconnect": "Device disconnected. Reconnect before upgrading.",
"defaultReadFailed": "Connected, but initial boat type read failed",
"connectOrReadFailed": "Connection or read failed",
"connectOrReadFailedAlert": "Bluetooth connection failed or device info read failed",
"cadence": "Cadence",
"cadenceUnit": "RPM (strokes/min)",
"boatSelect": "Boat Type",
"boatSwitching": "Switching boat type...",
"retry": "Please retry",
"boatKayak": "Kayak",
"boatRowing": "Rowing",
"boatRacing": "Racing",
"latestFirmware": "Latest Firmware",
"checking": "Checking..."
},
"info3": {
"bikeTypeFollow": "Follow Software",
"bikeTypeRoad": "Road Bike",
"bikeTypeMtb26": "Mountain Bike 26\"",
"bikeTypeMtb275": "Mountain Bike 27.5\"",
"bikeTypeMtb29": "Mountain Bike 29\"",
"bikeTypeSmallWheel": "Small Wheel Bike",
"ergOn": "On",
"ergOff": "Off",
"readUsedHoursTimeout": "Timeout reading usage hours",
"readUsedMileageTimeout": "Timeout reading usage mileage",
"powerTrimRangeError": "Please enter a value between 50.00 and 200.00 with up to 2 decimals",
"deviceNotReady": "Device is not ready yet. Please try again later",
"invalidWeight": "Please enter a valid weight",
"waitFff3Timeout": "Timeout waiting for FFF3 response",
"readPowerTrimTimeout": "Timeout reading current power trim",
"waitWeightAckTimeout": "Timeout waiting for weight setting confirmation",
"readWeightTimeout": "Timeout reading current weight",
"readBikeTypeTimeout": "Timeout reading bike type",
"waitBikeTypeAckTimeout": "Timeout waiting for bike type confirmation",
"readErgTimeout": "Timeout reading ERG smoothing",
"waitErgAckTimeout": "Timeout waiting for ERG smoothing confirmation",
"connectReadFailed": "Device connection or read failed",
"readFailed": "Read failed",
"connecting": "Connecting",
"pendingTag": "Pending",
"settingTag": "Applying",
"currentWeightValue": "Current: {{value}} kg",
"currentPowerTrimValue": "Current: {{value}} %",
"currentTextValue": "Current: {{value}}",
"usedMileage": "Usage Mileage",
"speedKph": "Speed/km/h",
"weightSetting": "Weight Setting",
"powerTrim": "Power Trim",
"bikeType": "Bike Type",
"ergSmooth": "ERG Smoothing",
"settingWeight": "Setting weight...",
"weightSetDone": "Weight set successfully",
"weightSetFailed": "Weight setting failed",
"settingPowerTrim": "Setting power trim...",
"powerTrimSetDone": "Power trim set successfully",
"powerTrimSetFailed": "Power trim setting failed",
"settingBikeType": "Setting bike type...",
"bikeTypeSetDone": "Bike type set successfully",
"bikeTypeSetFailed": "Bike type setting failed",
"settingErgSmooth": "Setting ERG smoothing...",
"ergSmoothSetDone": "ERG smoothing set successfully",
"ergSmoothSetFailed": "ERG smoothing setting failed",
"confirmWeightChange": "Set weight to {{value}}kg?",
"confirmPowerTrimChange": "Set power trim to {{value}}%?",
"helpText": "Hold to view help"
},
"spindown": {
"title": "Spindown",
"headerTitle": "Trainer Spindown",
"connecting": "Connecting",
"connected": "Connected",
"targetLabel": "Target Speed",
"targetHint": "Ride to reach the target speed first",
"currentSpeed": "Current Speed",
"statusReach36": "Ride to reach {{speed}} km/h",
"statusReached36": "Reached {{high}} km/h. Stop pedaling and wait until speed drops to {{low}} km/h",
"statusCalibrating": "Spindown in progress, please wait",
"statusCompleted": "Spindown completed",
"deviceNotReady": "Device not ready, please try again",
"connectFailed": "Failed to connect. Please go back and try again",
"timeout": "Timed out waiting for spindown time",
"failedRetry": "Spindown failed, please try again",
"step1Title": "Ride to {{speed}} km/h",
"step1Desc": "Automatically proceeds after reaching the target speed",
"step2Title": "Stop pedaling and coast",
"step2Desc": "Starts spindown automatically when speed drops to {{speed}} km/h",
"step3Title": "Spindown Completed",
"step3Loading": "Reading spindown time...",
"step3Pending": "Will appear here after completion",
"loading": "Connecting and preparing spindown...",
"result": "Spindown completed, time {{seconds}}s",
"retry": "Restart Spindown"
},
"dfu": {
"title": "Firmware Update",
"preparing": "Preparing...",
"reading": "Reading...",
"bluetoothName": "Bluetooth Name", "bluetoothName": "Bluetooth Name",
"latestVersion": "Latest Version", "latestVersion": "Latest Version",
"currentVersion": "Current Version", "currentVersion": "Current Version",
"upgradeStatus": "Update Status", "upgradeStatus": "Update Status",
"stateConnecting": "Connecting…", "stateConnecting": "Connecting…",
"stateStarting": "Initializing…", "stateStarting": "Initializing…",
"stateEnablingDfuMode": "Enabling DFU Mode…", "stateEnablingDfuMode": "Enabling DFU Mode…",
"stateUploading": "Uploading Firmware…", "stateUploading": "Uploading Firmware…",
"stateValidating": "Validating Firmware…", "stateValidating": "Validating Firmware…",
"stateDisconnecting": "Disconnecting…", "stateDisconnecting": "Disconnecting…",
"stateCompleted": "Update Completed", "stateCompleted": "Update Completed",
"stateAborted": "Aborted", "stateAborted": "Aborted",
"stateFailed": "Update Failed", "stateFailed": "Update Failed",
"stateInitializing": "Starting…", "stateInitializing": "Starting…",
"stateErrored": "Update Error!", "stateErrored": "Update Error!",
"pleaseWait": "Please Wait", "pleaseWait": "Please Wait",
"doNotReturn": "Updating in progress, do not go back or close the app!", "doNotReturn": "Updating in progress, do not go back or close the app!",
"cannotUpgrade": "Cannot Update", "cannotUpgrade": "Cannot Update",
"hardwareNotFound": "Firmware not found for hardware version {hardware}", "hardwareNotFound": "Firmware not found for hardware version {{hardware}}",
"noNeedUpgrade": "No Update Needed", "noNeedUpgrade": "No Update Needed",
"alreadyLatest": "Already the latest firmware, 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", "upgradeSuccess": "Update Successful",
"upgradeSuccessMessage": "Update successful, please reconnect the device", "upgradeSuccessMessage": "Update successful, please reconnect the device",
"upgradeFailed": "Update Failed", "upgradeFailed": "Update Failed",
"dfuFailed": "DFU Failed", "dfuFailed": "DFU Failed",
"confirm": "OK" "confirm": "OK"
}, },
"privacy": { "privacy": {
"title": "Privacy Policy", "title": "Privacy Policy",
"content": "This application respects and protects the personal privacy of all users. This Privacy Policy applies only to the POWERFUN Settings App products or services provided by Wuxi Zhixingpai Sports Culture Development Co., Ltd. Please read and fully understand this policy before using our products or services.\n\n1. How We Collect and Use Your Information\n\n1. The POWERFUN Settings App does not require registration or login and does not collect any personal information.\n\n2. During your use of our products or services, we may use the following permissions:\n- Android ID\n- Storage\n- Location\n- Bluetooth\n\n3. Third-party SDK usage:\n\n(1) Tencent Bugly \nProvider: Tencent Computer Systems Company Limited \nPurpose: crash reporting and performance monitoring.\n\n(2) Aliyun OSS \nProvider: Alibaba Cloud Computing Co., Ltd. \nPurpose: storage of configuration and firmware update files.\n\n2. Updates to This Policy\n\nWe may update this policy from time to time. Changes will be published on this page.\n\n3. Contact Us\n\nEmail: bike99@qq.com\n\nWuxi Zhixingpai Sports Culture Development Co., Ltd. \nEffective date: July 1, 2019" "content": "This application respects and protects the personal privacy of all users. This Privacy Policy applies only to the POWERFUN Settings App products or services provided by Wuxi Zhixingpai Sports Culture Development Co., Ltd. Please read and fully understand this policy before using our products or services.\n\n1. How We Collect and Use Your Information\n\n1. The POWERFUN Settings App does not require registration or login and does not collect any personal information.\n\n2. During your use of our products or services, we may use the following permissions:\n- Android ID\n- Storage\n- Location\n- Bluetooth\n\n3. Third-party SDK usage:\n\n(1) Tencent Bugly \nProvider: Tencent Computer Systems Company Limited \nPurpose: crash reporting and performance monitoring.\n\n(2) Aliyun OSS \nProvider: Alibaba Cloud Computing Co., Ltd. \nPurpose: storage of configuration and firmware update files.\n\n2. Updates to This Policy\n\nWe may update this policy from time to time. Changes will be published on this page.\n\n3. Contact Us\n\nEmail: bike99@qq.com\n\nWuxi Zhixingpai Sports Culture Development Co., Ltd. \nEffective date: July 1, 2019"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"language": "Language", "language": "Language",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"version": "Version" "version": "Version"
}, },
"languageModal": { "languageModal": {
"title": "Language" "title": "Language"
}, },
"info": { "info": {
"title": "Device Info", "title": "Device Info",
"bluetoothName": "Bluetooth Name", "bluetoothName": "Bluetooth Name",
"idNumber": "ID Number", "idNumber": "ID Number",
"firmwareVersion": "Firmware Version", "firmwareVersion": "Firmware Version",
"battery": "Battery", "battery": "Battery",
"connectionStatus": "Connection Status", "connectionStatus": "Connection Status",
"connected": "Connected", "connected": "Connected",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"reading": "Reading...", "reading": "Reading...",
"power": "Power/W", "power": "Power/W",
"cadence": "Cadence/RPM", "cadence": "Cadence/RPM",
"balance": "L/R Balance/%", "balance": "L/R Balance/%",
"balanceHeader": "L / R", "balanceHeader": "L / R",
"powerTrimTitle": "Power Trim Settings", "powerTrimTitle": "Power Trim Settings",
"currentTrim": "Current Trim", "currentTrim": "Current Trim",
"trimPlaceholder": "Enter 50-200", "trimPlaceholder": "Enter 50-200",
"updateValue": "Update Value", "updateValue": "Update Value",
"calibrateButton": "Calibrate Zero", "calibrateButton": "Calibrate Zero",
"calibrating": "Calibrating... Waiting for device", "calibrating": "Calibrating... Waiting for device",
"firmwareUpgrade": "Firmware Update", "firmwareUpgrade": "Firmware Update",
"readingInfo": "Reading information...", "readingInfo": "Reading information...",
"readSuccess": "Read successfully!", "readSuccess": "Read successfully!",
"writingTrim": "Writing power trim...", "writingTrim": "Writing power trim...",
"trimUpdateSuccess": "Power trim updated successfully!", "trimUpdateSuccess": "Power trim updated successfully!",
"disconnectTitle": "Notice", "disconnectTitle": "Notice",
"disconnectMessage": "Device disconnected, please reconnect", "disconnectMessage": "Device disconnected, please reconnect",
"reconnectMessage": "Please reconnect the device", "reconnectMessage": "Please reconnect the device",
"confirm": "OK", "confirm": "OK",
"trimRangeAlert": "Power trim adjusts the power meter's high and low deviation. Default value is 100%. Adjustable range is 50%-200%. Please enter a number between 50 and 200 without the % symbol. Click the button below to update the power meter after entering.", "trimRangeAlert": "Power trim adjusts the power meter's high and low deviation. Default value is 100%. Adjustable range is 50%-200%. Please enter a number between 50 and 200 without the % symbol. Click the button below to update the power meter after entering.",
"deviceNotConnected": "Device not connected", "deviceNotConnected": "Device not connected",
"writeFailed": "Write failed", "writeFailed": "Write failed",
"calibrationSuccess": "Calibration Successful", "calibrationSuccess": "Calibration Successful",
"calibrationValue": "Calibration Value", "calibrationValue": "Calibration Value",
"calibrationError": "Calibration Error", "calibrationError": "Calibration Error",
"calibrationTimeout": "Device not responding, please try again", "calibrationTimeout": "Device not responding, please try again",
"calibrationFormatError": "Invalid response format from device", "calibrationFormatError": "Invalid response format from device",
"calibrationSendError": "Failed to send calibration command", "calibrationSendError": "Failed to send calibration command",
"error": "Error" "error": "Error"
}
} }
}

View File

@ -1,17 +1,26 @@
{ {
"nav": { "nav": {
"scan": "搜索", "scan": "搜索",
"info": "设备信息", "info": "设备信息",
"dfu": "固件升级", "dfu": "固件升级",
"privacy": "隐私协议" "privacy": "隐私协议"
}, },
"home": { "home": {
"title": "POWERFUN设置", "title": "POWERFUN设置",
"scan": "搜索设备", "scan": "搜索设备",
"privacy": "隐私协议", "privacy": "隐私协议",
"version": "版本号 v0.0.1" "version": "版本号 v0.0.1",
}, "powerMeter": "功率计",
"scan": { "paddle": "桨频器",
"T5trainer": "T5骑行台",
"updateTitle": "发现新版本",
"latestVersion": "最新版本",
"currentVersion": "当前版本",
"updateLogs": "更新日志",
"updateNow": "立即更新",
"updateLater": "稍后再说"
},
"scan": {
"title": "搜索设备", "title": "搜索设备",
"scanning": "搜索中...", "scanning": "搜索中...",
"noDevice": "暂无设备", "noDevice": "暂无设备",
@ -19,102 +28,243 @@
"tipBluetooth": "(请在设置中打开蓝牙)", "tipBluetooth": "(请在设置中打开蓝牙)",
"noName": "[无名称]", "noName": "[无名称]",
"rssiUnit": "dBm" "rssiUnit": "dBm"
}, },
"dfu": { "t5Scan": {
"title": "固件升级", "title": "搜索T5骑行台",
"preparing": "准备中...", "scanning": "搜索中...",
"reading": "读取中...", "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": "重新开始消旋"
},
"bluetoothName": "蓝牙名称", "dfu": {
"latestVersion": "最新版本", "title": "固件升级",
"currentVersion": "当前版本", "preparing": "准备中...",
"upgradeStatus": "升级状态", "reading": "读取中...",
"stateConnecting": "连接中…", "bluetoothName": "蓝牙名称",
"stateStarting": "初始化中…", "latestVersion": "最新版本",
"stateEnablingDfuMode": "启用 DFU 模式…", "currentVersion": "当前版本",
"stateUploading": "上传固件中…", "upgradeStatus": "升级状态",
"stateValidating": "校验固件…",
"stateDisconnecting": "断开连接…",
"stateCompleted": "升级完成",
"stateAborted": "已取消",
"stateFailed": "升级失败",
"stateInitializing": "启动中…",
"stateErrored": "升级出错!",
"pleaseWait": "请稍候", "stateConnecting": "连接中…",
"doNotReturn": "正在升级,请勿返回或关闭应用!", "stateStarting": "初始化中…",
"stateEnablingDfuMode": "启用 DFU 模式…",
"stateUploading": "上传固件中…",
"stateValidating": "校验固件…",
"stateDisconnecting": "断开连接…",
"stateCompleted": "升级完成",
"stateAborted": "已取消",
"stateFailed": "升级失败",
"stateInitializing": "启动中…",
"stateErrored": "升级出错!",
"cannotUpgrade": "无法升级", "pleaseWait": "请稍候",
"hardwareNotFound": "未找到硬件版本 {hardware} 的固件", "doNotReturn": "正在升级,请勿返回或关闭应用!",
"noNeedUpgrade": "无需升级",
"alreadyLatest": "已是最新固件,无需升级",
"upgradeSuccess": "升级成功", "cannotUpgrade": "无法升级",
"upgradeSuccessMessage": "升级成功,请重连设备", "hardwareNotFound": "未找到硬件版本 {{hardware}} 的固件",
"upgradeFailed": "升级失败", "noNeedUpgrade": "无需升级",
"dfuFailed": "DFU失败", "alreadyLatest": "已是最新固件,无需升级",
"invalidTargetDeviceId": "无法生成 DFU 目标设备 ID",
"manifestDownloadFailed": "manifest 下载失败HTTP {{status}}",
"manifestInvalidJson": "manifest 不是合法 JSON: {{preview}}",
"hardwareMismatch": "服务器固件硬件号不匹配:当前 {{current}},服务器 {{latest}}",
"firmwareDownloadFailed": "固件包下载失败HTTP {{status}}",
"firmwareFileMissing": "固件包下载后文件不存在: {{path}}",
"firmwareFileInvalid": "固件包文件无效,大小为 {{size}}",
"confirm": "确认" "upgradeSuccess": "升级成功",
}, "upgradeSuccessMessage": "升级成功,请重连设备",
"privacy": { "upgradeFailed": "升级失败",
"title": "隐私协议", "dfuFailed": "DFU失败",
"content": "本应用尊重并保护所有使用服务用户的个人隐私权。本隐私政策仅适用于无锡执行派体育文化发展有限公司的 POWERFUN 设置 APP 产品或服务。请在使用我们的产品或服务前,仔细阅读并了解本隐私政策。\n\n一、我们如何收集和使用您的信息\n\n1. POWERFUN 设置 APP 不需要注册和登录,也不会收集任何关于个人的信息。\n\n2. 在您使用我司产品或服务过程中,我们可能会使用以下权限和信息:\n- Android ID用于第三方或我们分析错误信息。\n- 存储权限:用于管理本地缓存。\n- 位置权限:用于设备产品与 APP 蓝牙连接。\n- 蓝牙权限:用于设备产品与 APP 蓝牙连接。\n\n3. 关于第三方 SDK 使用说明:\n\n1第三方 SDK 名称:腾讯 Bugly \n提供方深圳市腾讯计算机系统有限公司 \n收集类型系统版本、设备 ID、手机型号、网络状态、系统设置、网络与 WiFi 信息、手机状态、系统日志等。 \n使用目的用于监控应用性能与稳定性收集崩溃报告和使用数据以帮助我们识别并修复问题。\n\n2第三方 SDK 名称Aliyun OSS \n提供方阿里云计算有限公司 \n使用目的用于存储应用运行必要的配置和固件升级文件确保数据可靠存储。\n\n二、本政策如何更新\n\n我们的隐私政策可能会根据需求进行变更。未经您明确同意我们不会削减您依据隐私政策应享有的权利。任何变更我们将在本页面公布。对于重大变更我们会提供更为显著的通知。\n\n重大变更包括但不限于\n- 服务模式发生重大变化(如用户信息处理目的、类型、方式等)。\n- 在所有权或组织架构方面发生变更(如业务调整、破产并购等)。\n- 用户信息安全影响评估报告显示存在高风险。\n\n三、如何联系我们\n\n如果您对本隐私政策有任何疑问、意见或建议可通过以下方式与我们联系\n电子邮件bike99@qq.com\n\n一般情况下我们将在三十天内回复。\n\n无锡执行派体育文化发展有限公司 \n本政策自 2019 年 7 月 1 日起生效"
},
"settings": {
"title": "设置",
"language": "软件语言",
"privacy": "隐私协议",
"version": "版本号"
},
"languageModal": {
"title": "软件语言"
},
"info": {
"title": "设备信息",
"bluetoothName": "蓝牙名称",
"idNumber": "ID号",
"firmwareVersion": "固件版本",
"battery": "电量",
"connectionStatus": "连接状态",
"connected": "已连接",
"disconnected": "未连接",
"reading": "读取中...",
"power": "功率/W", "confirm": "确认"
"cadence": "踏频/RPM", },
"balance": "左右平衡/%", "privacy": {
"balanceHeader": "L / R", "title": "隐私协议",
"content": "本应用尊重并保护所有使用服务用户的个人隐私权。本隐私政策仅适用于无锡执行派体育文化发展有限公司的 POWERFUN 设置 APP 产品或服务。请在使用我们的产品或服务前,仔细阅读并了解本隐私政策。\n\n一、我们如何收集和使用您的信息\n\n1. POWERFUN 设置 APP 不需要注册和登录,也不会收集任何关于个人的信息。\n\n2. 在您使用我司产品或服务过程中,我们可能会使用以下权限和信息:\n- Android ID用于第三方或我们分析错误信息。\n- 存储权限:用于管理本地缓存。\n- 位置权限:用于设备产品与 APP 蓝牙连接。\n- 蓝牙权限:用于设备产品与 APP 蓝牙连接。\n\n3. 关于第三方 SDK 使用说明:\n\n1第三方 SDK 名称:腾讯 Bugly \n提供方深圳市腾讯计算机系统有限公司 \n收集类型系统版本、设备 ID、手机型号、网络状态、系统设置、网络与 WiFi 信息、手机状态、系统日志等。 \n使用目的用于监控应用性能与稳定性收集崩溃报告和使用数据以帮助我们识别并修复问题。\n\n2第三方 SDK 名称Aliyun OSS \n提供方阿里云计算有限公司 \n使用目的用于存储应用运行必要的配置和固件升级文件确保数据可靠存储。\n\n二、本政策如何更新\n\n我们的隐私政策可能会根据需求进行变更。未经您明确同意我们不会削减您依据隐私政策应享有的权利。任何变更我们将在本页面公布。对于重大变更我们会提供更为显著的通知。\n\n重大变更包括但不限于\n- 服务模式发生重大变化(如用户信息处理目的、类型、方式等)。\n- 在所有权或组织架构方面发生变更(如业务调整、破产并购等)。\n- 用户信息安全影响评估报告显示存在高风险。\n\n三、如何联系我们\n\n如果您对本隐私政策有任何疑问、意见或建议可通过以下方式与我们联系\n电子邮件bike99@qq.com\n\n一般情况下我们将在三十天内回复。\n\n无锡执行派体育文化发展有限公司 \n本政策自 2019 年 7 月 1 日起生效"
},
"settings": {
"title": "设置",
"language": "软件语言",
"privacy": "隐私协议",
"version": "版本号"
},
"languageModal": {
"title": "软件语言"
},
"info": {
"title": "设备信息",
"bluetoothName": "蓝牙名称",
"idNumber": "ID号",
"firmwareVersion": "固件版本",
"battery": "电量",
"connectionStatus": "连接状态",
"connected": "已连接",
"disconnected": "未连接",
"reading": "读取中...",
"powerTrimTitle": "功率微调设置", "power": "功率/W",
"currentTrim": "当前微调", "cadence": "踏频/RPM",
"trimPlaceholder": "输入50-200", "balance": "左右平衡/%",
"updateValue": "更新数值", "balanceHeader": "L / R",
"calibrateButton": "校准归零", "powerTrimTitle": "功率微调设置",
"calibrating": "校准中...等待设备反应", "currentTrim": "当前微调",
"firmwareUpgrade": "固件升级", "trimPlaceholder": "输入50-200",
"updateValue": "更新数值",
"readingInfo": "正在读取信息...", "calibrateButton": "校准归零",
"readSuccess": "读取成功!", "calibrating": "校准中...等待设备反应",
"writingTrim": "正在写入功率微调...", "firmwareUpgrade": "固件升级",
"trimUpdateSuccess": "功率微调更新成功!",
"disconnectTitle": "提示", "readingInfo": "正在读取信息...",
"disconnectMessage": "设备已断开,请重新连接设备", "readSuccess": "读取成功!",
"reconnectMessage": "请重新连接设备", "writingTrim": "正在写入功率微调...",
"confirm": "确定", "trimUpdateSuccess": "功率微调更新成功!",
"trimRangeAlert": "功率微调可调整功率计的高低偏差默认值100%。可调整的范围是50%-200%。请输入50至200的纯数字不需要包含%符号。输入后点击下方按钮更新进功率计设备。", "disconnectTitle": "提示",
"deviceNotConnected": "设备未连接", "disconnectMessage": "设备已断开,请重新连接设备",
"writeFailed": "写入失败", "reconnectMessage": "请重新连接设备",
"confirm": "确定",
"calibrationSuccess": "校准成功", "trimRangeAlert": "功率微调可调整功率计的高低偏差默认值100%。可调整的范围是50%-200%。请输入50至200的纯数字不需要包含%符号。输入后点击下方按钮更新进功率计设备。",
"calibrationValue": "校准值", "deviceNotConnected": "设备未连接",
"calibrationError": "校准错误", "writeFailed": "写入失败",
"calibrationTimeout": "设备未响应,请重试",
"calibrationFormatError": "设备返回数据格式错误", "calibrationSuccess": "校准成功",
"calibrationSendError": "发送校准命令失败", "calibrationValue": "校准值",
"error": "错误" "calibrationError": "校准错误",
} "calibrationTimeout": "设备未响应,请重试",
"calibrationFormatError": "设备返回数据格式错误",
"calibrationSendError": "发送校准命令失败",
"error": "错误"
} }
}

510
yarn.lock

File diff suppressed because it is too large Load Diff