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

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

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

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

Комментарии (11)


  1. Fzero0
    30.07.2021 13:30

    Можно было бы установите прошивку-интерпретатор JavaScript на ESP8266 и работать в одном стеке?


    1. Andreeyyy Автор
      30.07.2021 13:43

      Никогда этим не занимался, надо попробовать.


  1. swaftrade
    30.07.2021 13:30

    Хм. Просто как хобби изобрести велосипед. Тогда отлично.


    1. Andreeyyy Автор
      30.07.2021 13:35

      Если имеете в виду blynk, то это не его копия, а более кастомизируемый и сложный вариант. Тут каждому своё. Больше подобных проектов я не знаю.


      1. pfffffffffffff
        30.07.2021 21:40

        NodeRed


      1. maxizhur
        31.07.2021 11:13

        Home Assistant например...


  1. Stac
    30.07.2021 17:36

    Как быть с промышленно произведенными устройствами, Tuya, Xiaomi, whatever? Ими когда-то можно будет управлять?


    1. Andreeyyy Автор
      30.07.2021 19:38

      Пока в планах нет такого, это чисто под самоделки.


    1. Bluefox
      30.07.2021 23:29

      ioBroker - написан на node.js. Поддерживает огромное количество приборов: tuya, xiaomi, whatever и есть поддержка Алисы.

      А топикстартер мог бы просто написать драйвер на Node.js и пользовался бы экосистемой ioBroker, которая развивается с 2014 года и имеет 50к пользователей.


  1. Andreeyyy Автор
    31.07.2021 11:13

    Я немного неправильно объяснил задумку, поэтому в комментариях стали скидывать различные аналоги. Существенное отличие моей системы заключается в том, что она создавалась не под обычные задачи, а под самоделки, не вписывающиеся в рамки стандартных элементов управления. Специальный пульт для робота, например. Поэтому в appex такое деление на приложения, а не цельное управление всем и сразу. Лампа с статье не самый подходящий пример, но ничего другого пока продемонстрировать не могу.


  1. past
    31.07.2021 11:36

    MQTT? Не, не слышал