Привет всем любителям электроники и IoT-разработок! Сегодня я расскажу о своём пет-проекте "GSM Module Version 1" — компактном устройстве на базе ESP32S, которое объединяет GSM-связь, управление реле, обработку входных сигналов и удобный веб-интерфейс. Этот модуль можно использовать для автоматизации систем безопасности, удалённого управления и IoT-приложений. Я собрал его постепенно, сталкиваясь с различными вызовами, и в итоге получилась рабочая версия для тестирования и доработки. Давайте разберёмся по шагам: от идей до кода. 😊

Введение в проект

Модуль "GSM Module Version 1" — это устройство, которое:

  • Обрабатывает звонки и SMS: При поступлении звонка с известного номера включает реле на определённое время; команды SMS позволяют управлять реле удалённо.
  • Хранит базу номеров: В EEPROM содержатся до 5000 телефонных номеров с адресами для авторизации звонков.
  • Управляет нагрузками: 4 реле с задержками включения/выключения, привязанными к оптронным входам.
  • Предоставляет веб-доступ: Точка доступа Wi-Fi с паролем, четыре страницы настроек (адаптированные для мобильных устройств).
  • Мониторит состояния: Лог событий в реальном времени, индикация статуса через светодиоды и кнопку.

Проект основан на открытом ПО и Arduino IDE, с использованием библиотек TinyGSM, ESP32 WebServer и Wire. Общая стоимость компонентов — около 2000–3000 рублей, зависит от источников.

Необходимые компоненты

Перед сборкой соберите материалы. Вот полный список:

  • Микроконтроллер: ESP32S (модуль с ESP32-D0WDQ6).
  • GSM-модуль: SIM800L в плате с антенной и разъёмом для SIM-карты.
  • Память: EEPROM модуль (например, AT24C256, 256Кб) для хранения номеров.
  • Реле: 4 релейных модуля (5V, с оптопарами) для управления нагрузками.
  • Оптронные входы: 4 модуля оптронов (для гальванической развязки входных сигналов).
  • Индикация: 2 светодиода (красный и зелёный), 2 резистора 220 Ом.
  • Кнопка: Одна тактовая кнопка для переключения индикации.
  • Питание: Стабилизатор 5V/2A для реле, батарея/адаптер для ESP32.
  • Дополнительно: Макетная плата, провода, SIM-карта с GPRS.

Библиотеки для Arduino IDE:

  • TinyGsm (для SIM800L).
  • ESP32 Web Server (встроена в Arduino ESP32 ядро).
  • Wire (для I2C).
Схема подключения

Подключите компоненты к ESP32S по следующим пинам (GND и VCC общие, где 3.3V для ESP32, 5V для реле и SIM800L):

  • SIM800L: RX (GPIO16), TX (GPIO17), Reset (GPIO19).
  • EEPROM: SDA (GPIO21), SCL (GPIO22).
  • Реле: GPIO2, GPIO4, GPIO5, GPIO13.
  • Оптронные входы: GPIO14, GPIO25, GPIO26, GPIO27 (входы с внутренней подтяжкой).
  • Светодиоды: GPIO18 (в противофазе: красный анод через 220Ω to GPIO, катод to GND; зелёный катод через 220Ω to GPIO, анод to VCC).
  • Кнопка: GPIO32 to GND.

ESP32S
- GPIO16 (RX) --> SIM800L TX
- GPIO17 (TX) --> SIM800L RX
- GPIO19 (RST) --> SIM800L Reset/PWRKEY
- GPIO21 (SDA) --> EEPROM SDA
- GPIO22 (SCL) --> EEPROM SCL
- GPIO2 --> Реле 1
- GPIO4 --> Реле 2
- GPIO5 --> Реле 3
- GPIO13 --> Реле 4
- GPIO14 --> Оптрон 1 --> GND
- GPIO25 --> Оптрон 2 --> GND
- GPIO26 --> Оптрон 3 --> GND
- GPIO27 --> Оптрон 4 --> GND
- GPIO18 --> [220Ω] --> Анод красного LED --> GND
[220Ω] --> Катод зелёного LED --> VCC
- GPIO32 --> Кнопка --> GND

Для защиты используйте диоды на реле и оптроны для соединения с внешними сигналами.

Программная часть: Код Arduino IDE

Полный код проекта (разделён на части для удобства вставки в файл .ino). Установите библиотеки через менеджер Arduino IDE.

Часть 1: Включаемые файлы, структуры и глобальные переменные



#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <TinyGsmClient.h>

// Определения пинов (на основе нашего проекта)
#define SIM800_RESET_PIN 19
HardwareSerial SerialGSM(2); // UART2 для SIM800L
TinyGsm modem(SerialGSM);

// I2C EEPROM адреса
#define EEPROM_I2C_ADDR 0x50
#define MAX_NUMBERS 5000
#define NUMBERS_PER_PAGE 100

// Структура для номера телефона
struct PhoneEntry {
  char phone[16];  // Номер телефона
  char address[50]; // Адрес
};

// Выходы реле
#define RELAY1_PIN 2
#define RELAY2_PIN 4
#define RELAY3_PIN 5
#define RELAY4_PIN 13

// Входы оптронов
#define INPUT1_PIN 14
#define INPUT2_PIN 25
#define INPUT3_PIN 26
#define INPUT4_PIN 27

// Индикация состояния (противофазные LED на одном пине)
#define LED_STATE_PIN 18
bool isGreen = false;

// Кнопка для переключения режима
#define BUTTON_PIN 32

// WEB-сервер
WebServer server(80);
const char* wifi_ssid = "GSM";
const char* wifi_password = "98765432";
const char* web_password = "J9562876m"; // Пароль для веб-интерфейса
IPAddress apIP(192, 168, 10, 1);
IPAddress subnet(255, 255, 255, 0);

// Клиентские настройки Wi-Fi (замени на свои)
const char* client_ssid = "YourHomeWiFi";  // SSID домашней сети
const char* client_password = "YourHomePassword";  // Пароль

// Режимы
bool apMode = false;  // true = AP, false = Client
unsigned long buttonPressStart = 0;
bool buttonPressed = false;
// Таймеры для мигания LED в AP режиме
unsigned long lastBlink = 0;
bool blinkState = false;

// Настройки
char gsm_apn[32] = "internet";
char gsm_user[16] = "";
char gsm_pass[16] = "";
int relay_delay_on[4] = {0, 0, 0, 0};
int relay_delay_off[4] = {5000, 0, 0, 0};
bool relay_trigger[4] = {false, false, false, false};
unsigned long relay_timer[4] = {0, 0, 0, 0};
int input_threshold[4] = {LOW, LOW, LOW, LOW};
int input_relay_bind[4] = {-1, -1, -1, -1};
String input_history = "";
int pin[4] = {RELAY1_PIN, RELAY2_PIN, RELAY3_PIN, RELAY4_PIN};

String cssMobile = "@media (max-width: 600px) { body { font-size: 14px; padding: 10px; } table { font-size: 12px; } input { width: 90%; } } body { font-family: Arial; text-align: center; } input { margin: 5px; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; }";

// =================================================================================
// ФУНКЦИИ ДЛЯ EEPROM
// =================================================================================

void initEEPROM() {
  Wire.begin(21, 22); // SDA GPIO21, SCL GPIO22
}

void savePhoneToEEPROM(int index, const char* phone, const char* address) {
  if (index >= MAX_NUMBERS) return;
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((index * sizeof(PhoneEntry)) >> 8);
  Wire.write(index * sizeof(PhoneEntry));
  Wire.write(phone, strlen(phone) + 1);
  Wire.write(address, strlen(address) + 1);
  Wire.endTransmission();
  delay(5);
}

void loadPhoneFromEEPROM(int index, char* phone, char* address) {
  if (index >= MAX_NUMBERS) return;
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((index * sizeof(PhoneEntry)) >> 8);
  Wire.write(index * sizeof(PhoneEntry));
  Wire.endTransmission();
  Wire.requestFrom(EEPROM_I2C_ADDR, sizeof(PhoneEntry));
  Wire.readBytes(phone, 16);
  Wire.readBytes(address, 50);
}

bool isPhoneInDatabase(const char* caller) {
  PhoneEntry entry;
  for (int i = 0; i < MAX_NUMBERS; i++) {
    loadPhoneFromEEPROM(i, entry.phone, entry.address);
    if (strcmp(entry.phone, caller) == 0) return true;
  }
  return false;
}
// Конец Част 1 — перейди к Час 2
// =================================================================================
// ФУНКЦИИ ДЛЯ GSM
// =================================================================================

void initGSM() {
  SerialGSM.begin(9600, SERIAL_8N1, 16, 17); // RX GPIO16, TX GPIO17
  pinMode(SIM800_RESET_PIN, OUTPUT);
  digitalWrite(SIM800_RESET_PIN, HIGH);
  modem.init();
  modem.waitForNetwork();
  if (!modem.gprsConnect(gsm_apn, gsm_user, gsm_pass)) {
    Serial.println("GPRS failed");
  }
}

void handleCall() {
  if (modem.callReceive()) {
    char caller[20] = {0};
    modem.callNumber(caller);
    modem.callHangup();
    if (isPhoneInDatabase(caller)) {
      relay_timer[0] = millis() + relay_delay_on[0];
      digitalWrite(RELAY1_PIN, HIGH);
      input_history += String(millis()) + ": Звонок от " + String(caller) + ", Реле1 вкл\n";
    }
  }
}

void handleSMS() {
  int smsCount = modem.getSmsCount();
  for (int i = 0; i < smsCount; i++) {
    String sms = modem.getSmsText(i);
    String sender = modem.getSmsSender(i);
    if (sms.length() > 0) {
      if (sms.indexOf("RELAY1 ON") >= 0) digitalWrite(RELAY1_PIN, HIGH);
      else if (sms.indexOf("RELAY1 OFF") >= 0) digitalWrite(RELAY1_PIN, LOW);
      modem.sendSms(sender, "Команда принята");
      modem.deleteSms(i);
    }
  }
  SerialGSM.print("AT+CMGD=1,4\r");
  delay(500);
  while (SerialGSM.available()) SerialGSM.read();
}
// Конец Част 2 — перейди к Час 3
// =================================================================================
// ФУНКЦИИ ДЛЯ РЕЛЕ И ВХОДОВ И МИГАНИЯ LED
// =================================================================================

void initRelaysAndInputs() {
  pinMode(RELAY1_PIN, OUTPUT);
  pinMode(RELAY2_PIN, OUTPUT);
  pinMode(RELAY3_PIN, OUTPUT);
  pinMode(RELAY4_PIN, OUTPUT);
  pinMode(INPUT1_PIN, INPUT_PULLUP);
  pinMode(INPUT2_PIN, INPUT_PULLUP);
  pinMode(INPUT3_PIN, INPUT_PULLUP);
  pinMode(INPUT4_PIN, INPUT_PULLUP);
  pinMode(LED_STATE_PIN, OUTPUT);
  digitalWrite(LED_STATE_PIN, HIGH); // Изначально красный
  pinMode(BUTTON_PIN, INPUT_PULLUP);
}

void handleRelaysAndInputs() {
  unsigned long now = millis();
  bool inputStates[4] = {digitalRead(INPUT1_PIN) == input_threshold[0], digitalRead(INPUT2_PIN) ==
  ...digitalRead(INPUT2_PIN) == input_threshold[1], digitalRead(INPUT3_PIN) == input_threshold[2], digitalRead(INPUT4_PIN) == input_threshold[3]};
  for (int i = 0; i < 4; i++) {
    if (inputStates[i] && input_relay_bind[i] >= 0) {
      relay_timer[input_relay_bind[i]] = now + relay_delay_on[input_relay_bind[i]];
      digitalWrite(pin[i], HIGH);
      input_history += String(now) + ": Вход " + String(i+1) + " -> Реле " + String(input_relay_bind[i]+1) + "\n";
    }
  }
  for (int i = 0; i < 4; i++) {
    if (relay_timer[i] > 0 && now >= relay_timer[i]) {
      if (digitalRead(pin[i]) == HIGH) {
        digitalWrite(pin[i], LOW);
        relay_timer[i] = now + relay_delay_off[i];
      } else {
        relay_timer[i] = 0;
      }
    }
  }
  
  // Обработка кнопки для переключения режима (удержание > 3 сек)
  bool buttonState = digitalRead(BUTTON_PIN);
  if (buttonState == LOW && !buttonPressed) {
    buttonPressStart = now;
    buttonPressed = true;
  }
  if (buttonState == HIGH && buttonPressed) {
    if (now - buttonPressStart > 3000) {
      apMode = !apMode;
      Serial.println(apMode ? "Перешел в режим AP" : "Перешел в режим Client");
      setupWiFi();
    }
    buttonPressed = false;
  }
  
  // Мигание LED в AP режиме: попеременно красный (HIGH) / зелёный (LOW)
  if (apMode && now - lastBlink > 500) {
    lastBlink = now;
    blinkState = !blinkState;
    digitalWrite(LED_STATE_PIN, blinkState ? HIGH : LOW); // HIGH = красный, LOW = зелёный
  } else if (!apMode) {
    digitalWrite(LED_STATE_PIN, isGreen ? LOW : HIGH); // Возврат к нормальному состоянию
  }
}
// Конец Част 3 — перейди к Час 4
// =================================================================================
// ФУНКЦИИ ДЛЯ WI-ФИ И WEB-ИНТЕРФЕЙСА
// =================================================================================

void setupWiFi() {
  WiFi.disconnect();
  if (apMode) {
    WiFi.softAPConfig(apIP, apIP, subnet);
    WiFi.softAP(wifi_ssid, wifi_password);
    Serial.println("Режим AP активен");
  } else {
    WiFi.begin(client_ssid, client_password);
    while (WiFi.status() != WL_CONNECTED) {
      delay(1000);
    }
    Serial.println("Подключен к клиентской сети, IP: " + WiFi.localIP().toString());
  }
  server.begin();
}

void setupWebServer() {
  server.on("/", []() { if (!server.authenticate("admin", web_password)) return server.requestAuthentication(); handleRoot(); });
  server.on("/numbers", []() { if (!server.authenticate("admin", web_password)) return server.requestAuthentication(); handleNumbers(); });
  server.on("/relays", []() { if (!server.authenticate("admin", web_password)) return server.requestAuthentication(); handleRelays(); });
  server.on("/inputs", []() { if (!server.authenticate("admin", web_password)) return server.requestAuthentication(); handleInputs(); });
  server.on("/save", HTTP_POST, []() { server.send(200, "text/html", "<p>Настройки сохранены</p>"); });
  server.on("/save_number", HTTP_POST, []() { 
    int id = server.arg("id").toInt();
    String phone = server.arg("phone");
    String address = server.arg("address");
    savePhoneToEEPROM(id, phone.c_str(), address.c_str());
    server.send(200, "text/html", "<p>Номер сохранён</p>");
  });
  server.on("/save_relay", HTTP_POST, []() { 
    int relay = server.arg("relay").toInt();
    relay_delay_on[relay] = server.arg("delay_on").toInt();
    relay_delay_off[relay] = server.arg("delay_off").toInt();
    relay_trigger[relay] = server.arg("trigger") == "on";
    server.send(200, "text/html", "<p>Реле обновлено</p>");
  });
  server.on("/save_input", HTTP_POST, []() { 
    int input = server.arg("input").toInt();
    input_relay_bind[input] = server.arg("bind").toInt();
    input_threshold[input] = server.arg("threshold").toInt();
    server.send(200, "text/html", "<p>Вход обновлён</p>");
  });
}

void handleRoot() {
  String html = "<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Главная</title><style>" + cssMobile + "</style></head><body>";
  html += "<h1>Главная страница GSM Module V1 (Режим: " + String(apMode ? "AP" : "Client") + ")</h1>";
  html += "<form action=\"/save\" method=\"POST\"><p>APN: <input type=\"text\" name=\"apn\" value=\"" + String(gsm_apn) + "\"></p>";
  html += "<p>Wi-Fi Пароль: <input type=\"text\" name=\"wifi_pass\" value=\"" + String("автоматически") + "\"></p>";
  html += "<p><input type=\"submit\" value=\"Сохранить\"></p></form>";
  html += "<h2>Мониторинг</h2><div><pre>" + input_history + "</pre></div>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}
// Конец Част 4 — перейди к Час 5
void handleNumbers() {
  int currentPage = server.arg("page").toInt();
  if (currentPage < 0) currentPage = 0;
  int totalPages = (MAX_NUMBERS + NUMBERS_PER_PAGE - 1) / NUMBERS_PER_PAGE;
  if (currentPage >= totalPages) currentPage = totalPages - 1;
  int startIndex = currentPage * NUMBERS_PER_PAGE;
  int endIndex = min(startIndex + NUMBERS_PER_PAGE, MAX_NUMBERS);
  String html = "<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>База номеров</title><style>" + cssMobile + "</style></head><body>";
  html += "<h1>База номеров (страница " + String(currentPage + 1) + " из " + String(totalPages) + ")</h1>";
  html += "<form action=\"/save_number\" method=\"POST\"><p>ID: <input type=\"number\" name=\"id\" min=\"0\" max=\"4999\" value=\"" + String(startIndex) + "\"></p>";
  html += "<p>Номер: <input type=\"text\" name=\"phone\" placeholder=\"+71234567890\"></p>";
  html += "<p>Адрес: <input type=\"text\" name=\"address\" placeholder=\"Описание\"></p>";
  html += "<p><input type=\"submit\" value=\"Добавить/Обновить\"></p></form>";
  html += "<table><tr><th>ID</th><th>Номер</th><th>Адрес</th></tr>";
  for (int i = startIndex; i < endIndex; i++) {
    PhoneEntry entry;
    loadPhoneFromEEPROM(i, entry.phone, entry.address);
    html += "<tr><td>" + String(i) + "</td><td>" + String(entry.phone) + "</td><td>" + String(entry.address) + "</td></tr>";
  }
  html += "</table>";
  html += "<div>";
  if (currentPage > 0) html += "<a href=\"/numbers?page=" + String(currentPage - 1) + "\">« Предыдущая</a> | ";
  for (int p = max(0, currentPage - 2); p <= min(totalPages - 1, currentPage + 2); p++) {
    if (p == currentPage) html += "<strong>" + String(p + 1) + "</strong> ";
    else html += "<a href=\"/numbers?page=" + String(p) + "\">" + String(p + 1) + "</a> ";
  }
  if (currentPage < totalPages - 1) html += " | <a href=\"/numbers?page=" + String(currentPage + 1) + "\">Следующая »</a>";
  html += "</div></body></html>";
  server.send(200, "text/html", html);
}

void handleRelays() {
  String html = "<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Настройки реле</title><style>" + cssMobile + "</style></head><body><h1>Настройки реле</h1>";
  for (int i = 0; i < 4; i++) {
    html += "<h2>Реле " + String(i+1) + "</h2><form action=\"/save_relay\" method=\"POST\">";
    html += "<p><input type=\"hidden\" name=\"relay\" value=\"" + String(i) + "\"></p>";
    html += "<p>Задержка вкл (мс): <input type=\"number\" name=\"delay_on\" value=\"" + String(relay_delay_on[i]) + "\"></p>";
    html += "<p>Задержка выкл (мс): <input type=\"number\" name=\"delay_off\" value=\"" + String(relay_delay_off[i]) + "\"></p>";
    html += "<p>Триггер: <input type=\"checkbox\" name=\"trigger\" " + (relay_trigger[i] ? "checked" : "") + "></p>";
    html += "<p><input type=\"submit\" value=\"Сохранить\"></p></form>";
  }
  html += "</body></html>";
  server.send(200, "text/html", html);
}

void handleInputs() {
  String html = "<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Настройки входов</title><style>" + cssMobile + "</style></head><body><h1>Настройки входов</h1>";
  html += "<p>Привязка к реле, пороги, история.</p>";
  for (int i = 0; i < 4; i++) {
    html += "<form action=\"/save_input\" method=\"POST\">";
    html += "<p><input type=\"hidden\" name=\"input\" value=\"" + String(i) + "\"></p>";
    html += "<p>Привязка к реле: <input type=\"number\" name=\"bind\" value=\"" + String(input_relay_bind[i]) + "\"></p>";
    html += "<p>Порог: <select name=\"threshold\"><option value=\"0\">LOW</option><option value=\"1\">HIGH</option></select></p>";
    html += "<p><input type=\"submit\" value=\"Сохранить\"></p></form>";
  }
  html += "<h2>История</h2><pre>" + input_history + "</pre>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

// =================================================================================
// SETUP И LOOP
// =================================================================================

void setup() {
  Serial.begin(115200);
  initEEPROM();
  initGSM();
  initRelaysAndInputs();
  setupWiFi(); // Начальный режим Client (по умолчанию)
  setupWebServer();
  Serial.println("GSM Module V1 готов! Держи кнопку >3 сек для переключения режима.");
}

void loop() {
  handleCall();
  handleSMS();
  handleRelaysAndInputs();
  server.handleClient();
  delay(100);
}
// Конец программы — все 5 частей вставлены