[JavaScript, Node.JS, ReactJS] Домашнее IoT-устройство глазами JS-разработчика

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
01-Фев-2021 18:31

В один момент мы задумались с товарищем, а почему бы не попробовать сделать свое домашнее 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, мобильной и десктопной разработкой. Это расширяет кругозор и дает новые возможности.На сегодня все.Всем добра!
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_node.js, #_reactjs, #_javascript, #_iot, #_expo, #_nodejs, #_raspberry, #_react, #_blog_kompanii_megafon (
Блог компании МегаФон
)
, #_javascript, #_node.js, #_reactjs
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 14:37
Часовой пояс: UTC + 5