Домашнее IoT-устройство глазами JS-разработчика

от автора

В один момент мы задумались с товарищем, а почему бы не попробовать сделать свое домашнее IoT-устройство? Недолго думая, мы остановились на концепции устройства, которое позволяет отслеживать незваных гостей и оповещать хозяина. Как это можно сделать и что для этого требуется?

Через какое-то время стало ясно, что для нашей задачи должен подойти Raspberry pi в сопровождении датчика движения и камеры. На него напишем драйвер, повесим несколько различных сервисов на удаленном сервере, сделаем мобильное приложение и цель будет достигнута. Звучит вполне неплохо, самое время пробовать.

Для начала мы заказали:

  • сам Raspberry

  • модуль камеры

  • модуль детектора движения с ИК-пироэлектрическим датчиком

  • соединительные провода

В заказе отсутствовал блок питания — в качестве замены полностью подойдет зарядное устройство от мобильного телефона 5V/1A. В результате получилось такого вида устройство:

Архитектура IoT-системы

Следующий шагом была спроектирована архитектура:

Для нашего устройства был необходим драйвер, который отслеживал бы сигнал с датчика движения, запускал камеру, собирал всю полученную информацию и отправлял дальше. В результате мы применили комплексное решение с использованием библиотек на Java и Python.

Отправленная информация поступала на вход к «гвоздю»‎(на текущем этапе не было особой необходимости в «гвозде»‎, так как трафик с одного устройства не загрузил бы базу, но мы решили добавить его сразу на будущее). Основная задача «гвоздя»‎ — управление трафиком и постепенная запись в БД (на Postgres) событий. «Гвоздь»‎ был реализован на Java.

Далее к БД обращались 3 сервиса:

  • Rest API (Java) предоставлял всю необходимую информацию для клиента

  • Auth (Node.JS) — сервис авторизации

  • Notification (Node.JS) — сервис для push-уведомлений

И, собственно, само мобильное приложение. В качестве инструмента был выбран React Native.

Мы распределили с товарищем обязанности: так как я являюсь JS-разработчиком, я взял на себя реализацию мобильного приложения, Auth и Notification сервисов. Далее в статье рассмотрим подробнее реализацию этих элементов. Описание остальных деталей будет в отдельном материале (Ссылка на будущее на отдельную статью).

Auth service

Сервис авторизации реализован на основе JWT-токена. Он включает в себя функциональность регистрации и аутентификации пользователей.

Роутинг сервиса выглядит следующим образом:

const router = require('express').Router(); const {loggedIn, adminOnly} = require("../helpers/auth.middleware"); const userController = require('../controllers/user.controller');  // Регистрация нового пользователя router.post('/register', userController.register);  // Логин router.post('/login', userController.login);  // Проверка на авторизацию для сторонних сервисов router.get('/auth', loggedIn, (req, res) => res.send(true));  // Только для админа router.get('/adminonly', loggedIn, adminOnly, userController.adminonly);  module.exports = router;

При регистрации генерируется хэш-пароль с использованием bcryptjs и отправляется дальше в БД.

exports.register = async (req, res) => {          // Генерируем хэш     const salt = await bcrypt.genSalt(10);     const hasPassword = await bcrypt.hash(req.body.password, salt);      // Создаем экземпляр юзера      const user = new User({         mobile: req.body.mobile,         email: req.body.email,         username: req.body.username,         password: hasPassword,         status: req.body.status || 1     });     // Сохраняем пользователя в БД     try {         const id = await User.create(user);         user.id = id;         delete user.password;         res.send(user);     }     catch (err){         res.status(500).send({error: err.message});     } };

В итоге имеем такие записи:

Для самой авторизации использовался пакет jsonwebtoken:

exports.login = async (req, res) => {     try {         // Проверяем существует ли пользователь         const user = await User.login(req.body.username);         if (user) {             const validPass = await bcrypt.compare(req.body.password, user.password);             if (!validPass) return res.status(400).send({error: "Password is wrong"});              // Создаем и устанавливаем токен             const token = jwt.sign({id: user.id, user_type_id: user.user_type_id}, config.TOKEN_SECRET,{ expiresIn: config.EXPIRATION});             res.header("auth-token", token).send({"token": token, user: user.username});         }     }     catch (err) {         if( err instanceof NotFoundError ) {             res.status(401).send({error: err.message});         }         else {             const error_data = {                 entity: 'User',                 model_obj: {param: req.params, body: req.body},                 error_obj: err,                 error_msg: err.message             };             res.status(500).send(error_data);         }     }         };

Для сторонних сервисов был реализован отдельный метод проверки токена:

exports.loggedIn = function (req, res, next) {     let token = req.header('Authorization');     if (!token) return res.status(401).send("Access Denied");      try {     	// Выцепляем токен из заголовка         if (token.startsWith('Bearer ')) {             token = token.slice(7, token.length).trimLeft();         }         // Проверяем на валидность, что токен активен         const verified = jwt.verify(token, config.TOKEN_SECRET);         req.user = verified;         next();     }     catch (err) {         res.status(400).send("Invalid Token");     } }

Мобильное приложение

Требования к приложению достаточно простые:

  1. экран авторизации

  2. экран с устройствами (возможность добавлять, удалять, смотреть информацию)

  3. экран со списком событий (просмотренные/непросмотренные)

  4. возможность смотреть детальную информацию по каждому из них, включая видео

До этого момента у меня практически не было опыта в мобильной разработке. Так как большую часть времени я занимаюсь front-end разработкой на различных фреймворках, в частности на React, то выбор пал сразу на React Native. Осталось только определиться, использовать ли Expo. Рассмотрим основные «‎за»‎ и «‎против»‎:

Плюсы использования Expo:

  1. Настройка проекта проста и может быть выполнена за считанные минуты;

  2. Общий доступ к приложению очень прост (через QR-код или ссылку) — вам не нужно отправлять весь файл .apk или .ipa;

  3. Интегрирует некоторые базовые библиотеки в стандартный проект (Push-уведомления, Asset Manager,…).

Минусы:

  1. Нельзя добавить собственные модули, написанные на Java / Objective-C;

  2. Из-за большого количества интегрированных библиотек, вес приложения увеличивается.

Взвесив все «‎за»‎ и «‎против», понял, что с Expo процесс разработки пройдет заметно быстрее, это было самым главным на тот момент. Так оно по итогу и оказалось. Но если рассматривать дальнейшие перспективы, различные доработки, то становится понятно, что все может быть не так радужно. В случае использования нативных модулей пришлось бы делать detach, который, по опыту многих знакомых, работает криво. К счастью, мне с головой хватило того, что возможно делать с Expo.

Создав пустой проект и открыв его, сразу стало понятно, что больших различий с проектами на React я не вижу. А это хорошо!

В качестве state-менеджера выбрал MobX — мне нравится концепция observable и с его использованием не нужно писать много кода.

Для HTTP запросов я всегда обращаюсь к axios, но в этот раз решил использовать superagent для разнообразия. В итоге, все запросы были разбиты на сущности:

import superagentPromise from 'superagent-promise'; import _superagent from 'superagent'; import Auth from './auth'; import Alarms from './alarms'; import Notification from './notification'; import Devices from './devices'; import commonStore from "../store/commonStore"; import authStore from "../store/authStore"; import getEnvVars from "../environment";  const superagent = superagentPromise(_superagent, global.Promise);  const {apiRoot: API_ROOT} = getEnvVars();  const handleErrors = (err: any) => {     if (err && err.response && err.response.status === 401) {         authStore.logout();     }     return err; };  const responseBody = (res: any) => res.body;  //Добавление токена к запросу const tokenPlugin = (req: any) => {     if (commonStore.token) {         req.set('authorization', `Token ${commonStore.token}`);     } };  export interface RequestsAgent {     del: (url: string) => any;     get: (url: string) => any;     put: (url: string, body: object) => any;     post: (url: string, body: object, root?: string) => any; }  const requests: RequestsAgent = {     del: (url: string) =>         superagent             .del(`${API_ROOT}${url}`)             .use(tokenPlugin)             .end(handleErrors)             .then(responseBody),     get: (url: string) =>         superagent             .get(`${API_ROOT}${url}`)             .use(tokenPlugin)             .end(handleErrors)             .then(responseBody),     put: (url: string, body: object) =>         superagent             .put(`${API_ROOT}${url}`, body)             .use(tokenPlugin)             .end(handleErrors)             .then(responseBody),     post: (url: string, body: object, root?: string) =>         superagent             .post(`${root ? root : API_ROOT}${url}`, body)             .use(tokenPlugin)             .end(handleErrors)             .then(responseBody), };  export default {     Auth: Auth(requests),     Alarms: Alarms(requests),     Notification: Notification(requests),     Devices: Devices(requests) };

Пример api из auth.ts:

import {RequestsAgent} from "./index"; import getEnvVars from "../environment"; const {apiAuth} = getEnvVars();   export default (requests: RequestsAgent) => {     return {         login: (username: string, password: string) =>             requests.post('/api/users/login', {username, password}, apiAuth),         register: (username: string, email: string, password: string) =>             requests.post('/api/users/register', { user: { username, email, password } }),     }; }

Далее к ним можно обратиться из необходимых мест. Пример из authStore:

    @action     register(): any {         this.inProgress = true;         this.errors = null;         return agent.Auth.register(this.values.username, this.values.email, this.values.password)             .then(({ user }) => commonStore.setToken(user.token))             .then(() => userStore.pullUser())             .catch(action((err) => {                 this.errors = err.response && err.response.body && err.response.body.errors;                 throw err;             }))             .finally(action(() => { this.inProgress = false; }));     }

К слову для хранения информации на клиенте, в случае с React Native, мы не можем обратиться к LocalStorage, для этого есть AsyncStorage. Туда я положил token для авторизации. Работа с AsyncStorage выглядит привычным образом за исключением того, что операции асинхронные:

const token = await AsyncStorage.getItem('token');

При генерации пустого приложения Expo добавляется дефолтный роутинг и создается структура с BottomTabNavigator. Мне этот вариант отлично подошел — осталось только корректно прописать роутинги для нужных экранов:

const BottomTab = createBottomTabNavigator<BottomTabParamList>();  export default function BottomTabNavigator() {     const colorScheme = useColorScheme();      return (         <BottomTab.Navigator             tabBarOptions={{activeTintColor: Colors[colorScheme].tint}}>             <BottomTab.Screen                 name="Устройства"                 component={DeviceNavigator}                 options={{                     tabBarIcon: ({color}) => <TabBarIcon name="calculator-outline" color={color}></TabBarIcon>,                 }}             />             <BottomTab.Screen                 name="События"                 component={AlarmsNavigator}                 options={{                     tabBarIcon: ({color}) => <NotificationBadge color={color}/>,                 }}             />         </BottomTab.Navigator>     ); }

И для примера — сам DeviceNavigator:

const TabThreeStack = createStackNavigator<TabThreeParamList>();  function DeviceNavigator() {     const navigation = useNavigation();     const {colors} = useTheme();     return (         <TabThreeStack.Navigator>             <TabThreeStack.Screen                 name="DeviceScreen"                 component={DevicesScreen}                 options={{                     headerTitle: 'Устройства',                     headerRight: () => <Ionicons color={colors.primary} onPress={() => navigation.navigate('DeviceScreenAdd')} name={"add-circle-outline"}/>                 }}             />             <TabThreeStack.Screen                 name="AddDeviceScreen"                 component={AddDeviceScreen}                 options={{                     headerTitle: 'Добавить устройство'                 }}             />             <TabThreeStack.Screen                 name="DeviceInfoScreen"                 component={DeviceInfoScreen}                 options={{                     headerTitle: 'Информация о устройстве'                 }}             />         </TabThreeStack.Navigator>     ); }

Далее началась реализации самих экранов и привычная разработка для react-разработчика со своими тонкостями. По итогу получили такие экраны:

Для воспроизведения видео использовался пакет expo-video-player. Вставляем в необходимое место сам видеоплеер, в uri прокидываем ссылку на стрим видео. Важно, чтобы на сервере корректно была настроена работа с Content-range. В итоге получили:

Notification service

Для push-уведомлений создаем отдельный сервис. Наши push уведомления происходят после добавления нового события в БД. Для этого вешаем слушатель:

    client.query('LISTEN new_alarm_event');      client.on('notification', async (data) => {         writeToAll(data.payload)     });

Во время данного события говорим expo сгенерировать уведомление через функцию:

const writeToAll = async msg => {     const tokensArray = Array.from(tokensSet);      if (tokensArray.length > 0) {         const messages = tokensArray.map(token => ({             to: token,             sound: 'default',             body: msg,             data: { msg },         })) 				// Группируем сообщения, чтобы отправить все разом         let chunks = expo.chunkPushNotifications(messages);          (async () => {             for (let chunk of chunks) {                 try {                 		// Отправляем пакет в службу уведомлений Expo                     const receipts = await expo.sendPushNotificationsAsync(chunk);                     console.log(receipts);                 } catch (error) {                     console.error(error);                 }             }         })();     }     else {         console.log(`cant write, ${tokensArray.length} users`)     }      return tokensArray.length }

Также не забываем зарегистрировать устройство в самом мобильном приложении:

 const registerForPushNotifications = async () => {     const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);     if (status !== 'granted') {         alert('No notification permissions!');         return;     } 		// получаем токен для мобильного устройства     let token = await Notifications.getExpoPushTokenAsync(); 		// отправляем на регистрацию в наш notification service     await sendPushNotification(token); }  export default registerForPushNotifications;

Заключение

В течение небольшого промежутка времени была реализована IoT-система. Устройство полностью справляется с поставленной на текущий момент задачей. В дальнейших планах — добавление отслеживания хозяина устройства, чтобы не детектировать лишнее событие входа (на основе телефона хозяина).

Оценивая проделанную работу, мне приятно осознавать, что в текущий момент знание JS позволяет заниматься не только frontend разработкой, но и брать на себя задачи, связанные с backend, мобильной и десктопной разработкой. Это расширяет кругозор и дает новые возможности.

На сегодня все.

Всем добра!

ссылка на оригинал статьи https://habr.com/ru/company/megafon/blog/540334/