Как стать автором
Обновить
2072.45

HabraTab: извлечение и модернизация движка (о, сколько нам открытий чудных...)

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров3.8K


Недавно на Хабре вышла статья «HabraTab — девайс для хаброзависимых», которая вызвала неподдельный интерес у хабропользователей и, можно сказать, произвела своего рода фурор (на данный момент рейтинг статьи +137).

Действительно, проект довольно интересный как своей концепцией, так и исполнением, как программным, так железным и даже дизайнерским — девайс выглядит весьма своеобразно и оригинально.

Каждый нашёл в нём что-то своё, сам девайс меня не заинтересовал, но зато заинтересовал код, который может получать данные (кроме Хабра) с различных сайтов в интернете и затем эти данные использовать в IoT системах. Также этот код можно использовать для получения данных со встроенных веб-интерфейсов различных устройств в локальной сети, чему можно найти множество применений в реальных проектах по автоматизации (и не только).

Автор любезно открыл код, что сделало возможным его исследование и модернизацию, чем я с большим удовольствием и занялся. Далее я представлю результаты своих изысканий по этой теме.

Итак, начнём…

План статьи


Статья будет поделена на 5 частей:

  1. Извлечение движка парсинга данных из оригинального кода
  2. Добавление подсистемы сбора статистики и анализ её работы
  3. Приколы в коде
  4. Пример получения данных о статье (статьях) с Хабра
  5. Общие вопросы и планы на будущее

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

1. Извлечение движка


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

С другой стороны, кому-то не нужны показания температуры и влажности на Хабро-шильдике, а кому-то не нужно на нём текущее время и т. д. А у кого-то есть в наличии другой дисплей, а кому-то нужен крупный шрифт на огромном дисплее и т. п.

Мне, так вообще нужен только движок для использования в IoT системах для получения данных с различных сайтов и веб-интерфейсов сетевых устройств. Поэтому первым естественным желанием у меня было «отделить мух от котлет» и извлечь движок из прошивки HabraTab, чтобы потом можно было его использовать в других проектах.

Ломать, как говорится, — не строить. Или, как любил говаривать старина Микеланджело, — создать шедевр нетрудно, нужно только отсечь всё лишнее. В данном случае операция не очень сложная, но нужно, конечно, иметь какое-то представление о том, что делаешь.

После нескольких взмахов скальпелем и некоторых доработок кода, в моём распоряжении оказался сам движок. Положительным побочным эффектом этой операции стало отсутствие необходимости в дополнительных библиотеках — теперь проект компилируется без них.

Код движка. Нужно понимать, что это не законченное решение, а только первый шаг на долгом пути совершенствования подсистемы получения данных со страниц сайтов при помощи контроллеров на ESP32.

/*
  Parsing Engine test
*/

#include <HTTPClient.h>

const char ssid[] = "ssid"; // <--- актуализировать
const char pass[] = "pass"; // <--- актуализировать

String sURL = "https://habr.com/ru/users/";
String userName = "ENGIN33RRR";

WiFiClientSecure client;
HTTPClient http;

String karma = "000";
String ratin = "000.0";
String posit = "000";

void setup() {
  xTaskCreatePinnedToCore(
    FileUpdate, // функция потока
    "Task1",    // название потока
    10000,      // стек потока
    NULL,       // параметры потока
    2,          // приоритет потока
    NULL,       // идентифкатор потока
    1);         // ядро для выполнения потока
    
  delay(500);

  xTaskCreatePinnedToCore(
    Graph,
    "Task2",
    16000,
    NULL,
    1,
    NULL,
    0);
    
  delay(500);
} // setup

void connectWifi() {
  WiFi.mode(WIFI_STA);
  delay(10);
  
  Serial.print(F("Connecting to Wi-Fi"));
  WiFi.begin(ssid, pass);
  delay(10);
  byte count = 0;
  while (WiFi.status() != WL_CONNECTED && count < 15) {
    Serial.print('.');
    count++;
    delay(500);
  }
  Serial.println();
  delay(10);
}

void reconnectWifi() {
  WiFi.disconnect();
  vTaskDelay(1000);
  
  Serial.print(F("Reconnecting to Wi-Fi"));
  WiFi.begin(ssid, pass);
  byte count = 0;
  while (WiFi.status() != WL_CONNECTED && count < 15 ) {
    Serial.print('.');
    count++;
    delay(500); 
  }
  Serial.println();
  delay(10);
}

void printValuesFilter() {
  Serial.print(karma.toInt());      Serial.print('/');
  Serial.print(ratin.toFloat(), 1); Serial.print('/');
  Serial.print(posit.toInt());      Serial.println();
}

void getValues() {
  Serial.println(F("Request..."));

  http.begin(client, sURL + userName + "/");  // открываем HTTP соединение
  delay(10);
  
  int httpCode = http.GET();
  delay(10);

  Serial.print(F(" code: ")); Serial.println(httpCode);
  
  if (httpCode == 200) {
    WiFiClient* stream = http.getStreamPtr(); // пребразуем данные в поток Stream

    if (stream->available()) {
      // Karma
      stream->find(R"rawliteral(karma__votes_positive">)rawliteral");
      for (int i = 0; i < 5; i++) {
        stream->read();
      }
      for (byte i = 0; i < 5; i++) {
        karma[i] = stream->read();
      }
      
      // Rating
      stream->find(R"rawliteral(tm-rating__counter">)rawliteral");
      for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}

      // Position
      stream->find("В рейтинге");
      for (int i = 0; i < 118; i++) {stream->read();}
      for (byte i = 0; i < 4; i++) {posit[i] = stream->read();}

      printValuesFilter();
    }

    delay(10);
  } else {
    Serial.printf(" (%s)\n", http.errorToString(httpCode).c_str());
  }

  http.end();
  delay(10);
}

void requestWorks() {
  if (WiFi.status() == WL_CONNECTED) {
    getValues();
  } else {
    reconnectWifi();
  }
}

void FileUpdate(void* pvParameters) {
  Serial.begin(115200);
  Serial.println();
  Serial.println(F("Starting Parsing Engine test..."));
  http.setTimeout(3000);
  //http.setReuse(true);
  connectWifi();
  client.setInsecure(); // игнорирование HTTPS сертификатов
  
  for (;;) {
    requestWorks();
    vTaskDelay(10000);
  }
}

void printData() {
  //...
}

void Graph(void* pvParameters) { // поток отрисовки
  
  for (;;) {
    printData();
    vTaskDelay(1);
  }
}

void loop() {
  
}

Этот движок работает и работает вполне прилично, то есть его уже в таком виде можно «засунуть» в прошивку на ESP32 и использовать для своих целей от получения данных о курсах валют до парсинга данных с различных устройств в локальной сети (сетевые принтеры, UPS-ы и т. д.), которые штатно не имеют API интерфейсов и не предусматривают выдачу внутренних данных по запросам из сети.

Скриншот тестовой работы движка. Всё работает хорошо, но есть моменты, о которых мы поговорим далее.



В этой версии кода парсинг сделан на «педальной тяге», здесь вручную в скетче задаются «якоря» и вручную же задаётся алгоритм поиска на странице нужных значений. Недостаток этого метода и его «ахиллесова пята» очевидны: стоит сайту, с которого получают данные, немного изменить HTML код — и работа системы мгновенно «сломается».

      stream->find(R"rawliteral(karma__votes_positive">)rawliteral");
      for (int i = 0; i < 5; i++) {
        stream->read();
      }
      for (byte i = 0; i < 5; i++) {
        karma[i] = stream->read();
      }

Для каких-то экспериментов это допустимо, но для готовой системы нужно, конечно, предусматривать решение этой проблемы. Первое, что приходит в голову — это создание веб-интерфейса, в котором можно будет задать якоря и отступы без перекомпиляции самого кода прошивки ESP32.

Примечание. Особо продвинутые программисты могут попытаться автоматизировать этот процесс или вообще переложить бремя поиска новых якорей и отступов на ChatGPT.

Вторая часть проблемы заключается в том, что с сайта мы получаем сырые данные с вкраплениями по(ту)стороннего мусора. Это происходит потому, что мы заранее не можем определить количество разрядов в получаемых данных. Например, рейтинг пользователя может иметь один разряд, а может и три; может быть целым числом, а может иметь дробную часть и т. д.

void printValuesFilter() {
  Serial.print(karma.toInt());      Serial.print('/');
  Serial.print(ratin.toFloat(), 1); Serial.print('/');
  Serial.print(posit.toInt());      Serial.println();
}

Поэтому в текущей версии кода в качестве «фильтра» применяется преобразование строковых значений (сырых данных, полученных с сайта) в значения типов Int и Float. Это паллиативное решение, которое как-то работает, но в дальнейшем, конечно, должно быть заменено на нормальный фильтр.

Следующая проблема заключается в том, что движок в нынешнем его виде, не имеет 100% эффективности, то есть часть запросов выполняется успешно, а часть по тем или иным причинам оканчивается неудачей.

Это конечно «не дело» и так быть не должно. Далее мы попробуем разобраться с причинами возникновения ошибок и вообще глубиной этой проблемы.

Добавление подсистемы сбора статистики и анализ её работы


Все ошибки получения данных и работы движка можно подразделить на два типа:

  1. Ошибки доступа к сайту и веб-странице.
  2. Ошибки парсинга и фильтрации данных.

Из просмотра листингов вывода телеметрии в Serial невозможно ничего понять и невозможно сделать какие-то осмысленные и объективные выводы о причинах возникновения ошибок парсинга. Поэтому в код пришлось добавить специальную подсистему сбора статистики работы движка.

Эта подсистема автоматически собирает статистику по произведённым запросам и в реальном времени выводит процентные соотношения по всем типам ошибок. А вот уже на основании этой (объективной) статистики можно будет сделать какие-то осмысленные выводы о качестве работы движка и причинах возникновения ошибок.

Код движка с добавленной подсистемой сбора статистики:

Parsing Engine Stat
/*
  Parsing Engine Stat
*/

#include <HTTPClient.h>

const char ssid[] = "ssid"; // актуализировать
const char pass[] = "pass"; // актуализировать

String sURL = "https://habr.com/ru/users/";
String userName = "ENGIN33RRR";

WiFiClientSecure client;
HTTPClient http;

String karma = "000";
String ratin = "000.0";
String posit = "000";

int   kma = 68;    // актуализировать
float rtg = 134.3; // актуализировать
int   pos = 21;    // актуализировать

unsigned long counter1 = 0;

long cntReq = 0;
long cntSuc = 0;
long cntErr = 0;
long cntBad = 0;

void setup() {
  xTaskCreatePinnedToCore(
    FileUpdate, // функция потока
    "Task1",    // название потока
    10000,      // стек потока
    NULL,       // параметры потока
    2,          // приоритет потока
    NULL,       // идентифкатор потока
    1);         // ядро для выполнения потока
    
  delay(500);

  xTaskCreatePinnedToCore(
    Graph,
    "Task2",
    16000,
    NULL,
    1,
    NULL,
    0);
    
  delay(500);
} // setup

void FileUpdate(void* pvParameters) {
  Serial.begin(115200);
  Serial.println();
  Serial.println(F("Starting Parsing Engine Stat..."));
  http.setTimeout(3000);
  //http.setReuse(true);
  connectWifi();
  client.setInsecure(); // игнорирование HTTPS сертификатов
  
  for (;;) {
    requestWorks();
    vTaskDelay(10000);
  }
}


void Graph(void* pvParameters) { // поток отрисовки
  
  for (;;) {
    //counter1Works();
    printData();
    vTaskDelay(1);
  }
}


void loop() {
  
}


Module Request
/*
  Module Request
*/

void checkBad() {
  if (karma.toInt() != kma || ratin.toFloat() != rtg || posit.toInt() != pos) {
    cntBad++;
  }
}

void getValues() {
  Serial.println(F("Request..."));

  http.begin(client, sURL + userName + "/");  // открываем HTTP соединение
  delay(10);
  
  int httpCode = http.GET();
  delay(10);
  cntReq++;
  Serial.print(F(" code: ")); Serial.println(httpCode);
  
  if (httpCode == 200) {
    WiFiClient* stream = http.getStreamPtr(); // пребразуем данные в поток Stream

    if (stream->available()) {
      // Karma
      stream->find(R"rawliteral(karma__votes_positive">)rawliteral");
      for (int i = 0; i < 5; i++) {
        stream->read();
      }
      for (byte i = 0; i < 5; i++) {
        karma[i] = stream->read();
      }
      
      // Rating
      stream->find(R"rawliteral(tm-rating__counter">)rawliteral");
      for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}

      // Rating
      stream->find(R"rawliteral(tm-rating__counter">)rawliteral");
      for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}

      // Position
      stream->find("В рейтинге");
      for (int i = 0; i < 118; i++) {stream->read();}
      for (byte i = 0; i < 4; i++) {posit[i] = stream->read();}

      //printValuesRaw();
      printValuesFilter();
    }
    cntSuc++;
    checkBad();
    delay(10);
  } else {
    Serial.printf(" (%s)\n", http.errorToString(httpCode).c_str());
    cntErr++;
  }

  http.end();
  delay(10);
}

void requestWorks() {
  if (WiFi.status() == WL_CONNECTED) {
    getValues();
    
    printStat();
  } else {
    reconnectWifi();
  }
}


Module Print
/*
  Module Print
*/

void printStat() {
  float perc = (float)cntReq / 100.0;
  float percSuc = (float)cntSuc / 100.0;
  long cntOk = cntSuc - cntBad;
  Serial.print(F(" Req:")); Serial.print(cntReq);
  Serial.print(F(" Suc:"));   Serial.print(cntSuc); Serial.print(F("(")); Serial.print((float)cntSuc/perc, 0);
  Serial.print(F("%) Err:")); Serial.print(cntErr); Serial.print(F("(")); Serial.print((float)cntErr/perc, 0);
  Serial.print(F("%) Bad:")); Serial.print(cntBad); Serial.print(F("(")); Serial.print((float)cntBad/percSuc, 0);
  Serial.print(F("%) Ok:"));  Serial.print(cntOk); Serial.print(F("(")); Serial.print((float)cntOk/perc, 0);
  Serial.print(F("%)"));
  Serial.println();
}

void printValuesFilter() {
  Serial.print(karma.toInt());      Serial.print('/');
  Serial.print(ratin.toFloat(), 1); Serial.print('/');
  Serial.print(posit.toInt());      Serial.println();
}

void printValuesRaw() {
  Serial.println(karma);
  Serial.println(ratin);
  Serial.println(posit);
}

void counter1Works() {
  if (millis() > counter1 + 1000) {
    Serial.println('.');
    counter1 = millis();
  }
}


Module Wi-Fi
/*
  Module Wi-Fi
*/

void connectWifi() {
  WiFi.mode(WIFI_STA);
  delay(10);
  
  Serial.print(F("Connecting to Wi-Fi"));
  WiFi.begin(ssid, pass);
  delay(10);
  byte count = 0;
  while (WiFi.status() != WL_CONNECTED && count < 15) {
    Serial.print('.');
    count++;
    delay(500);
  }
  Serial.println();
  delay(10);
}

void reconnectWifi() {
  WiFi.disconnect();
  vTaskDelay(1000);
  
  Serial.print(F("Reconnecting to Wi-Fi"));
  WiFi.begin(ssid, pass);
  byte count = 0;
  while (WiFi.status() != WL_CONNECTED && count < 15 ) {
    Serial.print('.');
    count++;
    delay(500); 
  }
  Serial.println();
  delay(10);
}


Стартуем движок и наблюдаем за статистикой. В начале всё нормально, но уже третий запрос заканчивается ошибкой. Для каких-то выводов ждём сбора статистики с нескольких сотен запросов.



Расшифровка сокращений в строке статистики:

Req — общее количество произведённых запросов и номер текущего запроса.

Suc — количество успешных запросов к серверу и их процентное соотношение. «Успешных» технически, то есть вообще полученных от сервера. Качество ответов не учитывается, данные в ответе могут быть и «битыми».

Err — количество запросов к серверу, которые завершились ошибкой (то есть ответ вообще не получен) и их процентное соотношение.

Bad — данные получены, но они «битые» и их процентное отношение ко всем полученным данным (но не к количеству всех запросов).

Ok — количество успешно завершённых запросов и их процентное соотношение к количеству всех произведённых запросов (по существу «главный» параметр, который определяет качество работы всей системы в целом).

Небольшие пояснения по новому варианту кода.

Добавляем в скетч переменные для подсчёта статистики:

long cntReq = 0;
long cntSuc = 0;
long cntErr = 0;
long cntBad = 0;

Для определения количества полученных «битых» данных от сервера вручную добавляем в скетч заведомо правильные значения (на момент запуска теста).

int   kma = 68;    // актуализировать
float rtg = 137.3; // актуализировать
int   pos = 20;    // актуализировать

В функции printStat() подсчитываем процентные соотношения и выводим в Serial статистику по запросам.

void printStat() {
  float perc = (float)cntReq / 100.0;
  float percSuc = (float)cntSuc / 100.0;
  long cntOk = cntSuc - cntBad;
  Serial.print(F(" Req:")); Serial.print(cntReq);
  Serial.print(F(" Suc:"));   Serial.print(cntSuc); Serial.print(F("(")); Serial.print((float)cntSuc/perc, 0);
  Serial.print(F("%) Err:")); Serial.print(cntErr); Serial.print(F("(")); Serial.print((float)cntErr/perc, 0);
  Serial.print(F("%) Bad:")); Serial.print(cntBad); Serial.print(F("(")); Serial.print((float)cntBad/percSuc, 0);
  Serial.print(F("%) Ok:"));  Serial.print(cntOk); Serial.print(F("(")); Serial.print((float)cntOk/perc, 0);
  Serial.print(F("%)"));
  Serial.println();
}

Ниже представлен скриншот со статистикой работы движка после 200 запросов к серверу Хабра. Цифры процентов могут немного «гулять» из-за округления до целых значений. Это округление сделано умышленно — тут нам не важны десятые и сотые доли процентов — нам нужно понять общую картину качества работы движка.



Из представленного скриншота видно, что около 9% запросов к серверу Хабра оканчиваются неудачей. Как правило это код 7 (no HTTP server) и код 11 (read Timeout). Иногда встречаются и более экзотические ошибки. Трудно сказать в чём причина этих ошибок — возможно это связано с тем, что сервер Хабра не справляется с пиковыми нагрузками от множества клиентов. Кстати, различные «глюки» загрузки страниц Хабра наблюдаются и при работе с обычным веб-браузером.

Нужно сказать, вышеприведённая статистика работы движка довольно благостная — ошибок относительно немного и такие ошибки хорошо детектируются и обходятся в коде. Но не всё так радужно: иногда (по пока невыясненным мной причинам) начинают «сыпаться» Bad ошибки парсинга переменных. То ли это связано с сервером Хабра, то ли с самим кодом движка, но я бы поставил на какие-то глюки работы FreeRTOS, её алгоритмов распределения памяти и работы со стеком и кучей — по косвенным признакам очень похоже на подобную природу «глюков».

Сетевое взаимодействие и работа движка — это динамические процессы, то есть определённым (нелинейным) образом растянутые по времени, поэтому для более полного понимания работы системы нужно каким-то образом отображать временные интервалы происходящих событий.

Для этого в Serial вывод добавляются секундные маркеры (в виде точек) — и сразу становится видна динамика сетевого взаимодействия движка и сайта.

void counter1Works() {
  if (millis() > counter1 + 1000) {
    Serial.println('.');
    counter1 = millis();
  }
}

Динамический режим вывода можно включать и отключать в скетче — он не всегда нужен, иногда важна не динамика, а только последовательность событий и текущие значения параметров и элементов системы.

void Graph(void* pvParameters) { // поток отрисовки
  
  for (;;) {
    //counter1Works();
    printData();
    vTaskDelay(1);
  }
}

Например, в динамическом режиме хорошо видно, что на получение ответа (страницы) от сервера Хабра, после посылки запроса к нему, уходит около трёх секунд, а вот поиск значений в HTML коде страницы и их обработка происходит практически «мгновенно».



Приколы в коде


В процессе экспериментов я столкнулся с необъяснимым для меня поведением компилятора, который я иначе как «приколом» назвать не могу.

Вышеприведённый код движка с подсистемой сбора статистики прекрасно компилируется и работает и в нём есть такой (совершенно безобидный) фрагмент:

      // Rating
      stream->find(R"rawliteral(tm-rating__counter">)rawliteral");
      for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}

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

      // Rating
      stream->find(R"rawliteral(tm-rating__counter">)rawliteral");
      for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}

      // Rating
      stream->find(R"rawliteral(tm-rating__counter">)rawliteral");
      for (byte i = 0; i < 7; i++) {ratin[i] = stream->read();}

то скетч перестаёт компилироваться, чего никак не может быть в принципе — если этот участок кода один раз прошёл проверку компилятором, то и второй раз обязан её пройти.

Я пока не успел разобраться с этим вопросом, но у меня есть два предположения:
  1. Это ошибка компилятора (что маловероятно и в это с трудом верится).
  2. Сам код содержит синтаксическую ошибку, которая по каким-то причинам «пропускается» компилятором.

Надеюсь, «старшие товарищи» внесут ясность в этот вопрос и объяснят как такое вообще может быть.

Пример получения данных о статье (статьях) с Хабра


Здесь нас ожидает «облом» в самом неожиданном месте. У меня уже был готов код для этого раздела и я начал его описание, но меня вдруг посетила мысль:

«Ё! Когда мы получаем данные со страницы пользователя на Хабре, то это не очень изящное, но более-менее допустимое действие, но когда мы получаем данные о параметрах статьи со страницы самой этой статьи, то каждый раз заново загружаем всю страницу и тем самым… попутно увеличиваем количество просмотров!»

Очевидно, что это уже (хоть и ненамеренно) выходит за рамки безобидных экспериментов с электричеством и может быть неоднозначно воспринято администрацией Хабра.

Поэтому публикацию этого раздела и кода я остановил и обратился к администрации Хабра за официальными комментариями и разъяснениями её позиции по этому вопросу.

Официального ответа я пока не получил, возможно он последует после публикации этой статьи.

А пока администрация Хабра размышляет над свой позицией по этому вопросу, мы можем немного порассуждать об ещё одной интересной теме — API Хабра.

Насколько я понял из неофициальных контактов с официальными представителями Хабра, API у Хабра есть, но он непубличный. Мне со стороны трудно рассуждать о технических и организационных проблемах, не дающих сделать его публичным, но, если всё-таки есть такая возможность, то может быть настало время Хабру, наконец, запустить в полноценную работу этот сервис.

Ждём-с…

Общие вопросы и планы на будущее


Ну и напоследок несколько вопросов, которые у меня возникли в процессе экспериментов с движком HabraTab:

  • Почему одни и те же запросы на одних и тех же страницах иногда дают различные результаты? По идее, так не должно быть и сырые данные всегда должны быть одинаковыми (при одинаковых исходных условиях).
  • В чём причина спорадического возникновения Bad ошибок?
  • Как на ESP32 получать данные с сайтов, которые требуют для своей работы Javascript?
  • Как на ESP32 получать данные с сайтов, которые требуют авторизации?

Планы на будущее:

Основная проблема на данный момент — возникновения Bad ошибок, поэтому в планах в первую очередь разобраться с причинами этого явления и сделать движок на 100% стабильным.

Затем можно поэкспериментировать с сетевым взаимодействием и поднять его эффективность с 90 до 100%.

Далее можно будет упростить или автоматизировать определение якорей и отступов на целевых страницах.

Ну и т. д. и т. п., усовершенствовать движок можно до бесконечности или в соответствии с требованием конкретных проектов.

Заключение


Как пример: в моём хозяйстве есть замечательный сетевой блок бесперебойного питания APC Back-UPS HS 500, который имеет веб-интерфейс, но не имеет сетевого API, через которое я бы мог получать данные о его текущих параметрах для интеграции в систему «умного дома».

Раньше эту проблему я решал при помощи инструментария системы MajorDoMo, теперь можно попробовать отказаться от её услуг и решить проблему с помощью маломощного и недорогого контроллера на ESP32.

Я надеюсь, что на этих примерах мне удалось донести до вас потенциал движка HabraTab для использования в IoT проектах и проектах по автоматизации.

Теги:
Хабы:
+27
Комментарии7

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud