expo-ble-app-demo/ble/hooks/useBleExplorer.ts

492 lines
20 KiB
TypeScript

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 transferEmptyTestPackage = useCallback(async () => {
if (!state.connectedDevice) {
setError('No device connected');
return;
}
await fileTransferService.transferTestPackage(
state.connectedDevice.id,
(progress) => {
setState(prev => ({...prev, transferProgress: progress * 100}));
}
)
}, [fileTransferService, setError, state.connectedDevice])
const clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []);
return {
...state,
startScan,
stopScan,
connectToDevice,
disconnectDevice,
requestDeviceInfo,
queryDeviceVersion,
queryActivationStatus,
transferSampleFile,
transferEmptyTestPackage,
clearLogs,
updateActivationTime: () => {
},
sendIdentityCheck: () => {
},
};
};