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);

}

Такая лампа получилась. Ниже приведу видео с демонстрацией.

Заключение

Итак, данный проект предназначен для людей, которые хотят настроить свой самодельный умный дом максимально гибко. Также он подойдет для создания дистанционно управляемых устройств, т.к. сокеты работают очень быстро. Я, например, теперь поголовно всё буду подключать сюда.

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

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

Источник: https://habr.com/ru/post/570432/


Интересные статьи

Интересные статьи

Прежде чем разбираться с реализацией серверного UI (SDUI) от Airbnb, важно понять, что это вообще такое и какие преимущества оно дает относительно традиционного клиентско...
Ранее мы обсудили выбор между смарт-тв и классическим телевизором, плюс — затронули тему акустической подготовки помещения. Продолжаем анализировать компоненты домашнего ...
Привет, читатель! Меня зовут Ирина, я веду телеграм-канал об астрофизике и квантовой механике «Quant». На этот раз подготовила для вас перевод статьи о процессе конфигурации Солнечной системы в т...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
В MIT представили интерактивный инструмент, который дает понять, почему интеллектуальная система принимает то или иное решение. В этом материале — о том, как он работает.