Каталог
Зателефонуйте мені
Каталог

Тижневий таймер з WiFi на ESP8285 з використанням JSON-розкладу

Тижневий таймер з WiFi на ESP8285 з використанням JSON-розкладу
Автор: Andriy Savechka Опубліковано: 17.11.2024 Переглядів: 558 Коментарів: 3

Автоматизація пристроїв за допомогою ESP8285 (та її подібних) — це ефективний спосіб впорядкувати рутинні завдання у вашому будинку або офісі. Одним із популярних рішень є розробка тижневого таймера, який дозволяє налаштувати розклад роботи пристроїв, таких як реле, для кожного дня тижня.

У цій статті я розповім, як реалізувати тижневий таймер на ESP8266, використовуючи JSON для збереження і обробки розкладу. Ви дізнаєтеся, як зчитувати дані з веб-інтерфейсу, синхронізувати час через NTP і перевіряти стан розкладу в режимі реального часу. Також покажемо, як гнучко змінювати налаштування, щоб система відповідала вашим потребам.

Це рішення стане в нагоді для автоматизації освітлення, опалення, поливу або інших систем, що вимагають регулярного ввімкнення та вимкнення протягом тижня.

Як вазон став технічним проектом: історія адаптації під нові умови

Публікуючи цю статтю, довго вагався, адже вона трохи відхиляється від нашого звичного формату про промислову автоматизацію. Але, як кажуть, не лише контролерами живемо! Усе почалося з нашого переїзду в новий офіс, разом із яким переїхав і мій улюблений вазон. Ця рослина має свою захопливу історію, яку можна розповісти окремо, але наразі не про це. Лише коротко хочу подякувати пану Ігорю, який свого часу допоміг мені виростити цей вазон. ДЯКУЮ!

У новому офісі для вазона, на жаль, не знайшлося місця біля вікна, тому йому доводиться жити під штучним освітленням. Однак на вихідних уся електрика в офісі вимикається, і рослина залишається у повній темряві. Це мене трохи стурбувало, тому я вирішив діяти. Швидко знайшов на популярному китайському сайті LED Plant Grow Light із повним спектром світла й одразу замовив. Але постало питання: як автоматизувати ввімкнення і вимкнення лампи?

Щодня вмикати її вручну – не варіант. Використовувати контролер? Занадто складно для такого завдання. Таймер на DIN-рейку? Міг би підійти, але постійно підлаштовувати його вручну – теж незручно. Тож я знову повернувся на той самий сайт і знайшов реле від SONOFF. Раніше вже мав справу з таким пристроєм і знав, що всередині стоїть ESP8285 – ідеально для перепрошивки.

Чому не скористався стандартним функціоналом від виробника? Причин кілька:

  1. Не люблю, коли щось працює за закритою логікою, і я не можу це контролювати.
  2. Ще одна програма на телефоні, яка відправляє дані невідомо куди, мені не потрібна.
  3. Немає гарантії, що одного дня все це просто не перестане працювати.

Так і народився цей невеличкий проєкт, технічну частину якого розглянемо далі. У ньому вдалося поєднати одразу кілька сучасних технологій, зокрема C++, HTML, CSS, JavaScript, WebSocket та JSON.

Увага! Важливе застереження

Перед тим як почати, хочу наголосити на важливості безпеки. У цьому проєкті ви будете працювати з електричною напругою, яка становить серйозну небезпеку для вашого життя та здоров’я.

Цей проєкт не для вас, якщо:
 - ви не знаєте, чим відрізняється фаза від нуля
 - ви не розумієте, що таке постійний і змінний струм
 - ви не дотримуєтесь правил безпеки при роботі з електроприладами.

Електрика – це не іграшка. Будьте максимально уважними та обережними, адже робота з високою напругою завжди пов’язана з ризиком. Якщо у вас є сумніви щодо своїх знань чи навичок – краще відмовтеся від реалізації цього проєкту. Ваше життя та безпека важливіші за будь-яку технічну цікавість.

Усім іншим – ласкаво просимо далі!

 

Що знадобиться з боку "заліза"

Для реалізації цього проєкту вам потрібні наступні компоненти:

  • Wi-Fi реле Sonoff BASICR2 – основа проєкту, яку будемо перепрошивати;

  • Будь-який USB-TTL адаптер – для підключення реле до комп’ютера і прошивки;

  • Pin Header з кроком 2,54 мм – для підключення адаптера до реле;

  • Паяльник – разом із усім необхідним приладдям для пайки (олово, флюс, пінцет тощо).

Зібравши всі ці інструменти, можна переходити до підготовки обладнання.

Підготовка  Sonoff BASICR2

Розбираємо корпус реле, всередині якого розташована компактна плата керування.

так вона виглядає зверху

а так знизу.

Все, що потрібно зробити, — це припаяти чотири контакти та підключити до них USB TTL адаптер.

Програмування

Після завершення складання схеми можна приступити до завантаження скетчу на пристрій.

Структура проекту виглядає так

Процес завантаження складається з двох етапів: спочатку завантажується сам скетч, а потім — дані sketch data(папка data). Тут знаходиться наша HTML сторінка.

Для цього виконайте наступні кроки:

  1. Відкрийте Sketch у Arduino IDE. Та виберіть наступні параметри для прошивки плати

  2. Переконайтеся, що ваш USB-to-Serial конвертер підключено правильно.

  3. Натисніть і утримуйте кнопку скидання (reset) на друкованій платі, підключіть USB > Serial конвертер до комп'ютера.

  4. Світлодіод на Sonoff Basic має вимкнутися. Якщо він світиться червоним або блимає синім, спробуйте знову від'єднати та під'єднати пристрій (утримуючи кнопку скидання).

  5. ESP8285 має перейти в режим завантажувача (bootloader mode).

  6. Натисніть кнопку завантаження (Upload). Процес завантаження триватиме певний час — НЕ ВІД'ЄДНУЙТЕ пристрій, поки процес не завершиться. 

  7. Відкдючіть  USB-to-Serial конвертер

  8. Натисніть і утримуйте кнопку скидання (reset) на друкованій платі, підключіть USB > Serial конвертер до комп'ютера.

  9. Перейдіть в розділ меню "Інструменти" -> ESP8266 Sketch Data Upload. НЕ ВІД'ЄДНУЙТЕ пристрій, поки процес не завершиться. 

Для тих, хто не знайомий з ESP8266 Sketch Data Upload, Ви можете завантажити цю утиліту за посиланням Arduino ESP8266 filesystem uploader

Код

У цьому розділі ми розглянемо, як працює весь код «під капотом» і що саме забезпечує реалізацію заданого функціоналу.

Cкетч ARDUINO IDE

#include <FS.h>                   // Для роботи з файловою системою SPIFFS
#include <ESP8266WiFi.h>          // Бібліотека для WiFi
#include <NTPClient.h>            // Бібліотека для роботи з NTP
#include <WebSocketsServer.h>     // Бібліотека для WebSocket
#include <ESP8266WebServer.h>     // HTTP-сервер
#include <ArduinoJson.h>          // Бібліотека для JSON v6
#include <WiFiUdp.h>              // Для UDP-з'єднання

#define relay 12
#define led 13
#define btn 0

const char* defaultSchedule = R"(
{
  "monday_start": "08:00",
  "monday_end": "17:00",
  "tuesday_start": "08:00",
  "tuesday_end": "17:00",
  "wednesday_start": "08:00",
  "wednesday_end": "17:00",
  "thursday_start": "08:00",
  "thursday_end": "17:00",
  "friday_start": "08:00",
  "friday_end": "17:00",
  "saturday_start": "10:00",
  "saturday_end": "14:00",
  "sunday_start": "10:00",
  "sunday_end": "14:00"
}
)";

const char* ap_default_psk = "q12345678";

WiFiUDP ntpUDP;
const long utcOffsetInSeconds = 3600 * 2; // Базовий зсув UTC+2 (зимовий час)
NTPClient timeClient(ntpUDP, "pool.ntp.org", utcOffsetInSeconds, 60 * 60 * 1000);
ESP8266WebServer server(80);
WebSocketsServer webSocket(81);

String scheduleData;
bool stR1, ledState, lastBtn, currentBtn, timeSyncOk= false;

const int LONG_PRESS_TIME = 4000;
unsigned long pressedTime, lastMillisWebSock, lastLedMillis,brdcast = 0;


bool isDST(int day, int month, int weekday) {
  // Літній час: остання неділя березня до останньої неділі жовтня (Європа)
  if (month < 3 || month > 10) {
    return false; // Січень, лютий, листопад, грудень - зимовий час
  }
  if (month > 3 && month < 10) {
    return true; // Квітень до вересня - літній час
  }

  int lastSunday = day - (weekday - 1); // Остання неділя місяця
  if (month == 3) {
    return lastSunday >= 25; // Літній час починається останньої неділі березня
  }
  if (month == 10) {
    return lastSunday < 25; // Літній час закінчується останньої неділі жовтня
  }
  return false; // За замовчуванням
}

// Збереження JSON у SPIFFS
void saveToSPIFFS(const char* path, const String& data) {
  File file = SPIFFS.open(path, "w");
  if (!file) {
    Serial.println("Помилка запису у файл");
    return;
  }
  file.print(data);
  file.close();
  Serial.println("Дані збережено у SPIFFS");
}

// Завантаження JSON із SPIFFS
String loadFromSPIFFS(const char* path) {
  if (!SPIFFS.exists(path)) {
    Serial.println("Файл не знайдено, повертається порожній рядок");
    return "";
  }

  File file = SPIFFS.open(path, "r");
  if (!file) {
    Serial.println("Помилка відкриття файлу");
    return "";
  }

  String data = file.readString();
  file.close();
  return data;
}

// Ініціалізація WiFi
void initWiFi() {
  WiFi.mode(WIFI_STA);
  Serial.println("\n Connecting to WiFi");
  WiFi.begin();
  unsigned long startTime = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startTime < 10000) {
    Serial.print('.');
    delay(500);
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("Connected to WiFi\nIP: " + WiFi.localIP().toString());
  } else {
    Serial.println("\nSwitching to AP mode");
    WiFi.mode(WIFI_AP);
    WiFi.softAP("Timer_" + String(ESP.getChipId(), HEX), ap_default_psk);
  }
  WiFi.setAutoReconnect(true);
  WiFi.persistent(true);
}

// Перевірка часу у діапазоні
bool isTimeInRange(String currentTime, String startTime, String endTime) {
  return (currentTime >= startTime && currentTime < endTime);
}

// Обробка подій WebSocket
void webSocketEvent(uint8_t client_num, WStype_t type, uint8_t* payload, size_t length) {
  switch (type) {
    case WStype_DISCONNECTED:
      Serial.printf("[%u] Disconnected!\n", client_num);
      break;
    case WStype_CONNECTED: {
      IPAddress ip = webSocket.remoteIP(client_num);
      Serial.printf("[%u] Connected from %d.%d.%d.%d\n", client_num, ip[0], ip[1], ip[2], ip[3]);
    } break;
    case WStype_TEXT: {
      Serial.printf("[%u] Received: %s\n", client_num, payload);

      DynamicJsonDocument doc(1024);
      DeserializationError error = deserializeJson(doc, payload);

      if (error) {
        Serial.printf("deserializeJson failed: %s\n", error.c_str());
        return;
      }

      const char* type = doc["type"];
      if (strcmp(type, "timer") == 0) {
        scheduleData = doc["data"].as<String>();
        saveToSPIFFS("/schedule.json", scheduleData);
        webSocket.sendTXT(client_num, "{\"status\":\"schedule_saved\"}");
      
      } else if (strcmp(type, "config") == 0) {
        // Створюємо об'єкт JSON
        DynamicJsonDocument doc(1024);  // Виділяємо пам'ять для JSON

        // Десеріалізуємо scheduleData і додаємо до об'єкта "data"
        deserializeJson(doc, scheduleData);

        // Додаємо поле "type"
        doc["type"] = "config";
    
        // Створюємо рядок для відправки
        String jsonData;
        serializeJson(doc, jsonData);
    
        // Відправляємо дані через WebSocket
        webSocket.sendTXT(client_num, jsonData);

      } else if (strcmp(type, "wifi") == 0) {
        const char* ssid = doc["ssid"];
        const char* pass = doc["pass"];
        WiFi.begin(ssid, pass);
        webSocket.sendTXT(client_num, "{\"status\":\"wifi_saved\"}");
        
    } else if (strcmp(type, "scan") == 0) {
      sendNetworkList(client_num);
     }
    } break;
  }
}

// Обробка кнопки
void handleButton() {
  currentBtn = !digitalRead(btn);
  if (currentBtn && !lastBtn) pressedTime = millis();

  if (currentBtn && millis() - pressedTime > LONG_PRESS_TIME) {
    Serial.println("Скидання до налаштувань за замовчуванням");
    SPIFFS.remove("/schedule.json");
    ESP.eraseConfig();
    ESP.restart();
  }
  lastBtn = currentBtn;
}

String getContentType(String filename) {
  if (server.hasArg("download")) return "application/octet-stream";
  else if (filename.endsWith(".htm")) return "text/html";
  else if (filename.endsWith(".html")) return "text/html";
  else if (filename.endsWith(".tpl")) return "text/html";
  else if (filename.endsWith(".css")) return "text/css";
  else if (filename.endsWith(".js")) return "application/javascript";
  else if (filename.endsWith(".png")) return "image/png";
  else if (filename.endsWith(".gif")) return "image/gif";
  else if (filename.endsWith(".jpg")) return "image/jpeg";
  else if (filename.endsWith(".svg")) return "image/jpeg";
  else if (filename.endsWith(".ico")) return "image/x-icon";
  else if (filename.endsWith(".xml")) return "text/xml";
  else if (filename.endsWith(".pdf")) return "application/x-pdf";
  else if (filename.endsWith(".zip")) return "application/x-zip";
  else if (filename.endsWith(".gz")) return "application/x-gzip";
  return "text/plain";
}

bool handleFileRead(String path) {
  if (path.endsWith("/")) path += "index.html";
  String contentType = getContentType(path);
  String pathWithGz = path + ".gz";
  if (SPIFFS.exists(pathWithGz) || SPIFFS.exists(path)) {
    if (SPIFFS.exists(pathWithGz))
      path += ".gz";
    File file = SPIFFS.open(path, "r");
    size_t sent = server.streamFile(file, contentType);
    file.close();
    return true;
  }
  return false;
}

// Ініціалізація HTTP-сервера
void initHttpServer() {
    server.onNotFound([]() {
    if (!handleFileRead(server.uri()))
      server.send(404, "text/plain", "FileNotFound");
  });
  server.begin();
  Serial.println("HTTP server started");
}

void sendStatus() {
    // Створення об'єкта JSON
    DynamicJsonDocument jsonDoc(200);

    // Отримуємо поточний час
    int currentHour = timeClient.getHours();
    int currentMinute = timeClient.getMinutes();
    int currentSecond = timeClient.getSeconds();

    // Отримуємо рівень сигналу Wi-Fi
    int wifiRSSI = WiFi.RSSI();

    // Додаємо дані в JSON
    jsonDoc["type"] = "status";
    jsonDoc["time"] = String(currentHour) + ":" + 
                      (currentMinute < 10 ? "0" : "") + String(currentMinute) + ":" + 
                      (currentSecond < 10 ? "0" : "") + String(currentSecond); // Формат часу
    jsonDoc["rssi"] = wifiRSSI;
    jsonDoc["relay"] = digitalRead(relay);

    // Серіалізуємо JSON у рядок
    String output;
    serializeJson(jsonDoc, output);

    // Надсилаємо JSON через WebSocket всім клієнтам
    webSocket.broadcastTXT(output);
}

// Функція для створення JSON з переліком Wi-Fi мереж
void sendNetworkList(uint8_t client_num) {
    // Створюємо об'єкт JSON
    DynamicJsonDocument jsonDoc(1024);

    // Додаємо статус
    jsonDoc["status"] = "wifinetworks";

    // Створюємо масив для мереж
    JsonArray networks = jsonDoc.createNestedArray("networks");

    // Скануємо Wi-Fi мережі
    int n = WiFi.scanNetworks();
    if (n == 0) {
        Serial.println("No networks found");
    } else {
        for (int i = 0; i < n; ++i) {
            // Додаємо об'єкт для кожної мережі
            JsonObject network = networks.createNestedObject();
            network["network"] = WiFi.SSID(i);
            network["rssi"] = WiFi.RSSI(i);
            network["encryption"] = WiFi.encryptionType(i);
        }
    }

    // Серіалізуємо JSON у рядок
    String output;
    serializeJson(jsonDoc, output);

    // Надсилаємо JSON через WebSocket
    webSocket.sendTXT(client_num, output);
}

void setup() {
  pinMode(led, OUTPUT);
  pinMode(relay, OUTPUT);
  pinMode(btn, INPUT);
  Serial.begin(115200);

  if (!SPIFFS.begin()) {
    Serial.println("Помилка ініціалізації SPIFFS");
    return;
  }

  initWiFi();
  timeClient.begin();

  scheduleData = loadFromSPIFFS("/schedule.json");
  if (scheduleData.isEmpty()) {
    scheduleData = defaultSchedule;
    saveToSPIFFS("/schedule.json", scheduleData);
  }

  webSocket.begin();
  webSocket.onEvent(webSocketEvent);

  initHttpServer();
}

void handleRelay(){
  int dayOfWeek = (timeClient.getDay() + 6) % 7;
  String currentTime = timeClient.getFormattedTime().substring(0, 5);

  StaticJsonDocument<512> doc;
  DeserializationError error = deserializeJson(doc, scheduleData);

  if (error) {
    Serial.println("Помилка парсингу JSON");
    return;
  }

  String dayNames[] = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"};
  String startTime = doc[dayNames[dayOfWeek] + "_start"].as<String>();
  String endTime = doc[dayNames[dayOfWeek] + "_end"].as<String>();

  if (timeSyncOk && isTimeInRange(currentTime, startTime, endTime)) {
    digitalWrite(relay, HIGH);
  } else {
    digitalWrite(relay, LOW);
  }
}

void loop() {
  if (timeClient.update()) {
    timeSyncOk = true;
    Serial.println("Синхронізація успішна!");
    Serial.print("Поточний час: ");
    Serial.println(timeClient.getFormattedTime());
  }
  
  webSocket.loop();
  server.handleClient();
  handleButton();
  handleRelay();

 if (millis() - brdcast > 1000) {
    brdcast = millis();
    sendStatus();
  }
    if (millis() - lastLedMillis > 2500) {
    lastLedMillis = millis();
    if (WiFi.status() == WL_CONNECTED) {
      digitalWrite(led, ledState = !ledState);
    }
  }
}

 

Цей код створює систему для управління реле на основі розкладу, отриманого через WebSocket, та інтегрує кілька функцій для роботи з мережею, збереженням даних, таймером і віддаленим доступом.

Опис його основних частин і функцій:

1. Апаратна частина

  • Піни:
    • relay (12): Контролює реле.
    • led (13): Індикація стану.
    • btn (0): Кнопка для керування (зокрема, скидання налаштувань).

2. Робота з розкладом

  • Розклад зберігається у форматі JSON у файловій системі SPIFFS.
  • Якщо файл розкладу (/schedule.json) не існує, використовується стандартний розклад defaultSchedule.
  • Розклад передбачає визначення робочих годин для кожного дня тижня, наприклад:
    {
      "monday_start": "08:00",
      "monday_end": "17:00"
    }
    
  • Функція handleRelay:
    • Отримує поточний час і день тижня через NTP.
    • Перевіряє, чи поточний час входить у визначений інтервал для поточного дня.
    • Вмикає або вимикає реле відповідно до розкладу.

3. WebSocket-сервер

  • Порт WebSocket: 81.
  • Використовується для приймання/передавання даних, таких як розклад, Wi-Fi-налаштування або запити статусу.
  • Основні типи запитів:
    • "timer": Отримує новий розклад і зберігає його у SPIFFS.
    • "config": Відправляє поточний розклад клієнту.
    • "wifi": Приймає SSID і пароль для підключення до Wi-Fi.
    • "scan": Відправляє список доступних Wi-Fi мереж.

4. HTTP-сервер

  • Порт HTTP-сервера: 80.
  • Обслуговує запити для доступу до файлів (наприклад, HTML або CSS), які зберігаються в SPIFFS.
  • Використовує функцію handleFileRead, щоб динамічно обробляти файли.

5. Керування часом

  • NTP-синхронізація:
    • Час отримується з сервера pool.ntp.org.
    • Враховується зсув UTC+2 (зимовий час).
    • Функція isDST визначає, чи використовується літній час.
  • Час використовується для перевірки поточного стану реле відповідно до розкладу.

6. Збереження даних

  • SPIFFS: Використовується для зберігання налаштувань розкладу (schedule.json).
  • Функції:
    • saveToSPIFFS: Зберігає дані у файл.
    • loadFromSPIFFS: Завантажує дані з файлу.

7. Ініціалізація Wi-Fi

  • Підключення до Wi-Fi у режимі STA.
  • Якщо підключення не вдається, запускається точка доступу (AP) із заданим паролем.

8. Обробка кнопки

  • Якщо кнопка утримується довше ніж 4 секунди (LONG_PRESS_TIME), відбувається:
    • Скидання розкладу до налаштувань за замовчуванням.
    • Видалення налаштувань Wi-Fi.
    • Перезавантаження модуля.

9. Інші функції

  • sendStatus: Відправляє поточний час, стан реле та рівень сигналу Wi-Fi через WebSocket.
  • sendNetworkList: Відправляє список доступних Wi-Fi мереж у JSON-форматі.

10. Головна програма (setup, loop)

  • setup:
    • Ініціалізує SPIFFS, Wi-Fi, WebSocket і HTTP-сервер.
    • Завантажує або створює розклад.
  • loop:
    • Обробляє події WebSocket.
    • Працює із кнопкою та оновлює реле згідно з розкладом.

HTML + JAVASCRIPT

HTML відповідає за структуру документа, тоді як JavaScript додає функціональність, дозволяючи реалізовувати динамічні елементи, анімації та інтерактивність.

 

<!DOCTYPE html>
<html lang="uk">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Тижневий Таймер</title>
        <style>
            body {
                font-family: Arial, sans-serif;
                margin: 0;
                padding: 0;
                display: flex;
                justify-content: center;
                align-items: center;
                min-height: 100vh;
                background-color: #f9f9f9;
            }

            .container {
                width: 90%;
                max-width: 400px;
                background: #fff;
                border-radius: 10px;
                box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
                overflow: hidden;
            }
            .language-switcher {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 10px;
				background: #f1f1f1;
            }

            .language-switcher .brand {
                margin: 0;
                font-size: 1.2rem;
                font-weight: bold;
                color: #2c5853;
            }
			
			.brand a {
				text-decoration: none; 
				color: inherit; 
			}

			.brand a:hover {
				color: #007bff; /* Змінює колір тексту при наведенні */
			}


            .language-switcher .buttons {
                display: flex;
            }
            .language-switcher button {
                margin: 0 5px;
                padding: 5px 10px;
                border: none;
                border-radius: 5px;
                cursor: pointer;
                font-size: 14px;
            }
            .language-switcher button.active {
                background-color: #007bff;
                color: white;
            }

            .message {
                font-size: 14px;
                margin-top: 10px;
                opacity: 0;
                transition: opacity 0.5s ease;
                position: absolute;
                top: 10px;
                left: 50%;
                transform: translateX(-50%);
                z-index: 10;
                padding: 10px;
                border-radius: 5px;
                box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
            }

            .message.error-message {
                background-color: #f8d7da;
                color: #721c24;
            }

            .message.success-message {
                background-color: #d4edda;
                color: #155724;
            }

            .message.show {
                opacity: 1;
            }
            .status-bar {
                display: flex;
                justify-content: space-between;
                align-items: center;
            }
            .status-item {
                display: flex;
                align-items: center;
                font-size: 14px;
                color: #333;
            }
            .icon {
                margin-right: 5px;
            }

            .tabs {
                display: flex;
                border-bottom: 1px solid #ddd;
                background: #f9f9f9;
            }

            .tabs button {
                flex: 1;
                padding: 15px 0;
                background: none;
                border: none;
                font-size: 16px;
                cursor: pointer;
                outline: none;
                border-bottom: 2px solid transparent;
                transition: all 0.3s ease;
                color: #555;
            }

            .tabs button:hover {
                color: #007bff;
            }

            .tabs button.active {
                border-bottom: 2px solid #007bff;
                color: #007bff;
                font-weight: bold;
            }

            .tab-content {
                padding: 20px;
                display: none;
            }

            .tab-content.active {
                display: block;
            }

            .timer-container h2 {
                margin-bottom: 20px;
                text-align: center;
            }

            .timer-table {
                width: 100%;
                border-collapse: collapse;
            }

            .timer-table th,
            .timer-table td {
                border: 1px solid #ddd;
                padding: 8px;
                text-align: center;
                font-size: 14px;
            }

            .timer-table th {
                background-color: #f4f4f4;
            }

            .save-btn {
                background-color: #007bff;
                color: white;
                border: none;
                padding: 10px;
                border-radius: 4px;
                cursor: pointer;
                margin: 10px 0;
                font-size: 16px;
                width: 100%;
            }

            .save-btn:hover {
                background-color: #0069d9;
            }

            input[type="time"],
            input[type="text"],
            input[type="password"] {
                width: 100%;
                padding: 10px;
                margin: 10px 0;
                border: 1px solid #ddd;
                border-radius: 4px;
                font-size: 14px;
                box-sizing: border-box;
            }

            .list-group {
                list-style-type: none;
                padding: 0;
                margin: 10px 0;
                max-height: 150px;
                overflow-y: auto;
                border: 1px solid #ddd;
                border-radius: 4px;
                background-color: #fafafa;
            }

            .list-group li {
                padding: 10px;
                display: flex;
                justify-content: space-between;
                align-items: center;
                cursor: pointer;
                border-bottom: 1px solid #ddd;
            }

            .list-group li:hover {
                background-color: #e0f7fa;
            }

            .badge {
                background-color: #4caf50;
                color: white;
                padding: 3px 7px;
                border-radius: 4px;
                font-size: 12px;
            }
		

            @media (max-width: 480px) {
                .timer-table th,
                .timer-table td {
                    font-size: 12px;
                    padding: 6px;
                }

                .tabs button {
                    font-size: 14px;
                }

                .save-btn {
                    font-size: 14px;
                    padding: 8px;
                }
            }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="language-switcher">
                <h2 class="brand">
				<a href="https://www.a2m.com.ua" target="_blank" rel="noopener noreferrer">A2M</a>
				</h2>
                <div class="buttons">
                    <button type="button" id="lang-uk" class="active" onclick="setLanguage('uk')">Українська</button>
                    <button type="button" id="lang-en" onclick="setLanguage('en')">English</button>
                </div>
            </div>

            <div id="message" class="message"></div>
            <div class="tabs">
                <button class="tab-link active" data-lang-key="tab-timer" data-tab="tab1">Таймер</button>
                <button class="tab-link" data-lang-key="tab-settings" data-tab="tab2">Налаштування</button>
            </div>
            <div id="tab1" class="tab-content active">
                <div class="timer-container">
                    <div class="status-bar">
                        <div class="status-item">
                            <!-- Іконка годинника -->
                            <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
                                <circle cx="12" cy="12" r="10" stroke="black" stroke-width="2" fill="none" />
                                <line x1="12" y1="6" x2="12" y2="12" stroke="black" stroke-width="2" />
                                <line x1="12" y1="12" x2="16" y2="14" stroke="black" stroke-width="2" />
                            </svg>
                            <span id="current-time">00:00:00</span>
                        </div>
                        <div class="status-item">
                            <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="white" class="icon" viewBox="0 0 16 16">
                                <rect width="100%" height="100%" rx="5" fill="green" id="powerIcon" />
                                <path d="M7.5 1v7h1V1z" />
                                <path d="M3 8.812a5 5 0 0 1 2.578-4.375l-.485-.874A6 6 0 1 0 11 3.616l-.501.865A5 5 0 1 1 3 8.812" />
                            </svg>
                            <span data-lang-key="relay">Реле</span>
                        </div>

                        <div class="status-item">
                            <svg class="icon" viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
                                <path
                                    d="M16.2427 5.75732C18.5858 8.10047 18.5858 11.8995 16.2427 14.2426M7.75734 14.2426C5.41419 11.8995 5.41419 8.10047 7.75734 5.75732M4.92869 17.0711C1.02345 13.1658 1.02345 6.8342 4.92869 2.92896M19.0713 2.92896C22.9765 6.8342 22.9765 13.1658 19.0713 17.0711M12 12C13.1045 12 14 11.1046 14 10C14 8.89543 13.1045 8 12 8C10.8954 8 9.99998 8.89543 9.99998 10C9.99998 11.1046 10.8954 12 12 12ZM12 12V21"
                                    stroke="currentColor"
                                    stroke-width="2"
                                    stroke-linecap="round"
                                    stroke-linejoin="round"
                                />
                            </svg>
                            <span id="signal-strength">RSSI: 0%</span>
                        </div>
                    </div>
                    <h2 data-lang-key="title-timer">Тижневий Таймер</h2>
                    <table class="timer-table">
                        <form id="timerForm">
                            <thead>
                                <tr>
                                    <th data-lang-key="day">День</th>
                                    <th data-lang-key="start">Початок</th>
                                    <th data-lang-key="end">Кінець</th>
                                </tr>
                            </thead>
                            <tbody>
                                <tr>
                                    <td data-lang-key="monday">Понеділок</td>
                                    <td><input type="time" name="monday_start" required /></td>
                                    <td><input type="time" name="monday_end" required /></td>
                                </tr>
                                <tr>
                                    <td data-lang-key="tuesday">Вівторок</td>
                                    <td><input type="time" name="tuesday_start" required /></td>
                                    <td><input type="time" name="tuesday_end" required /></td>
                                </tr>
                                <tr>
                                    <td data-lang-key="wednesday">Середа</td>
                                    <td><input type="time" name="wednesday_start" required /></td>
                                    <td><input type="time" name="wednesday_end" required /></td>
                                </tr>
                                <tr>
                                    <td data-lang-key="thursday">Четвер</td>
                                    <td><input type="time" name="thursday_start" required /></td>
                                    <td><input type="time" name="thursday_end" required /></td>
                                </tr>
                                <tr>
                                    <td data-lang-key="friday">П'ятниця</td>
                                    <td><input type="time" name="friday_start" required /></td>
                                    <td><input type="time" name="friday_end" required /></td>
                                </tr>
                                <tr>
                                    <td data-lang-key="saturday">Субота</td>
                                    <td><input type="time" name="saturday_start" required /></td>
                                    <td><input type="time" name="saturday_end" required /></td>
                                </tr>
                                <tr>
                                    <td data-lang-key="sunday">Неділя</td>
                                    <td><input type="time" name="sunday_start" required /></td>
                                    <td><input type="time" name="sunday_end" required /></td>
                                </tr>
                            </tbody>
                        </form>
                    </table>
                    <button type="button" data-lang-key="save" class="save-btn" onclick="submitForm()">Зберегти</button>
                </div>
            </div>
            <div id="tab2" class="tab-content">
                <h2 data-lang-key="tab-settings">Налаштування</h2>
                <div id="wlan_list">
                    <h3>Wi-Fi</h3>
                    <button class="save-btn" data-lang-key="scan" onclick="scanWifi()">Сканувати мережі</button>
                    <ul id="list" class="list-group">
                        <!-- Список Wi-Fi мереж буде згенеровано тут -->
                    </ul>
                    <input id="s" name="s" type="text" placeholder="SSID" />
                    <input id="p" name="p" type="password" placeholder="Password" />
                    <button data-lang-key="connect" class="save-btn" onclick="saveWifi()">Зберегти & Підключитись</button>
                </div>
            </div>
        </div>
        <script>
            const translations = {
                uk: {
                    "err-fill-fields": "Будь ласка, заповніть всі поля",
                    "err-fill-fields-for": "Будь ласка, заповніть всі поля для ",
                    "err-range": "Час початку повинен бути раніше часу закінчення.",
                    "timer-saved": "Налаштування збережено",
                    "wifi-saved": "Дані збережено. Пробуємо підключитися...",
                    "tab-timer": "Таймер",
                    "tab-settings": "Налаштування",
                    "title-timer": "Тижневий Таймер",
                    relay: "Реле",
                    day: "День",
                    start: "Початок",
                    end: "Кінець",
                    save: "Зберегти",
                    connect: "Зберегти & Підключитись",
                    "settings-title": "Налаштування",
                    "wifi-title": "Wi-Fi",
                    scan: "Сканувати мережі",
                    monday: "Понеділок",
                    tuesday: "Вівторок",
                    wednesday: "Середа",
                    thursday: "Четвер",
                    friday: "П'ятниця",
                    saturday: "Субота",
                    sunday: "Неділя",
                },
                en: {
                    "err-fill-fields": "Please fill all fields",
                    "err-fill-fields-for": "Please fill all fields for ",
                    "err-range": "The start time must be before the end time.",
                    "timer-saved": "Settings saved",
                    "wifi-saved": "Data saved. Trying to connect...",
                    "tab-timer": "Timer",
                    "tab-settings": "Settings",
                    "title-timer": "Weekly Timer",
                    relay: "Relay",
                    day: "Day",
                    start: "Start",
                    end: "End",
                    save: "Save",
                    connect: "Save & Connect",
                    "settings-title": "Settings",
                    "wifi-title": "Wi-Fi",
                    scan: "Scan Networks",
                    monday: "Monday",
                    tuesday: "Tuesday",
                    wednesday: "Wednesday",
                    thursday: "Thursday",
                    friday: "Friday",
                    saturday: "Saturday",
                    sunday: "Sunday",
                },
            };

            function setLanguage(lang) {
                localStorage.setItem("language", lang);
                document.querySelectorAll("[data-lang-key]").forEach((el) => {
                    const key = el.getAttribute("data-lang-key");
                    el.textContent = translations[lang][key];
                });

                document.querySelectorAll(".language-switcher button").forEach((btn) => btn.classList.remove("active"));
                document.getElementById(`lang-${lang}`).classList.add("active");
            }

            document.addEventListener("DOMContentLoaded", () => {
                const savedLanguage = localStorage.getItem("language") || "uk";
                setLanguage(savedLanguage);
            });

            const tabs = document.querySelectorAll(".tab-link");
            const contents = document.querySelectorAll(".tab-content");

            tabs.forEach((tab) => {
                tab.addEventListener("click", () => {
                    tabs.forEach((t) => t.classList.remove("active"));
                    contents.forEach((c) => c.classList.remove("active"));

                    tab.classList.add("active");
                    document.getElementById(tab.getAttribute("data-tab")).classList.add("active");
                });
            });

            var connection = new WebSocket("ws://" + location.hostname + ":81/", ["arduino"]);
            connection.onopen = function () {
                const data = {};
                data["type"] = "config";
                connection.send(JSON.stringify(data));
            };
            connection.onerror = function (error) {
                console.log("WebSocket Error ", error);
            };
            connection.onmessage = function (e) {
                console.log("Server: ", e.data);

                try {
                    var jsonData = JSON.parse(e.data);
                } catch (error) {
                    console.log("Received non-JSON message:", e.data);
                    return;
                }

                if (jsonData.type === "status") {
                    document.getElementById("current-time").innerText = jsonData.time;
                    document.getElementById("signal-strength").innerText = "RSSI:" + jsonData.rssi + "%";

                    const icon = document.getElementById("powerIcon");
                    // Перевірка значення параметра `relay` і встановлення кольору
                    if (jsonData.relay) {
                        icon.setAttribute("fill", "green"); // Якщо relay = true, колір зелений
                    } else {
                        icon.setAttribute("fill", "red"); // Якщо relay = false, колір червоний
                    }
                }
                if (jsonData.type === "config") {
                    fillFormWithJSON(jsonData);
                }

                if (jsonData.status === "wifinetworks") {
                    var out = "";
                    var networks = jsonData.networks;
                    for (var i = 0; i < networks.length; i++) {
                        out += "<li onclick='selectNetwork(this)'><span class='network-name'>" + networks[i].network + "</span><span class='badge'>" + networks[i].rssi + "</span></li>";
                    }
                    document.getElementById("list").innerHTML = out;
                } else if (jsonData.status === "message") {
                    alert(jsonData.message);
                }
            };

            function saveWifi() {
                var v1 = document.getElementById("s").value.trim(); // SSID
                var v2 = document.getElementById("p").value.trim(); // Password
                var message = document.getElementById("message");
                var success = document.getElementById("success");
                const savedLanguage = localStorage.getItem("language") || "uk";

                // Перевірка на порожні поля
                if (v1 === "" || v2 === "") {
                    showMessage(translations[savedLanguage]["err-fill-fields"], "error");
                    return;
                }

                // Якщо обидва поля заповнені, відправляємо дані
                var wSetup = { type: "wifi", ssid: v1, pass: v2 };
                connection.send(JSON.stringify(wSetup));

                // Відобразити повідомлення про успіх
                showMessage(translations[savedLanguage]["wifi-saved"], "success");
            }

            function scanWifi() {
                document.getElementById("list").innerHTML = "<li>Searching...</li>";
                const data = {};
                data["type"] = "scan";
                connection.send(JSON.stringify(data));
            }

            function selectNetwork(link) {
                document.getElementById("s").value = link.querySelector(".network-name").innerText;
                document.getElementById("p").focus();
            }

            function submitForm() {
                const savedLanguage = localStorage.getItem("language") || "uk";
                const form = document.getElementById("timerForm");
                const formData = new FormData(form);
                const data = {}; // Об'єкт для збереження всіх даних форми
                let isValid = true;

                const days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];

                // Додаємо 'type' на тому ж рівні, що і data
                const result = {
                    type: "timer",
                    data: data, // Додаємо об'єкт з даними в поле 'data'
                };

                days.forEach((day) => {
                    const start = formData.get(`${day}_start`);
                    const end = formData.get(`${day}_end`);

                    if (!start || !end) {
                        showMessage(`${translations[savedLanguage]["err-fill-fields-for"]} ${day}`, "error");
                        isValid = false;
                        return;
                    }

                    if (start >= end) {
                        showMessage(`${day} ${translations[savedLanguage]["err-range"]}`, "error");
                        isValid = false;
                        return;
                    }

                    // Додаємо дані для кожного дня в об'єкт 'data'
                    data[`${day}_start`] = start;
                    data[`${day}_end`] = end;
                });

                // Якщо є помилки валідації, не відправляємо дані
                if (!isValid) return;

                // Відправляємо об'єкт 'result' з полем 'data', яке містить всі дані
                connection.send(JSON.stringify(result));
                showMessage(translations[savedLanguage]["timer-saved"], "success");
            }
            function fillFormWithJSON(data) {
                const days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];

                days.forEach((day) => {
                    if (data[`${day}_start`] && data[`${day}_end`]) {
                        document.querySelector(`input[name="${day}_start"]`).value = data[`${day}_start`];
                        document.querySelector(`input[name="${day}_end"]`).value = data[`${day}_end`];
                    }
                });
            }

            function showMessage(text, type) {
                const messageElement = document.getElementById("message");

                // Додати текст повідомлення
                messageElement.textContent = text;

                // Очистити попередні класи (успіх або помилка)
                messageElement.className = "message";

                // Додати відповідний клас для стилізації
                if (type === "error") {
                    messageElement.classList.add("error-message");
                } else if (type === "success") {
                    messageElement.classList.add("success-message");
                }

                // Показати повідомлення
                messageElement.classList.add("show");

                // Зникнення повідомлення через 2 секунди
                setTimeout(() => {
                    messageElement.classList.remove("show");
                }, 3000);
            }
        </script>
    </body>
</html>

Виглядає HTML ось так

Інтерфейс пристрою продуманий так, щоб налаштування та використання були максимально простими й зрозумілими. Він складається з двох основних вкладок: "Таймер" і "Налаштування", кожна з яких виконує свою ключову роль у роботі тижневого таймера.

На вкладці "Таймер" ви можете задати свій власний графік роботи реле. Для кожного дня тижня доступні поля для введення часу початку та закінчення роботи. Інтерфейс інтуїтивно зрозумілий:

  • Ви просто вибираєте потрібний день, вводите час у відповідні поля, а при потребі використовуєте зручний часовий селектор.

  • Таймер не дасть зробити помилку — якщо час початку буде пізніше часу завершення, система попередить про це й допоможе виправити.

Крім цього, тут же ви бачите поточний час, статус реле (включено/вимкнено) та рівень сигналу Wi-Fi, щоб завжди бути в курсі, чи все працює як слід. Усе, що вам залишається — це натиснути кнопку "Зберегти", і ваш графік готовий!

У вкладці "Налаштування" ви налаштовуєте Wi-Fi. Сканування відобразить усі доступні мережі — їхні назви та рівень сигналу.

Просто оберіть свою мережу зі списку, введіть пароль і натисніть "Зберегти". А якщо щось піде не так, наприклад, слабкий сигнал чи неправильний пароль, ви одразу отримаєте відповідне повідомлення.

Обидві вкладки створені так, щоб навіть користувач, який вперше працює з тижневим таймером, міг усе налаштувати за кілька хвилин. Інтерфейс простий і зрозумілий, а система підказок і сповіщень дозволяє уникнути помилок.

Висновок

Розроблений таймер для ESP8266 є чудовим рішенням для автоматизації процесів, які потребують чіткого виконання завдань за розкладом. Завдяки простому алгоритму роботи, можливості зберігати дані в пам'яті та зручному інтерфейсу налаштувань, цей таймер може бути легко адаптований до різних потреб — від керування освітленням до автоматизації поливу.

За потреби ви легко можете додати інтеграцію з іншими сервісами, такими як Home Assistant чи інші IoT-платформи, що значно розширить функціональні можливості пристрою та дозволить зробити його частиною комплексної системи розумного дому. Проєкт демонструє гнучкість та функціональність ESP82xx, відкриваючи нові можливості для створення інноваційних і зручних у використанні пристроїв.

upd.

Таймер починає працювати тільки якщо було успішне оновлення дати часу через NTP(timeSyncOk = true). Зроблено з метою безпеки, щоб після вимкнення живлення таймер спрацьовував у заданий час, а не у випадкові інтервали.

Для тих в кого не працює комбінація CTRL+C CTRL+V архів з готовим скетчем ;)

Коментарі

Додайте коментар...

Ім'я
E-mail (Не буде опублікований)
Ваш коментар
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
Авторизація
Немаєте акаунта? Реєстрація
Забыли пароль?
E-mail
Введите e-mail Вашей учетной записи, чтобы получить пароль.
Введите корректно e-mail!
viber-chatЧат «А2М» в Viber telegram-chatЧат «А2М» в Telegram
Telegram QR
💬 Актуальні ціни
завжди під рукою