Bluetooth Gateway (#11)
Add bluetooth gateway functionality and documentation Co-authored-by: Luke Baker <lubaker@microsoft.com> Co-authored-by: Vincenzo Caruso <vicaruso@microsoft.com>
This commit is contained in:
Родитель
2d336cbb02
Коммит
64b7d5593d
|
@ -18,6 +18,7 @@ The main features of the app are:
|
|||
- Sample properties (readonly and writeable).
|
||||
- Commands handling to enable/disable telemetry items and set their sending interval.
|
||||
- Commands logs to trace data in app.
|
||||
- Bluetooth Gateway (see [Bluetooth.md](./docs/Bluetooth.md) for documentation/implementation details)
|
||||
|
||||
## Build and Run
|
||||
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# Bluetooth Gateway
|
||||
|
||||
## What is this?
|
||||
|
||||
The Bluetooth feature of the Azure IoT PaaD application allows you to read data from Bluetooth Low Energy
|
||||
(BLE) devices around your phone and send it as telemetry to your Azure IoT Central application or an Azure IoT Hub.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Once connected to Azure IoT, select the Bluetooth tab from the bottom tab bar.
|
||||
1. The app will start scanning for BLE devices nearby. Once they are found, they will show in the list.
|
||||
1. Tapping on a device will bring you to a detail view where you can see the data read from the device.
|
||||
Every time this data updates, it will be sent as a telemetry message.
|
||||
- For most devices, the only data read will be the RSSI (signal strength). See below to learn how to read specific data from your own BLE sensor by implementing the provided interface for your device.
|
||||
|
||||
## Implementing devices
|
||||
|
||||
Note: Currently, the PaaD application supports reading data from BLE advertisements only. It does not support the BLE connection mechanism, nor reading/writing data to/from BLE characteristics or services.
|
||||
|
||||
### Implementation structure
|
||||
|
||||
- All relevant code can be found in [`src/bluetooth`](../src/bluetooth/)
|
||||
- [`BleDevice.ts`](../src/bluetooth/devices/BleDevice.ts) contains the interface that must be implemented in order to read specific data from a given device's BLE advertisement. Refer to the inline documentation for the purpose of each method.
|
||||
- [`Govee5074.ts`](../src/bluetooth/devices/Govee5074.ts) contains an example implementation of the [Govee H5074 sensor](https://www.amazon.com/Govee-Hygrometer-Thermometer-Bluetooth-Notification/dp/B09BHSLWBL)
|
||||
- [`GenericDevice.ts`](../src/bluetooth/devices/GenericDevice.ts) is a generic device implementation that reads the device's signal strength only.
|
||||
- Once your device model is implemented, it needs to be added to the `DeviceModels` set in [`BleManger.ts`](../src/bluetooth/BleManager.ts).
|
|
@ -41,6 +41,8 @@
|
|||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>"IoT Plug and Play" wants to access to bluetooth to collect data from external sensors</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>"IoT Plug and Play" needs access to your camera for scanning QR codes and connect devices</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
|
|
|
@ -74,6 +74,7 @@ PODS:
|
|||
- fmt (6.2.1)
|
||||
- glog (0.3.5)
|
||||
- libevent (2.1.12)
|
||||
- MultiplatformBleAdapter (0.1.9)
|
||||
- OpenSSL-Universal (1.1.1100)
|
||||
- Permission-Camera (3.3.1):
|
||||
- RNPermissions
|
||||
|
@ -284,6 +285,9 @@ PODS:
|
|||
- React-jsinspector (0.68.0)
|
||||
- React-logger (0.68.0):
|
||||
- glog
|
||||
- react-native-ble-plx (2.0.3):
|
||||
- MultiplatformBleAdapter (= 0.1.9)
|
||||
- React-Core
|
||||
- react-native-camera (4.2.1):
|
||||
- React-Core
|
||||
- react-native-camera/RCT (= 4.2.1)
|
||||
|
@ -439,6 +443,7 @@ DEPENDENCIES:
|
|||
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
|
||||
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
|
||||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||
- react-native-ble-plx (from `../node_modules/react-native-ble-plx`)
|
||||
- react-native-camera (from `../node_modules/react-native-camera`)
|
||||
- "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)"
|
||||
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
|
||||
|
@ -484,6 +489,7 @@ SPEC REPOS:
|
|||
- FlipperKit
|
||||
- fmt
|
||||
- libevent
|
||||
- MultiplatformBleAdapter
|
||||
- OpenSSL-Universal
|
||||
- SocketRocket
|
||||
- YogaKit
|
||||
|
@ -527,6 +533,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native/ReactCommon/jsinspector"
|
||||
React-logger:
|
||||
:path: "../node_modules/react-native/ReactCommon/logger"
|
||||
react-native-ble-plx:
|
||||
:path: "../node_modules/react-native-ble-plx"
|
||||
react-native-camera:
|
||||
:path: "../node_modules/react-native-camera"
|
||||
react-native-geolocation:
|
||||
|
@ -606,6 +614,7 @@ SPEC CHECKSUMS:
|
|||
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
|
||||
glog: 476ee3e89abb49e07f822b48323c51c57124b572
|
||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||
MultiplatformBleAdapter: 5a6a897b006764392f9cef785e4360f54fb9477d
|
||||
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
|
||||
Permission-Camera: bae27a8503530770c35aadfecbb97ec71823382a
|
||||
RCT-Folly: 4d8508a426467c48885f1151029bc15fa5d7b3b8
|
||||
|
@ -621,6 +630,7 @@ SPEC CHECKSUMS:
|
|||
React-jsiexecutor: 010a66edf644339f6da72b34208b070089680415
|
||||
React-jsinspector: 90f0bfd5d04e0b066c29216a110ffb9a6c34f23f
|
||||
React-logger: 8474fefa09d05f573a13c044cb0dfd751d4e52e3
|
||||
react-native-ble-plx: f10240444452dfb2d2a13a0e4f58d7783e92d76e
|
||||
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
|
||||
react-native-geolocation: c956aeb136625c23e0dce0467664af2c437888c9
|
||||
react-native-get-random-values: 30b3f74ca34e30e2e480de48e4add2706a40ac8f
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -28,6 +28,7 @@
|
|||
"react-native": "0.68.0",
|
||||
"react-native-animatable": "^1.3.3",
|
||||
"react-native-azure-iotcentral-client": "^1.1.10",
|
||||
"react-native-ble-plx": "^2.0.3",
|
||||
"react-native-camera": "^4.2.1",
|
||||
"react-native-charts-wrapper": "^0.5.7",
|
||||
"react-native-circular-progress": "^1.3.7",
|
||||
|
@ -52,20 +53,20 @@
|
|||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@react-native-community/eslint-config": "^2.0.0",
|
||||
"@react-native-community/eslint-config": "^3.1.0",
|
||||
"@types/react-native": "^0.67.4",
|
||||
"@types/react-native-charts-wrapper": "^0.5.2",
|
||||
"@types/react-native-torch": "^1.1.0",
|
||||
"@types/react-native-version-check": "^3.4.4",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint": "^8.26.0",
|
||||
"jest": "^26.6.3",
|
||||
"metro-react-native-babel-preset": "^0.67.0",
|
||||
"prettier": "^2.6.2",
|
||||
"react-native-svg-transformer": "^1.0.0",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"typescript": "^4.6.3"
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "react-native"
|
||||
|
|
50
src/App.tsx
50
src/App.tsx
|
@ -1,19 +1,21 @@
|
|||
/* eslint-disable react/no-unstable-nested-components */
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import React, {useState, useEffect, useContext, useMemo} from 'react';
|
||||
import {View, Platform, ViewStyle, TextStyle} from 'react-native';
|
||||
import React, {useState, useEffect, useContext} from 'react';
|
||||
import {View, Platform, StyleSheet} from 'react-native';
|
||||
import Settings from './Settings';
|
||||
import {
|
||||
NavigationContainer,
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
getFocusedRouteNameFromRoute,
|
||||
} from '@react-navigation/native';
|
||||
import {
|
||||
NavigationParams,
|
||||
Pages,
|
||||
NavigationPages,
|
||||
Literal,
|
||||
Screens,
|
||||
// ChartType,
|
||||
} from 'types';
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context';
|
||||
|
@ -49,6 +51,7 @@ const Stack = createStackNavigator<NavigationPages>();
|
|||
|
||||
export default function App() {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<SafeAreaProvider>
|
||||
|
@ -101,17 +104,24 @@ const Navigation = React.memo(() => {
|
|||
) {
|
||||
return {
|
||||
...defaultOptions,
|
||||
headerShown:
|
||||
getFocusedRouteNameFromRoute(route) !== Screens.BLUETOOTH_STACK,
|
||||
headerTitle: () => (
|
||||
<Text style={{
|
||||
...styles.logoText,
|
||||
color: colors.text,
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
...styles.logoText,
|
||||
color: colors.text,
|
||||
}}>
|
||||
{Strings.Title}
|
||||
</Text>
|
||||
),
|
||||
headerTitleAlign: 'left',
|
||||
headerLeft: () => <Logo />,
|
||||
headerRight: () => <Profile navigate={navigation.navigate} />,
|
||||
headerRight: () => (
|
||||
<View style={styles.headerButtons}>
|
||||
<Profile navigate={navigation.navigate} />
|
||||
</View>
|
||||
),
|
||||
};
|
||||
}
|
||||
return defaultOptions;
|
||||
|
@ -228,21 +238,21 @@ const Navigation = React.memo(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const Logo = React.memo(function Logo() {
|
||||
export const Logo = React.memo(function Logo() {
|
||||
const {colors, dark} = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.logoContainer}>
|
||||
{dark ? (
|
||||
<LogoDark width={30} fill={colors.primary} />
|
||||
) : (
|
||||
<LogoLight width={30} fill={colors.primary} />
|
||||
)}
|
||||
{dark ? (
|
||||
<LogoDark width={30} fill={colors.primary} />
|
||||
) : (
|
||||
<LogoLight width={30} fill={colors.primary} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const Profile = React.memo((props: {navigate: any}) => {
|
||||
export const Profile = React.memo((props: {navigate: any}) => {
|
||||
const {colors} = useTheme();
|
||||
return (
|
||||
<View style={styles.marginHorizontal10}>
|
||||
|
@ -264,7 +274,7 @@ const Profile = React.memo((props: {navigate: any}) => {
|
|||
);
|
||||
});
|
||||
|
||||
const styles: Literal<ViewStyle | TextStyle> = {
|
||||
export const styles = StyleSheet.create({
|
||||
logoContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
@ -282,4 +292,10 @@ const styles: Literal<ViewStyle | TextStyle> = {
|
|||
marginEnd20: {
|
||||
marginEnd: 20,
|
||||
},
|
||||
};
|
||||
marginEnd10: {
|
||||
marginEnd: 10,
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
|
20
src/Home.tsx
20
src/Home.tsx
|
@ -45,6 +45,7 @@ import {
|
|||
import {DEFAULT_DELIVERY_INTERVAL} from './sensors';
|
||||
import {Icon} from '@rneui/themed';
|
||||
import {playTorch} from 'tools/Torch';
|
||||
import {BluetoothPage} from 'bluetooth/Bluetooth';
|
||||
|
||||
const Tab = createBottomTabNavigator<NavigationScreens>();
|
||||
|
||||
|
@ -125,6 +126,10 @@ const Root = React.memo<{
|
|||
type: 'material-community',
|
||||
},
|
||||
}) as IIcon,
|
||||
[Screens.BLUETOOTH_STACK]: {
|
||||
name: 'bluetooth',
|
||||
type: 'material-community',
|
||||
},
|
||||
});
|
||||
|
||||
const icons = iconsRef.current;
|
||||
|
@ -367,6 +372,21 @@ const Root = React.memo<{
|
|||
/>
|
||||
)}
|
||||
</Tab.Screen>
|
||||
|
||||
<Tab.Screen
|
||||
name={Screens.BLUETOOTH_STACK}
|
||||
component={BluetoothPage}
|
||||
options={{
|
||||
tabBarIcon: ({color, size}) => (
|
||||
<TabBarIcon
|
||||
icon={icons[Screens.BLUETOOTH_STACK]}
|
||||
color={color}
|
||||
size={size}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name={Screens.FILE_UPLOAD_SCREEN}
|
||||
component={FileUpload}
|
||||
|
|
|
@ -104,9 +104,12 @@ export const Registration = React.memo<{
|
|||
}, [parentNavigator, route]);
|
||||
|
||||
useEffect(() => {
|
||||
parentNavigator?.addListener('beforeRemove', () => {
|
||||
const listener = () => {
|
||||
setRegisteringNew(false);
|
||||
});
|
||||
};
|
||||
parentNavigator?.addListener('beforeRemove', listener);
|
||||
|
||||
return () => parentNavigator?.removeListener('beforeRemove', listener);
|
||||
}, [parentNavigator, setRegisteringNew]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import {BleManager, BleManagerOptions, Device} from 'react-native-ble-plx';
|
||||
import {BleDeviceModel} from './devices/BleDevice';
|
||||
import {GenericDeviceModel} from './devices/GenericDevice';
|
||||
import {Govee5074Model} from './devices/Govee5074';
|
||||
|
||||
const DeviceModels = new Set<BleDeviceModel>([Govee5074Model]);
|
||||
|
||||
export class IotcBleManager extends BleManager {
|
||||
protected constructor(options?: BleManagerOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
private static instance: IotcBleManager;
|
||||
private resetDeviceListCallback: () => void = () => {};
|
||||
|
||||
public static getInstance(): IotcBleManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new IotcBleManager();
|
||||
}
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
public getModelForDevice(device: Device): BleDeviceModel {
|
||||
for (const model of DeviceModels.values()) {
|
||||
if (model.matches(device)) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
return GenericDeviceModel;
|
||||
}
|
||||
|
||||
public resetDeviceList(): void {
|
||||
this.resetDeviceListCallback?.();
|
||||
}
|
||||
|
||||
public setResetDeviceListCallback(callback: () => void) {
|
||||
this.resetDeviceListCallback = callback;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,354 @@
|
|||
/* eslint-disable react/no-unstable-nested-components */
|
||||
import {createStackNavigator, StackScreenProps} from '@react-navigation/stack';
|
||||
import {Icon, ListItem} from '@rneui/themed';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
PermissionsAndroid,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import {Device, State, UUID} from 'react-native-ble-plx';
|
||||
import {IotcBleManager} from './BleManager';
|
||||
import {ItemProps, Pages} from 'types';
|
||||
import {Loader, Text} from '../components';
|
||||
import {useIoTCentralClient, useTheme} from '../hooks';
|
||||
import CardView from 'CardView';
|
||||
import {Logo, Profile, styles as appStyles} from 'App';
|
||||
import Strings from 'strings';
|
||||
|
||||
type BluetoothStackParamList = {
|
||||
[Pages.BLUETOOTH_LIST]: undefined;
|
||||
[Pages.BLUETOOTH_DETAIL]: {
|
||||
deviceId: UUID;
|
||||
deviceName: string;
|
||||
};
|
||||
};
|
||||
|
||||
const BluetoothStack = createStackNavigator<BluetoothStackParamList>();
|
||||
|
||||
export function BluetoothPage() {
|
||||
const {colors} = useTheme();
|
||||
|
||||
return (
|
||||
<BluetoothStack.Navigator
|
||||
initialRouteName={Pages.BLUETOOTH_LIST}
|
||||
screenOptions={({navigation, route}) => {
|
||||
const isListPage: boolean = route.name === Pages.BLUETOOTH_LIST;
|
||||
|
||||
return {
|
||||
headerTitle: () => (
|
||||
<Text
|
||||
style={{
|
||||
...appStyles.logoText,
|
||||
color: colors.text,
|
||||
}}>
|
||||
{isListPage ? Strings.Title : route.params?.deviceName ?? ''}
|
||||
</Text>
|
||||
),
|
||||
headerTitleAlign: 'left',
|
||||
headerLeft: isListPage ? () => <Logo /> : undefined,
|
||||
headerBackTitleVisible: false,
|
||||
headerRight: () => (
|
||||
<View style={appStyles.headerButtons}>
|
||||
{isListPage && <ReloadButton />}
|
||||
<Profile navigate={navigation.navigate} />
|
||||
</View>
|
||||
),
|
||||
};
|
||||
}}>
|
||||
<BluetoothStack.Screen
|
||||
name={Pages.BLUETOOTH_LIST}
|
||||
component={BluetoothList}
|
||||
/>
|
||||
<BluetoothStack.Screen
|
||||
name={Pages.BLUETOOTH_DETAIL}
|
||||
component={BluetoothDetail}
|
||||
/>
|
||||
</BluetoothStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
type BluetoothListProps = StackScreenProps<
|
||||
BluetoothStackParamList,
|
||||
typeof Pages.BLUETOOTH_LIST
|
||||
>;
|
||||
|
||||
function BluetoothList({navigation}: BluetoothListProps) {
|
||||
const {colors} = useTheme();
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
const {devices} = useBluetoothDevicesList(isVisible);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribeFocus = navigation.addListener('focus', () => {
|
||||
setIsVisible(true);
|
||||
});
|
||||
const unsubscribeBlur = navigation.addListener('blur', () => {
|
||||
setIsVisible(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus();
|
||||
unsubscribeBlur();
|
||||
};
|
||||
}, [navigation, setIsVisible]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList<Device>
|
||||
data={devices}
|
||||
renderItem={({item}) => (
|
||||
<BluetoothDeviceListItem
|
||||
item={item}
|
||||
colors={colors}
|
||||
navigation={navigation}
|
||||
/>
|
||||
)}
|
||||
onRefresh={() => IotcBleManager.getInstance().resetDeviceList()}
|
||||
refreshing={devices.length === 0}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={devices.length === 0}
|
||||
onRefresh={() => IotcBleManager.getInstance().resetDeviceList()}
|
||||
colors={[colors.text]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface BluetoothDeviceListItemProps {
|
||||
item: Device;
|
||||
colors: ReturnType<typeof useTheme>['colors'];
|
||||
navigation: BluetoothListProps['navigation'];
|
||||
}
|
||||
|
||||
function BluetoothDeviceListItem({
|
||||
item,
|
||||
colors,
|
||||
navigation,
|
||||
}: BluetoothDeviceListItemProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={_e => {
|
||||
navigation.navigate(Pages.BLUETOOTH_DETAIL, {
|
||||
deviceId: item.id,
|
||||
deviceName: item.name ?? '',
|
||||
});
|
||||
}}>
|
||||
<ListItem bottomDivider containerStyle={{backgroundColor: colors.card}}>
|
||||
<ListItem.Content
|
||||
style={{
|
||||
...styles.item,
|
||||
backgroundColor: colors.card,
|
||||
}}>
|
||||
<ListItem.Title style={{...styles.itemTitle, color: colors.text}}>
|
||||
{item.name}
|
||||
</ListItem.Title>
|
||||
|
||||
<ListItem.Subtitle
|
||||
style={{...styles.subtitleContainer, color: colors.text}}>
|
||||
<View style={styles.subtitleContent}>
|
||||
<Icon
|
||||
name="signal"
|
||||
type="material-community"
|
||||
color={colors.text}
|
||||
/>
|
||||
<Text style={styles.rssiText}>{item.rssi} dBm</Text>
|
||||
</View>
|
||||
</ListItem.Subtitle>
|
||||
</ListItem.Content>
|
||||
</ListItem>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function useBluetoothDevicesList(shouldScan: boolean) {
|
||||
const [devices, setDevices] = React.useState<Device[]>([]);
|
||||
const deviceMap = React.useRef<Map<UUID, Device> | null>(null);
|
||||
|
||||
if (deviceMap.current === null) {
|
||||
deviceMap.current = new Map();
|
||||
}
|
||||
|
||||
const bleManager = IotcBleManager.getInstance();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function scan() {
|
||||
if (!shouldScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||
{
|
||||
title: 'Bluetooth Permission',
|
||||
message:
|
||||
'Application would like to use bluetooth and location permissions',
|
||||
buttonNeutral: 'Ask Me Later',
|
||||
buttonNegative: 'Cancel',
|
||||
buttonPositive: 'OK',
|
||||
},
|
||||
);
|
||||
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
|
||||
throw new Error('Permission rejected');
|
||||
}
|
||||
}
|
||||
|
||||
const sub = bleManager.onStateChange(s => {
|
||||
if (s === State.PoweredOn) {
|
||||
sub.remove();
|
||||
|
||||
bleManager.startDeviceScan(null, {scanMode: 2}, (e, device) => {
|
||||
if (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (!device?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
deviceMap.current?.set(device.id, device);
|
||||
setDevices(Array.from(deviceMap.current?.values() ?? []));
|
||||
});
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
bleManager.setResetDeviceListCallback(() => {
|
||||
deviceMap.current?.clear();
|
||||
setDevices([]);
|
||||
});
|
||||
|
||||
scan().catch(console.error);
|
||||
}, [bleManager, setDevices, shouldScan]);
|
||||
|
||||
return {devices};
|
||||
}
|
||||
|
||||
type BluetoothDetailProps = StackScreenProps<
|
||||
BluetoothStackParamList,
|
||||
typeof Pages.BLUETOOTH_DETAIL
|
||||
>;
|
||||
|
||||
function BluetoothDetail({
|
||||
route: {
|
||||
params: {deviceId, deviceName},
|
||||
},
|
||||
}: BluetoothDetailProps) {
|
||||
const [items, setData] = React.useState<ItemProps[] | null>(() => null);
|
||||
const [iotcentralClient] = useIoTCentralClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
const bleManager = IotcBleManager.getInstance();
|
||||
|
||||
bleManager.startDeviceScan(null, {scanMode: 2}, (error, device) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (device?.id !== deviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = bleManager.getModelForDevice(device);
|
||||
|
||||
const deviceData = model.onScan(device);
|
||||
if (!deviceData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemProps = model.getItemProps(deviceData);
|
||||
|
||||
iotcentralClient?.sendTelemetry(deviceData);
|
||||
iotcentralClient?.sendProperty({bleDeviceName: device.name});
|
||||
|
||||
setData(
|
||||
itemProps.map(item => ({
|
||||
...item,
|
||||
sendInterval(_value) {},
|
||||
enable(_value) {},
|
||||
})),
|
||||
);
|
||||
});
|
||||
}, [deviceId, iotcentralClient]);
|
||||
|
||||
if (!(deviceName && items)) {
|
||||
return (
|
||||
<View style={styles.listLoaderContainer}>
|
||||
<Loader visible message="Scanning for device" style={styles.loader} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardView items={items} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReloadButton() {
|
||||
const {colors} = useTheme();
|
||||
return (
|
||||
<View>
|
||||
<Icon
|
||||
style={styles.marginEnd10}
|
||||
name="reload"
|
||||
type={Platform.select({ios: 'ionicon', android: 'material-community'})}
|
||||
color={colors.text}
|
||||
onPress={() => {
|
||||
IotcBleManager.getInstance().resetDeviceList();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 0,
|
||||
},
|
||||
item: {
|
||||
fontSize: 18,
|
||||
height: 60,
|
||||
},
|
||||
itemTitle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
marginEnd10: {
|
||||
marginEnd: 10,
|
||||
},
|
||||
deviceName: {
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
subtitleContainer: {
|
||||
marginTop: 10,
|
||||
},
|
||||
subtitleContent: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginTop: 0,
|
||||
},
|
||||
rssiText: {
|
||||
marginStart: 5,
|
||||
},
|
||||
listLoaderContainer: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loader: {
|
||||
width: '75%',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
import {Device} from 'react-native-ble-plx';
|
||||
import {ItemProps} from 'types';
|
||||
|
||||
export interface BleDeviceModel<DataType = any> {
|
||||
/**
|
||||
* Return true if the given device matches this model, false if not
|
||||
* `onScan` will only be called for this device if it matches according to this function
|
||||
* Often this will be based on the name of the device but it can also be based on the
|
||||
* bytes of the advertisement packet as well.
|
||||
*/
|
||||
matches(device: Device): boolean;
|
||||
/**
|
||||
* Process the data from the device advertisement and return an array of `ItemProps` that will
|
||||
* be used to render the telemetry in the detail view.
|
||||
*
|
||||
* Return `null` if the scan is invalid for your purposes and should be ignored.
|
||||
* (Some devices use multiple advertisement formats and not all of them contain sensor data)
|
||||
*/
|
||||
onScan(device: Device): DataType | null;
|
||||
/**
|
||||
* Map the data read in `onScan` into `itemProps` so it can be displayed in the app's UI
|
||||
*/
|
||||
getItemProps(data: DataType): DeviceItemProps[];
|
||||
}
|
||||
|
||||
export type DeviceItemProps = Omit<ItemProps, 'enable' | 'sendInterval'>;
|
|
@ -0,0 +1,30 @@
|
|||
import {Device} from 'react-native-ble-plx';
|
||||
import {BleDeviceModel, DeviceItemProps} from './BleDevice';
|
||||
|
||||
interface GenericDeviceData {
|
||||
rssi: number;
|
||||
}
|
||||
|
||||
export const GenericDeviceModel: BleDeviceModel<GenericDeviceData> = {
|
||||
matches(_device: Device): boolean {
|
||||
return true;
|
||||
},
|
||||
onScan(device: Device) {
|
||||
return {
|
||||
rssi: device.rssi ?? 0,
|
||||
};
|
||||
},
|
||||
getItemProps: function (data: any): DeviceItemProps[] {
|
||||
return [
|
||||
{
|
||||
id: 'rssi',
|
||||
name: 'RSSI',
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
dataType: 'number',
|
||||
value: data.rssi,
|
||||
unit: 'dBm',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
|
@ -0,0 +1,85 @@
|
|||
import {Device} from 'react-native-ble-plx';
|
||||
import {BleDeviceModel, DeviceItemProps} from './BleDevice';
|
||||
import {Buffer} from 'buffer';
|
||||
|
||||
interface Govee5074Data {
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
battery: number;
|
||||
rssi?: number;
|
||||
}
|
||||
|
||||
export const Govee5074Model: BleDeviceModel<Govee5074Data> = {
|
||||
matches(device: Device): boolean {
|
||||
return device.name?.startsWith('Govee_H5074') ?? false;
|
||||
},
|
||||
onScan(device: Device): Govee5074Data | null {
|
||||
if (!device.manufacturerData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buf = Buffer.from(device.manufacturerData, 'base64');
|
||||
if (buf.toString('ascii').includes('INTELLI_ROCKS')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 88 ec 00 14 09 3b 0e 64 02
|
||||
// 0 1 2 [3 4] [5 6] [7] 8
|
||||
// ^ ^ ^
|
||||
// temp hum batt
|
||||
const temperature = buf.readInt16LE(3) / 100;
|
||||
const humidity = buf.readInt16LE(5) / 100;
|
||||
const battery = buf.readUint8(7);
|
||||
|
||||
const data: Govee5074Data = {
|
||||
temperature,
|
||||
humidity,
|
||||
battery,
|
||||
rssi: device.rssi ?? undefined,
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
getItemProps: function (data: Govee5074Data): DeviceItemProps[] {
|
||||
const props: DeviceItemProps[] = [
|
||||
data.temperature && {
|
||||
id: 'temp',
|
||||
name: 'Temperature',
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
dataType: 'number',
|
||||
value: data.temperature,
|
||||
unit: '°C',
|
||||
},
|
||||
data.humidity && {
|
||||
id: 'humidity',
|
||||
name: 'Humidity',
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
dataType: 'number',
|
||||
value: data.humidity,
|
||||
unit: '%',
|
||||
},
|
||||
{
|
||||
id: 'rssi',
|
||||
name: 'RSSI',
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
dataType: 'number',
|
||||
value: data.rssi,
|
||||
unit: 'dBm',
|
||||
},
|
||||
data.battery && {
|
||||
id: 'battery',
|
||||
name: 'Battery',
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
dataType: 'number',
|
||||
value: data.battery,
|
||||
unit: '%',
|
||||
},
|
||||
].filter(Boolean) as DeviceItemProps[];
|
||||
|
||||
return props;
|
||||
},
|
||||
};
|
|
@ -16,6 +16,7 @@ export const Screens = {
|
|||
LOGS_SCREEN: 'Logs',
|
||||
HEALTH_SCREEN: 'Health',
|
||||
FILE_UPLOAD_SCREEN: 'Image Upload',
|
||||
BLUETOOTH_STACK: 'Bluetooth',
|
||||
} as const;
|
||||
|
||||
export const Pages = {
|
||||
|
@ -25,6 +26,8 @@ export const Pages = {
|
|||
INTERVAL: 'Interval',
|
||||
THEME: 'Theme',
|
||||
SETTINGS: 'Settings',
|
||||
BLUETOOTH_LIST: 'Bluetooth List',
|
||||
BLUETOOTH_DETAIL: 'Bluetooth Detail',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
Загрузка…
Ссылка в новой задаче