expo-ble模块测试demo 联调BLE模块
This commit is contained in:
477
ble/hooks/useBleExplorer.ts
Normal file
477
ble/hooks/useBleExplorer.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Alert, PermissionsAndroid, Platform} from 'react-native';
|
||||
import {
|
||||
ActivationStatus,
|
||||
BLE_UUIDS,
|
||||
BleClient,
|
||||
BleDevice,
|
||||
BleError,
|
||||
BleProtocolService, COMMAND_TYPES,
|
||||
ConnectionState,
|
||||
DeviceInfo,
|
||||
} from '../index';
|
||||
import {DeviceInfoService} from '../services/DeviceInfoService'
|
||||
import {FileTransferService} from "../services/FileTransferService";
|
||||
import {ScanMode} from "react-native-ble-plx";
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import {File, Directory, Paths} from 'expo-file-system';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
|
||||
interface BleState {
|
||||
isScanning: boolean;
|
||||
isConnected: boolean;
|
||||
connectedDevice: BleDevice | null;
|
||||
deviceInfo: DeviceInfo | null;
|
||||
version: string;
|
||||
isActivated: boolean;
|
||||
transferProgress: number;
|
||||
isTransferring: boolean;
|
||||
discoveredDevices: BleDevice[];
|
||||
loading: {
|
||||
connecting: boolean;
|
||||
querying: boolean;
|
||||
transferring: boolean;
|
||||
};
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
|
||||
export const useBleExplorer = () => {
|
||||
const bleClient = BleClient.getInstance();
|
||||
const deviceInfoService = DeviceInfoService.getInstance();
|
||||
const fileTransferService = FileTransferService.getInstance();
|
||||
const protocolService = BleProtocolService.getInstance();
|
||||
|
||||
const [state, setState] = useState<BleState>({
|
||||
isScanning: false,
|
||||
isConnected: false,
|
||||
connectedDevice: null,
|
||||
deviceInfo: null,
|
||||
version: '',
|
||||
isActivated: false,
|
||||
transferProgress: 0,
|
||||
isTransferring: false,
|
||||
discoveredDevices: [],
|
||||
loading: {
|
||||
connecting: false,
|
||||
querying: false,
|
||||
transferring: false,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const setError = useCallback(
|
||||
(error: string | null) => {
|
||||
setState(prev => ({...prev, error}));
|
||||
if (error) {
|
||||
console.error(`ERROR: ${error}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestBluetoothPermissions = useCallback(async (): Promise<boolean> => {
|
||||
if (Platform.OS !== 'android') return true;
|
||||
try {
|
||||
const permissions = [
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
||||
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||
];
|
||||
const results = await PermissionsAndroid.requestMultiple(permissions);
|
||||
const allGranted = Object.values(results).every(result => result === PermissionsAndroid.RESULTS.GRANTED);
|
||||
if (!allGranted) {
|
||||
const denied = Object.entries(results)
|
||||
.filter(([_, result]) => result !== PermissionsAndroid.RESULTS.GRANTED)
|
||||
.map(([permission]) => permission.split('.').pop());
|
||||
setError(`Bluetooth permissions required: ${denied.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(`Permission request failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}, [setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
console.log('BLE not supported on web platform');
|
||||
return;
|
||||
}
|
||||
|
||||
const onConnectionStateChange = ({deviceId, state: connState}: {
|
||||
deviceId: string,
|
||||
state: ConnectionState
|
||||
}) => {
|
||||
const isConnected = connState === ConnectionState.CONNECTED;
|
||||
setState(prev => {
|
||||
const updatedDevices = prev.discoveredDevices.map(d => {
|
||||
if (d.id === deviceId) {
|
||||
const newDevice = Object.assign(Object.create(Object.getPrototypeOf(d)), d) as BleDevice;
|
||||
newDevice.connected = isConnected;
|
||||
return newDevice;
|
||||
}
|
||||
return d;
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isConnected,
|
||||
connectedDevice: connState === ConnectionState.DISCONNECTED ? null : prev.connectedDevice,
|
||||
discoveredDevices: updatedDevices,
|
||||
};
|
||||
});
|
||||
console.log(`Connection state ${deviceId}: ${connState}`);
|
||||
};
|
||||
|
||||
const onScanError = (error: BleError) => {
|
||||
setError(`Scan error: ${error.message}`);
|
||||
setState(prev => ({...prev, isScanning: false}));
|
||||
};
|
||||
|
||||
bleClient.addListener('connectionStateChange', onConnectionStateChange);
|
||||
bleClient.addListener('scanError', onScanError);
|
||||
|
||||
const onDeviceInfo = (info: DeviceInfo) => {
|
||||
setState(prev => ({...prev, deviceInfo: info}));
|
||||
console.log(`Device info received: ${info.devname}`);
|
||||
};
|
||||
|
||||
const onActivationStatus = (status: ActivationStatus) => {
|
||||
const activated = status.state === 1;
|
||||
setState(prev => ({...prev, isActivated: activated}));
|
||||
console.log(`Activation status: ${activated ? 'Activated' : 'Not activated'}`);
|
||||
};
|
||||
|
||||
deviceInfoService.addListener('deviceInfo', onDeviceInfo);
|
||||
deviceInfoService.addListener('activationStatus', onActivationStatus);
|
||||
|
||||
return () => {
|
||||
bleClient.removeListener('connectionStateChange', onConnectionStateChange);
|
||||
bleClient.removeListener('scanError', onScanError);
|
||||
deviceInfoService.removeListener('deviceInfo', onDeviceInfo);
|
||||
deviceInfoService.removeListener('activationStatus', onActivationStatus);
|
||||
};
|
||||
}, [bleClient, deviceInfoService, setError]);
|
||||
|
||||
const startScan = useCallback(async () => {
|
||||
if (Platform.OS === 'web') return;
|
||||
try {
|
||||
setError(null);
|
||||
console.log('Starting scan...');
|
||||
const hasPerms = await requestBluetoothPermissions();
|
||||
if (!hasPerms) return;
|
||||
|
||||
setState(prev => ({...prev, isScanning: true, discoveredDevices: []}));
|
||||
|
||||
// Check for already connected devices
|
||||
try {
|
||||
const relevantDevices = await bleClient.getConnectedDevices([BLE_UUIDS.SERVICE]);
|
||||
|
||||
if (relevantDevices.length > 0) {
|
||||
console.log(`Found ${relevantDevices.length} system-connected devices`);
|
||||
setState(prev => {
|
||||
// Avoid duplicates if any
|
||||
const newDevices = relevantDevices.filter(
|
||||
nd => !prev.discoveredDevices.some(ed => ed.id === nd.id)
|
||||
);
|
||||
newDevices.forEach(d => {
|
||||
d.connected = false;
|
||||
}); // Treat as disconnected until explicit connect
|
||||
return {
|
||||
...prev,
|
||||
discoveredDevices: [...prev.discoveredDevices, ...newDevices]
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to check connected devices", e);
|
||||
}
|
||||
await bleClient.startScan(
|
||||
// [BLE_UUIDS.SERVICE],
|
||||
null,
|
||||
{scanMode: ScanMode.LowLatency, allowDuplicates: false},
|
||||
(result) => {
|
||||
setState(prev => {
|
||||
const device = result.device as BleDevice;
|
||||
|
||||
// Filter only for service UUID
|
||||
if (device.name?.startsWith("707")) {
|
||||
// continue
|
||||
} else if (!device.serviceUUIDs) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (prev.discoveredDevices.find(d => d.id === device.id)) return prev;
|
||||
|
||||
device.connected = false;
|
||||
|
||||
console.debug(`Device found: ${device.name} (${device.id}), serviceUUIDs: ${device.serviceUUIDs}`);
|
||||
return {...prev, discoveredDevices: [...prev.discoveredDevices, device]};
|
||||
});
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(`Start scan failed: ${e.message}`);
|
||||
}
|
||||
}, [bleClient, requestBluetoothPermissions, setError]);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
bleClient.stopScan();
|
||||
setState(prev => ({...prev, isScanning: false}));
|
||||
console.log('Scan stopped');
|
||||
}, [bleClient]);
|
||||
|
||||
const connectToDevice = useCallback(async (device: BleDevice) => {
|
||||
try {
|
||||
stopScan();
|
||||
setState(prev => ({...prev, loading: {...prev.loading, connecting: true}}));
|
||||
console.log(`Connecting to ${device.name}...`);
|
||||
|
||||
const connectedDevice = await bleClient.connect(device.id) as BleDevice;
|
||||
connectedDevice.connected = true;
|
||||
|
||||
// Scan and log all services and characteristics
|
||||
try {
|
||||
const services = await connectedDevice.services();
|
||||
for (const service of services) {
|
||||
console.log(`[Service] ${service.uuid}`);
|
||||
const characteristics = await service.characteristics();
|
||||
for (const char of characteristics) {
|
||||
const props = [
|
||||
char.isReadable ? 'Read' : '',
|
||||
char.isWritableWithResponse ? 'Write' : '',
|
||||
char.isWritableWithoutResponse ? 'WriteNoResp' : '',
|
||||
char.isNotifiable ? 'Notify' : '',
|
||||
char.isIndicatable ? 'Indicate' : ''
|
||||
].filter(Boolean).join(',');
|
||||
console.log(` -> [Char] ${char.uuid} (${props})`);
|
||||
}
|
||||
}
|
||||
} catch (scanError) {
|
||||
console.error(`Error scanning services: ${scanError}`);
|
||||
}
|
||||
|
||||
await protocolService.initialize(device.id);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
connectedDevice,
|
||||
isConnected: true,
|
||||
loading: {...prev.loading, connecting: false}
|
||||
}));
|
||||
console.log('Connected and Protocol initialized');
|
||||
} catch (e: any) {
|
||||
setError(`Connection failed: ${e.message}`);
|
||||
setState(prev => ({...prev, loading: {...prev.loading, connecting: false}}));
|
||||
}
|
||||
}, [bleClient, protocolService, stopScan, setError]);
|
||||
|
||||
const disconnectDevice = useCallback(async () => {
|
||||
try {
|
||||
console.log('Disconnecting...');
|
||||
await bleClient.disconnect();
|
||||
protocolService.disconnect();
|
||||
} catch (e: any) {
|
||||
console.error(`Disconnect failed: ${e.message}`);
|
||||
}
|
||||
}, [bleClient, protocolService]);
|
||||
|
||||
const requestDeviceInfo = useCallback(async () => {
|
||||
if (!state.connectedDevice) return;
|
||||
try {
|
||||
console.log(`[${state.connectedDevice.id}] Requesting Device Info...`);
|
||||
await deviceInfoService.queryDeviceInfo(state.connectedDevice.id);
|
||||
console.log(`[${state.connectedDevice.id}] Device Info query request sent.`);
|
||||
} catch (e: any) {
|
||||
setError(`Request failed: ${e.message}`);
|
||||
}
|
||||
}, [deviceInfoService, state.connectedDevice, setError]);
|
||||
|
||||
const queryActivationStatus = useCallback(async () => {
|
||||
if (!state.connectedDevice) return;
|
||||
try {
|
||||
console.log('Querying Activation Status...');
|
||||
await deviceInfoService.queryActivationStatus(state.connectedDevice.id);
|
||||
} catch (e: any) {
|
||||
setError(`Query failed: ${e.message}`);
|
||||
}
|
||||
}, [deviceInfoService, state.connectedDevice, setError]);
|
||||
|
||||
const queryDeviceVersion = useCallback(async () => {
|
||||
if (!state.connectedDevice) return;
|
||||
try {
|
||||
console.log(`[${state.connectedDevice.id}] Requesting Device Version...`);
|
||||
await deviceInfoService.queryDeviceVersion(state.connectedDevice.id);
|
||||
console.log(`[${state.connectedDevice.id}] Device Version query request sent`);
|
||||
} catch (e: any) {
|
||||
setError(`Request failed: ${e.message}`);
|
||||
}
|
||||
}, [state.connectedDevice, deviceInfoService, setError])
|
||||
|
||||
const pickImage = async () => {
|
||||
// No permissions request is necessary for launching the image library.
|
||||
// Manually request permissions for videos on iOS when `allowsEditing` is set to `false`
|
||||
// and `videoExportPreset` is `'Passthrough'` (the default), ideally before launching the picker
|
||||
// so the app users aren't surprised by a system dialog after picking a video.
|
||||
// See "Invoke permissions for videos" sub section for more details.
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('Permission required', 'Permission to access the media library is required.');
|
||||
return [];
|
||||
}
|
||||
|
||||
let result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images', 'videos'],
|
||||
allowsEditing: false,
|
||||
aspect: [1, 1],
|
||||
quality: 1.0,
|
||||
});
|
||||
if (!result.canceled) {
|
||||
return result.assets;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const convertToANI = useCallback(async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise<File> => {
|
||||
const tempFile = new File(tempDir, `${media.fileName}.ani`)
|
||||
const formData = new FormData();
|
||||
formData.append('file', {
|
||||
uri: media.uri,
|
||||
name: media.fileName || 'video.mp4',
|
||||
type: media.mimeType || 'video/mp4',
|
||||
} as any);
|
||||
const response = await fetch("https://bowongai-test--ani-video-converter-fastapi-app.modal.run/api/convert/ani", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {'Accept': 'multipart/form-data',}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Conversion failed with status ${response.status}`);
|
||||
}
|
||||
const content = await response.arrayBuffer()
|
||||
tempFile.write(new Uint8Array(content));
|
||||
const tempFileInfo = tempFile.info()
|
||||
console.debug(`Converted video saved to ${tempFile.uri}, size : ${tempFileInfo.size} bytes`);
|
||||
return tempFile;
|
||||
}, []);
|
||||
|
||||
const transferSampleFile = useCallback(async () => {
|
||||
if (!state.connectedDevice) {
|
||||
setError('No device connected');
|
||||
return;
|
||||
}
|
||||
const tempDir = new Directory(Paths.cache, 'anis')
|
||||
if (!tempDir.exists) tempDir.create();
|
||||
const medias = await pickImage();
|
||||
if (medias.length === 0) return;
|
||||
console.debug(`[${state.connectedDevice.id}] processing ${medias.length} files...`);
|
||||
for (const media of medias) {
|
||||
if (media.type === 'video') {
|
||||
let tempFile: File;
|
||||
console.debug(`Converting video: ${media.fileName || 'video'}...`);
|
||||
tempFile = await convertToANI(tempDir, media)
|
||||
console.log(`Transferring converted file to device...`);
|
||||
await fileTransferService.transferFile(
|
||||
state.connectedDevice.id,
|
||||
tempFile.uri,
|
||||
COMMAND_TYPES.TRANSFER_ANI_VIDEO,
|
||||
(progress) => {
|
||||
setState(prev => ({...prev, transferProgress: progress * 100}));
|
||||
// Optional: throttle logs to avoid spam
|
||||
if (Math.round(progress * 100) % 10 === 0) {
|
||||
// addLog(`Transfer progress: ${Math.round(progress * 100)}%`);
|
||||
}
|
||||
}
|
||||
);
|
||||
tempFile.delete();
|
||||
console.log(`Transfer successful`);
|
||||
} else if (media.type === 'image') {
|
||||
try {
|
||||
console.debug(`Processing image: ${media.fileName || 'image'}...`);
|
||||
|
||||
// Check if it is a GIF
|
||||
const isGif = media.mimeType === 'image/gif' || media.uri.toLowerCase().endsWith('.gif');
|
||||
|
||||
if (isGif) {
|
||||
console.debug(`Converting GIF to ANI: ${media.fileName || 'gif'}...`);
|
||||
const tempFile = await convertToANI(tempDir, media)
|
||||
console.log(`Transferring converted file to device...`);
|
||||
await fileTransferService.transferFile(
|
||||
state.connectedDevice.id,
|
||||
tempFile.uri,
|
||||
COMMAND_TYPES.TRANSFER_ANI_VIDEO,
|
||||
(progress) => {
|
||||
setState(prev => ({...prev, transferProgress: progress * 100}));
|
||||
}
|
||||
);
|
||||
console.log(`Transfer successful`);
|
||||
tempFile.delete();
|
||||
console.log(`Cleaned up temp file`);
|
||||
return;
|
||||
}
|
||||
|
||||
let imageUri = media.uri;
|
||||
|
||||
// Check if conversion is needed (if not jpeg)
|
||||
// Note: mimeType might not always be reliable, so we can also check filename extension if needed,
|
||||
// or just always run manipulator to ensure consistency.
|
||||
// Here we check if it is NOT jpeg/jpg
|
||||
const isJpeg = media.mimeType === 'image/jpeg' || media.mimeType === 'image/jpg' || media.uri.toLowerCase().endsWith('.jpg') || media.uri.toLowerCase().endsWith('.jpeg');
|
||||
|
||||
if (!isJpeg) {
|
||||
console.debug(`Converting image to JPEG...`);
|
||||
const context = ImageManipulator.ImageManipulator.manipulate(media.uri);
|
||||
const imageRef = await context.renderAsync();
|
||||
const result = await imageRef.saveAsync({
|
||||
compress: 1,
|
||||
format: ImageManipulator.SaveFormat.JPEG,
|
||||
});
|
||||
imageUri = result.uri;
|
||||
console.debug(`Conversion successful: ${imageUri}`);
|
||||
}
|
||||
|
||||
console.log(`Transferring image to device...`);
|
||||
await fileTransferService.transferFile(
|
||||
state.connectedDevice.id,
|
||||
imageUri,
|
||||
COMMAND_TYPES.TRANSFER_JPEG_IMAGE, // Assuming PHOTO_ALBUM is for images, adjust if needed
|
||||
(progress) => {
|
||||
setState(prev => ({...prev, transferProgress: progress * 100}));
|
||||
}
|
||||
);
|
||||
|
||||
// If we created a temporary file via manipulation, we might want to clean it up?
|
||||
// ImageManipulator results are usually in cache, which OS cleans up, but we can't easily delete if we don't own it.
|
||||
// For this flow, we just leave it.
|
||||
|
||||
console.log(`Transfer successful`);
|
||||
} catch (e: any) {
|
||||
console.error(`Error: ${e.message}`);
|
||||
setError(e.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`Unsupported media type: ${media.type}`);
|
||||
}
|
||||
}
|
||||
}, [state.connectedDevice, fileTransferService, convertToANI, setError]);
|
||||
|
||||
const clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startScan,
|
||||
stopScan,
|
||||
connectToDevice,
|
||||
disconnectDevice,
|
||||
requestDeviceInfo,
|
||||
queryDeviceVersion,
|
||||
queryActivationStatus,
|
||||
transferSampleFile,
|
||||
clearLogs,
|
||||
updateActivationTime: () => {
|
||||
},
|
||||
sendIdentityCheck: () => {
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user