В один момент мы задумались с товарищем, а почему бы не попробовать сделать свое домашнее 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, мобильной и десктопной разработкой. Это расширяет кругозор и дает новые возможности.
На сегодня все.
Всем добра!
squonk
Как по мне странно делать зоопарк: либо Java либо JS.
ilalexdev Автор
На реализацию данного проекта закладывалась неделя. Чтобы все успеть, распределили обязанности в соответствии с компетенциями.
nochkin
Почему странно? Каждый подход-фреймворк-язык имеет свои особенности, которые особо хорошо проявляются в своей среде. Но надо знать где и как.
Это как использовать плоскую отвёртку для винтов с плоским шлицем и крестовым. Это часто можно сделать и не надо инвестировать в обе отвёртке. Но если есть в наличие обе отвёртки уже, то более подходящий инструмент работает лучше.
Единственная выгода использовать JS везде и повсюду обычно в том случае когда есть хорошие знания JS, а остальные знания только поверхностные. Это очень хорошо помогает быстро запустить full-stack проект зная только JS.
squonk
Зоопарк технологий это всегда плохо. В данном случае один сервер на Java прекрасно бы со всем справился. А еще бы его можно скомпилять в бинарник через GraalVm.
nochkin
Потому я и интересуюсь ПОЧЕМУ это плохо. А в ответ получаю «это всегда плохо». Мне так до сих пор и не понятно.
squonk
Я ведь написал. Вместо одного сервиса надо поддерживать три, причем на разных технологиях.
nochkin
Так почему так плохо поддерживать разные технологии?
Если у меня серверный код на JS, то мне надо обязательно иметь базу данных на JS или я могу её реализовать на чём-то другом, что лучше подходит под задачи (например, запустить новый сервис на MySQL или MongoDB)?
squonk
Если посмотреть описание, то сервисы написаны на Js и Java. За базу никто не говорил. И чем в данном случае JS лучше Java?
nochkin
Наверно, мы просто друг друга не так поняли.
В данном случае JS может быть лучше Java тем, что авторы проекта знают JS лучше и могут не знать Java вообще.
Но если бы они хорошо знали, например, Python, то серверную часть можно было сделать на Python'е проще и быстрее. И при этом оставить фронт на JS.
Ещё как вариант — сделать мобильную версию сайта на JS вместо мобильного приложения на JS если опыт в этом больше.
Я об этом.