В один момент мы задумались с товарищем, а почему бы не попробовать сделать свое домашнее 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"); } }
Мобильное приложение
Требования к приложению достаточно простые:
-
экран авторизации
-
экран с устройствами (возможность добавлять, удалять, смотреть информацию)
-
экран со списком событий (просмотренные/непросмотренные)
-
возможность смотреть детальную информацию по каждому из них, включая видео
До этого момента у меня практически не было опыта в мобильной разработке. Так как большую часть времени я занимаюсь front-end разработкой на различных фреймворках, в частности на React, то выбор пал сразу на React Native. Осталось только определиться, использовать ли Expo. Рассмотрим основные «за» и «против»:
Плюсы использования Expo:
-
Настройка проекта проста и может быть выполнена за считанные минуты;
-
Общий доступ к приложению очень прост (через QR-код или ссылку) — вам не нужно отправлять весь файл .apk или .ipa;
-
Интегрирует некоторые базовые библиотеки в стандартный проект (Push-уведомления, Asset Manager,…).
Минусы:
-
Нельзя добавить собственные модули, написанные на Java / Objective-C;
-
Из-за большого количества интегрированных библиотек, вес приложения увеличивается.
Взвесив все «за» и «против», понял, что с 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/
Добавить комментарий