Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f09babe86 | ||
| 64beed9c4e | |||
| 8dd0edd2ce |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
81
App.tsx
81
App.tsx
@ -1,23 +1,55 @@
|
||||
console.log("🔥 当前 App.tsx 已加载");
|
||||
import * as React from "react";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
|
||||
import HomeScreen from "./src/HomeScreen";
|
||||
import ScanScreen from "./src/ScanScreen";
|
||||
import ScanScreen2 from "./src/ScanScreen2";
|
||||
import ScanScreen3 from "./src/ScanScreen3";
|
||||
import InfoScreen from "./src/InfoScreen";
|
||||
import DfuScreen from "./src/DfuScreen";
|
||||
import PrivacyScreen from "./src/PrivacyScreen";
|
||||
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
|
||||
import pxToDp from "./src/helper/pxToDp";
|
||||
import SettingScreen from "./src/SettingScreen";
|
||||
import './src/i18n'
|
||||
import InfoScreen2 from "./src/InfoScreen2";
|
||||
import InfoScreen3 from "./src/InfoScreen3";
|
||||
import SpindownScreen from "./src/SpindownScreen";
|
||||
import { decode } from "base-64";
|
||||
|
||||
|
||||
// 不要 global.atob
|
||||
// 不要 atob
|
||||
|
||||
const base64ToBytes = (base64: string): number[] => {
|
||||
const binary = decode(base64);
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes.push(binary.charCodeAt(i));
|
||||
}
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export type RootStackParamList = {
|
||||
Splash: undefined;
|
||||
Home: undefined;
|
||||
Scan: undefined;
|
||||
ScanScreen2: undefined;
|
||||
ScanScreen3: undefined;
|
||||
Info: { peripheral: any };
|
||||
Dfu: { deviceId: string; name: string; firmware: string };
|
||||
Info2: { peripheral: any };
|
||||
Info3: { peripheral: any };
|
||||
Spindown: { peripheral: any };
|
||||
Dfu: {
|
||||
deviceId: string;
|
||||
systemId?: string;
|
||||
address?: string | number;
|
||||
name: string;
|
||||
firmware: string;
|
||||
};
|
||||
Privacy: undefined;
|
||||
Setting: undefined;
|
||||
};
|
||||
@ -25,6 +57,23 @@ export type RootStackParamList = {
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export default function App() {
|
||||
|
||||
//linshi
|
||||
React.useEffect(() => {
|
||||
const test = async () => {
|
||||
try {
|
||||
console.log("🔥 App.tsx fetch test start");
|
||||
const resp = await fetch("https://www.baidu.com");
|
||||
console.log("🔥 App.tsx fetch status =", resp.status);
|
||||
const text = await resp.text();
|
||||
console.log("🔥 App.tsx fetch text length =", text.length);
|
||||
} catch (e) {
|
||||
console.log("❌ App.tsx fetch error =", e);
|
||||
}
|
||||
};
|
||||
|
||||
test();
|
||||
}, []);
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
@ -32,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
109
CLAUDE.md
Normal 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
|
||||
```
|
||||
@ -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 {
|
||||
|
||||
@ -45,4 +45,4 @@ edgeToEdgeEnabled=false
|
||||
|
||||
|
||||
|
||||
|
||||
org.gradle.java.home=E:\\jdk\\jdk-17.0.12
|
||||
@ -2316,7 +2316,7 @@ PODS:
|
||||
- SocketRocket
|
||||
- RNCAsyncStorage (2.2.0):
|
||||
- React-Core
|
||||
- RNDeviceInfo (15.0.1):
|
||||
- RNDeviceInfo (15.0.2):
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
@ -2707,7 +2707,7 @@ SPEC CHECKSUMS:
|
||||
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
|
||||
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
|
||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
|
||||
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
|
||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
|
||||
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e
|
||||
|
||||
@ -207,10 +207,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
|
||||
@ -246,10 +250,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
|
||||
@ -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",
|
||||
|
||||
@ -41,6 +41,10 @@
|
||||
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<false/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@ -53,9 +57,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
741
package-lock.json
generated
741
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
// InfoScreen.tsx此页面为功率计信息页面
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
@ -6,7 +7,6 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
useColorScheme,
|
||||
Alert,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
@ -57,6 +57,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
const [rightbalance, setRightBalance] = useState<number>(0);
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isDeviceReady, setIsDeviceReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [serial, setSerial] = useState(t('info.reading'));
|
||||
const [firmware, setFirmware] = useState(t('info.reading'));
|
||||
@ -72,6 +73,12 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
const notifySubscribedRef = useRef(false);
|
||||
const disconnectingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const deviceReadyRef = useRef(false);
|
||||
const readyResolverRef = useRef<(() => void) | null>(null);
|
||||
const readSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const powerDataSubscribedRef = useRef(false);
|
||||
const initialInfoLoadedRef = useRef(false);
|
||||
|
||||
const prevConnectedRef = useRef(isConnected);
|
||||
const isActiveRef = useRef(true);
|
||||
@ -83,6 +90,227 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
const calibrationTimeoutRef = useRef<number | null>(null);
|
||||
const isCalibratingRef = useRef<boolean>(false);
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const toUint8Array = (raw: unknown): Uint8Array | null => {
|
||||
if (!raw) return null;
|
||||
if (raw instanceof Uint8Array) return raw;
|
||||
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
||||
if (Array.isArray(raw)) return new Uint8Array(raw);
|
||||
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
"buffer" in raw &&
|
||||
(raw as { buffer?: unknown }).buffer instanceof ArrayBuffer
|
||||
) {
|
||||
const view = raw as {
|
||||
buffer: ArrayBuffer;
|
||||
byteOffset?: number;
|
||||
byteLength?: number;
|
||||
};
|
||||
return new Uint8Array(
|
||||
view.buffer,
|
||||
view.byteOffset || 0,
|
||||
view.byteLength
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const decodeTextValue = (raw: unknown) => {
|
||||
const bytes = toUint8Array(raw);
|
||||
if (!bytes || bytes.length === 0) return null;
|
||||
|
||||
const text = String.fromCharCode(...Array.from(bytes))
|
||||
.replace(/\0/g, "")
|
||||
.trim();
|
||||
|
||||
return text || null;
|
||||
};
|
||||
|
||||
const waitForDeviceReady = (timeoutMs = 6000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (deviceReadyRef.current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (readyResolverRef.current === onReady) {
|
||||
readyResolverRef.current = null;
|
||||
}
|
||||
reject(new Error("device ready timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
const onReady = () => {
|
||||
clearTimeout(timer);
|
||||
if (readyResolverRef.current === onReady) {
|
||||
readyResolverRef.current = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
readyResolverRef.current = onReady;
|
||||
});
|
||||
|
||||
const readCharacteristicWithRetry = async (
|
||||
serviceUuid: string,
|
||||
characteristicUuid: string,
|
||||
attempts = 3,
|
||||
delayMs = 180
|
||||
) => {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const value = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(serviceUuid),
|
||||
fullUUID(characteristicUuid)
|
||||
);
|
||||
const bytes = toUint8Array(value);
|
||||
|
||||
if (bytes && bytes.length > 0) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
lastError = new Error("empty characteristic value");
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("read characteristic failed");
|
||||
};
|
||||
|
||||
const readTextCharacteristic = async (serviceUuid: string, characteristicUuid: string) => {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const bytes = await readCharacteristicWithRetry(
|
||||
serviceUuid,
|
||||
characteristicUuid,
|
||||
1
|
||||
);
|
||||
const text = decodeTextValue(bytes);
|
||||
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
} catch {
|
||||
// Continue retrying with a short gap below.
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
await sleep(180);
|
||||
}
|
||||
}
|
||||
|
||||
return "未知";
|
||||
};
|
||||
|
||||
const readBatteryCharacteristic = async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const bytes = await readCharacteristicWithRetry("180f", "2a19", 1);
|
||||
const batteryValue = bytes[0];
|
||||
|
||||
if (Number.isInteger(batteryValue) && batteryValue >= 0 && batteryValue <= 100) {
|
||||
return `${batteryValue}%`;
|
||||
}
|
||||
} catch {
|
||||
// Continue retrying with a short gap below.
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
await sleep(180);
|
||||
}
|
||||
}
|
||||
|
||||
return "未知";
|
||||
};
|
||||
|
||||
const subscribePowerDataIfNeeded = async () => {
|
||||
if (!deviceReadyRef.current || !initialInfoLoadedRef.current || powerDataSubscribedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("✅ 订阅 2A63 特性通知...");
|
||||
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63")
|
||||
).catch(() => {});
|
||||
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63"),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const byteArray = toUint8Array(notifyEv.value);
|
||||
if (!byteArray) return;
|
||||
parseData(byteArray);
|
||||
} catch (err) {
|
||||
console.warn("❌ 处理通知数据失败", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
powerDataSubscribedRef.current = true;
|
||||
console.log("✅ 已订阅 2A63 特性通知");
|
||||
};
|
||||
|
||||
const subscribeFff3IfNeeded = async () => {
|
||||
if (notifySubscribedRef.current) return;
|
||||
|
||||
console.log("✅ device ready - subscribe notify FFF3");
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid)
|
||||
).catch(() => {});
|
||||
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = (notifyEv as any).value;
|
||||
const byteArray = toUint8Array(raw);
|
||||
if (!byteArray) return;
|
||||
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
||||
const rawTrim =
|
||||
byteArray.length >= 3
|
||||
? byteArray[1] | (byteArray[2] << 8)
|
||||
: byteArray[1];
|
||||
const displayTrim =
|
||||
byteArray.length >= 3
|
||||
? (rawTrim / 100).toFixed(2).replace(/\.?0+$/, "")
|
||||
: rawTrim.toString();
|
||||
|
||||
setPowerTrim(displayTrim);
|
||||
} else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
||||
handleFFF3Response(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("notify parse error", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
notifySubscribedRef.current = true;
|
||||
console.log("✅ notify 已订阅");
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 页面获得焦点
|
||||
@ -145,49 +373,40 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
if (addr !== deviceKey) return;
|
||||
|
||||
console.log("🔌 connection event:", ev.connectionStatus);
|
||||
setIsConnected(
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
|
||||
);
|
||||
const connected =
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
|
||||
const isReady = ev.connectionStatus === "ready";
|
||||
|
||||
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
||||
console.log("✅ device ready - subscribe notify FFF3");
|
||||
setIsConnected(connected);
|
||||
setIsDeviceReady(isReady);
|
||||
deviceReadyRef.current = isReady;
|
||||
|
||||
if (!isReady) {
|
||||
notifySubscribedRef.current = false;
|
||||
powerDataSubscribedRef.current = false;
|
||||
}
|
||||
|
||||
if (isReady && readyResolverRef.current) {
|
||||
const resolveReady = readyResolverRef.current;
|
||||
readyResolverRef.current = null;
|
||||
resolveReady();
|
||||
}
|
||||
|
||||
if (isReady && !notifySubscribedRef.current) {
|
||||
try {
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid)
|
||||
).catch(() => { });
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = (notifyEv as any).value;
|
||||
if (!raw) return;
|
||||
const byteArray = raw instanceof Uint8Array
|
||||
? raw
|
||||
: new Uint8Array(raw);
|
||||
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
||||
// 功率微调数据
|
||||
setPowerTrim(byteArray[1].toString());
|
||||
//setInputTrim(byteArray[1].toString());
|
||||
}
|
||||
else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
||||
// 校准响应数据 (05XXXX格式)
|
||||
handleFFF3Response(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("notify parse error", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
notifySubscribedRef.current = true;
|
||||
console.log("✅ notify 已订阅");
|
||||
await subscribeFff3IfNeeded();
|
||||
} catch (err) {
|
||||
console.warn("❌ notify 订阅失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isReady && initialInfoLoadedRef.current && !powerDataSubscribedRef.current) {
|
||||
try {
|
||||
await subscribePowerDataIfNeeded();
|
||||
} catch (err) {
|
||||
console.warn("❌ 实时数据订阅失败:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -201,45 +420,44 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 首次连接并读取信息 ==========
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
let shouldShowReadSuccess = false;
|
||||
try {
|
||||
await Central.connectPeripheral(peripheral);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
await waitForDeviceReady(6000);
|
||||
await sleep(150);
|
||||
await subscribeFff3IfNeeded();
|
||||
await sleep(120);
|
||||
|
||||
const readStr = async (srv: string, char: string) => {
|
||||
try {
|
||||
const v = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(srv),
|
||||
fullUUID(char)
|
||||
);
|
||||
return v ? String.fromCharCode(...(v as any)) : "未知";
|
||||
} catch {
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
const serialValue = await readTextCharacteristic("180a", "2a25");
|
||||
const firmwareValue = await readTextCharacteristic("180a", "2a28");
|
||||
const hardwareValue = await readTextCharacteristic("180a", "2a27");
|
||||
const batteryValue = await readBatteryCharacteristic();
|
||||
|
||||
setSerial(await readStr("180a", "2a25"));
|
||||
setFirmware(await readStr("180a", "2a28"));
|
||||
setHardware(await readStr("180a", "2a27"));
|
||||
|
||||
try {
|
||||
const v = await Central.readCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("180f"),
|
||||
fullUUID("2a19")
|
||||
);
|
||||
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
|
||||
} catch {
|
||||
setBattery("未知");
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setSerial(serialValue);
|
||||
setFirmware(firmwareValue);
|
||||
setHardware(hardwareValue);
|
||||
setBattery(batteryValue);
|
||||
initialInfoLoadedRef.current = true;
|
||||
}
|
||||
|
||||
await sleep(120);
|
||||
await subscribePowerDataIfNeeded();
|
||||
|
||||
shouldShowReadSuccess =
|
||||
serialValue !== "未知" ||
|
||||
firmwareValue !== "未知" ||
|
||||
hardwareValue !== "未知" ||
|
||||
batteryValue !== "未知";
|
||||
|
||||
console.log("✅ info 读取完成");
|
||||
|
||||
|
||||
|
||||
try {
|
||||
await sleep(150);
|
||||
await Central.writeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
@ -251,22 +469,35 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
||||
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
||||
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
|
||||
await sleep(300);
|
||||
} catch (err) {
|
||||
console.warn("❌ 首次读取功率微调失败", err);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.warn("❌ 读取失败", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
// ✅ 显示读取成功提示 2 秒
|
||||
setReadSuccessToast(true);
|
||||
setTimeout(() => setReadSuccessToast(false), 2000);
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (!cancelled && mountedRef.current && shouldShowReadSuccess) {
|
||||
setReadSuccessToast(true);
|
||||
if (readSuccessTimeoutRef.current) {
|
||||
clearTimeout(readSuccessTimeoutRef.current);
|
||||
}
|
||||
readSuccessTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setReadSuccessToast(false);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
initialInfoLoadedRef.current = false;
|
||||
};
|
||||
}, [peripheral]);
|
||||
|
||||
|
||||
@ -274,59 +505,19 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
|
||||
useEffect(() => {
|
||||
const subscribeToPowerData = async () => {
|
||||
try {
|
||||
// 确保设备已经连接
|
||||
if (!isConnected) return;
|
||||
|
||||
console.log("✅ 订阅 2A63 特性通知...");
|
||||
|
||||
// 先取消之前的订阅(防止重复订阅)
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63")
|
||||
).catch(() => { });
|
||||
|
||||
// 订阅 2A63 特性通知
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID("1818"),
|
||||
fullUUID("2a63"),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = notifyEv.value;
|
||||
if (raw) {
|
||||
// 将 ArrayBuffer 转换为字节数组
|
||||
const byteArray = new Uint8Array(raw);
|
||||
|
||||
// 解析数据
|
||||
parseData(byteArray);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("❌ 处理通知数据失败", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ 已订阅 2A63 特性通知");
|
||||
} catch (err) {
|
||||
if (peripheral && deviceReadyRef.current && initialInfoLoadedRef.current) {
|
||||
subscribePowerDataIfNeeded().catch((err) => {
|
||||
console.warn("❌ 订阅 2A63 特性失败", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 只在设备已连接时进行订阅
|
||||
if (peripheral && isConnected) {
|
||||
subscribeToPowerData();
|
||||
});
|
||||
}
|
||||
|
||||
// 清理订阅(当组件卸载或者连接状态变化时)
|
||||
return () => {
|
||||
if (peripheral && isConnected) {
|
||||
if (peripheral && powerDataSubscribedRef.current) {
|
||||
powerDataSubscribedRef.current = false;
|
||||
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
|
||||
}
|
||||
};
|
||||
}, [peripheral, isConnected]);
|
||||
}, [peripheral, isDeviceReady]);
|
||||
|
||||
const cadenceStateRef = useRef({
|
||||
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
|
||||
@ -442,7 +633,7 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
// ========== 写入功率微调 ==========
|
||||
const updatePowerTrim = async () => {
|
||||
const val = parseInt(inputTrim);
|
||||
const val = Number(inputTrim);
|
||||
if (isNaN(val) || val < 50 || val > 200) {
|
||||
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
|
||||
return;
|
||||
@ -458,13 +649,16 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
|
||||
try {
|
||||
setPowerTrimLoading(true);
|
||||
const scaledVal = Math.round(val * 100);
|
||||
const low = scaledVal & 0xff;
|
||||
const high = (scaledVal >> 8) & 0xff;
|
||||
|
||||
console.log("🚀 写入功率微调", val);
|
||||
console.log("🚀 写入功率微调", val, "=>", scaledVal);
|
||||
await Central.writeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerWriteUuid),
|
||||
new Uint8Array([0x02, val]).buffer,
|
||||
new Uint8Array([0x02, low, high]).buffer,
|
||||
{ withoutResponse: false }
|
||||
);
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
||||
@ -483,6 +677,8 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
} catch (err) {
|
||||
console.warn("❌ 写入失败", err);
|
||||
Alert.alert(t('info.writeFailed'));
|
||||
} finally {
|
||||
setPowerTrimLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -584,12 +780,22 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
deviceReadyRef.current = false;
|
||||
initialInfoLoadedRef.current = false;
|
||||
powerDataSubscribedRef.current = false;
|
||||
readyResolverRef.current = null;
|
||||
// 清理校准超时定时器
|
||||
if (calibrationTimeoutRef.current) {
|
||||
clearTimeout(calibrationTimeoutRef.current);
|
||||
calibrationTimeoutRef.current = null;
|
||||
}
|
||||
if (readSuccessTimeoutRef.current) {
|
||||
clearTimeout(readSuccessTimeoutRef.current);
|
||||
readSuccessTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -714,10 +920,12 @@ export default function InfoScreen({ route, navigation }: Props) {
|
||||
}
|
||||
// 蓝牙已连接,正常跳转 DFU
|
||||
navigation.navigate("Dfu", {
|
||||
deviceId: deviceKey,
|
||||
name: peripheral.name,
|
||||
firmware,
|
||||
});
|
||||
deviceId: deviceKey,
|
||||
systemId: peripheral.systemId,
|
||||
address: peripheral.address,
|
||||
name: peripheral.name ?? "",
|
||||
firmware: firmware ?? "",
|
||||
});
|
||||
}}
|
||||
style={styles.pressable}
|
||||
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击
|
||||
|
||||
1227
src/InfoScreen2.tsx
Normal file
1227
src/InfoScreen2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2049
src/InfoScreen3.tsx
Normal file
2049
src/InfoScreen3.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
// src/ScanScreen.tsx
|
||||
// src/ScanScreen.tsx此页面为功率计搜索页面
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
@ -76,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
|
||||
|
||||
// 更新或添加设备,并记录最后扫描时间
|
||||
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
||||
if (!p?.name || !p.name.startsWith("POWERFUN")) return;
|
||||
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
|
||||
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
||||
|
||||
setDevices((prev) => {
|
||||
|
||||
238
src/ScanScreen2.tsx
Normal file
238
src/ScanScreen2.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
// src/ScanScreen.tsx此页面为桨频器搜索页面
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import {
|
||||
Central,
|
||||
CentralEventMap,
|
||||
ScannedPeripheral,
|
||||
} from "@systemic-games/react-native-bluetooth-le";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList>;
|
||||
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MyStatusbar from "./component/MyStatusbar";
|
||||
import MyHeader from "./component/MyHeader";
|
||||
|
||||
export default function ScanScreen2({ navigation }: Props) {
|
||||
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// 将 RSSI 转换为信号格数(0-4)
|
||||
const getSignalLevel = (rssi?: number): number => {
|
||||
if (rssi === undefined) return 0;
|
||||
if (rssi >= -50) return 4; // 很强
|
||||
if (rssi >= -65) return 3; // 强
|
||||
if (rssi >= -80) return 2; // 一般
|
||||
if (rssi >= -90) return 1; // 弱
|
||||
return 0; // 无信号
|
||||
};
|
||||
|
||||
// 渲染信号图标(格数+颜色)
|
||||
const renderSignalIcon = (rssi?: number) => {
|
||||
const level = getSignalLevel(rssi);
|
||||
|
||||
let iconColor = "#aaa"; // 默认灰色
|
||||
switch (level) {
|
||||
case 4:
|
||||
iconColor = "#8BC34A"; // 亮绿
|
||||
break;
|
||||
case 3:
|
||||
iconColor = "#4CAF50"; // 深绿
|
||||
break;
|
||||
case 2:
|
||||
iconColor = "#FF9800"; // 橙色
|
||||
break;
|
||||
case 1:
|
||||
iconColor = "#F44336"; // 红色
|
||||
break;
|
||||
}
|
||||
|
||||
const iconName =
|
||||
level === 4
|
||||
? "wifi-strength-4"
|
||||
: level === 3
|
||||
? "wifi-strength-3"
|
||||
: level === 2
|
||||
? "wifi-strength-2"
|
||||
: level === 1
|
||||
? "wifi-strength-1"
|
||||
: "wifi-strength-outline";
|
||||
|
||||
return <Icon name={iconName} size={22} color={iconColor} />;
|
||||
};
|
||||
|
||||
// 更新或添加设备,并记录最后扫描时间
|
||||
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
||||
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-STK"))) return;
|
||||
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
||||
|
||||
setDevices((prev) => {
|
||||
const now = Date.now();
|
||||
const exists = prev.find((d) => d.systemId === p.systemId);
|
||||
if (exists) {
|
||||
return prev.map((d) =>
|
||||
d.systemId === p.systemId ? { ...p, lastSeen: now } : d
|
||||
);
|
||||
} else {
|
||||
return [...prev, { ...p, lastSeen: now }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 清理消失或信号低的设备
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setDevices((prev) =>
|
||||
prev.filter(
|
||||
(d) =>
|
||||
(d.advertisementData?.rssi ?? -999) >= -90 &&
|
||||
now - d.lastSeen < 3000
|
||||
)
|
||||
);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
setScanning(false);
|
||||
try { Central.stopScan(); } catch {}
|
||||
if (handlerRef.current) {
|
||||
try { Central.removeListener("scannedPeripheral", handlerRef.current); } catch {}
|
||||
handlerRef.current = null;
|
||||
}
|
||||
try { Central.shutdown(); } catch {}
|
||||
}, []);
|
||||
|
||||
const startScan = useCallback(() => {
|
||||
stopScan();
|
||||
setDevices([]);
|
||||
setScanning(true);
|
||||
|
||||
const onScanned = (payload: CentralEventMap["scannedPeripheral"]) => {
|
||||
const p = payload?.peripheral;
|
||||
console.log("🔥 scanned raw peripheral =", JSON.stringify(p, null, 2));
|
||||
console.log("🔥 scanned raw peripheral keys =", p ? Object.keys(p) : []);
|
||||
|
||||
if (p?.name && p.advertisementData?.isConnectable) {
|
||||
updatePeripherals(p);
|
||||
}
|
||||
};
|
||||
handlerRef.current = onScanned;
|
||||
|
||||
try {
|
||||
Central.initialize();
|
||||
Central.addListener("scannedPeripheral", onScanned);
|
||||
Central.startScan([]);
|
||||
} catch (e) {
|
||||
console.warn("Central scan start error:", e);
|
||||
setScanning(false);
|
||||
handlerRef.current = null;
|
||||
try { Central.shutdown(); } catch {}
|
||||
}
|
||||
}, [stopScan, updatePeripherals]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
stopScan();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 200));
|
||||
startScan();
|
||||
setRefreshing(false);
|
||||
}, [stopScan, startScan]);
|
||||
|
||||
useEffect(() => {
|
||||
startScan();
|
||||
return () => { stopScan(); };
|
||||
}, [startScan, stopScan]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
||||
<MyHeader
|
||||
title={t('paddleScan.title')}
|
||||
textColor="#333"
|
||||
backgroundColor="#f2f3f7"
|
||||
navigation={navigation}
|
||||
></MyHeader>
|
||||
<FlatList
|
||||
data={devices}
|
||||
keyExtractor={(item) => item.systemId}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
console.log("🔥 navigate item =", JSON.stringify(item, null, 2));
|
||||
console.log("🔥 navigate item keys =", Object.keys(item));
|
||||
navigation.navigate("Info2", { peripheral: item });
|
||||
}}
|
||||
style={styles.deviceRow}
|
||||
>
|
||||
<View style={styles.deviceInfo}>
|
||||
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
|
||||
<View style={styles.rssiRow}>
|
||||
{renderSignalIcon(item.advertisementData?.rssi)}
|
||||
<Text style={styles.rssiText}>
|
||||
{item.advertisementData?.rssi ?? "--"} dBm
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyBox}>
|
||||
{scanning ? (
|
||||
<>
|
||||
<Text style={styles.emptyText}>{t("paddleScan.scanning")}</Text>
|
||||
<Text style={styles.tips}>{t("paddleScan.tipScanning")}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.emptyText}>{t("paddleScan.noDevice")}</Text>
|
||||
<Text style={styles.tips}>{t("paddleScan.tipBluetooth")}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#fff" },
|
||||
deviceRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 15,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#eee",
|
||||
},
|
||||
deviceInfo: { flexDirection: "column" },
|
||||
deviceName: { fontSize: 16, fontWeight: "500" },
|
||||
rssiRow: { flexDirection: "row", alignItems: "center", marginTop: 4 },
|
||||
rssiText: { fontSize: 14, color: "#666", marginLeft: 6 },
|
||||
emptyBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 50,
|
||||
},
|
||||
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
|
||||
tips: { fontSize: 14, color: "#888", textAlign: "center" },
|
||||
});
|
||||
231
src/ScanScreen3.tsx
Normal file
231
src/ScanScreen3.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
// src/ScanScreen3.tsx此页面为T5骑行台搜索页面
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import {
|
||||
Central,
|
||||
CentralEventMap,
|
||||
ScannedPeripheral,
|
||||
} from "@systemic-games/react-native-bluetooth-le";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList>;
|
||||
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MyStatusbar from "./component/MyStatusbar";
|
||||
import MyHeader from "./component/MyHeader";
|
||||
|
||||
export default function ScanScreen3({ navigation }: Props) {
|
||||
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// 将 RSSI 转换为信号格数(0-4)
|
||||
const getSignalLevel = (rssi?: number): number => {
|
||||
if (rssi === undefined) return 0;
|
||||
if (rssi >= -50) return 4; // 很强
|
||||
if (rssi >= -65) return 3; // 强
|
||||
if (rssi >= -80) return 2; // 一般
|
||||
if (rssi >= -90) return 1; // 弱
|
||||
return 0; // 无信号
|
||||
};
|
||||
|
||||
// 渲染信号图标(格数+颜色)
|
||||
const renderSignalIcon = (rssi?: number) => {
|
||||
const level = getSignalLevel(rssi);
|
||||
|
||||
let iconColor = "#aaa"; // 默认灰色
|
||||
switch (level) {
|
||||
case 4:
|
||||
iconColor = "#8BC34A"; // 亮绿
|
||||
break;
|
||||
case 3:
|
||||
iconColor = "#4CAF50"; // 深绿
|
||||
break;
|
||||
case 2:
|
||||
iconColor = "#FF9800"; // 橙色
|
||||
break;
|
||||
case 1:
|
||||
iconColor = "#F44336"; // 红色
|
||||
break;
|
||||
}
|
||||
|
||||
const iconName =
|
||||
level === 4
|
||||
? "wifi-strength-4"
|
||||
: level === 3
|
||||
? "wifi-strength-3"
|
||||
: level === 2
|
||||
? "wifi-strength-2"
|
||||
: level === 1
|
||||
? "wifi-strength-1"
|
||||
: "wifi-strength-outline";
|
||||
|
||||
return <Icon name={iconName} size={22} color={iconColor} />;
|
||||
};
|
||||
|
||||
// 更新或添加设备,并记录最后扫描时间
|
||||
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
||||
if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-T5"))) return;
|
||||
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
||||
|
||||
setDevices((prev) => {
|
||||
const now = Date.now();
|
||||
const exists = prev.find((d) => d.systemId === p.systemId);
|
||||
if (exists) {
|
||||
return prev.map((d) =>
|
||||
d.systemId === p.systemId ? { ...p, lastSeen: now } : d
|
||||
);
|
||||
} else {
|
||||
return [...prev, { ...p, lastSeen: now }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 清理消失或信号低的设备
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setDevices((prev) =>
|
||||
prev.filter(
|
||||
(d) =>
|
||||
(d.advertisementData?.rssi ?? -999) >= -90 &&
|
||||
now - d.lastSeen < 3000
|
||||
)
|
||||
);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
setScanning(false);
|
||||
try { Central.stopScan(); } catch {}
|
||||
if (handlerRef.current) {
|
||||
try { Central.removeListener("scannedPeripheral", handlerRef.current); } catch {}
|
||||
handlerRef.current = null;
|
||||
}
|
||||
try { Central.shutdown(); } catch {}
|
||||
}, []);
|
||||
|
||||
const startScan = useCallback(() => {
|
||||
stopScan();
|
||||
setDevices([]);
|
||||
setScanning(true);
|
||||
|
||||
const onScanned = (payload: CentralEventMap["scannedPeripheral"]) => {
|
||||
const p = payload?.peripheral;
|
||||
if (p?.name && p.advertisementData?.isConnectable) {
|
||||
updatePeripherals(p);
|
||||
}
|
||||
};
|
||||
handlerRef.current = onScanned;
|
||||
|
||||
try {
|
||||
Central.initialize();
|
||||
Central.addListener("scannedPeripheral", onScanned);
|
||||
Central.startScan([]);
|
||||
} catch (e) {
|
||||
console.warn("Central scan start error:", e);
|
||||
setScanning(false);
|
||||
handlerRef.current = null;
|
||||
try { Central.shutdown(); } catch {}
|
||||
}
|
||||
}, [stopScan, updatePeripherals]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
stopScan();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 200));
|
||||
startScan();
|
||||
setRefreshing(false);
|
||||
}, [stopScan, startScan]);
|
||||
|
||||
useEffect(() => {
|
||||
startScan();
|
||||
return () => { stopScan(); };
|
||||
}, [startScan, stopScan]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
||||
<MyHeader
|
||||
title={t('t5Scan.title')}
|
||||
textColor="#333"
|
||||
backgroundColor="#f2f3f7"
|
||||
navigation={navigation}
|
||||
></MyHeader>
|
||||
<FlatList
|
||||
data={devices}
|
||||
keyExtractor={(item) => item.systemId}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate("Info3", { peripheral: item })}
|
||||
style={styles.deviceRow}
|
||||
>
|
||||
<View style={styles.deviceInfo}>
|
||||
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
|
||||
<View style={styles.rssiRow}>
|
||||
{renderSignalIcon(item.advertisementData?.rssi)}
|
||||
<Text style={styles.rssiText}>
|
||||
{item.advertisementData?.rssi ?? "--"} dBm
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyBox}>
|
||||
{scanning ? (
|
||||
<>
|
||||
<Text style={styles.emptyText}>{t("t5Scan.scanning")}</Text>
|
||||
<Text style={styles.tips}>{t("t5Scan.tipScanning")}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.emptyText}>{t("t5Scan.noDevice")}</Text>
|
||||
<Text style={styles.tips}>{t("t5Scan.tipBluetooth")}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#fff" },
|
||||
deviceRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 15,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#eee",
|
||||
},
|
||||
deviceInfo: { flexDirection: "column" },
|
||||
deviceName: { fontSize: 16, fontWeight: "500" },
|
||||
rssiRow: { flexDirection: "row", alignItems: "center", marginTop: 4 },
|
||||
rssiText: { fontSize: 14, color: "#666", marginLeft: 6 },
|
||||
emptyBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 50,
|
||||
},
|
||||
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
|
||||
tips: { fontSize: 14, color: "#888", textAlign: "center" },
|
||||
});
|
||||
796
src/SpindownScreen.tsx
Normal file
796
src/SpindownScreen.tsx
Normal file
@ -0,0 +1,796 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Text as RNText,
|
||||
} from "react-native";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import {
|
||||
Central,
|
||||
ConnectionStatus,
|
||||
ScannedPeripheral,
|
||||
} from "@systemic-games/react-native-bluetooth-le";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { RootStackParamList } from "../App";
|
||||
import MyHeader from "./component/MyHeader";
|
||||
import MyStatusbar from "./component/MyStatusbar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { observeFtmsIndoorBikeData } from "./helper/ftmsIndoorBikeDataBus";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Spindown">;
|
||||
type Phase = "reach36" | "wait18" | "calibrating" | "completed" | "error";
|
||||
|
||||
const powerServiceUuid = "fff1";
|
||||
const powerWriteUuid = "fff2";
|
||||
const powerNotifyUuid = "fff3";
|
||||
|
||||
const fullUUID = (uuid: string) =>
|
||||
uuid.length === 4
|
||||
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
|
||||
: uuid;
|
||||
|
||||
const Text = ({ children, style, ...props }: any) => {
|
||||
return (
|
||||
<RNText style={[{ color: "black", fontSize: 16 }, style]} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SpindownScreen({ route, navigation }: Props) {
|
||||
const { peripheral } = route.params;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deviceKey = useMemo(
|
||||
() => peripheral.address || peripheral.systemId,
|
||||
[peripheral.address, peripheral.systemId]
|
||||
);
|
||||
|
||||
const [speedKph, setSpeedKph] = useState<number>(0);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isFff3Ready, setIsFff3Ready] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [phase, setPhase] = useState<Phase>("reach36");
|
||||
const [step1Done, setStep1Done] = useState(false);
|
||||
const [step2Done, setStep2Done] = useState(false);
|
||||
const [statusText, setStatusText] = useState(
|
||||
t("spindown.statusReach36", { speed: 36 })
|
||||
);
|
||||
const [spindownSeconds, setSpindownSeconds] = useState<number | null>(null);
|
||||
|
||||
const notifySubscribedRef = useRef(false);
|
||||
const spindownStartedRef = useRef(false);
|
||||
const spindownModeEnabledRef = useRef(false);
|
||||
const resolverRef = useRef<((value: Uint8Array) => void) | null>(null);
|
||||
const rejecterRef = useRef<((reason?: any) => void) | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const hasNavigatedBackRef = useRef(false);
|
||||
const skipDisconnectOnLeaveRef = useRef(false);
|
||||
const isConnectedRef = useRef(false);
|
||||
const isFff3ReadyRef = useRef(false);
|
||||
const spindownResultPromiseRef = useRef<Promise<number> | null>(null);
|
||||
const DEBUG = false;
|
||||
const phaseRef = useRef<Phase>("reach36");
|
||||
const lastUiUpdateRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
phaseRef.current = phase;
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
isConnectedRef.current = isConnected;
|
||||
}, [isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
isFff3ReadyRef.current = isFff3Ready;
|
||||
}, [isFff3Ready]);
|
||||
|
||||
// 新增:缓存最近收到的 0x09 消旋结果,避免结果包先到、waiter 后挂导致丢包
|
||||
const lastSpindownPacketRef = useRef<Uint8Array | null>(null);
|
||||
|
||||
const bytesToHex = (bytes: Uint8Array) =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
||||
.join("");
|
||||
|
||||
const clearWaiter = () => {
|
||||
resolverRef.current = null;
|
||||
rejecterRef.current = null;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatSpindownSeconds = (seconds: number) => {
|
||||
return seconds.toFixed(2).replace(/\.?0+$/, "");
|
||||
};
|
||||
|
||||
const subscribeFff3IfNeeded = async () => {
|
||||
if (notifySubscribedRef.current) return;
|
||||
|
||||
try {
|
||||
await Central.unsubscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid)
|
||||
).catch(() => {});
|
||||
|
||||
await Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerNotifyUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const raw = notifyEv.value;
|
||||
if (!raw) return;
|
||||
|
||||
const bytes = new Uint8Array(raw);
|
||||
|
||||
if (DEBUG) {
|
||||
console.log("[BLE] FFF3 =", bytes.length);
|
||||
}
|
||||
|
||||
if (bytes.length >= 3 && bytes[0] === 0x09) {
|
||||
lastSpindownPacketRef.current = bytes;
|
||||
}
|
||||
|
||||
resolverRef.current?.(bytes);
|
||||
} catch (err) {
|
||||
rejecterRef.current?.(err);
|
||||
clearWaiter();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
notifySubscribedRef.current = true;
|
||||
isFff3ReadyRef.current = true;
|
||||
setIsFff3Ready(true);
|
||||
} catch {
|
||||
isFff3ReadyRef.current = false;
|
||||
setIsFff3Ready(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const waitForSpindownTime = (timeout = 5000) => {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
clearWaiter();
|
||||
|
||||
// 先吃缓存,防止 0x09 结果包在 runSpindown 前就已经到了
|
||||
const cached = lastSpindownPacketRef.current;
|
||||
if (cached && cached.length >= 3 && cached[0] === 0x09) {
|
||||
console.log("[BLE] use cached spindown packet =", bytesToHex(cached));
|
||||
|
||||
const rawSeconds = cached[1] | (cached[2] << 8);
|
||||
lastSpindownPacketRef.current = null;
|
||||
|
||||
if (rawSeconds === 0xffff) {
|
||||
reject(new Error(t("spindown.failedRetry")));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(rawSeconds / 100);
|
||||
return;
|
||||
}
|
||||
|
||||
resolverRef.current = (value: Uint8Array) => {
|
||||
if (value.length < 3 || value[0] !== 0x09) {
|
||||
console.log("[BLE] ignore unrelated FFF3(spindown):", bytesToHex(value));
|
||||
return;
|
||||
}
|
||||
|
||||
lastSpindownPacketRef.current = null;
|
||||
|
||||
const rawSeconds = value[1] | (value[2] << 8);
|
||||
if (rawSeconds === 0xffff) {
|
||||
clearWaiter();
|
||||
reject(new Error(t("spindown.failedRetry")));
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySeconds = rawSeconds / 100;
|
||||
clearWaiter();
|
||||
resolve(displaySeconds);
|
||||
};
|
||||
|
||||
rejecterRef.current = reject;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
clearWaiter();
|
||||
reject(new Error(t("spindown.timeout")));
|
||||
}, timeout);
|
||||
});
|
||||
};
|
||||
|
||||
const prepareSpindownResultWait = (timeout = 45000) => {
|
||||
if (spindownResultPromiseRef.current) {
|
||||
return spindownResultPromiseRef.current;
|
||||
}
|
||||
|
||||
const promise = waitForSpindownTime(timeout);
|
||||
spindownResultPromiseRef.current = promise;
|
||||
void promise.catch(() => {});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const writeFff2 = async (bytes: number[]) => {
|
||||
await Central.writeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(powerServiceUuid),
|
||||
fullUUID(powerWriteUuid),
|
||||
new Uint8Array(bytes).buffer,
|
||||
{ withoutResponse: false }
|
||||
);
|
||||
};
|
||||
|
||||
const enableSpindownMode = async () => {
|
||||
if (spindownModeEnabledRef.current) return;
|
||||
if (!isConnectedRef.current || !isFff3ReadyRef.current) return;
|
||||
|
||||
// 开始一轮新的消旋前清掉旧缓存,避免上一次结果污染
|
||||
lastSpindownPacketRef.current = null;
|
||||
|
||||
console.log("[BLE] write FFF2 1001");
|
||||
await writeFff2([0x10, 0x01]);
|
||||
spindownModeEnabledRef.current = true;
|
||||
};
|
||||
|
||||
const disableSpindownMode = async () => {
|
||||
if (!spindownModeEnabledRef.current) return;
|
||||
|
||||
try {
|
||||
console.log("[BLE] write FFF2 1000");
|
||||
await writeFff2([0x10, 0x00]);
|
||||
} finally {
|
||||
spindownModeEnabledRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runSpindown = async () => {
|
||||
if (spindownStartedRef.current) return;
|
||||
spindownStartedRef.current = true;
|
||||
|
||||
try {
|
||||
if (!isConnectedRef.current || !isFff3ReadyRef.current) {
|
||||
throw new Error(t("spindown.deviceNotReady"));
|
||||
}
|
||||
|
||||
setStatusText(t("spindown.statusCalibrating"));
|
||||
const seconds = await prepareSpindownResultWait(5000);
|
||||
setSpindownSeconds(seconds);
|
||||
setPhase("completed");
|
||||
phaseRef.current = "completed";
|
||||
setStatusText(t("spindown.statusCompleted"));
|
||||
} catch (err: any) {
|
||||
console.warn("消旋失败:", err);
|
||||
setPhase("error");
|
||||
phaseRef.current = "error";
|
||||
setStatusText("消旋失败,请重新消旋");
|
||||
Alert.alert(
|
||||
"消旋失败",
|
||||
err?.message || "本次消旋未完成,请检查速度是否符合要求后重新尝试。",
|
||||
[{ text: "确定" }]
|
||||
);
|
||||
} finally {
|
||||
spindownResultPromiseRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
const connectionHandler = async (ev: {
|
||||
peripheral: ScannedPeripheral;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}) => {
|
||||
const addr = ev.peripheral.address || ev.peripheral.systemId;
|
||||
if (addr !== deviceKey) return;
|
||||
|
||||
const connected =
|
||||
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
|
||||
|
||||
isConnectedRef.current = connected;
|
||||
setIsConnected(connected);
|
||||
|
||||
if (connected) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
}
|
||||
|
||||
if (ev.connectionStatus !== "ready") {
|
||||
isFff3ReadyRef.current = false;
|
||||
setIsFff3Ready(false);
|
||||
notifySubscribedRef.current = false;
|
||||
}
|
||||
|
||||
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
||||
await subscribeFff3IfNeeded();
|
||||
}
|
||||
|
||||
if (
|
||||
!connected &&
|
||||
hasConnectedOnceRef.current &&
|
||||
mountedRef.current &&
|
||||
!hasNavigatedBackRef.current
|
||||
) {
|
||||
hasNavigatedBackRef.current = true;
|
||||
skipDisconnectOnLeaveRef.current = true;
|
||||
|
||||
Alert.alert(t("common.notice"), t("info2.disconnectedNeedReconnect"), [
|
||||
{
|
||||
text: t("info.confirm"),
|
||||
onPress: () => {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: "ScanScreen3" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
Central.addListener("peripheralConnectionStatus", connectionHandler);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
Central.removeListener("peripheralConnectionStatus", connectionHandler);
|
||||
notifySubscribedRef.current = false;
|
||||
setIsFff3Ready(false);
|
||||
clearWaiter();
|
||||
};
|
||||
}, [deviceKey, navigation, peripheral, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 进入页面时清一次缓存,避免旧结果残留
|
||||
lastSpindownPacketRef.current = null;
|
||||
spindownResultPromiseRef.current = null;
|
||||
spindownStartedRef.current = false;
|
||||
phaseRef.current = "reach36";
|
||||
|
||||
// Reuse existing connection from Info3 instead of reconnecting here.
|
||||
isConnectedRef.current = true;
|
||||
setIsConnected(true);
|
||||
await subscribeFff3IfNeeded();
|
||||
} catch (err) {
|
||||
console.warn("连接设备失败:", err);
|
||||
isConnectedRef.current = false;
|
||||
setIsConnected(false);
|
||||
if (!cancelled) {
|
||||
Alert.alert(t("common.notice"), t("spindown.connectFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (skipDisconnectOnLeaveRef.current) return;
|
||||
disableSpindownMode().catch((err) => {
|
||||
console.warn("关闭消旋模式失败:", err);
|
||||
});
|
||||
};
|
||||
}, [peripheral, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !isFff3Ready) return;
|
||||
|
||||
enableSpindownMode().catch((err) => {
|
||||
console.warn("启用消旋模式失败:", err);
|
||||
spindownModeEnabledRef.current = false;
|
||||
});
|
||||
}, [isConnected, isFff3Ready]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!peripheral) return;
|
||||
|
||||
let cancelled = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
observeFtmsIndoorBikeData(peripheral, (data) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const speed = data.speedKph;
|
||||
const now = Date.now();
|
||||
|
||||
// ✅ UI 限速 10Hz
|
||||
if (now - lastUiUpdateRef.current > 100) {
|
||||
lastUiUpdateRef.current = now;
|
||||
setSpeedKph(speed);
|
||||
}
|
||||
|
||||
// ===== ⭐ 实时状态机(不再依赖 React state) =====
|
||||
const currentPhase = phaseRef.current;
|
||||
|
||||
// step1:到 36
|
||||
if (currentPhase === "reach36" && speed >= 36) {
|
||||
phaseRef.current = "wait18";
|
||||
setPhase("wait18");
|
||||
setStep1Done(true);
|
||||
setStatusText(t("spindown.statusReached36", { high: 36, low: 18 }));
|
||||
prepareSpindownResultWait();
|
||||
return;
|
||||
}
|
||||
|
||||
// step2:降到 18
|
||||
if (currentPhase === "wait18" && speed <= 18) {
|
||||
phaseRef.current = "calibrating";
|
||||
setPhase("calibrating");
|
||||
setStep2Done(true);
|
||||
setStatusText(t("spindown.statusCalibrating"));
|
||||
|
||||
runSpindown();
|
||||
return;
|
||||
}
|
||||
})
|
||||
.then((unsubscribe) => {
|
||||
if (cancelled) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
cleanup = unsubscribe;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup?.();
|
||||
};
|
||||
}, [peripheral, t]);
|
||||
|
||||
const renderStepState = (done: boolean, active: boolean) => {
|
||||
if (done) {
|
||||
return <Icon name="check-circle" size={28} color="#19a15f" />;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return <ActivityIndicator size="small" color="#eb3b3b" />;
|
||||
}
|
||||
|
||||
return <Icon name="circle-outline" size={26} color="#c6c6c6" />;
|
||||
};
|
||||
|
||||
const isDeviceReady = isConnected || isFff3Ready;
|
||||
const connectionLabel = isDeviceReady
|
||||
? t("spindown.connected")
|
||||
: t("spindown.connecting");
|
||||
const connectionColor = isDeviceReady ? "#19a15f" : "#eb3b3b";
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<MyStatusbar backgroundColor="#ffffff" dark={true} />
|
||||
<MyHeader
|
||||
backgroundColor="#ffffff"
|
||||
title={t("spindown.title")}
|
||||
textColor="#eb3b3b"
|
||||
navigation={navigation}
|
||||
/>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.connectionRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.connectionBadge,
|
||||
{ backgroundColor: `${connectionColor}16` },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[styles.connectionDot, { backgroundColor: connectionColor }]}
|
||||
/>
|
||||
<Text style={[styles.connectionText, { color: connectionColor }]}>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.heroTitle}>{t("spindown.headerTitle")}</Text>
|
||||
<Text style={styles.heroHint}>{statusText}</Text>
|
||||
|
||||
<View style={styles.targetCard}>
|
||||
<Text style={styles.targetLabel}>{t("spindown.targetLabel")}</Text>
|
||||
<Text style={styles.targetValue}>36 km/h</Text>
|
||||
<Text style={styles.targetHint}>{t("spindown.targetHint")}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.speedCard}>
|
||||
<Text style={styles.speedLabel}>{t("spindown.currentSpeed")}</Text>
|
||||
<Text style={styles.speedValue}>{speedKph.toFixed(1)} km/h</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepCard}>
|
||||
<View style={styles.stepRow}>
|
||||
<View style={styles.stepIndexWrap}>
|
||||
<Text style={styles.stepIndex}>1</Text>
|
||||
</View>
|
||||
<View style={styles.stepTextWrap}>
|
||||
<Text style={styles.stepTitle}>
|
||||
{t("spindown.step1Title", { speed: 36 })}
|
||||
</Text>
|
||||
<Text style={styles.stepDesc}>{t("spindown.step1Desc")}</Text>
|
||||
</View>
|
||||
{renderStepState(step1Done, phase === "reach36")}
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.stepRow}>
|
||||
<View style={styles.stepIndexWrap}>
|
||||
<Text style={styles.stepIndex}>2</Text>
|
||||
</View>
|
||||
<View style={styles.stepTextWrap}>
|
||||
<Text style={styles.stepTitle}>{t("spindown.step2Title")}</Text>
|
||||
<Text style={styles.stepDesc}>
|
||||
{t("spindown.step2Desc", { speed: 18 })}
|
||||
</Text>
|
||||
</View>
|
||||
{renderStepState(step2Done, phase === "wait18" || phase === "calibrating")}
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.stepRow}>
|
||||
<View style={styles.stepIndexWrap}>
|
||||
<Text style={styles.stepIndex}>3</Text>
|
||||
</View>
|
||||
<View style={styles.stepTextWrap}>
|
||||
<Text style={styles.stepTitle}>{t("spindown.step3Title")}</Text>
|
||||
<Text style={styles.stepDesc}>
|
||||
{phase === "error"
|
||||
? "消旋失败,请重新消旋"
|
||||
: spindownSeconds === null
|
||||
? phase === "calibrating"
|
||||
? t("spindown.step3Loading")
|
||||
: t("spindown.step3Pending")
|
||||
: `${formatSpindownSeconds(spindownSeconds)}s`}
|
||||
</Text>
|
||||
</View>
|
||||
{phase === "error" ? (
|
||||
<Icon name="close-circle" size={28} color="#eb3b3b" />
|
||||
) : (
|
||||
renderStepState(phase === "completed", phase === "calibrating")
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator size={28} color="#eb3b3b" />
|
||||
<Text style={styles.loadingText}>{t("spindown.loading")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{phase === "completed" && (
|
||||
<View style={styles.resultCard}>
|
||||
<Icon name="check-decagram" size={24} color="#19a15f" />
|
||||
<Text style={styles.resultText}>
|
||||
{t("spindown.result", {
|
||||
seconds: formatSpindownSeconds(spindownSeconds ?? 0),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.retryButton}
|
||||
onPress={() => navigation.replace("Spindown", { peripheral })}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("spindown.retry")}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const RED = "#eb3b3b";
|
||||
const BG = "#f6f7fb";
|
||||
const DARK = "#242424";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: BG,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 18,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 28,
|
||||
},
|
||||
heroCard: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 18,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 3,
|
||||
},
|
||||
connectionRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
connectionBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
connectionDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
connectionText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "800",
|
||||
color: DARK,
|
||||
marginTop: 10,
|
||||
},
|
||||
heroHint: {
|
||||
fontSize: 15,
|
||||
color: "#666666",
|
||||
lineHeight: 22,
|
||||
marginTop: 8,
|
||||
minHeight: 26,
|
||||
},
|
||||
targetCard: {
|
||||
marginTop: 12,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "#fff0f0",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffd4d4",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
targetLabel: {
|
||||
fontSize: 14,
|
||||
color: "#8a5555",
|
||||
fontWeight: "600",
|
||||
},
|
||||
targetValue: {
|
||||
marginTop: 4,
|
||||
fontSize: 36,
|
||||
color: RED,
|
||||
fontWeight: "900",
|
||||
lineHeight: 40,
|
||||
},
|
||||
targetHint: {
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
color: "#9b6c6c",
|
||||
},
|
||||
speedCard: {
|
||||
marginTop: 18,
|
||||
borderRadius: 22,
|
||||
backgroundColor: "#fff5f5",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffdede",
|
||||
paddingVertical: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
speedLabel: {
|
||||
fontSize: 15,
|
||||
color: "#666666",
|
||||
},
|
||||
speedValue: {
|
||||
fontSize: 34,
|
||||
fontWeight: "800",
|
||||
color: RED,
|
||||
marginTop: 8,
|
||||
},
|
||||
stepCard: {
|
||||
marginTop: 16,
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 3,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 18,
|
||||
},
|
||||
stepIndexWrap: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: "#fff1f1",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
stepIndex: {
|
||||
fontSize: 17,
|
||||
color: RED,
|
||||
fontWeight: "800",
|
||||
},
|
||||
stepTextWrap: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
stepTitle: {
|
||||
fontSize: 17,
|
||||
color: DARK,
|
||||
fontWeight: "700",
|
||||
lineHeight: 24,
|
||||
},
|
||||
stepDesc: {
|
||||
fontSize: 14,
|
||||
color: "#777777",
|
||||
lineHeight: 20,
|
||||
marginTop: 4,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: "#f0f0f0",
|
||||
},
|
||||
loadingWrap: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 18,
|
||||
},
|
||||
loadingText: {
|
||||
marginLeft: 10,
|
||||
fontSize: 14,
|
||||
color: "#666666",
|
||||
},
|
||||
resultCard: {
|
||||
marginTop: 16,
|
||||
backgroundColor: "#edf9f2",
|
||||
borderRadius: 18,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
resultText: {
|
||||
marginLeft: 10,
|
||||
color: "#156d45",
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 18,
|
||||
height: 46,
|
||||
borderRadius: 14,
|
||||
backgroundColor: RED,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
retryButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
@ -33,6 +33,7 @@ const LanguageModal: React.FC<LanguageModalProps> = ({ visible, onClose }) => {
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Pressable
|
||||
|
||||
135
src/component/UpdateModal.tsx
Normal file
135
src/component/UpdateModal.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
315
src/helper/ftmsIndoorBikeDataBus.ts
Normal file
315
src/helper/ftmsIndoorBikeDataBus.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import {
|
||||
Central,
|
||||
ScannedPeripheral,
|
||||
ConnectionStatus,
|
||||
} from "@systemic-games/react-native-bluetooth-le";
|
||||
|
||||
const ftmsServiceUuid = "1826";
|
||||
const ftmsIndoorBikeDataUuid = "2ad2";
|
||||
|
||||
const fullUUID = (uuid: string) =>
|
||||
uuid.length === 4
|
||||
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
|
||||
: uuid;
|
||||
|
||||
export type FtmsIndoorBikeData = {
|
||||
speedKph: number;
|
||||
cadence: number;
|
||||
power: number;
|
||||
raw: Uint8Array;
|
||||
};
|
||||
|
||||
type Listener = (data: FtmsIndoorBikeData) => void;
|
||||
|
||||
type SubscriptionEntry = {
|
||||
listeners: Set<Listener>;
|
||||
lastData: FtmsIndoorBikeData | null;
|
||||
subscribePromise: Promise<void> | null;
|
||||
subscribed: boolean;
|
||||
cadenceState: {
|
||||
lastCadenceValue: number;
|
||||
lastCadenceChangedTime: number;
|
||||
};
|
||||
lastEmitTime: number; // ✅ throttle 用
|
||||
};
|
||||
|
||||
const entries = new Map<string, SubscriptionEntry>();
|
||||
let connectionListenerInstalled = false;
|
||||
|
||||
const getDeviceKey = (peripheral: ScannedPeripheral) =>
|
||||
String(peripheral.address || peripheral.systemId || peripheral.name || "unknown");
|
||||
|
||||
const resetEntrySubscriptionState = (
|
||||
key: string,
|
||||
connectionStatus?: ConnectionStatus
|
||||
) => {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return;
|
||||
|
||||
entry.subscribed = false;
|
||||
entry.subscribePromise = null;
|
||||
entry.lastEmitTime = 0;
|
||||
entry.cadenceState.lastCadenceValue = 0;
|
||||
entry.cadenceState.lastCadenceChangedTime = 0;
|
||||
|
||||
if (connectionStatus !== "connected" && connectionStatus !== "ready") {
|
||||
entry.lastData = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureConnectionListenerInstalled = () => {
|
||||
if (connectionListenerInstalled) return;
|
||||
|
||||
Central.addListener("peripheralConnectionStatus", (ev) => {
|
||||
const key = getDeviceKey(ev.peripheral);
|
||||
|
||||
if (
|
||||
ev.connectionStatus === "disconnected" ||
|
||||
ev.connectionStatus === "disconnecting" ||
|
||||
ev.connectionStatus === "connecting"
|
||||
) {
|
||||
resetEntrySubscriptionState(key, ev.connectionStatus);
|
||||
}
|
||||
});
|
||||
|
||||
connectionListenerInstalled = true;
|
||||
};
|
||||
|
||||
// ✅ 默认关闭调试(避免性能问题)
|
||||
const DEBUG = false;
|
||||
|
||||
const bytesToHex = (bytes: Uint8Array) =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
||||
.join("");
|
||||
|
||||
const decodeBase64 = (value: string): Uint8Array | null => {
|
||||
try {
|
||||
const atobFn = (globalThis as { atob?: (data: string) => string }).atob;
|
||||
|
||||
if (typeof atobFn === "function") {
|
||||
const binary = atobFn(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
const output: number[] = [];
|
||||
|
||||
for (const char of value.replace(/=+$/, "")) {
|
||||
const index = chars.indexOf(char);
|
||||
if (index < 0) continue;
|
||||
|
||||
buffer = (buffer << 6) | index;
|
||||
bits += 6;
|
||||
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.push((buffer >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(output);
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn("[FTMS] decodeBase64 failed", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const toUint8Array = (raw: any): Uint8Array | null => {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw instanceof Uint8Array) return raw;
|
||||
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
||||
if (typeof raw === "string") return decodeBase64(raw);
|
||||
|
||||
if (Array.isArray(raw)) return new Uint8Array(raw);
|
||||
|
||||
if (raw?.buffer instanceof ArrayBuffer) {
|
||||
return new Uint8Array(raw.buffer, raw.byteOffset || 0, raw.byteLength);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn("[FTMS] toUint8Array failed", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseIndoorBikeData = (
|
||||
byteArray: Uint8Array,
|
||||
cadenceState: SubscriptionEntry["cadenceState"]
|
||||
): FtmsIndoorBikeData | null => {
|
||||
if (byteArray.length < 8) return null;
|
||||
|
||||
const currentTime = Date.now();
|
||||
const rawSpeed = byteArray[2] | (byteArray[3] << 8);
|
||||
const rawCadence = byteArray[4] | (byteArray[5] << 8);
|
||||
let rawPower = byteArray[6] | (byteArray[7] << 8);
|
||||
|
||||
const speedKph = Number((rawSpeed / 100.0).toFixed(1));
|
||||
const cadenceValue = rawCadence / 2.0;
|
||||
|
||||
if (cadenceValue !== cadenceState.lastCadenceValue) {
|
||||
cadenceState.lastCadenceChangedTime = currentTime;
|
||||
cadenceState.lastCadenceValue = cadenceValue;
|
||||
}
|
||||
|
||||
const cadence =
|
||||
cadenceState.lastCadenceChangedTime > 0 &&
|
||||
currentTime - cadenceState.lastCadenceChangedTime > 3000
|
||||
? 0
|
||||
: Math.round(cadenceValue);
|
||||
|
||||
if (rawPower & 0x8000) {
|
||||
rawPower -= 0x10000;
|
||||
}
|
||||
|
||||
const power = rawPower;
|
||||
|
||||
return {
|
||||
speedKph,
|
||||
cadence,
|
||||
power,
|
||||
raw: byteArray,
|
||||
};
|
||||
};
|
||||
|
||||
const ensureSubscribed = async (
|
||||
peripheral: ScannedPeripheral,
|
||||
entry: SubscriptionEntry
|
||||
) => {
|
||||
if (entry.subscribed) return;
|
||||
|
||||
if (entry.subscribePromise) {
|
||||
await entry.subscribePromise;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.subscribePromise = Central.subscribeCharacteristic(
|
||||
peripheral,
|
||||
fullUUID(ftmsServiceUuid),
|
||||
fullUUID(ftmsIndoorBikeDataUuid),
|
||||
(notifyEv) => {
|
||||
try {
|
||||
const byteArray = toUint8Array(notifyEv.value);
|
||||
if (!byteArray || byteArray.length === 0) return;
|
||||
|
||||
if (DEBUG) {
|
||||
const flags =
|
||||
byteArray.length >= 2
|
||||
? byteArray[0] | (byteArray[1] << 8)
|
||||
: 0;
|
||||
|
||||
console.log("[FTMS]", bytesToHex(byteArray));
|
||||
console.log(
|
||||
"[FTMS] flags =",
|
||||
"0x" + flags.toString(16).padStart(4, "0")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseIndoorBikeData(
|
||||
byteArray,
|
||||
entry.cadenceState
|
||||
);
|
||||
if (!parsed) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ✅ throttle: 限制为 10Hz(100ms 一次)
|
||||
if (now - entry.lastEmitTime < 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.lastEmitTime = now;
|
||||
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[FTMS] parsed =>",
|
||||
parsed.speedKph,
|
||||
parsed.cadence,
|
||||
parsed.power
|
||||
);
|
||||
}
|
||||
|
||||
entry.lastData = parsed;
|
||||
entry.listeners.forEach((listener) => listener(parsed));
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn("处理 2AD2 通知失败", err);
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
entry.subscribed = true;
|
||||
if (DEBUG) console.log("[FTMS] subscribed");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (DEBUG) console.warn("[FTMS] subscribe failed", err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.subscribePromise = null;
|
||||
});
|
||||
|
||||
await entry.subscribePromise;
|
||||
};
|
||||
|
||||
export const observeFtmsIndoorBikeData = async (
|
||||
peripheral: ScannedPeripheral,
|
||||
listener: Listener
|
||||
) => {
|
||||
ensureConnectionListenerInstalled();
|
||||
|
||||
const key = getDeviceKey(peripheral);
|
||||
let entry = entries.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
listeners: new Set(),
|
||||
lastData: null,
|
||||
subscribePromise: null,
|
||||
subscribed: false,
|
||||
cadenceState: {
|
||||
lastCadenceValue: 0,
|
||||
lastCadenceChangedTime: 0,
|
||||
},
|
||||
lastEmitTime: 0, // ✅ 初始化
|
||||
};
|
||||
entries.set(key, entry);
|
||||
}
|
||||
|
||||
if (entry.listeners.size === 0) {
|
||||
resetEntrySubscriptionState(key, "disconnected");
|
||||
}
|
||||
|
||||
entry.listeners.add(listener);
|
||||
await ensureSubscribed(peripheral, entry);
|
||||
|
||||
if (entry.lastData) {
|
||||
listener(entry.lastData);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const currentEntry = entries.get(key);
|
||||
if (!currentEntry) return;
|
||||
currentEntry.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const debugFtmsIndoorBikeDataEntries = () => {
|
||||
return Array.from(entries.entries()).map(([key, entry]) => ({
|
||||
key,
|
||||
listeners: entry.listeners.size,
|
||||
subscribed: entry.subscribed,
|
||||
lastDataHex: entry.lastData ? bytesToHex(entry.lastData.raw) : null,
|
||||
}));
|
||||
};
|
||||
@ -51,7 +51,7 @@ export const saveLanguage = async (language: string): Promise<void> => {
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
compatibilityJSON: 'v3',
|
||||
compatibilityJSON: 'v4',
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en },
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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\n(1)第三方 SDK 名称:腾讯 Bugly \n提供方:深圳市腾讯计算机系统有限公司 \n收集类型:系统版本、设备 ID、手机型号、网络状态、系统设置、网络与 WiFi 信息、手机状态、系统日志等。 \n使用目的:用于监控应用性能与稳定性,收集崩溃报告和使用数据以帮助我们识别并修复问题。\n\n(2)第三方 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\n(1)第三方 SDK 名称:腾讯 Bugly \n提供方:深圳市腾讯计算机系统有限公司 \n收集类型:系统版本、设备 ID、手机型号、网络状态、系统设置、网络与 WiFi 信息、手机状态、系统日志等。 \n使用目的:用于监控应用性能与稳定性,收集崩溃报告和使用数据以帮助我们识别并修复问题。\n\n(2)第三方 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": "错误"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user