Умный дом на ESP32 с котлом Navien

Сегодня расскажу про реализацию идеи «Умного Дома» своими руками на примере своего дачного участка. 

Зачем мне это надо? Изначально, только для одного — удаленного запуска газового котла Navien Deluxe, чтобы к приезду моей семьи дом был прогретым.

Т.к. штатный модуль WiFi у Navien довольно дорогой (также, как сторонний от Zont), а руки у меня не совсем кривые, то решено было изготовить контроллер самостоятельно при помощи модуля ESP32 и управляемого реле. Стоит отметить, что Navien запоминает последнее состояние, т.е. при подачи питания 220В котел запуститься с настройками, заданными перед пропаданием питания. На этом и основана логика работы контура ESP32-реле-стабилизатор-котёл.

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

Как я уже упомянул, за основу был взят модуль ESP32. Выбран он был, т.к. имелся опыт исользования ESP32-CAM и прошивки его кодом от s60sc. Ссылка на гитхабовский проект: https://github.com/s60sc/ESP32-CAM_MJPEG2SD. Сейчас, спустя время, правильней было бы его делать на ESP8266, потому что ESP32 глючноват и менее стабилен, но вроде я налавчился и останусь на нём.

На ESP32 поднят web-сервер (доступный только из внутренней vpn-сети) и крутится небольшой сайт. На нём есть страница управления котлом с показаниями датчиков температуры, страница просмотра видео с камер (пока только одной, той самой ESP32-CAM) и страница перезапуска узла питания. Последняя страница нужна для того, чтобы можно было удалённо оживить ESP32-CAM, когда он зависает, и сбросить 3G-подключение, когда и оно зависает без подключения к роутеру. Камера запитана от той же ветки, что узел связи. Подробнее на узле связи я останавливаться не буду, может потом как-нибудь.

Проект компилировался в Arduino IDE. Т.к. в языке C я не силён, за основу брался код из этих источников:

 — работа с сенсорами Dallas

 — работа с сайтом в памяти контроллера

 — работа с EEPROM для хранения состояния реле

 — watchdog для ESP32

Основные комментарии приведены в листинге, если есть вопросы — отвечу отдельно, обращайтесь. 

Шаблон сайта взят отсюда. Все необходимые файлы вытащены из папок в корень при заливке в память контроллера. Ненужное — удалено. Папка data проекта выглядит вот так:

HTML комментировать не буду, там и так всё очевидно, но если что-то будет не понятно, то опять же — обращайтесь.

Мой код прошивки контроллера:

// Подключаем используемые библиотеки
#include  // Считывание показаний датчиков по протоколу OneWire
#include  // Подключаем датчики Dallas
#include  // Библиотека для реализации WiFi-подключения
#include  // Хранение состояния реле
#include  // Асинхронный web-сервер
#include  // Хранение web-сайта
#include  // Контроль состояний ESP32 для автоматического перезапуска
// Задаём задержку, после которой считаем, что модуль завис и перезагружаем его (таймер watchdog)
#define WDT_TIMEOUT 30
// Используемый размер EEPROM для хранения состояний котла (ВКЛ/ВЫКЛ) (нужно при пропадании питания дома)
#define EEPROM_SIZE 1
// Сенсоры-датчики температуры подключены к GPIO4
#define ONE_WIRE_BUS 4
// здесь пишем учетные данные своей сети:
const char* ssid     = "wifi_ap_name";
const char* password = "wifi_ap_password";
char ST_ip[16]  = "192.168.0.11"; // Статический IP
char ST_sn[16]  = "255.255.255.0"; // маска
char ST_gw[16]  = "192.168.0.1"; // Шлюз
// задаем номера для выходных GPIO-контактов:
const int RelayPin = 27; // Управление реле питания котла
const int NetworkPin = 14; // Управление реле питания узла связи
// Создаем экземпляр класса «AsyncWebServer»
// под названием «server» и задаем ему номер порта «80»:
AsyncWebServer server(80);
// ------Вспомогательные переменные------
// Переменная для хранения HTTP-запроса
String header;
// Переменная для хранения текущего состояния выходного контакта RelayPin
int RelayState = HIGH;
// Переменные для хранения показаний температуры
String temperatureC1 = "";
String temperatureC2 = "";
float kitchenSensor, waterSensor;
// Переменные для таймера
unsigned long lastTime = 0;  
unsigned long timerDelay = 5000;
// Запускаем обработчик устройств OneWire
OneWire oneWire(ONE_WIRE_BUS);
// Подключаем наш обработчик к Dallas-сенсорам
DallasTemperature sensors(&oneWire);
// Описываем наши датчики
DeviceAddress sensor1 = { 0x28, 0xFF, 0x64, 0x02, 0xC9, 0xF6, 0x9A, 0xC0 }; // Кухня
DeviceAddress sensor2 = { 0x28, 0xFF, 0x64, 0x02, 0xCD, 0x87, 0x40, 0x05 }; // Теплоноситель
String readDSTemperatureC1() { // Опрашиваем датчик №1
  sensors.requestTemperatures(); 
  float tempC = sensors.getTempC(sensor1);
  if(tempC == -127.00) {
    Serial.println("Failed to read from sensor №1 (Kitchen)");
    return "--";
  } else {
    Serial.print("Kitchen Temperature Celsius: ");
    Serial.println(tempC); 
  }
  return String(tempC);
}
String readDSTemperatureC2() { // Опрашиваем датчик №1
  sensors.requestTemperatures(); 
  float tempC = sensors.getTempC(sensor2);
  if(tempC == -127.00) {
    Serial.println("Failed to read from sensor №2 (Water)");
    return "--";
  } else {
    Serial.print("Water Temperature Celsius: ");
    Serial.println(tempC); 
  }
  return String(tempC);
}
// Меняем заглушку на текущее состояние реле и температуры:
String processor(const String& var){
  // Создаем переменную для хранения состояния реле, у нас реле нормально закрытое, поэтому 0 - открыто, 1 - закрыто:
  String relayStateWeb;
  Serial.println(var);
  if(var == "STATE"){
    if(digitalRead(RelayPin)){
      relayStateWeb = "OFF";
    }
    else{
      relayStateWeb = "ON";
    }
    Serial.println(relayStateWeb);
    return relayStateWeb;
  }
  if(var == "TEMPERATUREC1"){
    return temperatureC1;
  }
  if(var == "TEMPERATUREC2"){
    return temperatureC2;
  }
  
  return String();
}
void WiFiStationConnected(WiFiEvent_t event, WiFiEventInfo_t info){
  Serial.println("Connected to AP successfully!");
}
void WiFiGotIP(WiFiEvent_t event, WiFiEventInfo_t info){
  // печатаем в мониторе порта локальный IP-адрес
  // и запускаем веб-сервер:
  Serial.println("");
  Serial.println("WiFi connected.");  // "WiFi подключен."
  Serial.print("IP address: ");  // "IP-адрес: "
  Serial.println(WiFi.localIP());
}
void WiFiStationDisconnected(WiFiEvent_t event, WiFiEventInfo_t info){
  Serial.println("Disconnected from WiFi access point");
  Serial.print("WiFi lost connection. Reason: ");
  Serial.println(info.wifi_sta_disconnected.reason);
  Serial.println("Trying to Reconnect");
  WiFi.begin(ssid, password);
}
 
void setup(void){
  Serial.begin(115200);
  
  esp_task_wdt_init(WDT_TIMEOUT, true); // Инициализируем контроль состояния паники контроллера для перезагрузки
  esp_task_wdt_add(NULL); // Активируем контроль состояния
  sensors.begin();
  
  // Инициализируем EEPROM с предопределенным размером
  EEPROM.begin(EEPROM_SIZE);
  // Определяем температуру 
  temperatureC1 = readDSTemperatureC1();
  temperatureC2 = readDSTemperatureC2();
  // Задаем начальное состояние наших выходных контактов 
  pinMode(RelayPin, OUTPUT);
  pinMode(NetworkPin, OUTPUT);
  digitalWrite(NetworkPin, HIGH);  
  // Инициализируем SPIFFS:
  if(!SPIFFS.begin(true)){
    Serial.println("An Error has occurred while mounting SPIFFS"); // "При монтировании SPIFFS произошла ошибка"
    return;
  }
  // прочитать последнее состояние реле из флэш-памяти
  RelayState = EEPROM.read(0);
  Serial.println((String) "Last state: " + RelayState);
  // Установить реле в последнее сохраненное состояние
  digitalWrite(RelayPin, RelayState);  
  // Подключаемся к WiFi-сети при помощи заданных выше SSID и пароля:
  Serial.print("Connecting to ");
  //  "Подключаемся к "
  Serial.println(ssid);

// Устанавливаем статический IP-адрес
  IPAddress _ip, _gw, _sn;
  _ip.fromString(ST_ip);
  _gw.fromString(ST_gw);
  _sn.fromString(ST_sn);
  WiFi.config(_ip, _gw, _sn); 
  WiFi.onEvent(WiFiStationConnected, ARDUINO_EVENT_WIFI_STA_CONNECTED);
  WiFi.onEvent(WiFiGotIP, ARDUINO_EVENT_WIFI_STA_GOT_IP);
  WiFi.onEvent(WiFiStationDisconnected, ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
  
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

// Ззапускаем веб-сервер:
  // URL для корневой страницы и прочих страниц веб-сервера:
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/index.html", String(), false, processor);
  });
  server.on("/index.html", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/index.html", String(), false, processor);
  });
  server.on("/cameras.html", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/cameras.html", String(), false, processor);
  });
  server.on("/temp.html", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/temp.html", String(), false, processor);
  });
  server.on("/network.html", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/network.html", String(), false, processor);
  });
  // URL для файла «style.css»:
  server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/style.css", "text/css");
  });
  // Прочее
  server.on("/reset.css", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/reset.css", "text/css");
  });
  server.on("/logo.png", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/logo.png", "text/css");
  });
  server.on("/bg.jpg", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/bg.jpg", "text/css");
  });
  server.on("/jquery-1.7.min.js", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/jquery-1.7.min.js", "text/css");
  });
  server.on("/jquery.easing.1.3.js", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/jquery.easing.1.3.js", "text/css");
  });
  server.on("/FF-cash.js", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/FF-cash.js", "text/css");
  });
  server.on("/header.jpg", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/header.jpg", "text/css");
  });
  server.on("/nav-shadow.png", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/nav-shadow.png", "text/css");
  });
  server.on("/nav.jpg", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/nav.jpg", "text/css");
  });
  server.on("/menu-li.jpg", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/menu-li.jpg", "text/css");
  });
  server.on("/header-content.jpg", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SPIFFS, "/header-content.jpg", "text/css");
  });
      
  // URL для переключения GPIO-контакта на «LOW»:
  server.on("/on", HTTP_GET, [](AsyncWebServerRequest *request){
    digitalWrite(RelayPin, LOW);    
    Serial.println("GPIO off, Relay on");  // "GPIO включен"
    RelayState = LOW;
    EEPROM.write(0, RelayState);
    EEPROM.commit();
    Serial.println("State saved in flash memory");            
    request->send(SPIFFS, "/index.html", String(), false, processor);
  });
  
  // URL для переключения GPIO-контакта на «HIGH»:
  server.on("/off", HTTP_GET, [](AsyncWebServerRequest *request){
    digitalWrite(RelayPin, HIGH);  
    Serial.println("GPIO on, Relay off");  // "GPIO выключен"
    RelayState = HIGH;
    EEPROM.write(0, RelayState);
    EEPROM.commit();
    Serial.println("State saved in flash memory");
    request->send(SPIFFS, "/index.html", String(), false, processor);
  });
  // URL для перезапуска узла связи:
  server.on("/reboot", HTTP_GET, [](AsyncWebServerRequest *request){
    digitalWrite(NetworkPin, LOW);  
    Serial.println("Network core power reset start");
    delay(2000);
    digitalWrite(NetworkPin, HIGH);   
    Serial.println("Network core power reset complete");   
    request->send(SPIFFS, "/network.html", String(), false, processor);
  });
  
  server.on("/temperaturec1", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/plain", temperatureC1.c_str());
  });
  server.on("/temperaturec2", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/plain", temperatureC2.c_str());
  });
  
  server.begin();
}
// В цикле опрашиваем сенсоры и следим за состоянием контроллера: 
void loop(void){ 
  delay(1000);
  if ((millis() - lastTime) > timerDelay) {
    temperatureC1 = readDSTemperatureC1();
    temperatureC2 = readDSTemperatureC2();
    lastTime = millis();
    }
  Serial.println("WDT RESET");
  esp_task_wdt_reset();
}

HTML-код главной страницы (index.html):


<!DOCTYPE html>
<html lang="en">
<head>
<title>П       ,  4 - сервер управления</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" media="screen" href="reset.css">
<link rel="stylesheet" type="text/css" media="screen" href="style.css">
<script src="jquery-1.7.min.js"></script>
<script src="jquery.easing.1.3.js"></script>
<script src="FF-cash.js"></script>
<!--[if lt IE 9]>
<script src="html5.js"></script>
<link rel="stylesheet" type="text/css" media="screen" href="ie.css">
<![endif]-->
</head>
<body>
<!--==============================header=================================-->
<header>
  <div class="main">
    <div class="wrap">
      <h1><a href="index.html"><img decoding="async" src="logo.png" alt=""></a></h1>

    </div>
    <div class="nav-shadow">
      <div>
        <nav>
          <ul class="menu">
            <li class="current"><a href="index.html">Главная</a></li>
            <li><a href="cameras.html">Камеры</a></li>
            <li><a href="network.html">Узел связи</a></li>
          </ul>
        </nav>
      </div>
    </div>
  </div>
  <div class="header-content header-subpages"></div>
</header>
<!--==============================content================================-->
<section id="content" >
<div class="card">    
    <div class="card-block1" >
      <p style = "margin:revert; font-size:14px"><strong>Питание котла</strong></p>
      <p>GPIO state: <strong> %STATE%</strong></p>
      <p><a href="/on"><button class="button">Включить</button></a></p>
      <p><a href="/off"><button class="button button2">Выключить</button></a></p>
    </div>
    <div class="card-block" >
       <iframe loading="lazy" src="temp.html" height="260px" width="100%"></iframe>
    </div>

</div>
</section>
<!--==============================footer=================================-->
<footer>
</footer>
</body>
</html>

HTML-код страницы просмотра камер (cameras.html):


<!DOCTYPE html>
<html lang="en">
<head>
<title>П        ,  4 - сервер управления</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" media="screen" href="reset.css">
<link rel="stylesheet" type="text/css" media="screen" href="style.css">

<script src="jquery-1.7.min.js"></script>
<script src="jquery.easing.1.3.js"></script>
<script src="FF-cash.js"></script>
<!--[if lt IE 9]>
<script src="html5.js"></script>
<link rel="stylesheet" type="text/css" media="screen" href="ie.css">
<![endif]-->
</head>
<body>
<!--==============================header=================================-->
<header>
  <div class="main">
    <div class="wrap">
      <h1><a href="index.html"><img decoding="async" src="logo.png" alt=""></a></h1>
    </div>
    <div class="nav-shadow">
      <div>
        <nav>
          <ul class="menu">
            <li><a href="index.html">Главная</a></li>
            <li class="current"><a href="cameras.html">Камеры</a></li>
            <li><a href="network.html">Узел связи</a></li>
          </ul>
        </nav>
      </div>
    </div>
  </div>
  <div class="header-content header-subpages"></div>
</header>
<!--==============================content================================-->
<section id="content">
  
    <div class="wrap">
	<html style="height: 100%;">
	<head>
		<meta name="viewport" content="width=device-width, minimum-scale=0.1">
	</head>
	<body style="margin: 0px; height: 100%">
		<img decoding="async" style="-webkit-user-select: none;width: 600px;height: 400px;" src="http://192.168.0.10:81/stream">
	</body>
	</html>
    </div>
  
</section>
<!--==============================footer=================================-->
<footer>

</footer>
</body>
</html>

Здесь мы просто выводим поток с адреса вещания камеры в локальной сети (http://192.168.0.10:81/stream)

HTML-код страницы управления узлом связи (network.html):


<!DOCTYPE html>
<html lang="en">
<head>
<title>П       , 4 - сервер управления</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" media="screen" href="reset.css">
<link rel="stylesheet" type="text/css" media="screen" href="style.css">
<script src="jquery-1.7.min.js"></script>
<script src="jquery.easing.1.3.js"></script>
<script src="FF-cash.js"></script>
<!--[if lt IE 9]>
<script src="html5.js"></script>
<link rel="stylesheet" type="text/css" media="screen" href="ie.css">
<![endif]-->
</head>
<body>
<!--==============================header=================================-->
<header>
  <div class="main">
    <div class="wrap">
      <h1><a href="index.html"><img decoding="async" src="logo.png" alt=""></a></h1>
    </div>
    <div class="nav-shadow">
      <div>
        <nav>
          <ul class="menu">
            <li><a href="index.html">Главная</a></li>
            <li><a href="cameras.html">Камеры</a></li>
            <li class="current"><a href="network.html">Узел связи</a></li>
          </ul>
        </nav>
      </div>
    </div>
  </div>
  <div class="header-content header-subpages"></div>
</header>
<!--==============================content================================-->
<section id="content" >
<div class="card">    
    <div class="card-block1" >
      <p style = "margin:revert; font-size:14px"><strong>Питание узла связи</strong></p>
<!--       <p>GPIO state: <strong> %STATE%</strong></p>-->
      <p><br></p>
      <p><br></p>
      <p><a href="/reboot"><button class="button">Перезапуск</button></a></p>
<!--      <p><a href="/net_off"><button class="button button2">Выключить</button></a></p>-->
    </div>
</div>
</section>
<!--==============================footer=================================-->
<footer>
</footer>
</body>
</html>

HTML-код блока отображения температуры (temp.html):


<!DOCTYPE HTML><html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">
  <style>
    html {
     font-family: Arial;
     display: inline-block;
     margin: 0px auto;
     text-align: center;
    }
    h2 { font-size: 1.5rem; }
    p { font-size: 1.5rem; }
    .units { font-size: 1.0rem; }
    .ds-labels{
      font-size: 1.0rem;
      vertical-align:middle;
      padding-bottom: 10px;
    }
  </style>
</head>
<body>
  <p align="left">
    <i class="fas fa-thermometer-half" style="color:#059e8a;"></i> 
    <span class="ds-labels">Температура на кухне</span> 
    <span id="temperaturec">%TEMPERATUREC1%</span>
    <sup class="units">&deg;C</sup>
  </p>
  <p align="left">
    <i class="fas fa-thermometer-half" style="color:#059e8a;"></i> 
    <span class="ds-labels">Температура теплоносителя</span> 
    <span id="temperaturec">%TEMPERATUREC2%</span>
    <sup class="units">&deg;C</sup>
  </p>
</body>
<script>
setInterval(function ( ) {
  var xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      document.getElementById("temperaturec").innerHTML = this.responseText;
    }
  };
  xhttp.open("GET", "/temperaturec1", true);
  xhttp.open("GET", "/temperaturec2", true);
  xhttp.send();
}, 10000) ;
</script>
</html>

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *