
Привет, Хабр! Хочу поделиться своим проектом, который разрабатывал почти год - 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 можно сделать пульт управления к роботу, приложение для сложного автополива, гайвер-лампы, самодельных часов с кучей датчиков на борту и т.д.
Это моя первая публикация. Если кому зайдет, могу забацать ещё статейку в продолжение темы. Например, как эту лампу к яндекс Алисе подключить.
Всем хорошего дня.
