Открыть список
Как стать автором
Обновить
70,49
Рейтинг

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

Блог компании МегаФонJavaScriptNode.JSReactJS

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

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

Всем добра!

Теги:javascriptiotexponodejsraspberryreact
Хабы: Блог компании МегаФон JavaScript Node.JS ReactJS
Всего голосов 4: ↑4 и ↓0 +4
Просмотры7.8K

Комментарии 9

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Похожие публикации

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
job.megafon.ru
Численность
свыше 10 000 человек
Дата регистрации

Блог на Хабре