expo-ble模块测试demo
This commit is contained in:
493
hooks/useBleExplorer.ts
Normal file
493
hooks/useBleExplorer.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
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,
|
||||
DeviceInfoService,
|
||||
FileTransferService
|
||||
} from '../ble/index';
|
||||
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;
|
||||
logs: string[];
|
||||
discoveredDevices: BleDevice[];
|
||||
loading: {
|
||||
connecting: boolean;
|
||||
querying: boolean;
|
||||
transferring: boolean;
|
||||
};
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
|
||||
const MAX_LOGS = 100;
|
||||
|
||||
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,
|
||||
logs: [],
|
||||
discoveredDevices: [],
|
||||
loading: {
|
||||
connecting: false,
|
||||
querying: false,
|
||||
transferring: false,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const addLog = useCallback((message: string) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
console.log(message);
|
||||
setState(prev => {
|
||||
const newLogs = [...prev.logs, `[${timestamp}] ${message}`];
|
||||
const trimmedLogs = newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
|
||||
return {...prev, logs: trimmedLogs};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setError = useCallback(
|
||||
(error: string | null) => {
|
||||
setState(prev => ({...prev, error}));
|
||||
if (error) {
|
||||
addLog(`ERROR: ${error}`);
|
||||
}
|
||||
},
|
||||
[addLog]
|
||||
);
|
||||
|
||||
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') {
|
||||
addLog('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,
|
||||
};
|
||||
});
|
||||
addLog(`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}));
|
||||
addLog(`Device info received: ${info.devname}`);
|
||||
};
|
||||
|
||||
const onActivationStatus = (status: ActivationStatus) => {
|
||||
const activated = status.state === 1;
|
||||
setState(prev => ({...prev, isActivated: activated}));
|
||||
addLog(`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, addLog, setError]);
|
||||
|
||||
const startScan = useCallback(async () => {
|
||||
if (Platform.OS === 'web') return;
|
||||
try {
|
||||
setError(null);
|
||||
addLog('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) {
|
||||
addLog(`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;
|
||||
|
||||
addLog(`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, addLog, setError]);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
bleClient.stopScan();
|
||||
setState(prev => ({...prev, isScanning: false}));
|
||||
addLog('Scan stopped');
|
||||
}, [bleClient, addLog]);
|
||||
|
||||
const connectToDevice = useCallback(async (device: BleDevice) => {
|
||||
try {
|
||||
stopScan();
|
||||
setState(prev => ({...prev, loading: {...prev.loading, connecting: true}}));
|
||||
addLog(`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) {
|
||||
addLog(`[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(',');
|
||||
addLog(` -> [Char] ${char.uuid} (${props})`);
|
||||
}
|
||||
}
|
||||
} catch (scanError) {
|
||||
addLog(`Error scanning services: ${scanError}`);
|
||||
}
|
||||
|
||||
await protocolService.initialize(device.id);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
connectedDevice,
|
||||
isConnected: true,
|
||||
loading: {...prev.loading, connecting: false}
|
||||
}));
|
||||
addLog('Connected and Protocol initialized');
|
||||
} catch (e: any) {
|
||||
setError(`Connection failed: ${e.message}`);
|
||||
setState(prev => ({...prev, loading: {...prev.loading, connecting: false}}));
|
||||
}
|
||||
}, [bleClient, protocolService, stopScan, addLog, setError]);
|
||||
|
||||
const disconnectDevice = useCallback(async () => {
|
||||
try {
|
||||
addLog('Disconnecting...');
|
||||
await bleClient.disconnect();
|
||||
protocolService.disconnect();
|
||||
} catch (e: any) {
|
||||
addLog(`Disconnect failed: ${e.message}`);
|
||||
}
|
||||
}, [bleClient, protocolService, addLog]);
|
||||
|
||||
const requestDeviceInfo = useCallback(async () => {
|
||||
if (!state.connectedDevice) return;
|
||||
try {
|
||||
addLog(`[${state.connectedDevice.id}] Requesting Device Info...`);
|
||||
await deviceInfoService.queryDeviceInfo(state.connectedDevice.id);
|
||||
addLog(`[${state.connectedDevice.id}] Device Info query request sent.`);
|
||||
} catch (e: any) {
|
||||
setError(`Request failed: ${e.message}`);
|
||||
}
|
||||
}, [deviceInfoService, state.connectedDevice, addLog, setError]);
|
||||
|
||||
const queryActivationStatus = useCallback(async () => {
|
||||
if (!state.connectedDevice) return;
|
||||
try {
|
||||
addLog('Querying Activation Status...');
|
||||
await deviceInfoService.queryActivationStatus(state.connectedDevice.id);
|
||||
} catch (e: any) {
|
||||
setError(`Query failed: ${e.message}`);
|
||||
}
|
||||
}, [deviceInfoService, state.connectedDevice, addLog, setError]);
|
||||
|
||||
const queryDeviceVersion = useCallback(async () => {
|
||||
if (!state.connectedDevice) return;
|
||||
try {
|
||||
addLog(`[${state.connectedDevice.id}] Requesting Device Version...`);
|
||||
await deviceInfoService.queryDeviceVersion(state.connectedDevice.id);
|
||||
addLog(`[${state.connectedDevice.id}] Device Version query request sent`);
|
||||
} catch (e: any) {
|
||||
setError(`Request failed: ${e.message}`);
|
||||
}
|
||||
}, [state.connectedDevice, deviceInfoService, addLog, 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}`);
|
||||
}
|
||||
tempFile.write(await response.bytes());
|
||||
const tempFileInfo = tempFile.info()
|
||||
addLog(`Converted video saved to ${tempFile.uri}, size : ${tempFileInfo.size} bytes`);
|
||||
return tempFile;
|
||||
}, [addLog]);
|
||||
|
||||
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;
|
||||
addLog(`[${state.connectedDevice.id}] processing ${medias.length} files...`);
|
||||
for (const media of medias) {
|
||||
if (media.type === 'video') {
|
||||
let tempFile: File;
|
||||
addLog(`Converting video: ${media.fileName || 'video'}...`);
|
||||
tempFile = await convertToANI(tempDir, media)
|
||||
addLog(`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();
|
||||
addLog(`Transfer successful`);
|
||||
} else if (media.type === 'image') {
|
||||
try {
|
||||
addLog(`Processing image: ${media.fileName || 'image'}...`);
|
||||
|
||||
// Check if it is a GIF
|
||||
const isGif = media.mimeType === 'image/gif' || media.uri.toLowerCase().endsWith('.gif');
|
||||
|
||||
if (isGif) {
|
||||
addLog(`Converting GIF to ANI: ${media.fileName || 'gif'}...`);
|
||||
const tempFile = await convertToANI(tempDir, media)
|
||||
addLog(`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}));
|
||||
}
|
||||
);
|
||||
addLog(`Transfer successful`);
|
||||
tempFile.delete();
|
||||
addLog(`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) {
|
||||
addLog(`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;
|
||||
addLog(`Conversion successful: ${imageUri}`);
|
||||
}
|
||||
|
||||
addLog(`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.
|
||||
|
||||
addLog(`Transfer successful`);
|
||||
} catch (e: any) {
|
||||
addLog(`Error: ${e.message}`);
|
||||
setError(e.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`Unsupported media type: ${media.type}`);
|
||||
}
|
||||
}
|
||||
}, [state.connectedDevice, fileTransferService, convertToANI, setError, addLog]);
|
||||
|
||||
const clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startScan,
|
||||
stopScan,
|
||||
connectToDevice,
|
||||
disconnectDevice,
|
||||
requestDeviceInfo,
|
||||
queryDeviceVersion,
|
||||
queryActivationStatus,
|
||||
transferSampleFile,
|
||||
clearLogs,
|
||||
updateActivationTime: () => {
|
||||
},
|
||||
sendIdentityCheck: () => {
|
||||
},
|
||||
};
|
||||
};
|
||||
323
hooks/useBlePeripheral.ts
Normal file
323
hooks/useBlePeripheral.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform, PermissionsAndroid } from 'react-native';
|
||||
import { BLE_UUIDS as PROTOCOL_UUIDS } from '../ble/index';
|
||||
import { BlePeripheralManager, PeripheralEventCallback, DeviceInfo } from '@/ble/manager/BlePeripheralManager';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
interface PeripheralState {
|
||||
isAdvertising: boolean;
|
||||
connectedCentralCount: number;
|
||||
logs: string[];
|
||||
deviceInfo: DeviceInfo;
|
||||
loading: {
|
||||
advertising: boolean;
|
||||
};
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 100;
|
||||
|
||||
const DEFAULT_DEVICE_INFO: DeviceInfo = {
|
||||
devname: 'BLE Test Device',
|
||||
version: '2.1.1',
|
||||
activated: true,
|
||||
brand: 0,
|
||||
size: 1,
|
||||
allspace: 1024,
|
||||
freespace: 512,
|
||||
};
|
||||
|
||||
export const useBlePeripheral = () => {
|
||||
const [state, setState] = useState<PeripheralState>({
|
||||
isAdvertising: false,
|
||||
connectedCentralCount: 0,
|
||||
logs: [],
|
||||
deviceInfo: DEFAULT_DEVICE_INFO,
|
||||
loading: {
|
||||
advertising: false,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Ref to track peripheral manager
|
||||
const peripheralManagerRef = useRef<BlePeripheralManager>(BlePeripheralManager.getInstance());
|
||||
|
||||
// Ref to track advertising state for cleanup
|
||||
const isAdvertisingRef = useRef(false);
|
||||
|
||||
// Ref to track component mount state
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Add log message with limit
|
||||
const addLog = useCallback((message: string) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
setState(prev => {
|
||||
const newLogs = [...prev.logs, `[${timestamp}] ${message}`];
|
||||
const trimmedLogs = newLogs.length > MAX_LOGS
|
||||
? newLogs.slice(-MAX_LOGS)
|
||||
: newLogs;
|
||||
return { ...prev, logs: trimmedLogs };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Set error with user visibility
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState(prev => ({ ...prev, error }));
|
||||
if (error) {
|
||||
addLog(`ERROR: ${error}`);
|
||||
}
|
||||
}, [addLog]);
|
||||
|
||||
// Initialize peripheral mode
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
addLog('BLE peripheral mode not supported on web platform');
|
||||
return;
|
||||
}
|
||||
|
||||
addLog('BLE Peripheral mode initialized (Real BLE)');
|
||||
addLog('Service UUID: ' + PROTOCOL_UUIDS.SERVICE);
|
||||
addLog('Write Characteristic UUID: ' + PROTOCOL_UUIDS.WRITE_CHARACTERISTIC);
|
||||
addLog('Read Characteristic UUID: ' + PROTOCOL_UUIDS.READ_CHARACTERISTIC);
|
||||
|
||||
// Set up event callbacks
|
||||
const callbacks: PeripheralEventCallback = {
|
||||
onAdvertisingStateChange: (isAdvertising, error) => {
|
||||
if (isMountedRef.current) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAdvertising,
|
||||
loading: { ...prev.loading, advertising: false },
|
||||
error: error || null
|
||||
}));
|
||||
addLog(`Advertising ${isAdvertising ? 'started' : 'stopped'}${error ? ` with error: ${error}` : ''}`);
|
||||
}
|
||||
},
|
||||
onCentralConnected: (centralId) => {
|
||||
if (isMountedRef.current) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
connectedCentralCount: peripheralManagerRef.current.getConnectedCentralsCount()
|
||||
}));
|
||||
addLog(`Central device connected: ${centralId}`);
|
||||
}
|
||||
},
|
||||
onCentralDisconnected: (centralId) => {
|
||||
if (isMountedRef.current) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
connectedCentralCount: peripheralManagerRef.current.getConnectedCentralsCount()
|
||||
}));
|
||||
addLog(`Central device disconnected: ${centralId}`);
|
||||
}
|
||||
},
|
||||
onCharacteristicRead: (centralId, characteristicUuid, data) => {
|
||||
if (isMountedRef.current) {
|
||||
addLog(`Characteristic read by ${centralId}: ${characteristicUuid}`);
|
||||
}
|
||||
},
|
||||
onCharacteristicWrite: (centralId, characteristicUuid, data) => {
|
||||
if (isMountedRef.current) {
|
||||
const jsonString = Buffer.from(data).toString('utf-8');
|
||||
addLog(`Characteristic write by ${centralId}: ${characteristicUuid}`);
|
||||
addLog(`Data: ${jsonString}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
peripheralManagerRef.current.addCallback(callbacks);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (isAdvertisingRef.current) {
|
||||
stopAdvertising();
|
||||
}
|
||||
peripheralManagerRef.current.removeCallback(callbacks);
|
||||
};
|
||||
}, [addLog]);
|
||||
|
||||
// Check and request necessary permissions for Android 12+
|
||||
const checkPermissions = useCallback(async (): Promise<boolean> => {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true; // iOS handles permissions differently
|
||||
}
|
||||
|
||||
try {
|
||||
const apiLevel = Platform.Version;
|
||||
if (typeof apiLevel === 'number' && apiLevel >= 31) {
|
||||
// Android 12+ (API 31+) requires multiple Bluetooth permissions for peripheral mode
|
||||
const requiredPermissions = [
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE,
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
||||
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN
|
||||
];
|
||||
|
||||
for (const permission of requiredPermissions) {
|
||||
const granted = await PermissionsAndroid.check(permission);
|
||||
|
||||
if (!granted) {
|
||||
const result = await PermissionsAndroid.request(permission, {
|
||||
title: 'Bluetooth Permission Required',
|
||||
message: 'This app needs Bluetooth permissions to act as a BLE peripheral device.',
|
||||
buttonNeutral: 'Ask Me Later',
|
||||
buttonNegative: 'Cancel',
|
||||
buttonPositive: 'OK',
|
||||
});
|
||||
|
||||
if (result !== PermissionsAndroid.RESULTS.GRANTED) {
|
||||
setError(`${permission} is required for peripheral mode`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Permission check failed:', error);
|
||||
setError('Failed to check Bluetooth permissions');
|
||||
return false;
|
||||
}
|
||||
}, [setError]);
|
||||
|
||||
// Start advertising as peripheral (real BLE)
|
||||
const startAdvertising = useCallback(async () => {
|
||||
if (Platform.OS === 'web') {
|
||||
setError('BLE peripheral not supported on web');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAdvertisingRef.current) {
|
||||
addLog('Already advertising as peripheral');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: { ...prev.loading, advertising: true }
|
||||
}));
|
||||
|
||||
// Check permissions first
|
||||
const hasPermissions = await checkPermissions();
|
||||
if (!hasPermissions) {
|
||||
if (isMountedRef.current) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: { ...prev.loading, advertising: false }
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDeviceInfo = peripheralManagerRef.current.getDeviceInfo();
|
||||
addLog('Starting BLE advertising...');
|
||||
addLog('Device Name: ' + currentDeviceInfo.devname);
|
||||
addLog('Advertising with service UUID: ' + PROTOCOL_UUIDS.SERVICE);
|
||||
|
||||
// iOS platform limitation warning
|
||||
if (Platform.OS === 'ios') {
|
||||
addLog('⚠️ iOS: Advertising will stop when app goes to background');
|
||||
}
|
||||
peripheralManagerRef
|
||||
await peripheralManagerRef.current.startAdvertising(currentDeviceInfo.devname);
|
||||
isAdvertisingRef.current = true;
|
||||
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
const errorMsg = `Failed to start advertising: ${error}`;
|
||||
addLog(errorMsg);
|
||||
setError(errorMsg);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: { ...prev.loading, advertising: false }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [addLog, setError, checkPermissions]);
|
||||
|
||||
// Stop advertising
|
||||
const stopAdvertising = useCallback(async () => {
|
||||
if (!isAdvertisingRef.current) {
|
||||
addLog('Not currently advertising');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
addLog('Stopping BLE advertising...');
|
||||
|
||||
await peripheralManagerRef.current.stopAdvertising();
|
||||
isAdvertisingRef.current = false;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAdvertising: false,
|
||||
connectedCentralCount: 0
|
||||
}));
|
||||
|
||||
addLog('BLE advertising stopped');
|
||||
} catch (error) {
|
||||
addLog(`Failed to stop advertising: ${error}`);
|
||||
}
|
||||
}, [addLog]);
|
||||
|
||||
// Get characteristic read value (for testing/debugging: Shows what response will be sent when central reads this characteristic)
|
||||
const getCharacteristicReadValue = useCallback((characteristicUuid: string) => {
|
||||
if (!isAdvertisingRef.current) {
|
||||
addLog('Cannot get read value - not advertising');
|
||||
return null;
|
||||
}
|
||||
|
||||
addLog(`Getting characteristic read value: ${characteristicUuid}`);
|
||||
|
||||
if (characteristicUuid === PROTOCOL_UUIDS.READ_CHARACTERISTIC) {
|
||||
// Return device info as JSON - use manager to get latest data instead of stale state
|
||||
const response = {
|
||||
type: 0x02, // Device info response type
|
||||
data: peripheralManagerRef.current.getDeviceInfo()
|
||||
};
|
||||
addLog('Returning device info response');
|
||||
return JSON.stringify(response);
|
||||
}
|
||||
|
||||
addLog('Unknown characteristic for read');
|
||||
return null;
|
||||
}, [addLog]);
|
||||
|
||||
// Update device info
|
||||
const updateDeviceInfo = useCallback((updates: Partial<DeviceInfo>) => {
|
||||
peripheralManagerRef.current.updateDeviceInfo(updates);
|
||||
// Sync state with manager to avoid stale state issues
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
deviceInfo: peripheralManagerRef.current.getDeviceInfo()
|
||||
}));
|
||||
addLog('Device info updated');
|
||||
}, [addLog]);
|
||||
|
||||
// Reset device info
|
||||
const resetDeviceInfo = useCallback(() => {
|
||||
peripheralManagerRef.current.resetDeviceInfo();
|
||||
// Sync state with manager to avoid stale state issues
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
deviceInfo: peripheralManagerRef.current.getDeviceInfo()
|
||||
}));
|
||||
addLog('Device info reset to defaults');
|
||||
}, [addLog]);
|
||||
|
||||
// Clear logs
|
||||
const clearLogs = useCallback(() => {
|
||||
setState(prev => ({ ...prev, logs: [] }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startAdvertising,
|
||||
stopAdvertising,
|
||||
getCharacteristicReadValue,
|
||||
updateDeviceInfo,
|
||||
resetDeviceInfo,
|
||||
clearLogs,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user