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

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

Всем добра!