Opensource система умного дома на nodejs

от автора

Интерфейс главного меню
Интерфейс главного меню

Привет, Хабр! Хочу поделиться своим проектом, который разрабатывал почти год — appex-system.

Дело началось с того, что я закончил изучение ноды. Нужно было запилить какой-нибудь проект, чтобы потренироваться, и я решил объединить 2 любимых дела — программирование и самоделки. И вот что из этого получилось.

Умный дом делится на устройства. Устройством может быть как одна плата (например esp8266), так и несколько (люстра, состоящая из 4 умных лампочек). Для каждого устройства пишется отдельное приложение на js. Устройство в месте с приложением объединяются в комнату, наподобие группы в телеграм, где и происходит их общение.

В каждой комнате имеется объект состояния. В свойствах этого объекта хранятся все нужные для работы данные — например статус лампочки. Общение между платами и приложением происходит по протоколу web sockets. Если запускать сервер локально, то ардуина получит команду через 4 миллисекунды после нажатия кнопки в приложении — вполне не плохо)

Для примера давайте соберем умную лампочку.

Чтобы посмотреть систему, можете использовать мой сервер. Если хотите поднять её на локальной машине, вот небольшая инструкция для Вас:

Локальная установка
  1. Ставим всё необходимое. Подробно заострять на этом внимание не буду, инфы полно в интернете. Можно почитать эти гайды: установка mongodb, установка nodejs.

  2. Клонируем репозиторий.git clone https://github.com/andaran/appex-system.

  3. Скачиваем необходимые пакеты. npm i

  4. Создаем конфиг окружения. Прописываем туда пароли для сессий, базу данных, порт, настройки для 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
  5. Собираем приложение. npm run build

  6. Запускаем! 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 код приложения.

  1. Добавляем объект состояния с состояниями по умолчанию.

    /* начальное состояние */ App.state = {   status: true,     isOnline: true,     autoEnable: false, }
  2. Определяем настройки.

    /* настройки */ App.settings = {   awaitResponse: true, }
  3. Находим необходимые элементы и вешаем на них слушатели события. Новое состояние отправляется на сервер с помощью метода 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 }); });
  4. Добавляем проверку на онлайн. Каждые 10 секунд меняем свойство isOnline на false. Если в течение 3 секунд лампа не опровергает это, гасим индикатор онлайна.

    /* проверка на онлайн */ setInterval(() => {     App.send({ isOnline: false });     setTimeout(() => {         if (!App.state.isOnline) {             indicator.style.backgroundColor = '#ccc';         }     }, 3000); }, 10000);
  5. Подписываемся на событие обновления состояния. При приходе нового состояния меняем внешний вид всех компонентов приложения на соответствующий.

    /* обновление состояния */ 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';   } });
  6. Запускаем приложение. На этом с написанием кода под него мы закончили.

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

Это моя первая публикация. Если кому зайдет, могу забацать ещё статейку в продолжение темы. Например, как эту лампу к яндекс Алисе подключить.

Всем хорошего дня.


ссылка на оригинал статьи https://habr.com/ru/articles/570432/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *