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:
Lucas Baker 2022-12-01 11:35:23 -08:00 коммит произвёл GitHub
Родитель 2d336cbb02
Коммит 64b7d5593d
16 изменённых файлов: 2000 добавлений и 622 удалений

Просмотреть файл

@ -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"

26
docs/Bluetooth.md Normal file
Просмотреть файл

@ -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

1957
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -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"

Просмотреть файл

@ -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',
},
});

Просмотреть файл

@ -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;
}
}

354
src/bluetooth/Bluetooth.tsx Normal file
Просмотреть файл

@ -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;
/**