Compare commits

..

3 Commits
1.0.1 ... main

Author SHA1 Message Date
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
26 changed files with 7079 additions and 1091 deletions

View File

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

81
App.tsx
View File

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

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

@ -83,7 +83,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2
versionName "1.0.1"
versionName "1.0.2"
}
signingConfigs {
debug {

View File

@ -45,4 +45,4 @@ edgeToEdgeEnabled=false
org.gradle.java.home=E:\\jdk\\jdk-17.0.12

View File

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

View File

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

View File

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

741
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,36 +1,111 @@
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet, StatusBar, Image } from "react-native";
import { NativeStackScreenProps,NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { useEffect, useState } from "react";
import { View, Text, TouchableOpacity, StyleSheet, Image, Platform } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App";
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
import UpdateModal from "./component/UpdateModal";
import pxToDp from "./helper/pxToDp";
import DeviceInfo from "react-native-device-info";
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
type Props = NativeStackScreenProps<RootStackParamList>;
interface UpdateInfo {
latestVersion: string;
downloadUrl: string;
updateLogs: string[];
}
const UPDATE_JSON_URL = `https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/apks/update.json?t=${Date.now()}`;
const compareVersions = (latest: string, current: string): boolean => {
const latestParts = latest.split(".").map(Number);
const currentParts = current.split(".").map(Number);
for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
const latestNum = latestParts[i] || 0;
const currentNum = currentParts[i] || 0;
if (latestNum > currentNum) return true;
if (latestNum < currentNum) return false;
}
return false;
};
export default function HomeScreen({ navigation }: Props) {
const { t } = useTranslation();
const [updateModalVisible, setUpdateModalVisible] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [currentVersion, setCurrentVersion] = useState("");
useEffect(() => {
const checkUpdate = async () => {
try {
const version = DeviceInfo.getVersion();
setCurrentVersion(version);
const resp = await fetch(UPDATE_JSON_URL);
if (!resp.ok) return;
const data = await resp.json();
const platform = Platform.OS === "ios" ? "ios" : "android";
const platformUpdate = data[platform];
if (!platformUpdate) return;
if (compareVersions(platformUpdate.latestVersion, version)) {
setUpdateInfo({
latestVersion: platformUpdate.latestVersion,
downloadUrl: platformUpdate.downloadUrl,
updateLogs: platformUpdate.updateLogs || [],
});
setUpdateModalVisible(true);
}
} catch (e) {
console.log("检查更新失败:", e);
}
};
checkUpdate();
}, []);
return (
<View style={styles.container}>
{/* 设置状态栏颜色 */}
<MyStatusbar backgroundColor="#E7141E" dark={false}></MyStatusbar>
<MyHeader title={t('home.title')} textColor="#fff" backgroundColor="#E7141E" hideBack navigation={navigation}
rightView={<TouchableOpacity style={{width:pxToDp(56),height:pxToDp(56),alignItems:'center',justifyContent:'center'}}
onPress={() => navigation.navigate("Setting")}>
<Image source={require('./img/Settings.png')} style={{width:pxToDp(48),height:pxToDp(44)}}></Image>
</TouchableOpacity>}></MyHeader>
{/* 中间按钮 */}
<MyHeader
title={t("home.title")}
textColor="#fff"
backgroundColor="#E7141E"
hideBack
navigation={navigation}
rightView={
<TouchableOpacity
style={{ width: pxToDp(56), height: pxToDp(56), alignItems: "center", justifyContent: "center" }}
onPress={() => navigation.navigate("Setting")}
>
<Image source={require("./img/Settings.png")} style={{ width: pxToDp(48), height: pxToDp(44) }}></Image>
</TouchableOpacity>
}
></MyHeader>
<View style={styles.centerBox}>
<Image source={require("./img/Search.png")} style={{width:pxToDp(568),height:pxToDp(344),marginBottom:pxToDp(100)}}></Image>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate("Scan")}
>
<Text style={styles.buttonText}>{t('home.scan')}</Text>
<Image source={require("./img/Search.png")} style={{ width: pxToDp(568), height: pxToDp(344), marginBottom: pxToDp(100) }}></Image>
<TouchableOpacity style={styles.button} onPress={() => navigation.navigate("Scan")}>
<Text style={styles.buttonText}>{t("home.powerMeter")}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.button, { marginTop: pxToDp(20) }]} onPress={() => navigation.navigate("ScanScreen2")}>
<Text style={styles.buttonText}>{t("home.paddle")}</Text>
</TouchableOpacity>
{/* <TouchableOpacity style={[styles.button, { marginTop: pxToDp(20) }]} onPress={() => navigation.navigate("ScanScreen3")}>
<Text style={styles.buttonText}>{t("home.T5trainer")}</Text>
</TouchableOpacity> */}
</View>
<UpdateModal
visible={updateModalVisible}
updateInfo={updateInfo}
currentVersion={currentVersion}
onClose={() => setUpdateModalVisible(false)}
/>
</View>
);
}
@ -42,18 +117,17 @@ const styles = StyleSheet.create({
},
centerBox: {
flex: 1,
// justifyContent: "center",
alignItems: "center",
backgroundColor:'#fff',
paddingTop:pxToDp(250)
backgroundColor: "#fff",
paddingTop: pxToDp(250),
},
button: {
backgroundColor: "#E7141E",
borderRadius: pxToDp(24),
width:pxToDp(300),
height:pxToDp(96),
alignItems:'center',
justifyContent:'center'
width: pxToDp(300),
height: pxToDp(96),
alignItems: "center",
justifyContent: "center",
},
buttonText: {
color: "#fff",
@ -66,7 +140,7 @@ const styles = StyleSheet.create({
},
privacyText: {
fontSize: 16,
color: "#E7141E", // 红色字体
color: "#E7141E",
marginBottom: 8,
},
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 {
View,
Text as RNText,
@ -6,7 +7,6 @@ import {
ScrollView,
StyleSheet,
ActivityIndicator,
useColorScheme,
Alert,
Pressable,
} from "react-native";
@ -57,6 +57,7 @@ export default function InfoScreen({ route, navigation }: Props) {
const [rightbalance, setRightBalance] = useState<number>(0);
const [isConnected, setIsConnected] = useState(false);
const [isDeviceReady, setIsDeviceReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [serial, setSerial] = useState(t('info.reading'));
const [firmware, setFirmware] = useState(t('info.reading'));
@ -72,6 +73,12 @@ export default function InfoScreen({ route, navigation }: Props) {
const notifySubscribedRef = useRef(false);
const disconnectingRef = useRef(false);
const mountedRef = useRef(true);
const deviceReadyRef = useRef(false);
const readyResolverRef = useRef<(() => void) | null>(null);
const readSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const powerDataSubscribedRef = useRef(false);
const initialInfoLoadedRef = useRef(false);
const prevConnectedRef = useRef(isConnected);
const isActiveRef = useRef(true);
@ -83,6 +90,227 @@ export default function InfoScreen({ route, navigation }: Props) {
const calibrationTimeoutRef = useRef<number | null>(null);
const isCalibratingRef = useRef<boolean>(false);
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
const toUint8Array = (raw: unknown): Uint8Array | null => {
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
if (Array.isArray(raw)) return new Uint8Array(raw);
if (
typeof raw === "object" &&
raw !== null &&
"buffer" in raw &&
(raw as { buffer?: unknown }).buffer instanceof ArrayBuffer
) {
const view = raw as {
buffer: ArrayBuffer;
byteOffset?: number;
byteLength?: number;
};
return new Uint8Array(
view.buffer,
view.byteOffset || 0,
view.byteLength
);
}
return null;
};
const decodeTextValue = (raw: unknown) => {
const bytes = toUint8Array(raw);
if (!bytes || bytes.length === 0) return null;
const text = String.fromCharCode(...Array.from(bytes))
.replace(/\0/g, "")
.trim();
return text || null;
};
const waitForDeviceReady = (timeoutMs = 6000) =>
new Promise<void>((resolve, reject) => {
if (deviceReadyRef.current) {
resolve();
return;
}
const timer = setTimeout(() => {
if (readyResolverRef.current === onReady) {
readyResolverRef.current = null;
}
reject(new Error("device ready timeout"));
}, timeoutMs);
const onReady = () => {
clearTimeout(timer);
if (readyResolverRef.current === onReady) {
readyResolverRef.current = null;
}
resolve();
};
readyResolverRef.current = onReady;
});
const readCharacteristicWithRetry = async (
serviceUuid: string,
characteristicUuid: string,
attempts = 3,
delayMs = 180
) => {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const value = await Central.readCharacteristic(
peripheral,
fullUUID(serviceUuid),
fullUUID(characteristicUuid)
);
const bytes = toUint8Array(value);
if (bytes && bytes.length > 0) {
return bytes;
}
lastError = new Error("empty characteristic value");
} catch (err) {
lastError = err;
}
if (attempt < attempts - 1) {
await sleep(delayMs);
}
}
throw lastError ?? new Error("read characteristic failed");
};
const readTextCharacteristic = async (serviceUuid: string, characteristicUuid: string) => {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const bytes = await readCharacteristicWithRetry(
serviceUuid,
characteristicUuid,
1
);
const text = decodeTextValue(bytes);
if (text) {
return text;
}
} catch {
// Continue retrying with a short gap below.
}
if (attempt < 2) {
await sleep(180);
}
}
return "未知";
};
const readBatteryCharacteristic = async () => {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const bytes = await readCharacteristicWithRetry("180f", "2a19", 1);
const batteryValue = bytes[0];
if (Number.isInteger(batteryValue) && batteryValue >= 0 && batteryValue <= 100) {
return `${batteryValue}%`;
}
} catch {
// Continue retrying with a short gap below.
}
if (attempt < 2) {
await sleep(180);
}
}
return "未知";
};
const subscribePowerDataIfNeeded = async () => {
if (!deviceReadyRef.current || !initialInfoLoadedRef.current || powerDataSubscribedRef.current) {
return;
}
console.log("✅ 订阅 2A63 特性通知...");
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID("1818"),
fullUUID("2a63")
).catch(() => {});
await Central.subscribeCharacteristic(
peripheral,
fullUUID("1818"),
fullUUID("2a63"),
(notifyEv) => {
try {
const byteArray = toUint8Array(notifyEv.value);
if (!byteArray) return;
parseData(byteArray);
} catch (err) {
console.warn("❌ 处理通知数据失败", err);
}
}
);
powerDataSubscribedRef.current = true;
console.log("✅ 已订阅 2A63 特性通知");
};
const subscribeFff3IfNeeded = async () => {
if (notifySubscribedRef.current) return;
console.log("✅ device ready - subscribe notify FFF3");
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid)
).catch(() => {});
await Central.subscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid),
(notifyEv) => {
try {
const raw = (notifyEv as any).value;
const byteArray = toUint8Array(raw);
if (!byteArray) return;
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
const rawTrim =
byteArray.length >= 3
? byteArray[1] | (byteArray[2] << 8)
: byteArray[1];
const displayTrim =
byteArray.length >= 3
? (rawTrim / 100).toFixed(2).replace(/\.?0+$/, "")
: rawTrim.toString();
setPowerTrim(displayTrim);
} else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
handleFFF3Response(byteArray);
}
} catch (err) {
console.warn("notify parse error", err);
}
}
);
notifySubscribedRef.current = true;
console.log("✅ notify 已订阅");
};
useFocusEffect(
React.useCallback(() => {
// 页面获得焦点
@ -145,49 +373,40 @@ export default function InfoScreen({ route, navigation }: Props) {
if (addr !== deviceKey) return;
console.log("🔌 connection event:", ev.connectionStatus);
setIsConnected(
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
);
const connected =
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
const isReady = ev.connectionStatus === "ready";
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
console.log("✅ device ready - subscribe notify FFF3");
setIsConnected(connected);
setIsDeviceReady(isReady);
deviceReadyRef.current = isReady;
if (!isReady) {
notifySubscribedRef.current = false;
powerDataSubscribedRef.current = false;
}
if (isReady && readyResolverRef.current) {
const resolveReady = readyResolverRef.current;
readyResolverRef.current = null;
resolveReady();
}
if (isReady && !notifySubscribedRef.current) {
try {
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid)
).catch(() => { });
await Central.subscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid),
(notifyEv) => {
try {
const raw = (notifyEv as any).value;
if (!raw) return;
const byteArray = raw instanceof Uint8Array
? raw
: new Uint8Array(raw);
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
// 功率微调数据
setPowerTrim(byteArray[1].toString());
//setInputTrim(byteArray[1].toString());
}
else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
// 校准响应数据 (05XXXX格式)
handleFFF3Response(byteArray);
}
} catch (err) {
console.warn("notify parse error", err);
}
}
);
notifySubscribedRef.current = true;
console.log("✅ notify 已订阅");
await subscribeFff3IfNeeded();
} catch (err) {
console.warn("❌ notify 订阅失败:", err);
}
}
if (isReady && initialInfoLoadedRef.current && !powerDataSubscribedRef.current) {
try {
await subscribePowerDataIfNeeded();
} catch (err) {
console.warn("❌ 实时数据订阅失败:", err);
}
}
};
@ -201,45 +420,44 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 首次连接并读取信息 ==========
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
let shouldShowReadSuccess = false;
try {
await Central.connectPeripheral(peripheral);
await new Promise<void>((resolve) => setTimeout(resolve, 500));
await waitForDeviceReady(6000);
await sleep(150);
await subscribeFff3IfNeeded();
await sleep(120);
const readStr = async (srv: string, char: string) => {
try {
const v = await Central.readCharacteristic(
peripheral,
fullUUID(srv),
fullUUID(char)
);
return v ? String.fromCharCode(...(v as any)) : "未知";
} catch {
return "未知";
}
};
const serialValue = await readTextCharacteristic("180a", "2a25");
const firmwareValue = await readTextCharacteristic("180a", "2a28");
const hardwareValue = await readTextCharacteristic("180a", "2a27");
const batteryValue = await readBatteryCharacteristic();
setSerial(await readStr("180a", "2a25"));
setFirmware(await readStr("180a", "2a28"));
setHardware(await readStr("180a", "2a27"));
try {
const v = await Central.readCharacteristic(
peripheral,
fullUUID("180f"),
fullUUID("2a19")
);
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
} catch {
setBattery("未知");
if (!cancelled && mountedRef.current) {
setSerial(serialValue);
setFirmware(firmwareValue);
setHardware(hardwareValue);
setBattery(batteryValue);
initialInfoLoadedRef.current = true;
}
await sleep(120);
await subscribePowerDataIfNeeded();
shouldShowReadSuccess =
serialValue !== "未知" ||
firmwareValue !== "未知" ||
hardwareValue !== "未知" ||
batteryValue !== "未知";
console.log("✅ info 读取完成");
try {
await sleep(150);
await Central.writeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
@ -251,22 +469,35 @@ export default function InfoScreen({ route, navigation }: Props) {
// notify 回调里已经订阅了,所以这里不用再重复订阅
// 可以稍等 300ms确保 notify 回来后 UI 会更新
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
await sleep(300);
} catch (err) {
console.warn("❌ 首次读取功率微调失败", err);
}
} catch (e) {
console.warn("❌ 读取失败", e);
} finally {
setIsLoading(false);
// ✅ 显示读取成功提示 2 秒
setReadSuccessToast(true);
setTimeout(() => setReadSuccessToast(false), 2000);
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
if (!cancelled && mountedRef.current && shouldShowReadSuccess) {
setReadSuccessToast(true);
if (readSuccessTimeoutRef.current) {
clearTimeout(readSuccessTimeoutRef.current);
}
readSuccessTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
setReadSuccessToast(false);
}
}, 2000);
}
}
})();
return () => {
cancelled = true;
initialInfoLoadedRef.current = false;
};
}, [peripheral]);
@ -274,59 +505,19 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
useEffect(() => {
const subscribeToPowerData = async () => {
try {
// 确保设备已经连接
if (!isConnected) return;
console.log("✅ 订阅 2A63 特性通知...");
// 先取消之前的订阅(防止重复订阅)
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID("1818"),
fullUUID("2a63")
).catch(() => { });
// 订阅 2A63 特性通知
await Central.subscribeCharacteristic(
peripheral,
fullUUID("1818"),
fullUUID("2a63"),
(notifyEv) => {
try {
const raw = notifyEv.value;
if (raw) {
// 将 ArrayBuffer 转换为字节数组
const byteArray = new Uint8Array(raw);
// 解析数据
parseData(byteArray);
}
} catch (err) {
console.warn("❌ 处理通知数据失败", err);
}
}
);
console.log("✅ 已订阅 2A63 特性通知");
} catch (err) {
if (peripheral && deviceReadyRef.current && initialInfoLoadedRef.current) {
subscribePowerDataIfNeeded().catch((err) => {
console.warn("❌ 订阅 2A63 特性失败", err);
}
};
// 只在设备已连接时进行订阅
if (peripheral && isConnected) {
subscribeToPowerData();
});
}
// 清理订阅(当组件卸载或者连接状态变化时)
return () => {
if (peripheral && isConnected) {
if (peripheral && powerDataSubscribedRef.current) {
powerDataSubscribedRef.current = false;
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
}
};
}, [peripheral, isConnected]);
}, [peripheral, isDeviceReady]);
const cadenceStateRef = useRef({
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
@ -442,7 +633,7 @@ export default function InfoScreen({ route, navigation }: Props) {
// ========== 写入功率微调 ==========
const updatePowerTrim = async () => {
const val = parseInt(inputTrim);
const val = Number(inputTrim);
if (isNaN(val) || val < 50 || val > 200) {
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
return;
@ -458,13 +649,16 @@ export default function InfoScreen({ route, navigation }: Props) {
try {
setPowerTrimLoading(true);
const scaledVal = Math.round(val * 100);
const low = scaledVal & 0xff;
const high = (scaledVal >> 8) & 0xff;
console.log("🚀 写入功率微调", val);
console.log("🚀 写入功率微调", val, "=>", scaledVal);
await Central.writeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerWriteUuid),
new Uint8Array([0x02, val]).buffer,
new Uint8Array([0x02, low, high]).buffer,
{ withoutResponse: false }
);
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
@ -483,6 +677,8 @@ export default function InfoScreen({ route, navigation }: Props) {
} catch (err) {
console.warn("❌ 写入失败", err);
Alert.alert(t('info.writeFailed'));
} finally {
setPowerTrimLoading(false);
}
};
@ -584,12 +780,22 @@ export default function InfoScreen({ route, navigation }: Props) {
};
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
deviceReadyRef.current = false;
initialInfoLoadedRef.current = false;
powerDataSubscribedRef.current = false;
readyResolverRef.current = null;
// 清理校准超时定时器
if (calibrationTimeoutRef.current) {
clearTimeout(calibrationTimeoutRef.current);
calibrationTimeoutRef.current = null;
}
if (readSuccessTimeoutRef.current) {
clearTimeout(readSuccessTimeoutRef.current);
readSuccessTimeoutRef.current = null;
}
};
}, []);
@ -714,10 +920,12 @@ export default function InfoScreen({ route, navigation }: Props) {
}
// 蓝牙已连接,正常跳转 DFU
navigation.navigate("Dfu", {
deviceId: deviceKey,
name: peripheral.name,
firmware,
});
deviceId: deviceKey,
systemId: peripheral.systemId,
address: peripheral.address,
name: peripheral.name ?? "",
firmware: firmware ?? "",
});
}}
style={styles.pressable}
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击

1227
src/InfoScreen2.tsx Normal file

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 {
View,
@ -76,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
// 更新或添加设备,并记录最后扫描时间
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
if (!p?.name || !p.name.startsWith("POWERFUN")) return;
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
setDevices((prev) => {

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
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
>
<Pressable style={styles.overlay} onPress={onClose}>
<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
.use(initReactI18next)
.init({
compatibilityJSON: 'v3',
compatibilityJSON: 'v4',
resources: {
zh: { translation: zh },
en: { translation: en },

View File

@ -1,120 +1,263 @@
{
"nav": {
"scan": "Scan",
"info": "Device Info",
"dfu": "Firmware Update",
"privacy": "Privacy Policy"
},
"home": {
"title": "POWERFUN Settings",
"scan": "Scan Devices",
"privacy": "Privacy Policy",
"version": "Version v0.0.1"
},
"scan": {
"title": "Scan Devices",
"scanning": "Scanning...",
"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"
},
"dfu": {
"title": "Firmware Update",
"preparing": "Preparing...",
"reading": "Reading...",
"nav": {
"scan": "Scan",
"info": "Device Info",
"dfu": "Firmware Update",
"privacy": "Privacy Policy"
},
"home": {
"title": "POWERFUN Settings",
"scan": "Scan Devices",
"privacy": "Privacy Policy",
"version": "Version v0.0.1",
"powerMeter": "Power Meter",
"paddle": "Paddle",
"T5trainer": "T5 Trainer",
"updateTitle": "Update Available",
"latestVersion": "Latest Version",
"currentVersion": "Current Version",
"updateLogs": "Update Logs",
"updateNow": "Update Now",
"updateLater": "Later"
},
"scan": {
"title": "Scan Devices",
"scanning": "Scanning...",
"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",
"latestVersion": "Latest Version",
"currentVersion": "Current Version",
"upgradeStatus": "Update Status",
"bluetoothName": "Bluetooth Name",
"latestVersion": "Latest Version",
"currentVersion": "Current Version",
"upgradeStatus": "Update Status",
"stateConnecting": "Connecting…",
"stateStarting": "Initializing…",
"stateEnablingDfuMode": "Enabling DFU Mode…",
"stateUploading": "Uploading Firmware…",
"stateValidating": "Validating Firmware…",
"stateDisconnecting": "Disconnecting…",
"stateCompleted": "Update Completed",
"stateAborted": "Aborted",
"stateFailed": "Update Failed",
"stateInitializing": "Starting…",
"stateErrored": "Update Error!",
"stateConnecting": "Connecting…",
"stateStarting": "Initializing…",
"stateEnablingDfuMode": "Enabling DFU Mode…",
"stateUploading": "Uploading Firmware…",
"stateValidating": "Validating Firmware…",
"stateDisconnecting": "Disconnecting…",
"stateCompleted": "Update Completed",
"stateAborted": "Aborted",
"stateFailed": "Update Failed",
"stateInitializing": "Starting…",
"stateErrored": "Update Error!",
"pleaseWait": "Please Wait",
"doNotReturn": "Updating in progress, do not go back or close the app!",
"pleaseWait": "Please Wait",
"doNotReturn": "Updating in progress, do not go back or close the app!",
"cannotUpgrade": "Cannot Update",
"hardwareNotFound": "Firmware not found for hardware version {hardware}",
"noNeedUpgrade": "No Update Needed",
"alreadyLatest": "Already the latest firmware, no update needed",
"cannotUpgrade": "Cannot Update",
"hardwareNotFound": "Firmware not found for hardware version {hardware}",
"noNeedUpgrade": "No Update Needed",
"alreadyLatest": "Already the latest firmware, no update needed",
"upgradeSuccess": "Update Successful",
"upgradeSuccessMessage": "Update successful, please reconnect the device",
"upgradeFailed": "Update Failed",
"dfuFailed": "DFU Failed",
"upgradeSuccess": "Update Successful",
"upgradeSuccessMessage": "Update successful, please reconnect the device",
"upgradeFailed": "Update Failed",
"dfuFailed": "DFU Failed",
"confirm": "OK"
},
"privacy": {
"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"
},
"settings": {
"title": "Settings",
"language": "Language",
"privacy": "Privacy Policy",
"version": "Version"
},
"languageModal": {
"title": "Language"
},
"info": {
"title": "Device Info",
"bluetoothName": "Bluetooth Name",
"idNumber": "ID Number",
"firmwareVersion": "Firmware Version",
"battery": "Battery",
"connectionStatus": "Connection Status",
"connected": "Connected",
"disconnected": "Disconnected",
"reading": "Reading...",
"confirm": "OK"
},
"privacy": {
"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"
},
"settings": {
"title": "Settings",
"language": "Language",
"privacy": "Privacy Policy",
"version": "Version"
},
"languageModal": {
"title": "Language"
},
"info": {
"title": "Device Info",
"bluetoothName": "Bluetooth Name",
"idNumber": "ID Number",
"firmwareVersion": "Firmware Version",
"battery": "Battery",
"connectionStatus": "Connection Status",
"connected": "Connected",
"disconnected": "Disconnected",
"reading": "Reading...",
"power": "Power/W",
"cadence": "Cadence/RPM",
"balance": "L/R Balance/%",
"balanceHeader": "L / R",
"power": "Power/W",
"cadence": "Cadence/RPM",
"balance": "L/R Balance/%",
"balanceHeader": "L / R",
"powerTrimTitle": "Power Trim Settings",
"currentTrim": "Current Trim",
"trimPlaceholder": "Enter 50-200",
"updateValue": "Update Value",
"powerTrimTitle": "Power Trim Settings",
"currentTrim": "Current Trim",
"trimPlaceholder": "Enter 50-200",
"updateValue": "Update Value",
"calibrateButton": "Calibrate Zero",
"calibrating": "Calibrating... Waiting for device",
"firmwareUpgrade": "Firmware Update",
"calibrateButton": "Calibrate Zero",
"calibrating": "Calibrating... Waiting for device",
"firmwareUpgrade": "Firmware Update",
"readingInfo": "Reading information...",
"readSuccess": "Read successfully!",
"writingTrim": "Writing power trim...",
"trimUpdateSuccess": "Power trim updated successfully!",
"readingInfo": "Reading information...",
"readSuccess": "Read successfully!",
"writingTrim": "Writing power trim...",
"trimUpdateSuccess": "Power trim updated successfully!",
"disconnectTitle": "Notice",
"disconnectMessage": "Device disconnected, please reconnect",
"reconnectMessage": "Please reconnect the device",
"confirm": "OK",
"disconnectTitle": "Notice",
"disconnectMessage": "Device disconnected, please reconnect",
"reconnectMessage": "Please reconnect the device",
"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.",
"deviceNotConnected": "Device not connected",
"writeFailed": "Write failed",
"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",
"writeFailed": "Write failed",
"calibrationSuccess": "Calibration Successful",
"calibrationValue": "Calibration Value",
"calibrationError": "Calibration Error",
"calibrationTimeout": "Device not responding, please try again",
"calibrationFormatError": "Invalid response format from device",
"calibrationSendError": "Failed to send calibration command",
"error": "Error"
}
"calibrationSuccess": "Calibration Successful",
"calibrationValue": "Calibration Value",
"calibrationError": "Calibration Error",
"calibrationTimeout": "Device not responding, please try again",
"calibrationFormatError": "Invalid response format from device",
"calibrationSendError": "Failed to send calibration command",
"error": "Error"
}
}

View File

@ -1,17 +1,26 @@
{
"nav": {
"scan": "搜索",
"info": "设备信息",
"dfu": "固件升级",
"privacy": "隐私协议"
},
"home": {
"title": "POWERFUN设置",
"scan": "搜索设备",
"privacy": "隐私协议",
"version": "版本号 v0.0.1"
},
"scan": {
"nav": {
"scan": "搜索",
"info": "设备信息",
"dfu": "固件升级",
"privacy": "隐私协议"
},
"home": {
"title": "POWERFUN设置",
"scan": "搜索设备",
"privacy": "隐私协议",
"version": "版本号 v0.0.1",
"powerMeter": "功率计",
"paddle": "桨频器",
"T5trainer": "T5骑行台",
"updateTitle": "发现新版本",
"latestVersion": "最新版本",
"currentVersion": "当前版本",
"updateLogs": "更新日志",
"updateNow": "立即更新",
"updateLater": "稍后再说"
},
"scan": {
"title": "搜索设备",
"scanning": "搜索中...",
"noDevice": "暂无设备",
@ -19,102 +28,236 @@
"tipBluetooth": "(请在设置中打开蓝牙)",
"noName": "[无名称]",
"rssiUnit": "dBm"
},
"dfu": {
"title": "固件升级",
"preparing": "准备中...",
"reading": "读取中...",
},
"t5Scan": {
"title": "搜索T5骑行台",
"scanning": "搜索中...",
"noDevice": "暂无T5骑行台设备",
"tipScanning": "(请确保骑行台有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)"
},
"paddleScan": {
"title": "搜索桨频器",
"scanning": "搜索中...",
"noDevice": "暂无桨频器设备",
"tipScanning": "(请确保桨频器设备有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)",
"noName": "[无名称]",
"rssiUnit": "dBm"
},
"common": {
"notice": "提示",
"unknown": "未知",
"unknownDevice": "未知设备",
"yes": "是",
"no": "否"
},
"info2": {
"waitingAction": "等待操作",
"notMatched": "未匹配",
"checkFailed": "检查失败",
"unknownBoatType": "读取到未知船型",
"readAbnormal": "读取返回异常:{{hex}}",
"writingBoat": "正在写入{{boatName}}...",
"writtenWaitRead": "已写入{{boatName}}200ms后读取确认...",
"requestingBoatRead": "正在请求读取船型...",
"setSuccess": "设置成功:{{boatName}}",
"notMatch": "不一致:期望 {{expected}},实际 {{actual}}",
"readResult": "读取返回:{{hex}}",
"actionFailed": "操作失败:{{message}}",
"failed": "失败",
"waitFff3Timeout": "等待 FFF3 返回超时",
"currentBoat": "✅ 当前船型:{{boatName}}",
"readCurrentBoat": "已读取当前船型:{{boatName}}",
"disconnectedNeedReconnect": "设备已断开,请重新连接",
"defaultReadFailed": "已连接,但默认读取船型失败",
"connectOrReadFailed": "连接或读取失败",
"connectOrReadFailedAlert": "蓝牙连接失败或设备信息读取失败",
"cadence": "桨频",
"cadenceUnit": "RPM(次/分钟)",
"boatSelect": "船型选择",
"boatSwitching": "船型切换中...",
"retry": "请重试",
"boatKayak": "皮艇",
"boatRowing": "划艇",
"boatRacing": "赛艇",
"latestFirmware": "最新固件",
"checking": "检查中..."
},
"info3": {
"bikeTypeFollow": "跟随软件",
"bikeTypeRoad": "公路车",
"bikeTypeMtb26": "山地车26寸",
"bikeTypeMtb275": "山地车27.5寸",
"bikeTypeMtb29": "山地车29寸",
"bikeTypeSmallWheel": "小轮车",
"ergOn": "开启",
"ergOff": "关闭",
"readUsedMileageTimeout": "读取使用里程超时",
"powerTrimRangeError": "请输入50.00-200.00之间的数,最多保留两位小数",
"deviceNotReady": "设备尚未准备完成,请稍后再试",
"invalidWeight": "请输入正确的体重",
"waitFff3Timeout": "等待FFF3返回超时",
"readPowerTrimTimeout": "读取当前功率微调超时",
"waitWeightAckTimeout": "等待体重设定确认超时",
"readWeightTimeout": "读取当前体重超时",
"readBikeTypeTimeout": "读取当前车型超时",
"waitBikeTypeAckTimeout": "等待车型设定确认超时",
"readErgTimeout": "读取ERG功率平滑超时",
"waitErgAckTimeout": "等待ERG功率平滑确认超时",
"connectReadFailed": "设备连接或读取失败",
"readFailed": "读取失败",
"connecting": "连接中",
"pendingTag": "待保存",
"settingTag": "设置中",
"currentWeightValue": "当前:{{value}} kg",
"currentPowerTrimValue": "当前:{{value}} %",
"currentTextValue": "当前:{{value}}",
"usedMileage": "使用里程",
"speedKph": "速度/km/h",
"weightSetting": "体重设定",
"powerTrim": "功率微调",
"bikeType": "车型选择",
"ergSmooth": "ERG功率平滑",
"settingWeight": "正在设置体重…",
"weightSetDone": "体重设定完成",
"weightSetFailed": "体重设定失败",
"settingPowerTrim": "正在设置功率微调…",
"powerTrimSetDone": "功率微调设定完成",
"powerTrimSetFailed": "功率微调设定失败",
"settingBikeType": "车型设定中",
"bikeTypeSetDone": "车型设定完成",
"bikeTypeSetFailed": "车型设定失败",
"settingErgSmooth": "ERG功率平滑设定中",
"ergSmoothSetDone": "ERG功率平滑设定完成",
"ergSmoothSetFailed": "ERG功率平滑设定失败",
"confirmWeightChange": "是否将体重改为{{value}}kg",
"confirmPowerTrimChange": "是否将功率微调改为{{value}}%",
"helpText": "按住查看说明"
},
"spindown": {
"title": "消旋",
"headerTitle": "骑行台消旋",
"connecting": "设备连接中",
"connected": "设备已连接",
"targetLabel": "目标速度",
"targetHint": "请先骑行加速到目标速度",
"currentSpeed": "当前速度",
"statusReach36": "请骑行加速到 {{speed}} km/h",
"statusReached36": "已达到 {{high}} km/h请停止踩踏并等待速度下降到 {{low}} km/h",
"statusCalibrating": "正在消旋,请等待",
"statusCompleted": "消旋完成",
"deviceNotReady": "设备未准备好,请稍后重试",
"connectFailed": "设备连接失败,请返回重试",
"timeout": "等待消旋时间返回超时",
"failedRetry": "消旋失败,请重试",
"step1Title": "请骑行到 {{speed}} km/h",
"step1Desc": "达到目标速度后自动进入下一步",
"step2Title": "停止踩踏并等待减速",
"step2Desc": "速度下降到 {{speed}} km/h 后自动开始消旋",
"step3Title": "消旋完成",
"step3Loading": "正在读取消旋时间...",
"step3Pending": "完成前会显示在这里",
"loading": "正在连接设备并准备消旋...",
"result": "消旋完成,时间 {{seconds}}s",
"retry": "重新开始消旋"
},
"bluetoothName": "蓝牙名称",
"latestVersion": "最新版本",
"currentVersion": "当前版本",
"upgradeStatus": "升级状态",
"dfu": {
"title": "固件升级",
"preparing": "准备中...",
"reading": "读取中...",
"stateConnecting": "连接中…",
"stateStarting": "初始化中…",
"stateEnablingDfuMode": "启用 DFU 模式…",
"stateUploading": "上传固件中…",
"stateValidating": "校验固件…",
"stateDisconnecting": "断开连接…",
"stateCompleted": "升级完成",
"stateAborted": "已取消",
"stateFailed": "升级失败",
"stateInitializing": "启动中…",
"stateErrored": "升级出错!",
"bluetoothName": "蓝牙名称",
"latestVersion": "最新版本",
"currentVersion": "当前版本",
"upgradeStatus": "升级状态",
"pleaseWait": "请稍候",
"doNotReturn": "正在升级,请勿返回或关闭应用!",
"stateConnecting": "连接中…",
"stateStarting": "初始化中…",
"stateEnablingDfuMode": "启用 DFU 模式…",
"stateUploading": "上传固件中…",
"stateValidating": "校验固件…",
"stateDisconnecting": "断开连接…",
"stateCompleted": "升级完成",
"stateAborted": "已取消",
"stateFailed": "升级失败",
"stateInitializing": "启动中…",
"stateErrored": "升级出错!",
"cannotUpgrade": "无法升级",
"hardwareNotFound": "未找到硬件版本 {hardware} 的固件",
"noNeedUpgrade": "无需升级",
"alreadyLatest": "已是最新固件,无需升级",
"pleaseWait": "请稍候",
"doNotReturn": "正在升级,请勿返回或关闭应用!",
"upgradeSuccess": "升级成功",
"upgradeSuccessMessage": "升级成功,请重连设备",
"upgradeFailed": "升级失败",
"dfuFailed": "DFU失败",
"cannotUpgrade": "无法升级",
"hardwareNotFound": "未找到硬件版本 {hardware} 的固件",
"noNeedUpgrade": "无需升级",
"alreadyLatest": "已是最新固件,无需升级",
"confirm": "确认"
},
"privacy": {
"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": "读取中...",
"upgradeSuccess": "升级成功",
"upgradeSuccessMessage": "升级成功,请重连设备",
"upgradeFailed": "升级失败",
"dfuFailed": "DFU失败",
"power": "功率/W",
"cadence": "踏频/RPM",
"balance": "左右平衡/%",
"balanceHeader": "L / R",
"confirm": "确认"
},
"privacy": {
"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": "功率微调设置",
"currentTrim": "当前微调",
"trimPlaceholder": "输入50-200",
"updateValue": "更新数值",
"power": "功率/W",
"cadence": "踏频/RPM",
"balance": "左右平衡/%",
"balanceHeader": "L / R",
"calibrateButton": "校准归零",
"calibrating": "校准中...等待设备反应",
"firmwareUpgrade": "固件升级",
"powerTrimTitle": "功率微调设置",
"currentTrim": "当前微调",
"trimPlaceholder": "输入50-200",
"updateValue": "更新数值",
"readingInfo": "正在读取信息...",
"readSuccess": "读取成功!",
"writingTrim": "正在写入功率微调...",
"trimUpdateSuccess": "功率微调更新成功!",
"calibrateButton": "校准归零",
"calibrating": "校准中...等待设备反应",
"firmwareUpgrade": "固件升级",
"disconnectTitle": "提示",
"disconnectMessage": "设备已断开,请重新连接设备",
"reconnectMessage": "请重新连接设备",
"confirm": "确定",
"readingInfo": "正在读取信息...",
"readSuccess": "读取成功!",
"writingTrim": "正在写入功率微调...",
"trimUpdateSuccess": "功率微调更新成功!",
"trimRangeAlert": "功率微调可调整功率计的高低偏差默认值100%。可调整的范围是50%-200%。请输入50至200的纯数字不需要包含%符号。输入后点击下方按钮更新进功率计设备。",
"deviceNotConnected": "设备未连接",
"writeFailed": "写入失败",
"disconnectTitle": "提示",
"disconnectMessage": "设备已断开,请重新连接设备",
"reconnectMessage": "请重新连接设备",
"confirm": "确定",
"calibrationSuccess": "校准成功",
"calibrationValue": "校准值",
"calibrationError": "校准错误",
"calibrationTimeout": "设备未响应,请重试",
"calibrationFormatError": "设备返回数据格式错误",
"calibrationSendError": "发送校准命令失败",
"error": "错误"
}
"trimRangeAlert": "功率微调可调整功率计的高低偏差默认值100%。可调整的范围是50%-200%。请输入50至200的纯数字不需要包含%符号。输入后点击下方按钮更新进功率计设备。",
"deviceNotConnected": "设备未连接",
"writeFailed": "写入失败",
"calibrationSuccess": "校准成功",
"calibrationValue": "校准值",
"calibrationError": "校准错误",
"calibrationTimeout": "设备未响应,请重试",
"calibrationFormatError": "设备返回数据格式错误",
"calibrationSendError": "发送校准命令失败",
"error": "错误"
}
}

510
yarn.lock

File diff suppressed because it is too large Load Diff