Привет, Хабр! Хочу поделиться своим проектом, который разрабатывал почти год - appex-system.
Дело началось с того, что я закончил изучение ноды. Нужно было запилить какой-нибудь проект, чтобы потренироваться, и я решил объединить 2 любимых дела - программирование и самоделки. И вот что из этого получилось.
Умный дом делится на устройства. Устройством может быть как одна плата (например esp8266), так и несколько (люстра, состоящая из 4 умных лампочек). Для каждого устройства пишется отдельное приложение на js. Устройство в месте с приложением объединяются в комнату, наподобие группы в телеграм, где и происходит их общение.
В каждой комнате имеется объект состояния. В свойствах этого объекта хранятся все нужные для работы данные - например статус лампочки. Общение между платами и приложением происходит по протоколу web sockets. Если запускать сервер локально, то ардуина получит команду через 4 миллисекунды после нажатия кнопки в приложении - вполне не плохо)
Для примера давайте соберем умную лампочку.
Чтобы посмотреть систему, можете использовать мой сервер. Если хотите поднять её на локальной машине, вот небольшая инструкция для Вас:
Локальная установка
Ставим всё необходимое. Подробно заострять на этом внимание не буду, инфы полно в интернете. Можно почитать эти гайды: установка mongodb, установка nodejs.
Клонируем репозиторий.
git clone https://github.com/andaran/appex-system
.Скачиваем необходимые пакеты.
npm i
-
Создаем конфиг окружения. Прописываем туда пароли для сессий, базу данных, порт, настройки для smtp почты.
nano .env
# .env # application sessionSecretKey1=********** sessionSecretKey2=********** database=mongodb://127.0.0.1/appex port=3001 # smtp mailer mailUser=appex.system@yandex.ru mailPass=********** mailPort=465 mailHost=smtp.yandex.ru
Собираем приложение.
npm run build
Запускаем!
node appex
Хотелось бы обратить внимание на smtp. Т.к. проект создавался для публикации в интернете, для регистрации на почту приходит код. Для отправки я использую обычный аккаунт яндекса. В конфиге нужно указать логин и пароль от него.
Ключи для сессий генерируем рандомные и забываем.
На этом установка завершена.
Для начала регистрируемся в системе. Думаю, с этим без проблем справится каждый. Далее Вам будет предложено пройти обучение. После его прохождения можно приступать к работе.
Для Вас уже создадутся приложение и комната к нему. Их и используем. Жмем на значок лампочки, чтобы открыть редактор кода. В нем сверху есть строка "Подключиться к комнате". При наведении на неё вылезет меню, в котором уже будут вписаны данные созданной комнаты. Можно открыть эмулятор, пожмакать на кнопку и убедиться, что всё работает.
Теперь займемся допиливанием кода. Чтобы было удобнее работать, можете нажать Alt + V. Код откроется на весь экран.
Дополним верстку приложения. В правом верхнем углу экрана будет находиться индикатор подключения лампы к сети и режим обычной лампы. Если включить этот режим, то после подачи электричества на лампу она загорится не зависимо от значения виртуального выключателя.
В .app-wrap в атрибуте data-theme укажем цвет статус бара телефона при открытии приложения. Ещё добавим готовый пресет выключателя. О пресетах написано в документации проекта.
HTML
<!-- обертка приложения -->
<div class="app-wrap"
data-role="config"
data-theme="rgba(239, 239, 239)">
<div class="tools-wrap">
<div class="indicator" id="indicator"></div>
[[Switch id="switch"]]
</div>
<!-- ----------------- -->
<div class="center-block">
<!-- кружок за кнопкой -->
<div class="app-button-wrap" id="app-button-wrap">
<!-- кнопка -->
<div class="app-button" id="app-button">
<!-- иконка -->
[[Icon name="faPowerOff"]]
</div>
</div>
</div>
</div>
Теперь напишем стили. Тут ничего необычного.
CSS
/* обертка приложения */
.app-wrap {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
/* кружок за кнопкой */
.app-button-wrap {
width: 120px;
height: 120px;
background: #c8d6e5;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
opacity: 0.8;
transition: .5s;
margin: auto;
}
/* кнопка */
.app-button {
width: 110px;
height: 110px;
background: white;
border-radius: 50%;
color: #c8d6e5;
display: flex;
justify-content: center;
align-items: center;
font-size: 32px;
transition: .1s;
}
/* кнопка при клике */
.app-button:active {
transform: scale(.98);
}
.center-block {
display: flex;
justify-content: center;
align-items: center;
}
.indicator {
width: 24px;
height: 24px;
border-radius: 50%;
background: #ccc;
}
#switch {
width: auto;
height: auto;
}
.tools-wrap {
position: absolute;
top: 5px;
right: 5px;
display: flex;
align-items: center;
}
.appex-preset-switch__handle {
width: 40px;
height: 24px;
}
.appex-preset-switch__handle:after {
box-shadow: none;
height: 20px;
width: 20px;
margin-left: -16px;
}
.appex-preset-switch__input:checked + .appex-preset-switch__handle {
background: #00d2d3;
}
.appex-preset-switch__input:checked + .appex-preset-switch__handle:after {
margin-right: -16px;
}
Теперь самое интересное - js код приложения.
-
Добавляем объект состояния с состояниями по умолчанию.
/* начальное состояние */ App.state = { status: true, isOnline: true, autoEnable: false, }
-
Определяем настройки.
/* настройки */ App.settings = { awaitResponse: true, }
-
Находим необходимые элементы и вешаем на них слушатели события. Новое состояние отправляется на сервер с помощью метода
App.send();
/* вешаем слушатели событий */ button.addEventListener('click', () => { App.send({ status: !App.state.status }); window.navigator.vibrate(40); }); swtch.addEventListener('change', e => { App.send({ autoEnable: e.target.checked }); });
-
Добавляем проверку на онлайн. Каждые 10 секунд меняем свойство isOnline на false. Если в течение 3 секунд лампа не опровергает это, гасим индикатор онлайна.
/* проверка на онлайн */ setInterval(() => { App.send({ isOnline: false }); setTimeout(() => { if (!App.state.isOnline) { indicator.style.backgroundColor = '#ccc'; } }, 3000); }, 10000);
-
Подписываемся на событие обновления состояния. При приходе нового состояния меняем внешний вид всех компонентов приложения на соответствующий.
/* обновление состояния */ App.on('update', state => { if (state.status) { button.style.color = '#00d2d3'; wrap.style.backgroundColor = '#00d2d3'; } else { button.style.color = '#c8d6e5'; wrap.style.backgroundColor = '#c8d6e5'; } swtch.querySelector('input').checked = state.autoEnable; if (state.isOnline) { indicator.style.backgroundColor = '#00d2d3'; } });
-
Запускаем приложение. На этом с написанием кода под него мы закончили.
/* запускаем приложение */ App.start();
Скриншот приложения
Пишем код для микроконтроллера
В качестве микроконтроллера был взят популярный esp-01. К нему докупил такие реле и блок питания.
Пилим прошивку и заливаем через arduino ide.
Прошивка
// необходимые библиотеки
#include <ESP8266WiFi.h>
#include <ArduinoJson.h>
#include <WebSocketsClient.h>
#include <SocketIOclient.h>
#include <string>
#include <unordered_map>
SocketIOclient socketIO;
/* ---==== Настройки ====--- */
#define ussid "" // Имя wifi
#define pass "" // Пароль wifi
#define roomID "" // ID комнаты
#define roomPass "" // Пароль комнаты
/* ------------------------ */
/*
Я достаточно долго искал способы хранения информации в c++,
которые будут больше всего похожи на объект js (мы ведь парсим json).
std::unordered_map - самый подходящий, т.к. из него можно вытянуть или
изменить значения свойства, название которого передано через переменную.
Это дает возможность выборочно обновлять значения во время
парсинга json`а.
[!!!] Данный список должен полностью соответсвовать
объекту App.state в коде приложения.
Также необходимо добавить свойство "lastChange" - оно показывает
время последнего обращения к комнате.
*/
std::unordered_map<std::string, std::string> receivedState = {
{ "status", "true" },
{ "isOnline", "true" },
{ "lastChange", "0" },
{ "autoEnable", "false" }
};
bool light = LOW;
/* ---==== Функция обновления состояния ====--- */
void updateParams(String messageType) {
/*
Именно в этой функции обрабатывается новое состояние.
К сожалению, все типы данных преобразуются в строки,
но запарсить число из строки позволяют встроенные
ардуиновские методы.
*/
String status = receivedState.at("status").c_str();
String online = receivedState.at("isOnline").c_str();
String autoEnable = receivedState.at("autoEnable").c_str();
/* включаем лампу */
if (status == "true" && messageType != "connectSuccess") {
light = LOW;
Serial.println("RELAY ON");
}
/* выключаем лампу */
if (status == "false" && messageType != "connectSuccess") {
light = HIGH;
Serial.println("RELAY OFF");
}
/* подтверждаем, что лампа в сети */
if (online == "false") {
DynamicJsonDocument doc(1024);
JsonObject sendState = doc.createNestedObject();
sendState["isOnline"] = true;
message("updateState", sendState);
}
/* включаем лампу при режиме автовключения */
if (messageType == "connectSuccess" && autoEnable == "true") {
DynamicJsonDocument doc(1024);
JsonObject sendState = doc.createNestedObject();
sendState["status"] = true;
message("updateState", sendState);
}
}
/* ---==== События ====--- */
void socketIOEvent(socketIOmessageType_t type, uint8_t * payload, size_t length) {
switch (type) {
case sIOtype_DISCONNECT: {
Serial.println("[IOc] Ошибка подключения!\n");
} break;
case sIOtype_CONNECT: {
Serial.println("[IOc] Подключено!");
// join default namespace (no auto join in Socket.IO V3)
socketIO.send(sIOtype_CONNECT, "/");
/*
Теперь подключаемся к комнате.
Это работает как группа в каком-нибудь мессенджере -
как только один участник напишет сообщение
(передаст обновление для объекта состояния),
это сообщение сразу же получат все другие участники
(телефоны, платы esp, можно и малину подключить).
*/
connectToRoom();
} break;
case sIOtype_EVENT: {
char* json = (char*) payload;
// парсим событие с новым состоянием
parseEvent(json);
} break;
default: {
Serial.println("[IOc] Пришло что-то непонятное :(");
hexdump(payload, length);
} break;
}
}
/* ---==== Замудреная функция парсинга ответа от сервера ====--- */
void parseEvent(char* json) {
/* parse json */
String messageType = "";
String parsedParams = "";
char oldSimbool;
bool parseTypeFlag = false;
bool parseParamsFlag = false;
for (unsigned long i = 0; i < strlen(json); i++) {
if (json[i] == '{') {
parseParamsFlag = true;
parsedParams = "";
}
if (json[i] == '"') {
if (parseTypeFlag) {
parseTypeFlag = false;
} else if (messageType.length() == 0) {
parseTypeFlag = true;
}
if (parseTypeFlag) {
continue;
}
}
if (parseTypeFlag) {
messageType += json[i];
}
if (parseParamsFlag) {
parsedParams += json[i];
}
if (json[i] == '}') {
parseParamsFlag = false;
}
oldSimbool = json[i];
}
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, parsedParams);
if (error) {
Serial.print("[ERR] Ошибка парсинга json!");
} else {
/* count quantity of params and delete unnecessary symbols */
String prms = "";
int prmsQuant = 1;
for (unsigned long i = 1; i < parsedParams.length() - 1; i++) {
if (parsedParams[i] == '"') { continue; }
if (parsedParams[i] == ',') { prmsQuant++; }
prms += parsedParams[i];
}
/* put params to array cells */
String namesAndValues[prmsQuant];
int numberOfParam = 0;
for (unsigned long i = 0; i < prms.length(); i++) {
if (prms[i] == ',') {
numberOfParam++;
continue;
}
namesAndValues[numberOfParam] += prms[i];
}
/* split params and values */
std::string prmName;
std::string prmValue;
char* values[prmsQuant];
bool typeFlag = false;
for (int i = 0; i < prmsQuant; i++) {
typeFlag = false;
prmName = "";
prmValue = "";
for (int j = 0; j < namesAndValues[i].length(); j++) {
if (namesAndValues[i][j] == ':') {
typeFlag = true;
continue;
}
if (typeFlag) {
prmValue += namesAndValues[i][j];
} else {
prmName += namesAndValues[i][j];
}
}
/* save changes */
if (receivedState.count(prmName) != 0) {
receivedState.at(prmName) = prmValue;
} else {
Serial.print("[ERR] Неизвестный параметр \"");
Serial.print(prmName.c_str());
Serial.println("\"!");
}
}
/* call update function */
updateParams(messageType);
}
}
/* ---==== Подключение к комнате ====--- */
void connectToRoom() {
// данные отсылаются в json
DynamicJsonDocument doc(1024);
JsonArray array = doc.to<JsonArray>();
// добавляем название события, в данном случае - "connectToRoom".
array.add("connectToRoom");
// добавляем id и пароль комнаты для прохождения аутентификации
JsonObject params = array.createNestedObject();
params["roomId"] = roomID;
params["roomPass"] = roomPass;
// преобразуем json в строку
String output;
serializeJson(doc, output);
// отправляем событие подключения к комнате
socketIO.sendEVENT(output);
}
/* ---==== Отправка данных ====--- */
void message(String eventType, JsonObject sendState) {
// данные отсылаются в json
DynamicJsonDocument doc(1024);
JsonArray array = doc.to<JsonArray>();
// добавляем название события, обычно это 'update'
array.add(eventType);
// добавляем id и пароль комнаты для прохождения аутентификации,
// добавляем обновленные данные
JsonObject params = array.createNestedObject();
params["roomId"] = roomID;
params["roomPass"] = roomPass;
params["params"] = sendState;
// преобразуем json в строку
String output;
serializeJson(doc, output);
// шлем событие на сервер appex
socketIO.sendEVENT(output);
}
/* ---==== Setup ====--- */
void setup() {
// запускаем Serial порт
Serial.begin(9600);
Serial.setDebugOutput(false);
pinMode(RELAY, OUTPUT);
digitalWrite(RELAY, light);
Serial.println("RELAY ON");
// подключаемся к WiFi
WiFi.begin(ussid, pass);
while (WiFi.status() != WL_CONNECTED) {
delay(300);
Serial.print(".");
}
Serial.println("\n[LOG] Wifi connected!\n");
// ip адрес устройства
String ip = WiFi.localIP().toString();
Serial.printf("[SETUP] IP adress: %s\n", ip.c_str());
// подключаемся к серверу
socketIO.beginSSL("appex-system.ru", 443, "/socket.io/?EIO=4");
// если пришел запрос
socketIO.onEvent(socketIOEvent);
}
/* ---==== Loop ====--- */
void loop() {
// слушаем сервер
socketIO.loop();
digitalWrite(RELAY, light);
}
Такая лампа получилась. Ниже приведу видео с демонстрацией.
Заключение
Итак, данный проект предназначен для людей, которым не хватает стандартных элементов управления других сервисов для создания собственного умного устройства.
С помощью appex можно сделать пульт управления к роботу, приложение для сложного автополива, гайвер-лампы, самодельных часов с кучей датчиков на борту и т.д.
Это моя первая публикация. Если кому зайдет, могу забацать ещё статейку в продолжение темы. Например, как эту лампу к яндекс Алисе подключить.
Всем хорошего дня.
Комментарии (11)
Stac
30.07.2021 17:36Как быть с промышленно произведенными устройствами, Tuya, Xiaomi, whatever? Ими когда-то можно будет управлять?
Bluefox
30.07.2021 23:29ioBroker - написан на node.js. Поддерживает огромное количество приборов: tuya, xiaomi, whatever и есть поддержка Алисы.
А топикстартер мог бы просто написать драйвер на Node.js и пользовался бы экосистемой ioBroker, которая развивается с 2014 года и имеет 50к пользователей.
Andreeyyy Автор
31.07.2021 11:13Я немного неправильно объяснил задумку, поэтому в комментариях стали скидывать различные аналоги. Существенное отличие моей системы заключается в том, что она создавалась не под обычные задачи, а под самоделки, не вписывающиеся в рамки стандартных элементов управления. Специальный пульт для робота, например. Поэтому в appex такое деление на приложения, а не цельное управление всем и сразу. Лампа с статье не самый подходящий пример, но ничего другого пока продемонстрировать не могу.
Fzero0
Можно было бы установите прошивку-интерпретатор JavaScript на ESP8266 и работать в одном стеке?
Andreeyyy Автор
Никогда этим не занимался, надо попробовать.