diff --git a/apps/backend/src/devices/devices.controller.ts b/apps/backend/src/devices/devices.controller.ts index 888dafc..d1a6572 100644 --- a/apps/backend/src/devices/devices.controller.ts +++ b/apps/backend/src/devices/devices.controller.ts @@ -16,6 +16,7 @@ import { } from './dto/update-device.dto'; import { User } from 'src/decorators/user.decorator'; import { Public } from 'src/decorators/public'; +import { UpdateDeviceSettingsDto } from './dto/update-device-settings.dto'; @Controller('devices') export class DevicesController { constructor(private readonly devicesService: DevicesService) {} @@ -44,6 +45,19 @@ export class DevicesController { return this.devicesService.updateStatus(deviceToken, updateDeviceStatusDto); } + @Patch(':id/settings') + updateSettings( + @Param('id') id: string, + @User() user: any, + @Body() updateDeviceSettingsDto: UpdateDeviceSettingsDto, + ) { + return this.devicesService.updateSettings( + id, + user, + updateDeviceSettingsDto, + ); + } + @Patch(':id') update( @Param('id') id: string, diff --git a/apps/backend/src/devices/devices.service.ts b/apps/backend/src/devices/devices.service.ts index 099dc49..480f4e4 100644 --- a/apps/backend/src/devices/devices.service.ts +++ b/apps/backend/src/devices/devices.service.ts @@ -8,6 +8,7 @@ import { Device } from './entities/device.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as crypto from 'crypto'; +import { UpdateDeviceSettingsDto } from './dto/update-device-settings.dto'; @Injectable() export class DevicesService { @@ -74,6 +75,30 @@ export class DevicesService { return updatedDevice; } + async updateSettings( + id: string, + user: any, + updateDeviceSettingsDto: UpdateDeviceSettingsDto, + ): Promise { + const device = await this.devicesRepository.findOneBy({ + user: { id: user.id }, + deviceId: id, + }); + + if (!device) { + throw new HttpException('Device does not exist', HttpStatus.NOT_FOUND); + } + + const updatedDeviceSettings = { + ...device, + ...updateDeviceSettingsDto, + }; + + await this.devicesRepository.save(updatedDeviceSettings); + + return updatedDeviceSettings; + } + async updateStatus( deviceToken: string, updateDeviceStatusDto: UpdateDeviceStatusDto, diff --git a/apps/backend/src/devices/dto/create-device-settings.dto.ts b/apps/backend/src/devices/dto/create-device-settings.dto.ts new file mode 100644 index 0000000..67f2485 --- /dev/null +++ b/apps/backend/src/devices/dto/create-device-settings.dto.ts @@ -0,0 +1,5 @@ +export class CreateDeviceSettingsDto { + samplingIntervalSec: number; + temperatureThreshold: number; + pm25Threshold: number; +} diff --git a/apps/backend/src/devices/dto/update-device-settings.dto.ts b/apps/backend/src/devices/dto/update-device-settings.dto.ts new file mode 100644 index 0000000..fd2c102 --- /dev/null +++ b/apps/backend/src/devices/dto/update-device-settings.dto.ts @@ -0,0 +1,6 @@ +import { CreateDeviceSettingsDto } from './create-device-settings.dto'; +import { PartialType } from '@nestjs/mapped-types'; + +export class UpdateDeviceSettingsDto extends PartialType( + CreateDeviceSettingsDto, +) {} diff --git a/apps/backend/src/devices/entities/device.entity.ts b/apps/backend/src/devices/entities/device.entity.ts index 610c45d..1be3b50 100644 --- a/apps/backend/src/devices/entities/device.entity.ts +++ b/apps/backend/src/devices/entities/device.entity.ts @@ -27,4 +27,10 @@ export class Device { user: User; @Column({ unique: true }) deviceToken: string; + @Column({ type: 'int', default: 300 }) + samplingIntervalSec: number; + @Column({ nullable: true, type: 'decimal' }) + temperatureThreshold: number; + @Column({ nullable: true, type: 'decimal' }) + pm25Threshold: number; } diff --git a/apps/mobile/src/api/authService.ts b/apps/mobile/src/api/authService.ts index 3d337ff..cdb1a67 100644 --- a/apps/mobile/src/api/authService.ts +++ b/apps/mobile/src/api/authService.ts @@ -8,7 +8,7 @@ export const login = async (email: string, password: string) => { }); return response.data; } catch (error) { - console.log(error); + throw error; } }; diff --git a/apps/mobile/src/api/axios.ts b/apps/mobile/src/api/axios.ts index 2ab7f4c..f11ffb7 100644 --- a/apps/mobile/src/api/axios.ts +++ b/apps/mobile/src/api/axios.ts @@ -2,7 +2,7 @@ import axios from "axios"; import { authStore } from "../stores/authStore"; export const instance = axios.create({ - baseURL: "http://192.168.1.74:3000/", + baseURL: "http://192.168.5.105:3000/", timeout: 10000, }); diff --git a/apps/mobile/src/api/deviceService.ts b/apps/mobile/src/api/deviceService.ts new file mode 100644 index 0000000..4408788 --- /dev/null +++ b/apps/mobile/src/api/deviceService.ts @@ -0,0 +1,17 @@ +import { instance } from "./axios"; + +export const patchDeviceSettings = async ( + deviceId: string, + samplingInterval: number, +) => { + const samplingIntervalSec = samplingInterval * 60; + + try { + const response = await instance.patch(`devices/${deviceId}/settings`, { + samplingIntervalSec: samplingIntervalSec, + }); + return response.data; + } catch (error) { + console.log(error); + } +}; diff --git a/apps/mobile/src/screens/dashboard.tsx b/apps/mobile/src/screens/dashboard.tsx index 5330036..1419001 100644 --- a/apps/mobile/src/screens/dashboard.tsx +++ b/apps/mobile/src/screens/dashboard.tsx @@ -32,7 +32,9 @@ const RangeItem = ({ name, onPress, isSelected }: RangeProps) => ( ]} > - {name} + + {name} + ); @@ -209,7 +211,7 @@ export function DashboardScreen() { - Dashboard Screen + Air Quality Dashboard {rangeList.map((r) => ( ))} - - Download CSV + + Download CSV + {!readings && Data could not be loaded} {sensorList.map((s) => ( ({ ...prev, - pressure_hpa: pressValue / 100, + pressure_hpa: pressValue, })); }, ); diff --git a/apps/mobile/src/screens/login.tsx b/apps/mobile/src/screens/login.tsx index 93da57c..a9e4411 100644 --- a/apps/mobile/src/screens/login.tsx +++ b/apps/mobile/src/screens/login.tsx @@ -1,4 +1,4 @@ -import { View, Text, TextInput, StyleSheet, Button } from "react-native"; +import { View, Text, TextInput, StyleSheet, Button, Alert } from "react-native"; import React from "react"; import { login } from "../api/authService"; import { authStore } from "../stores/authStore"; @@ -16,14 +16,26 @@ export function LoginScreen() { const navigation = useNavigation(); const onClick = async () => { - const response = await login(email, password); + try { + const response = await login(email, password); - if (response) { - const { accessToken, refreshToken } = response; + if (response) { + const { accessToken, refreshToken } = response; - authStore.getState().login(accessToken, refreshToken); - save("accessToken", accessToken); - save("refreshToken", refreshToken); + authStore.getState().login(accessToken, refreshToken); + save("accessToken", accessToken); + save("refreshToken", refreshToken); + } + } catch (error) { + if (error.response) { + if (error.response.status == 401) { + Alert.alert("Error", "Authentication error"); + } else { + Alert.alert("Error", "Something went wrong"); + } + } else { + Alert.alert("Error", "Network error"); + } } }; diff --git a/apps/mobile/src/screens/settings.tsx b/apps/mobile/src/screens/settings.tsx index d7e99e0..e4cc6f0 100644 --- a/apps/mobile/src/screens/settings.tsx +++ b/apps/mobile/src/screens/settings.tsx @@ -1,5 +1,10 @@ -import { View, Text, Button } from "react-native"; +import { View, Text, Button, TextInput, Alert, StyleSheet } from "react-native"; import { authStore } from "../stores/authStore"; +import { useState } from "react"; +import { patchDeviceSettings } from "../api/deviceService"; +import { manager } from "../ble/bleManager"; +import { deviceStore } from "../stores/deviceStore"; +import { Buffer } from "buffer"; import * as SecureStore from "expo-secure-store"; @@ -10,6 +15,68 @@ export function SettingsScreen() { SecureStore.deleteItemAsync("refreshToken"); }; + const onUpdateSamplingInterval = async () => { + const intervalInMin = parseInt(samplingInterval, 10); + + const intervalInSec = intervalInMin * 60; + + const intervalInMs = intervalInSec * 1000; + + const deviceId = deviceStore.getState().deviceId; + + if ( + intervalInMin < 1 || + intervalInMin > 30 || + Number.isNaN(intervalInMin) + ) { + Alert.alert( + "Out of range", + "The number must be between 1 and 30 minutes", + ); + return; + } + + const buf = Buffer.alloc(4); + + buf.writeUint32LE(intervalInMs, 0); + + const base64Data = buf.toString("base64"); + + patchDeviceSettings(deviceId, intervalInSec); + + try { + const services = await manager.servicesForDevice(deviceId); + const essService = services.find((s) => s.uuid.includes("181a")); + if (!essService) return; + + const characteristics = await manager.characteristicsForDevice( + deviceId, + essService.uuid, + ); + + manager.writeCharacteristicWithResponseForDevice( + deviceId, + essService.uuid, + characteristics[6].uuid, + base64Data, + ); + } catch (error) { + Alert.alert( + "Problem saving settings", + "Retry connecting to your device and try again", + ); + } + }; + + const [samplingInterval, setSamplingInterval] = useState(""); + + const handleChangeText = (inputText: string) => { + const cleanNumber = inputText.replace(/[^0-9]/g, ""); + + setSamplingInterval(cleanNumber); + }; + console.log(samplingInterval); + return ( Settings Screen + + + Set sampling interval + + + + + + ); } + +const styles = StyleSheet.create({ + input: { + height: 40, + margin: 12, + borderWidth: 1, + padding: 10, + }, +}); diff --git a/apps/mobile/src/stores/authStore.ts b/apps/mobile/src/stores/authStore.ts index fe1e5a9..e876787 100644 --- a/apps/mobile/src/stores/authStore.ts +++ b/apps/mobile/src/stores/authStore.ts @@ -25,9 +25,12 @@ export const authStore = create((set) => ({ const deviceId = deviceStore.getState().deviceId; if (deviceId) { - await manager.cancelDeviceConnection(deviceId); - - console.log("Device disconnected"); + try { + await manager.cancelDeviceConnection(deviceId); + console.log("Device disconnected"); + } catch (error) { + console.error("Problem disconnecting device ", error); + } } set({ diff --git a/firmware/src/main.c b/firmware/src/main.c index 1a5b436..c70f799 100644 --- a/firmware/src/main.c +++ b/firmware/src/main.c @@ -12,7 +12,6 @@ #define RETRY_DELAY_MS 10000 #define WARM_UP_INTERVAL_MS 10000 -#define WAKE_UP_INTERVAL 10000 #define LED0_NODE DT_ALIAS(led0) #define I2C_NODE DT_NODELABEL(bme680) @@ -27,6 +26,8 @@ static const struct device *const dev_i2c = DEVICE_DT_GET(I2C_NODE); static const pmsa003i_config_t pmsa003i_config = { .i2c = I2C_DT_SPEC_GET(I2C_PMSA003I_NODE)}; +static uint32_t wake_up_interval = 300000; // Default wake-up interval in milliseconds + static struct sensor_value temp; static struct sensor_value hum; static struct sensor_value press; @@ -65,6 +66,12 @@ struct bt_conn_cb connection_callbacks = { }; +static void on_reading_interval_changed(const uint32_t reading_interval_ms) +{ + wake_up_interval = reading_interval_ms; + LOG_INF("Wake-up interval updated to %u ms", wake_up_interval); +} + int main(void) { int ret; @@ -91,6 +98,17 @@ int main(void) return -1; } + struct sensor_settings_cb settings_callback = { + .reading_interval_cb = on_reading_interval_changed, + }; + + ret = settings_callback_init(&settings_callback); + if (ret) + { + LOG_ERR("Failed to read settings callback (err %d)", ret); + return -1; + } + LOG_INF("Advertising started %d\n", ret); if (!device_is_ready(dev_i2c)) @@ -203,7 +221,7 @@ int main(void) full_reading.temperature_c = temp.val1 * 100 + temp.val2 / 10000; full_reading.humidity_pct = hum.val1 * 100 + hum.val2 / 10000; - full_reading.pressure_hpa = press.val1 * 100 + press.val2 / 10000; + full_reading.pressure_hpa = press.val1 * 10 + press.val2 / 100000; full_reading.pm1_0_ugm3 = pmsa003i_data_raw.pm1_0; full_reading.pm2_5_ugm3 = pmsa003i_data_raw.pm2_5; full_reading.pm10_ugm3 = pmsa003i_data_raw.pm10_0; @@ -225,6 +243,6 @@ int main(void) gpio_pin_toggle_dt(&led); // bt_le_adv_stop(); - k_sleep(K_MSEC(WAKE_UP_INTERVAL)); + k_sleep(K_MSEC(wake_up_interval)); } } diff --git a/firmware/src/sensor_service.c b/firmware/src/sensor_service.c index 677baf8..95585b4 100644 --- a/firmware/src/sensor_service.c +++ b/firmware/src/sensor_service.c @@ -6,7 +6,11 @@ LOG_MODULE_REGISTER(sensor_service, LOG_LEVEL_DBG); +#define MIN_SAMPLING_INTERVAL_MS (1U * 60U * 1000U) +#define MAX_SAMPLING_INTERVAL_MS (30U * 60U * 1000U) + static bool notify_sensor_enabled; +static struct sensor_settings_cb settings_cb; static void airnode_ccc_sensor_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value) @@ -14,6 +18,45 @@ static void airnode_ccc_sensor_cfg_changed(const struct bt_gatt_attr *attr, notify_sensor_enabled = (value == BT_GATT_CCC_NOTIFY); } +static ssize_t write_ble_reading_interval(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, + uint16_t len, uint16_t offset, uint8_t flags) +{ + LOG_DBG("Attribute write, handle: %u, conn: %p", attr->handle, (void *)conn); + + if (len != sizeof(uint32_t)) + { + LOG_DBG("Sampling Interval: Incorrect data length"); + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + + if (offset != 0) + { + LOG_DBG("Sampling Interval: Incorrect data offset"); + return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); + } + + if (settings_cb.reading_interval_cb) + { + // Read the received value + uint32_t interval_ms = *((uint32_t *)buf); + + if (interval_ms < MIN_SAMPLING_INTERVAL_MS || interval_ms > MAX_SAMPLING_INTERVAL_MS) + { + LOG_DBG("Sampling Interval: Value out of bounds"); + return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED); + } + + // Call the application callback function to update the reading interval + settings_cb.reading_interval_cb(interval_ms); + } + else + { + LOG_DBG("Sampling Interval: Incorrect value"); + return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED); + } + return len; +} + BT_GATT_SERVICE_DEFINE(airnode_service, BT_GATT_PRIMARY_SERVICE(BT_UUID_ESS), @@ -45,7 +88,21 @@ BT_GATT_SERVICE_DEFINE(airnode_service, BT_GATT_CHARACTERISTIC(BT_UUID_GATT_PM10CONC, BT_GATT_CHRC_NOTIFY, BT_GATT_PERM_READ, NULL, NULL, NULL), BT_GATT_CCC(airnode_ccc_sensor_cfg_changed, - BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), ); + BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), + + BT_GATT_CHARACTERISTIC(BT_UUID_SENSOR_SETTINGS, BT_GATT_CHRC_WRITE, BT_GATT_PERM_WRITE, NULL, write_ble_reading_interval, NULL), + +); + +int settings_callback_init(struct sensor_settings_cb *callbacks) +{ + if (callbacks) + { + settings_cb.reading_interval_cb = callbacks->reading_interval_cb; + } + + return 0; +} int send_sensor_notify(struct airnode_readings sensor_value, SensorDataType type) { diff --git a/firmware/src/sensor_service.h b/firmware/src/sensor_service.h index 3db66b7..167063a 100644 --- a/firmware/src/sensor_service.h +++ b/firmware/src/sensor_service.h @@ -23,19 +23,22 @@ typedef enum PM10 } SensorDataType; -/** Initial Service UUID - 4cff14aa-fca7-4da8-89d7-952ac08b3085 */ -#define BT_UUID_SENSOR_SERVICE_VAL \ - BT_UUID_128_ENCODE(0x4cff14aa, 0xfca7, 0x4da8, 0x89d7, 0x952ac08b3085) +typedef void (*reading_interval_cb_t)(const uint32_t reading_interval_ms); -/** Value Characteristic UUID - 8123e770-73b5-4b07-aa24-99f776d1e37a */ -#define BT_UUID_SENSOR_DATA_VAL \ - BT_UUID_128_ENCODE(0x8123e770, 0x73b5, 0x4b07, 0xaa24, 0x99f776d1e37a) +struct sensor_settings_cb +{ + reading_interval_cb_t reading_interval_cb; +}; + +/** Value Characteristic UUID - d87f823c-4c33-4ddd-9ac4-4ada6ad5e913 */ +#define BT_UUID_SENSOR_SETTINGS_VAL \ + BT_UUID_128_ENCODE(0xd87f823c, 0x4c33, 0x4ddd, 0x9ac4, 0x4ada6ad5e913) /** Convert the array to a generic UUID */ -#define BT_UUID_SENSOR BT_UUID_DECLARE_128(BT_UUID_SENSOR_SERVICE_VAL) -#define BT_UUID_SENSOR_DATA BT_UUID_DECLARE_128(BT_UUID_SENSOR_DATA_VAL) +#define BT_UUID_SENSOR_SETTINGS BT_UUID_DECLARE_128(BT_UUID_SENSOR_SETTINGS_VAL) int send_sensor_notify(struct airnode_readings sensor_value, SensorDataType type); +int settings_callback_init(struct sensor_settings_cb *callbacks); #endif // SENSOR_SERVICE_H \ No newline at end of file