Умные камеры подождут: мониторинг промышленного оборудования на ESP8266 и оцифровка производства

от автора

Во время погружения в процессы производства, о которых шла речь в первой статье, от руководства прилетел интересный вопрос: «А можно как-то посчитать, сколько на самом деле работает наше оборудование (в контексте ленточных пил) в смену? Может, повесим умные камеры для анализа?»

Спойлер: посчитать получилось и в разы дешевле чем по умным камерам! Причем в процессе удалось оцифровать работу не только ленточных пил, но и ряд другого оборудования.

В статье будут: гипотеза, макетирование и прототипирование, создание платы и производство (PCB), микропрограмма, workflow n8n и планы на будущее.

Самое важное, что нужно помнить, обращая производство в цифру — что нам это дает?


Сразу для понимания ситуации поясню про какое оборудование шла первоначальная речь:

Фото из интернета - пример ленточнопильного станка (ProTech BS-500DSA)

Фото из интернета — пример ленточнопильного станка (ProTech BS-500DSA)

Выше типичный промышленный лентопильный станок: двигатель с редуктором, система подачи охлаждающей жидкости (для резки металла, как у нас), механизм вертикальной подачи режущего полотна, зажимное устройство для заготовки и, собственно, само полотно на пильной раме. Количество таких станков на производстве соответствует средней пропускной способностью участка, где они установлены. Но, так бывает, особенно в нынешних реалиях, загрузка оборудования похожа на синусоиду (или меандр, кому как нравится). При таком графике эффективность работы пил напрямую влияет на общий срок выпуска готовой продукции, делая этот участок критически важным.

Вопрос о чистом времени работы оборудования возник при поиске точек оптимизации работы участка. Руководство посмотрело в камеры общего наблюдения, «на глаз» прикинуло продуктивное время оператора станка и вынесло вердикт: целевое время работы — всего 30-40%, а остальное — простой. Рабочий то болтает, то в телефоне сидит, то вообще где-то ходит. Имея опыт работы с микроконтроллерами семейства ESP8266/ESP32 я предложил гипотезу: «С помощью неинвазивного токового датчика снять честный мониторинг работы оборудования и посмотрим на реальную картину».

Прототип: синусоида в мониторе порта

Первым делом помогли паспорта станков — взяли средние мощности, и на основе этих данных, был приобретен датчик тока SCT-013-015 (на 15А), плата NodeMCU Lolin v3 (на базе ESP8266), M5Stack ATOM Lite (на базе ESP32) и немного навесных элементов. Требовалось проверить, получится ли вообще однозначно детектировать режимы работы, и уже от этого двигаться дальше. Первичные тесты были проведены на M5Stack ATOM Lite, а пилот реализован на NodeMCU Lolin v3 — именно о нем далее и будет идти речь, хотя, возможно будут фото с Атомом.

Нулевой, чисто железный нюанс связки, это диапазон работы датчика, который нужно было адаптировать к входному диапазону контроллера. Трансформатор тока SCT-013-015 выдает переменный синусоидальный сигнал (от -1V до +1V), а единственный аналоговый вход умеет читать строго постоянное напряжение (от 0V до 3.3V). Если запустить синусоиду напрямую микроконтроллеру не понравится.

Использована простая схема смещения. Ее задача — «поднять» синусоиду переменного напряжения вверх, чтобы ее виртуальный центр находился ровно в средней точки рабочего диапазона входа контроллера (на отметке 1.65V). В таком случае волна колеблется в безопасном и читаемом для диапазоне: от 0.65V до 2.65V. Реализуется это резистивным делителем из двух резисторов равных номиналов (взял по 10 кОм), включенных между шиной питания (3.3V) и GND. Их средняя точка становится источником опорного напряжения смещения. Один вывод датчика SCT-013-015 (основание штекера) соединяется со средней точкой делителя, а второй вывод (кончик) уходит напрямую на аналоговый вход A0 NodeMCU. При этом сам вход A0 «видит» сумму постоянного смещения и переменного сигнала с датчика, что полностью исключает уход синусоиды в опасную отрицательную зону.

Подключения SCT-013-015 к NodeMCU

Подключения SCT-013-015 к NodeMCU

Собрано, написано простой скетч обработки вывода значений в монитор порта:

Фото с «полевых» испытаний
Первые данные в мониторе порта - уверенная детекция показаний

Первые данные в мониторе порта — уверенная детекция показаний
Сварочный аппарат так же тестируется "на ура" (здесь использована ESP32)

Сварочный аппарат так же тестируется «на ура» (здесь использована ESP32)

Тесты идеи проводились в среде Arduino IDE и показали жизнеспособность задумки: в режиме холостого хода (простоя) станка и в режиме реального рабочего реза значения тока различались примерно в два раза. Этого более чем достаточно для однозначного программного разделения состояний.

Собираем данные: старый добрый Excel

Следующим шагом была организация сборки данных. Разворачивать базы данных на этапе тестирования гипотезы не хотелось, поэтому использовалась low-code платформа n8n, где был создан простейший workflow. Он принимал данные от контроллеров и складывал записи в обычный .csv файл.

Поток в n8n: берём все что приходил и сохраняем в .csv

Поток в n8n: берём все что приходил и сохраняем в .csv
Пример сырых данных в таблице Excel

Пример сырых данных в таблице Excel

Заключительный штрих для запуска пилота — прошивка для ESP8266. Необходимо было реализовать: подключение к Wi-Fi (развернут в цехах и успешно эксплуатируется), подсчет наработки, отправка событий на сервер n8n.

Алгоритм работы устройства получился следующим:

  • Событие ONLINE: При включении в сеть устройство отправляет статус о своем запуске. Дело в том, что наши цеха на ночь полностью обесточиваются, и для аналитики важно видеть, когда конкретно оборудование физически включили (фактическое начало смены).

  • Режим СТОП: В режиме простоя станка (когда он включен, но не режет) ничего не происходит, но при переходе в этот режим устройство оправляет уведомление.

  • Режим РАБОЧИЙ ХОД (РХ): Как только датчик фиксирует превышение порота запускается внутренний счетчик времени (в секундах). При остановке (переходе обратно в СТОП) счетчик останавливается, и устройство отправляет на сервер накопленное значение.

  • Идентификация: Каждое сообщение в пакете содержит уникальный MAC-адрес устройства.

Когда поток данных пошел в Excel, который с помощью встроенных скриптов автоматически подтягивал свежие данные из .csv с сервера, это позволило крутить статистику как угодно: считать чистое время, настраивать фильтры по событиям, конкретным станкам и датам, строить гипотезы и проверять их.

Картинки из Excel (данные и визуализация)
"Сырая" сводная статистика Excel

«Сырая» сводная статистика Excel
Обработанная статистика Excel

Обработанная статистика Excel

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

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

Готовые платы/изделия работающие в данный момент

Плата, изначально, проектировалась под установку в стандартную монтажную коробку формата 100х100х50мм доступную в строительных магазинах. Использовался минимально необходимый набор элементов без избыточного функционала (хотя есть два зарезервированных вывода — GPIO4 и 5) и готового блока питания в лице Hi-Link HLK-PM03. Вместо ESP-12 можно использовать ESP-07 с внешней антенной

Плата в процессе сборки

Плата в процессе сборки
Собранные в корпусах

Собранные в корпусах

Пока платы были в процессе обновился и функционал прошивки — в нем появился полноценный веб-интерфейс с элементами управления Это дало возможность дистанционно менять базовые настройки: выставлять порог срабатывания по току для конкретного станка и идентифицировать его, менять адрес сервера n8n, настраивать параметры доступа к Wi-Fi и еще ряд полезных мелочей. Сам код писался с активным применением ИИ, и, наверняка, далек от идеала, но с возложенными задачами справляется и этого достаточно.

Про прошивку и вэб интерфейс (код файла .ino)

В коде прошивки реализован функционал:

  • статистика на главной странице

  • доступ к настройкам по паролю

  • обновление по воздуху (OTA)

  • персонализация идентификации вэб интерфейса и устройства (текстовые метки и цветовое оформление)

  • индикация подключения к wifi (подключено/не подключено)

  • возможность изменения адреса вэбхук

  • возможность изменения настроек подключения к wifi

Вэб интерфейс устройства

Вэб интерфейс устройства

Т.к. устройства изначально проектировалось под свои задачи то все параметры внесены в прошивку с минимизацией дополнительных настроек. Код предоставляется читателям «как есть» для ознакомления или самостоятельной модернизации

#include <ESP8266WiFi.h>#include <ESP8266WebServer.h>#include <ESP8266HTTPClient.h>#include <ESP8266HTTPUpdateServer.h> #include <WiFiClient.h>#include <FS.h>#include <LittleFS.h>#include "EmonLib.h"// =========================================================================// БЛОК ЦЕНТРАЛЬНЫХ НАСТРОЕК (ЗДЕСЬ МЕНЯТЬ ДАННЫЕ ПО УМОЛЧАНИЮ)// =========================================================================const char* WIFI_SSID_DEFAULT   = "YOU_WIFI";          const char* WIFI_PASS_DEFAULT   = "password_wifi";    const char* ADMIN_PASS_DEFAULT  = "admin";           const char* N8N_WEBHOOK_DEFAULT = "http://192.168.11.11:5678/webhook/device-monitor"; // =========================================================================// СТРУКТУРА НАСТРОЕК (ХРАНИТСЯ В LITTLEFS)// =========================================================================struct WebConfig {  char wifi_ssid[32];  char wifi_pass[64];  char admin_pass[32];  char n8n_webhook[128];  float currentThreshold;  bool ledDisabled;     // Поля оформления и идентификации  char company_name[64];  char header_desc[64];  char device_type[32];  char inv_number[32];     char header_color[16];     char menu_color[16];       char bg_color[16];       };WebConfig myConfig;// --- НАСТРОЙКИ ЖЕЛЕЗА ESP-12F И КАЛИБРОВКИ ---const int sensorPin = A0;const int ledBlue   = LED_BUILTIN; const int ledGreen  = 12;          const int ledRed    = 13;          EnergyMonitor emon1;float calibrationK = 15.0;    float noiseOffset = 1.18;    float voltage = 230.0;       // --- ПОРОГИ И ТАЙМИНГИ ---float PX_threshold = 4.0;     const unsigned long DEBOUNCE_TIME = 2000;       const unsigned long WIFI_BLINK_INTERVAL = 300;  // --- ПЕРЕМЕННЫЕ ЛОГИКИ ---String deviceName = "";enum State { STOP, PX };State currentFlag = STOP;State targetState = STOP;unsigned long pxStartTime = 0;unsigned long stateChangeTimer = 0;unsigned long lastMeasurementTime = 0; bool onlineSent = false;bool wifiWasConnected = false;const char* BUFFER_FILE = "/offline_buffer.dat";const char* TEMP_FILE = "/tmp_buffer.dat";const char* WEB_CONFIG_FILE = "/web_config.bin";ESP8266WebServer server(80);ESP8266HTTPUpdateServer httpUpdater; WiFiClient wifiClient; float currentRMS = 0.0; struct LogEntry {    int markerType;     unsigned long duration;};// --- ДЕКЛАРАЦИЯ ПРОТОТИПОВ ФУНКЦИЙ ---void saveEventToBuffer(int markerType, unsigned long duration);bool sendWebhook(int markerType, unsigned long duration);void processOfflineBuffer();void handleLogin(const String& targetUrl, const String& activePage);String getHeaderHTML(const String& pageTitle, const String& activePage, bool autoRefresh);String getFooterHTML();void applyWebConfig();void loadWebConfig();void saveWebConfig();void initStorage();void handleWifiDisconnectBlink();// =========================================================================// ФУНКЦИИ РАБОТЫ С СИСТЕМОЙ ХРАНЕНИЯ// =========================================================================void applyWebConfig() {    PX_threshold = myConfig.currentThreshold;}void loadWebConfig() {    if (LittleFS.exists(WEB_CONFIG_FILE)) {        File f = LittleFS.open(WEB_CONFIG_FILE, "r");        if (f) {            size_t bytesRead = f.read((uint8_t*)&myConfig, sizeof(WebConfig));            f.close();            if (bytesRead == sizeof(WebConfig)) {                applyWebConfig();                return;            }        }    }        memset(&myConfig, 0, sizeof(WebConfig));    strncpy(myConfig.wifi_ssid, WIFI_SSID_DEFAULT, sizeof(myConfig.wifi_ssid) - 1);    strncpy(myConfig.wifi_pass, WIFI_PASS_DEFAULT, sizeof(myConfig.wifi_pass) - 1);    strncpy(myConfig.admin_pass, ADMIN_PASS_DEFAULT, sizeof(myConfig.admin_pass) - 1);    strncpy(myConfig.n8n_webhook, N8N_WEBHOOK_DEFAULT, sizeof(myConfig.n8n_webhook) - 1);    myConfig.currentThreshold = 4.0;    myConfig.ledDisabled = false;         strncpy(myConfig.company_name, "IIoT", sizeof(myConfig.company_name) - 1);    strncpy(myConfig.header_desc, "укажите имя устройства", sizeof(myConfig.header_desc) - 1);    strncpy(myConfig.device_type, "Основное", sizeof(myConfig.device_type) - 1);     strncpy(myConfig.inv_number, "не указан", sizeof(myConfig.inv_number) - 1);         strncpy(myConfig.header_color, "#ed1c24", sizeof(myConfig.header_color) - 1);     strncpy(myConfig.menu_color, "#000000", sizeof(myConfig.menu_color) - 1);       strncpy(myConfig.bg_color, "#ffffff", sizeof(myConfig.bg_color) - 1);               saveWebConfig();}void saveWebConfig() {    File f = LittleFS.open(WEB_CONFIG_FILE, "w");    if (f) {        f.write((uint8_t*)&myConfig, sizeof(WebConfig));        f.close();        applyWebConfig();    }}void initStorage() {    if (!LittleFS.begin()) {        LittleFS.format();        LittleFS.begin();    }    loadWebConfig();}bool isAuthenticated() {    if (!server.hasHeader("Cookie")) return false;    String cookie = server.header("Cookie");    String targetAuth = "auth=" + String(myConfig.admin_pass);    int index = cookie.indexOf(targetAuth);    if (index == -1) return false;    char nextChar = cookie.charAt(index + targetAuth.length());    if (nextChar == '\0' || nextChar == ';' || nextChar == ' ') return true;    return false;}// =========================================================================// ПЕРЕРАБОТАННЫЙ ВЕБ-ИНТЕРФЕЙС — ДВУХСТРОЧНОЕ МЕНЮ И СТИЛИ// =========================================================================String getHeaderHTML(const String& pageTitle, const String& activePage, bool autoRefresh) {  String html;  html.reserve(4200);   html += F("<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width, initial-scale=1.0'>");  if (autoRefresh) html += F("<meta http-equiv='refresh' content='5'>"); // Оптимизировано до 5 секунд против зависаний  html += F("<title>"); html += pageTitle; html += F("</title><style>");  html += F("html,body{height:100%;margin:0;}body{font-family:Verdana,sans-serif;font-size:12px;color:#000;background-color:");  html += String(myConfig.bg_color);   html += F(";display:flex;flex-direction:column;}");  html += F(".header{background-color:"); html += String(myConfig.header_color); html += F(";color:#fff;padding:15px;text-align:left;}");  html += F(".header-title{font-size:18px;font-weight:bold;margin:0 0 5px 0;}.header-subnet{font-size:12px;margin:0 0 5px 0;}.header-desc{font-size:12px;margin:0;}");    // Стили для двухстрочного меню  html += F(".menu-container{background-color:"); html += String(myConfig.menu_color); html += F(";width:100%;display:block;}");  html += F(".menu-row{width:100%;clear:both;overflow:hidden;box-sizing:border-box;}");  html += F(".menu-row.top-row{border-bottom:1px solid #444;}");  html += F(".menu-row a{display:block;padding:10px 14px;text-decoration:none;font-size:13px;}");  html += F(".menu-row a:hover{background-color:#555;color:#fff;}");    // Верхняя строка: влево, белые ссылки  html += F(".top-row a{float:left;color:#fff;font-weight:normal;}");  html += F(".top-row a.active{font-weight:bold;text-decoration:underline;}");    // Нижняя строка: вправо, серые ссылки  html += F(".bottom-row a{float:right;color:#888;font-weight:normal;}");  html += F(".bottom-row a.active{color:#fff;font-weight:bold;text-decoration:underline;}");    html += F(".container{padding:20px;flex:1;}.page-title{font-size:14px;font-weight:bold;margin-bottom:15px;}");  html += F("input[type=text],input[type=password],input[type=number],select{font-family:Verdana;font-size:12px;padding:5px;width:250px;margin-bottom:10px;display:block;}");  html += F("input[type=submit]{font-family:Verdana;font-size:12px;padding:6px 15px;background-color:#333;color:#fff;border:none;cursor:pointer;}input[type=submit]:hover{background-color:#555;}.error-msg{color:red;font-weight:bold;margin-bottom:10px;}");  html += F(".footer{text-align:right;padding:20px;background-color:transparent;color:#000;font-size:12px;}.footer a{color:#0066cc;text-decoration:underline;}</style></head><body>");    // Вывод шапки с 5 строками  String currentConnectedNet = (WiFi.status() == WL_CONNECTED) ? WiFi.SSID() : F("Connecting...");  html += F("<div class='header'><p class='header-title'>"); html += String(myConfig.company_name);  html += F("</p><p class='header-subnet'>Подключено к Wi-Fi: "); html += currentConnectedNet;    if (strlen(myConfig.device_type) > 0) {      html += F("</p><p class='header-subnet'>Тип устройства: "); html += String(myConfig.device_type);  }    html += F("</p><p class='header-subnet'>Инвентарный номер: "); html += String(myConfig.inv_number);  html += F("</p><p class='header-desc'>Идентификатор: "); html += String(myConfig.header_desc);  html += F("</p></div>");    // Отрисовка двухстрочного меню  html += F("<div class='menu-container'>");    // Строка 1: Оперативная (Влево, Белый текст)  html += F("<div class='menu-row top-row'>");  html += F("<a href='/'"); html += (activePage == "root" ? F(" class='active'") : F("")); html += F(">Состояние</a>");  html += F("<a href='/sensor'"); html += (activePage == "sensor" ? F(" class='active'") : F("")); html += F(">Датчик</a>");  html += F("<a href='/n8n'"); html += (activePage == "n8n" ? F(" class='active'") : F("")); html += F(">Автоматизация N8N</a></div>");    // Строка 2: Административная (Вправо, Серый текст)  html += F("<div class='menu-row bottom-row'>");  html += F("<a href='/update'"); html += (activePage == "update" ? F(" class='active'") : F("")); html += F(">Микропрограмма</a>");  html += F("<a href='/access'"); html += (activePage == "access" ? F(" class='active'") : F("")); html += F(">Доступ</a>");  html += F("<a href='/style'"); html += (activePage == "style" ? F(" class='active'") : F("")); html += F(">Оформление</a></div></div>");    html += F("<div class='container'>");  return html;}String getFooterHTML() {  String html; html.reserve(200);  html += F("</div><div class='footer'>2026 г.</div></body></html>");  return html;}void handleLogin(const String& targetUrl, const String& activePage) {  if (isAuthenticated()) { server.sendHeader("Location", targetUrl); server.send(303); return; }  if (server.hasArg("password") && server.arg("password") == String(myConfig.admin_pass)) {      server.sendHeader("Set-Cookie", "auth=" + String(myConfig.admin_pass) + "; Path=/; HttpOnly");      server.sendHeader("Location", targetUrl); server.send(303); return;  }  String html = getHeaderHTML("Авторизация", activePage, false); html.reserve(800);  html += F("<div class='page-title'>Доступ ограничен. Введите пароль администратора:</div>");  if (server.hasArg("password")) html += F("<p class='error-msg'>Неверный пароль!</p>");  html += F("<form method='POST' action='"); html += targetUrl;  html += F("'>Пароль доступа: <input type='password' name='password'><input type='submit' value='Войти'></form>");  html += getFooterHTML(); server.send(200, "text/html", html);}// =========================================================================// СТРАНИЦЫ ИНТЕРФЕЙСА И НАСТРОЕК (ВЕРХНЯЯ И НИЖНЯЯ СТРОКИ МЕНЮ)// =========================================================================void handleRoot() {  if (server.hasArg("update_led")) {    myConfig.ledDisabled = server.hasArg("led_state");    saveWebConfig(); server.sendHeader("Location", "/"); server.send(303); return;  }  String html = getHeaderHTML("Состояние", "root", true); html.reserve(1200);  html += F("<div class='page-title'>Состояние системы</div><p>• Имя устройства (MAC): <b>"); html += WiFi.macAddress();  html += F("</b></p><p>• Текущий адрес (IP): <b>"); html += WiFi.localIP().toString();  html += F("</b></p><p>• Значение датчика: <b>"); html += String(currentRMS, 2);  html += F(" А</b></p><p>• Порог срабатывания: <b>"); html += String(myConfig.currentThreshold, 2);  html += F(" А</b></p><br><form method='POST' action='/'><input type='hidden' name='update_led' value='1'><label style='font-family:Verdana;font-size:12px;'>");  html += F("<input type='checkbox' name='led_state' value='1'"); html += (myConfig.ledDisabled ? F(" checked") : F(""));  html += F(" onchange='this.form.submit()'> Выключить индикаторы </label></form>");  html += getFooterHTML(); server.send(200, "text/html", html);}void handleSensor() {  if (!isAuthenticated()) { handleLogin("/sensor", "sensor"); return; }  if (server.hasArg("threshold")) { myConfig.currentThreshold = server.arg("threshold").toFloat(); saveWebConfig(); }  String html = getHeaderHTML("Настройки датчика", "sensor", false); html.reserve(1000);  html += F("<div class='page-title'>Параметры датчика</div><p>Текущее значение датчика: <b>"); html += String(currentRMS, 2);  html += F(" А</b></p><form method='POST' action='/sensor'>Порог срабатывания: <input type='number' step='0.01' name='threshold' value='");  html += String(myConfig.currentThreshold, 2); html += F("'><input type='submit' value='Сохранить'></form>");  html += getFooterHTML(); server.send(200, "text/html", html);}void handleN8N() {  if (!isAuthenticated()) { handleLogin("/n8n", "n8n"); return; }  if (server.hasArg("webhook")) { server.arg("webhook").toCharArray(myConfig.n8n_webhook, sizeof(myConfig.n8n_webhook)); saveWebConfig(); }  String html = getHeaderHTML("Настройки N8N", "n8n", false); html.reserve(1000);  html += F("<div class='page-title'>Автоматизация N8N</div><form method='POST' action='/n8n'>Адрес web-hook: <input type='text' name='webhook' value='");  html += String(myConfig.n8n_webhook); html += F("' style='width:400px;'><input type='submit' value='Сохранить'></form>");  html += getFooterHTML(); server.send(200, "text/html", html);}void handleStyle() {  if (!isAuthenticated()) { handleLogin("/style", "style"); return; }    if (server.hasArg("c_name") && server.hasArg("h_desc") && server.hasArg("d_type") && server.hasArg("i_num") &&      server.hasArg("h_col") && server.hasArg("m_col") && server.hasArg("bg_col")) {    server.arg("c_name").toCharArray(myConfig.company_name, sizeof(myConfig.company_name));    server.arg("h_desc").toCharArray(myConfig.header_desc, sizeof(myConfig.header_desc));    server.arg("d_type").toCharArray(myConfig.device_type, sizeof(myConfig.device_type));    server.arg("i_num").toCharArray(myConfig.inv_number, sizeof(myConfig.inv_number));    server.arg("h_col").toCharArray(myConfig.header_color, sizeof(myConfig.header_color));    server.arg("m_col").toCharArray(myConfig.menu_color, sizeof(myConfig.menu_color));    server.arg("bg_col").toCharArray(myConfig.bg_color, sizeof(myConfig.bg_color));    saveWebConfig();  }    String html = getHeaderHTML("Оформление", "style", false); html.reserve(2800);  html += F("<div class='page-title'>Внешний вид и идентификация</div><form method='POST' action='/style'>");    html += F("Имя компании (64 символа): <input type='text' name='c_name' maxlength='63' value='"); html += String(myConfig.company_name);    html += F("'>Тип устройства: <select name='d_type'>");  html += F("<option value='Основное'"); html += (String(myConfig.device_type) == F("Основное") ? F(" selected") : F("")); html += F(">Основное</option>");  html += F("<option value='Вспомогательное'"); html += (String(myConfig.device_type) == F("Вспомогательное") ? F(" selected") : F("")); html += F(">Вспомогательное</option>");  html += F("<option value=''"); html += (strlen(myConfig.device_type) == 0 ? F(" selected") : F("")); html += F(">Пустое значение</option></select>");    html += F("Инвентарный номер (32 символа): <input type='text' name='i_num' maxlength='31' value='"); html += String(myConfig.inv_number);    html += F("'>Идентификатор устройства (64 символа): <input type='text' name='h_desc' maxlength='63' value='"); html += String(myConfig.header_desc);    html += F("'>Цвет фона заголовка: <input type='color' name='h_col' value='"); html += String(myConfig.header_color);  html += F("' style='width:60px; height:30px; margin-bottom:10px; display:block;'>");    html += F("Цвет фона строк меню: <input type='color' name='m_col' value='"); html += String(myConfig.menu_color);  html += F("' style='width:60px; height:30px; margin-bottom:10px; display:block;'>");    html += F("Цвет фона страницы: <input type='color' name='bg_col' value='"); html += String(myConfig.bg_color);  html += F("' style='width:60px; height:30px; margin-bottom:15px; display:block;'>");    html += F("<input type='submit' value='Применить изменения'></form>");  html += getFooterHTML(); server.send(200, "text/html", html);}// =========================================================================// СТРАНИЦЫ АДМИНИСТРИРОВАНИЯ И МИКРОПРОГРАММЫ — КОНЕЦ ВЕБ-СЕРВЕРА// =========================================================================void handleAccess() {  if (!isAuthenticated()) { handleLogin("/access", "access"); return; }  if (server.hasArg("reboot")) {    server.sendHeader("Refresh", "10; url=/");     String html = getHeaderHTML("Перезагрузка", "access", false);    html += F("<div class='page-title'>Перезагрузка...</div><p>Устройство перезапускается...</p>");    html += getFooterHTML(); server.send(200, "text/html", html);    delay(1000); ESP.restart(); return;  }    if (server.hasArg("ssid") && server.hasArg("pass")) {    server.arg("ssid").toCharArray(myConfig.wifi_ssid, sizeof(myConfig.wifi_ssid));    server.arg("pass").toCharArray(myConfig.wifi_pass, sizeof(myConfig.wifi_pass));    saveWebConfig();    String html = getHeaderHTML("Настройки сохранены", "access", false);    html += F("<div class='page-title'>Параметры сети изменены!</div><p>Устройство перезагружается...</p>");    html += getFooterHTML(); server.send(200, "text/html", html);    delay(1000); ESP.restart(); return;  }    if (server.hasArg("admin_p")) { server.arg("admin_p").toCharArray(myConfig.admin_pass, sizeof(myConfig.admin_pass)); saveWebConfig(); }    String html = getHeaderHTML("Доступ", "access", false); html.reserve(1600);  html += F("<div class='page-title'>Администрирование и сеть</div><form method='POST' action='/access'>");  html += F("Имя сети Wi-Fi (SSID): <input type='text' name='ssid' value='"); html += String(myConfig.wifi_ssid);  html += F("'>Пароль Wi-Fi: <input type='password' name='pass' value='"); html += String(myConfig.wifi_pass);  html += F("'><br>Пароль администратора: <input type='password' name='admin_p' value='"); html += String(myConfig.admin_pass);  html += F("'><input type='submit' value='Сохранить параметры'></form><br><hr><br><form method='POST' action='/access'><input type='hidden' name='reboot' value='true'>");  html += F("<input type='submit' value='Перезагрузить устройство' style='background-color:#ed1c24;'></form>");  html += getFooterHTML(); server.send(200, "text/html", html);}void handleUpdatePage() {  if (!isAuthenticated()) { handleLogin("/update", "update"); return; }    String html = getHeaderHTML("Микропрограмма", "update", false); html.reserve(1200);  html += F("<div class='page-title'>Обновление микропрограммы по воздуху (OTA)</div>");  html += F("<p>Выберите скомпилированный бинарный файл <b>(.bin)</b> для загрузки в ESP-12F:</p><br>");    html += F("<form method='POST' action='/do_update' enctype='multipart/form-data' style='background:#f4f4f4; padding:20px; border:1px solid #ccc; width:350px;'>");  html += F("  <input type='file' name='update' style='margin-bottom:15px; display:block;'>");  html += F("  <input type='submit' value='Загрузить и обновить' style='background-color:#ed1c24; color:white;'>");  html += F("</form>");    html += getFooterHTML();  server.send(200, "text/html", html);}// =========================================================================// ЛОГИКА БУФЕРА, СЕТИ, ЗАМЕРОВ И ИНДИКАЦИИ// =========================================================================void saveEventToBuffer(int markerType, unsigned long duration) {    File f = LittleFS.open(BUFFER_FILE, "a");    if (!f) return;    LogEntry entry;    entry.markerType = markerType;    entry.duration = duration;    f.write((uint8_t*)&entry, sizeof(LogEntry));    f.close();}bool sendWebhook(int markerType, unsigned long duration) {    if (WiFi.status() != WL_CONNECTED) return false;    HTTPClient http;    http.setTimeout(3000);    if (http.begin(wifiClient, myConfig.n8n_webhook)) {        http.addHeader(F("Content-Type"), F("application/json"));                // Четкое разделение режимов: PX (1) и ONLINE (3)        String modeStr;        unsigned long currentDuration = duration;                if (markerType == 1) {            modeStr = F("PX");              // Окончание рабочего хода        } else if (markerType == 3) {            modeStr = F("ONLINE");          // Устройство вышло на связь            currentDuration = 0;        } else {            modeStr = F("STOP");            // Защитный флаг для буфера            currentDuration = 0;        }                // Сборка JSON строго с англоязычными ключами по ТЗ        String jsonPayload; jsonPayload.reserve(300);        jsonPayload += F("{\"mac_address\":\"");   jsonPayload += WiFi.macAddress();        jsonPayload += F("\",\"device_type\":\"");  jsonPayload += String(myConfig.device_type);         jsonPayload += F("\",\"inv_number\":\"");   jsonPayload += String(myConfig.inv_number);         jsonPayload += F("\",\"identifier\":\"");   jsonPayload += String(myConfig.header_desc);         jsonPayload += F("\",\"mode\":\"");         jsonPayload += modeStr;         jsonPayload += F("\",\"duration\":");       jsonPayload += String(currentDuration);        jsonPayload += F("}");                int httpResponseCode = http.POST(jsonPayload);        http.end();        return (httpResponseCode >= 200 && httpResponseCode < 300);    }    return false;}void processOfflineBuffer() {    if (!LittleFS.exists(BUFFER_FILE)) return;    File f = LittleFS.open(BUFFER_FILE, "r");    if (!f) return;    if (f.size() / sizeof(LogEntry) == 0) { f.close(); LittleFS.remove(BUFFER_FILE); return; }        bool allSent = true;    size_t lastSuccessfulPosition = 0;    while (f.available() >= sizeof(LogEntry)) {        LogEntry entry;        f.read((uint8_t*)&entry, sizeof(LogEntry));        if (sendWebhook(entry.markerType, entry.duration)) {            lastSuccessfulPosition = f.position();            ESP.wdtFeed();            delay(10);        } else { allSent = false; break; }    }    f.close();    if (allSent) {        LittleFS.remove(BUFFER_FILE);    } else if (lastSuccessfulPosition > 0) {        File fSrc = LittleFS.open(BUFFER_FILE, "r");        File fTmp = LittleFS.open(TEMP_FILE, "w");        if (fSrc && fTmp) {            fSrc.seek(lastSuccessfulPosition, SeekSet);            while (fSrc.available() >= sizeof(LogEntry)) {                LogEntry entry; fSrc.read((uint8_t*)&entry, sizeof(LogEntry));                fTmp.write((uint8_t*)&entry, sizeof(LogEntry));            }        }        if (fSrc) fSrc.close(); if (fTmp) fTmp.close();        LittleFS.remove(BUFFER_FILE); LittleFS.rename(TEMP_FILE, BUFFER_FILE);    }}void handleWifiDisconnectBlink() {    static unsigned long lastWifiBlink = 0;    static bool toggleState = false;    unsigned long currentMillis = millis();        digitalWrite(ledBlue, HIGH);    digitalWrite(ledGreen, HIGH);    if (currentMillis - lastWifiBlink >= WIFI_BLINK_INTERVAL) {        lastWifiBlink = currentMillis;        toggleState = !toggleState;        digitalWrite(ledRed, toggleState ? LOW : HIGH);     }}void setup() {    Serial.begin(115200);    delay(2500);     Serial.println();    Serial.println(F("===================================="));    Serial.println(F("[SYSTEM] Запуск системы мониторинга..."));    Serial.println(F("===================================="));        pinMode(ledBlue, OUTPUT);    pinMode(ledGreen, OUTPUT);    pinMode(ledRed, OUTPUT);        digitalWrite(ledBlue, HIGH);    digitalWrite(ledGreen, HIGH);    digitalWrite(ledRed, HIGH);        initStorage();    emon1.current(sensorPin, calibrationK);         WiFi.mode(WIFI_STA);    deviceName = "ESP12F_" + WiFi.macAddress();    deviceName.replace(":", "");         WiFi.begin(myConfig.wifi_ssid, myConfig.wifi_pass);        server.collectHeaders("Cookie");         server.on("/", HTTP_GET, handleRoot); server.on("/", HTTP_POST, handleRoot);     server.on("/sensor", HTTP_GET, handleSensor); server.on("/sensor", HTTP_POST, handleSensor);    server.on("/n8n", HTTP_GET, handleN8N); server.on("/n8n", HTTP_POST, handleN8N);    server.on("/style", HTTP_GET, handleStyle); server.on("/style", HTTP_POST, handleStyle);     server.on("/access", HTTP_GET, handleAccess); server.on("/access", HTTP_POST, handleAccess);    server.on("/update", HTTP_GET, handleUpdatePage);        httpUpdater.setup(&server, "/do_update");        server.begin();        // Защита от ложного старта: определяем статус станка на включении питания    double startIrms = emon1.calcIrms(740);    if (startIrms >= PX_threshold) {        currentFlag = PX;        pxStartTime = millis();    } else {        currentFlag = STOP;    }        ESP.wdtEnable(2000); }void loop() {    ESP.wdtFeed();     server.handleClient();     unsigned long currentMillis = millis();    bool isConnected = (WiFi.status() == WL_CONNECTED);        if (!isConnected) {        handleWifiDisconnectBlink();         wifiWasConnected = false;        onlineSent = false;     } else {        digitalWrite(ledRed, HIGH);         if (!wifiWasConnected) {            Serial.print(F("[WIFI] Connected. IP: ")); Serial.print(WiFi.localIP());            Serial.printf(" | Pass: %s\n", myConfig.admin_pass);            wifiWasConnected = true;        }        if (!onlineSent) {            if (sendWebhook(3, 0)) { // 3 = ONLINE                onlineSent = true;                processOfflineBuffer();             }        }                if (myConfig.ledDisabled) {            digitalWrite(ledBlue, HIGH);            digitalWrite(ledGreen, HIGH);        } else {            digitalWrite(ledBlue, LOW);             digitalWrite(ledGreen, LOW);         }    }    if (currentMillis - lastMeasurementTime >= 500) {        lastMeasurementTime = currentMillis;        ESP.wdtFeed();                 server.handleClient();        yield();                 double rawIrms = emon1.calcIrms(740);                 yield();         double cleanIrms = (rawIrms > noiseOffset) ? rawIrms : 0.0;        currentRMS = cleanIrms;                 State instantaneousState = (cleanIrms >= PX_threshold) ? PX : STOP;        if (instantaneousState != currentFlag) {            if (instantaneousState != targetState) {                targetState = instantaneousState;                stateChangeTimer = currentMillis;             } else if (currentMillis - stateChangeTimer >= DEBOUNCE_TIME) {                                // Нагрузка выключилась — отправляем пакет PX со временем работы                if (instantaneousState == STOP && currentFlag == PX) {                    unsigned long durationSec = (currentMillis - pxStartTime) / 1000;                    currentFlag = STOP;                    if (!sendWebhook(1, durationSec)) {                        saveEventToBuffer(1, durationSec);                        onlineSent = false;                     }                }                 // Нагрузка включилась — просто молча взводим внутренний таймер                else if (instantaneousState == PX && currentFlag == STOP) {                    currentFlag = PX;                    pxStartTime = currentMillis;                 }                stateChangeTimer = 0;             }        } else {            targetState = currentFlag;            stateChangeTimer = 0;        }                static unsigned long lastSerialLogTime = 0;        if (currentMillis - lastSerialLogTime >= 5000) {            lastSerialLogTime = currentMillis;            Serial.printf("[MONITOR] Ток: %.2f A | %s | Wi-Fi: %s\n",                           cleanIrms, (currentFlag == PX) ? "РАБОЧИЙ ХОД" : "СТОП", isConnected ? "ONLINE" : "OFFLINE");        }    }}
Про workflow (код файла .json)

В связи с увеличением числа оборудования попавшем (и продолжающим попадать) под мониторинг данные были разведены на два потока — основное (непосредственно участвующему в техпроцессе) и вспомогательное (сервисное, обеспечивающее)

Текущий workflow

Текущий workflow

Листинг workflow можно импортировать сохранив в файл расширения .json — типовая возможность n8n:

{  "name": "Мониторинг оборудования на ПК",  "nodes": [    {      "parameters": {        "httpMethod": "POST",        "path": "device-monitor",        "options": {}      },      "name": "Webhook",      "type": "n8n-nodes-base.webhook",      "typeVersion": 1,      "position": [        32,        0      ],      "id": "b0292397-eccf-44df-95ed-aaf0ac3a2cf9",      "webhookId": "03bc596a-14be-4764-8818-9ed7573de00c"    },    {      "parameters": {        "keepOnlySet": true,        "values": {          "string": [            {              "name": "Дата",              "value": "={{ $now.format('yyyy-MM-dd') }}"            },            {              "name": "Время",              "value": "={{ $now.format('HH:mm:ss') }}"            },            {              "name": "Имя устройства",              "value": "={{ $json.body.identifier }}"            },            {              "name": "Маркер",              "value": "={{ $json.body.mode }}"            },            {              "name": "Длительность (сек)",              "value": "={{ $json.body.duration }}"            },            {              "name": "Тип устройства",              "value": "={{ $json.body.device_type }}"            },            {              "name": "Инв.номер",              "value": "={{ $json.body.inv_number }}"            },            {              "name": "Устройство",              "value": "={{ $json.body.mac_address }}"            }          ]        },        "options": {}      },      "name": "Format Data",      "type": "n8n-nodes-base.set",      "typeVersion": 1,      "position": [        224,        0      ],      "id": "70134335-0837-4eb4-a94d-4938009842a3"    },    {      "parameters": {        "rules": {          "values": [            {              "conditions": {                "options": {                  "caseSensitive": true,                  "leftValue": "",                  "typeValidation": "strict",                  "version": 3                },                "conditions": [                  {                    "id": "2e781f25-b9a1-4379-8fae-43abb742f0de",                    "leftValue": "={{ $json['Тип устройства'] }}",                    "rightValue": "Основное",                    "operator": {                      "type": "string",                      "operation": "equals"                    }                  }                ],                "combinator": "and"              },              "renameOutput": true,              "outputKey": "Цех"            }          ]        },        "options": {          "fallbackOutput": "extra",          "renameFallbackOutput": "Вспомогательное"        }      },      "type": "n8n-nodes-base.switch",      "typeVersion": 3.4,      "position": [        400,        0      ],      "id": "69fb53d5-d7ce-4ae2-9785-283f5cc4b661",      "name": "Switch"    },    {      "parameters": {        "jsCode": "const d = $input.item.json;\n\n// 1. Статус (Маркер)\nconst statusMarker = d[\"Маркер\"] !== undefined ? d[\"Маркер\"] : \"\";\n\n// 2. Длительность (сек)\nconst duration = d[\"Длительность (сек)\"] !== undefined ? d[\"Длительность (сек)\"] : \"0\";\n\n// 3. Тип оборудования (Тип устройства)\nconst deviceType = d[\"Тип устройства\"] !== undefined ? d[\"Тип устройства\"] : \"\";\n\n// 4. Инвентарный номер\nconst invNumber = (d[\"Инв\"] && d[\"Инв\"][\"номер\"]) !== undefined ? d[\"Инв\"][\"номер\"] : \"\";\n\n// 5. MAC-адрес (Устройство)\nconst macAddress = d[\"Устройство\"] !== undefined ? d[\"Устройство\"] : \"\";\n\n// Сборка строки строго по вашему ТЗ\nconst csvRow = `${d[\"Дата\"]};${d[\"Время\"]};${d[\"Имя устройства\"]};${statusMarker};${duration};${deviceType};${invNumber};${macAddress}\\r\\n`;\n\n// Кодирование в Base64 для ноды записи файла\nconst base64Data = Buffer.from('\\ufeff' + csvRow, 'utf8').toString('base64');\n\nreturn [\n  {\n    json: d,\n    binary: {\n      data: {\n        data: base64Data,\n        mimeType: 'text/csv; charset=utf-8',\n        fileName: 'service_device.csv'\n      }\n    }\n  }\n];\n"      },      "type": "n8n-nodes-base.code",      "typeVersion": 2,      "position": [        672,        128      ],      "id": "201f566b-2d05-4492-94d8-4a73616c0ad4",      "name": "CodeJS_for Service_Device"    },    {      "parameters": {        "jsCode": "const d = $input.item.json;\n\n// 1. Статус (Маркер)\nconst statusMarker = d[\"Маркер\"] !== undefined ? d[\"Маркер\"] : \"\";\n\n// 2. Длительность (сек)\nconst duration = d[\"Длительность (сек)\"] !== undefined ? d[\"Длительность (сек)\"] : \"0\";\n\n// 3. Тип оборудования (Тип устройства)\nconst deviceType = d[\"Тип устройства\"] !== undefined ? d[\"Тип устройства\"] : \"\";\n\n// 4. Инвентарный номер\nconst invNumber = (d[\"Инв\"] && d[\"Инв\"][\"номер\"]) !== undefined ? d[\"Инв\"][\"номер\"] : \"\";\n\n// 5. MAC-адрес (Устройство)\nconst macAddress = d[\"Устройство\"] !== undefined ? d[\"Устройство\"] : \"\";\n\n// Сборка строки строго по вашему ТЗ\nconst csvRow = `${d[\"Дата\"]};${d[\"Время\"]};${d[\"Имя устройства\"]};${statusMarker};${duration};${deviceType};${invNumber};${macAddress}\\r\\n`;\n\n// Кодирование в Base64 для ноды записи файла\nconst base64Data = Buffer.from('\\ufeff' + csvRow, 'utf8').toString('base64');\n\nreturn [\n  {\n    json: d,\n    binary: {\n      data: {\n        data: base64Data,\n        mimeType: 'text/csv; charset=utf-8',\n        fileName: 'monitor.csv'\n      }\n    }\n  }\n];"      },      "type": "n8n-nodes-base.code",      "typeVersion": 2,      "position": [        672,        -112      ],      "id": "5b5f8cd9-e774-430b-8a37-8211a573293d",      "name": "CodeJS_for Main_Device"    },    {      "parameters": {        "operation": "write",        "fileName": "/shared_files/monitoring_pila/monitor.csv",        "options": {          "append": true        }      },      "type": "n8n-nodes-base.readWriteFile",      "typeVersion": 1.1,      "position": [        880,        -112      ],      "id": "c8b040c8-74d7-42a4-bc99-df69a1c23221",      "name": "Write monitor.csv"    },    {      "parameters": {        "operation": "write",        "fileName": "/shared_files/monitoring_pila/service_device.csv",        "options": {          "append": true        }      },      "type": "n8n-nodes-base.readWriteFile",      "typeVersion": 1.1,      "position": [        880,        128      ],      "id": "37013596-76b9-4d30-8033-a6a523039fb3",      "name": "Write service_device.csv"    }  ],  "pinData": {},  "connections": {    "Webhook": {      "main": [        [          {            "node": "Format Data",            "type": "main",            "index": 0          }        ]      ]    },    "Format Data": {      "main": [        [          {            "node": "Switch",            "type": "main",            "index": 0          }        ]      ]    },    "Switch": {      "main": [        [          {            "node": "CodeJS_for Main_Device",            "type": "main",            "index": 0          }        ],        [          {            "node": "CodeJS_for Service_Device",            "type": "main",            "index": 0          }        ]      ]    },    "CodeJS_for Service_Device": {      "main": [        [          {            "node": "Write service_device.csv",            "type": "main",            "index": 0          }        ]      ]    },    "CodeJS_for Main_Device": {      "main": [        [          {            "node": "Write monitor.csv",            "type": "main",            "index": 0          }        ]      ]    }  },  "active": true,  "settings": {    "executionOrder": "v1",    "binaryMode": "separate",    "availableInMCP": false  },  "versionId": "d15a7a44-4c7b-4e07-990c-43bf6d036d54",  "meta": {    "instanceId": "9f089c0c79dd0951ae393e7fedf3550c9424b030573aa787574b915237b7b469"  },  "id": "3bJfGlRw3pcBIR8l",  "tags": []}

Что нам это дает?

Ответ на самый главный вопрос бизнеса имеет несколько конкретных подпунктов, которые полностью оправдали разработку:

  • Контроль производственной дисциплины. Изначальный интерес руководства о возможности контроля рабочего времени сотрудников удовлетворен. Я скептически отношусь к подобным историям, но работаю в рамках системы. Реальное использование рабочего времени оказалось порядка 80-85% и на эту тему у нас были интересные дебаты.

  • Оптимизация логистики (самое важное на мой взгляд). Дебаты о использовании рабочего времени выявили не оптимальность поставки заготовок на данный участок. Если ранее жалобы на простои были лишь субъективным и оценочным мнением самих работников (которому руководство не всегда верило), то теперь цифры, а при необходимости камеры наблюдения предоставили реальный расклад. Стало четко видно, сколько именно станок стоит между операциями, какова их длительность и каков реальный коэффициент фактического использования оборудования. Проблема оказалась далеко не в «ленивых» рабочих.

  • У нас будет ТОиР?! Обеспечивающее подразделение производства (после соответствующего просвещения) быстро увидело возможность использования разработки в своих интересах. Например, установка аналогичных датчиков на воздушные компрессоры позволила дистанционно получать показания наработки. Внедрение отметок о проводимых работы на данном оборудовании, фактически, заложило фундамент для полноценной системы ТОиР (технического обслуживания и ремонта).

В данный момент продолжается активное тестирование системы в формате связки n8n и Excel. Параллельно идет проработка моделей использования данных. Например зная закрепление борудования за оператором и время работы можно использовать для формирования ТУРВ (табелей учета рабочего времени). Общую наработку для планирование ремонтных работ, а время работы между полной сменой пил для выявление наиболее экономически обоснованного поставщика. Полный перевод сбора данных в систему 1С предполагается к концу года (техническое ограничение — загрузка программистов и формализация в виде задания своих «хотелок» в отчетах )

На этом пока всё. Надеюсь, этот кейс по оцифровке цеха «на коленке» окажется кому-то полезным или как минимум интересным. Неописанные нюансы и оптимизацию прошивки готов обсудить в комментариях!

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