[Из песочницы] Разработка умных устройств на примере контроллера теплого пола на ESP8266

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

Контроллер предназначен анализировать показания датчиков (проводных и беспроводных) и поддерживать заданную (с учетом расписания, в т.ч. по дням недели) температуру в каждой отдельной зоне, путем включения/выключения котла и управления петлями водяного теплого пола с помощью термоголовок на коллекторе.

Аппаратное обеспечение


В качестве контроллера был выбран ESP8266 (WeMos D1 mini), как т.к. имеет WiFi на борту. Вместо ESP8266 может быть использован любой другой контроллер или микрокомпьютер — общие идеи останутся неизменными, главное чтобы на выбранной системе можно было бы развернуть WEB-сервер с поддержкой WEB-сокетов. В проекте так же были использованы:

  • RTC: DS3231 — надо же определять день недели и текущее время. Проект задумывался как автономное устройство, способное работать без интернета, поэтому NTP не подходит.
  • Беспроводные датчики температуры: NoName, 433МГц, от китайской метеостанции — готовое решение, работают от батареек. Что еще надо? А надо чтобы период передачи данных был не фиксированный. Проблема в том, что период передачи 35 секунд и не много плавает. И бывают ситуации, когда сигналы двух датчиков накладываются. В этом случае один или два датчика выпадают из системы на некоторое время. Проблему можно решить, используя похожие датчики, в которых переключение каналов так же изменяет период передачи данных.
  • Приемник 433МГц: Rxb6 — по обзорам в интернетах и личному опыту не плохой приемник.
  • Проводные датчики температуры: DS18B20 — очень удобны тем, что не требуется заранее знать количество датчиков.
  • Мастер шины 1Wire: DS2482–100 — протокол 1Wire очень чувствителен к таймингам, поэтому все реализации программного мастера шины используют delay, что очень не хорошо для многозадачности. Используя эту микросхему, можно использовать преимущества шины 1Wire и избавиться от ее недостатков за счет трансляции 1Wire <-> i2c. Протокол i2c имеет линию синхронизации, за счет чего не критичен к таймингам и часто в контроллерах реализован аппаратно.
  • Сторожевой таймер: TPL5000DGST — для данного проекта не столь важен непрерывный аптайм, однако доступность очень важна. В ESP8266 есть встроенный сторожевой таймер. Но как показала практика, иногда все же бывают ситуации, когда он не справляется и система зависает. Внешний аппаратный сторожевой таймер призван бороться с нештатными ситуациями. Сконфигурирован на задержку в 64сек. Присоединен к ноге TX — при работе система постоянно пишет в Serial отладочную информацию и отсутствие активности более одной минуты свидетельствует о зависании системы.
  • Расширитель портов: 74HC595 — использование этого расширителя требует 4х ног контроллера — три на передачу состояния и одну для того, чтобы при подаче питания реле не щелкали. В следующий раз буду использовать PCF8574 — шина i2c все равно используются, т.е. дополнительных ног MCU не требуется и при подаче питания выходы в 1 установлены.
  • Релейный модуль: NoName, 8-ми канальный, 5В — сказать нечего, кроме того, что включение реле происходит по низкому уровню на входах модуля. Твердотельные реле в данном проекте использовать недопустимо, т.к. коммутация контактов котла должна выполняться сухим контактом — в общем случае я не знаю напряжение и постоянный или переменный ток на контактах.


Операционная система


Операционная система — все то ПО, которое обеспечивает работоспособность прикладной программы. Операционная система так же является прослойкой между железом и прикладной программой, предоставляя высокоуровневый интерфейс доступа к аппаратным ресурсам. В случае ESP компонентами операционной системы можно считать:

Файловая система


В проекте используется SPIFFS, тут вроде все понятно, это самый простой путь. Для простого доступа к файлам на устройстве извне, я использую библиотеку nailbuster/esp8266FTPServer.

Система распределения процессорного времени


Это одна из основных функций операционной системы и ESP не станет исключением. За параллельное выполнение различных потоков алгоритма отвечает глобальный объект (синглтон) Timers. Класс достаточно простой и предоставляет следующий функционал:

  • Периодическое выполнение функции, с заданным интервалом. Пример инициализации таймера:
    Timers.add(doLoop, 6000, F("OneWireSensorsClass::doLoop")); // третий параметр – отладочная информация
    
  • Однократное выполнение функции через указанный промежуток времени. Например так выполняется отложенное сканирование WiFi сетей:
    Timers.once([]() { WiFi.scanNetworks(true);}, 1);
    


Таким образом функция loop выглядит подобным образом:

void loop(void) {
        ESP.wdtFeed();

        Timers.doLoop();

        CPULoadInfo.doLoop();
}


На практике функция loop содержит еще несколько строк, о них будет рассказано ниже.
Листинг класса Timers приложен.

Учет процессорного времени


Сервисная функция, не имеющая практического применения. Тем не менее она есть. Реализуется сингтоном CPULoadInfo. При инициализации объекта, выполняется замер количества итераций пустого цикла за небольшой промежуток времени:

void CPULoadInfoClass::init() {
        uint32_t currTime = millis();
        // определяем максимальное число циклов в 1сек
        while ((millis() - currTime) < 10) {
                delay(0);
                MaxLoopsInSecond++;
        }
        MaxLoopsInSecond *= 100;
}


Затем, считаем количество вызовов процедуры loop в секунду, считаем загрузку процессора в процентах и сохраняем данные в буфер:

void CPULoadInfoClass::doLoop() {
        static uint32_t prevTime = 0;
        uint32_t currTime = millis();
        LoopsInSecond++;
        if ((currTime - prevTime) > 1000) {
                memmove(CPULoadPercentHistory, &CPULoadPercentHistory[1], sizeof(CPULoadPercentHistory) - 1);

                int8_t load = ((MaxLoopsInSecond - LoopsInSecond) * 100) / MaxLoopsInSecond;
                CPULoadPercentHistory[sizeof(CPULoadPercentHistory) - 1] = load;

                prevTime = currTime;
                LoopsInSecond = 0;
        }
}


Использование этого подхода позволяет получить так же использование процессора каждым отдельным потоком (если соединить эту подсистему с классом Timers), но как я уже сказал — практического применения этому я не вижу.

Система ввода-вывода


Для общения с пользователем используется UART-USB и WEB интерфейс. Про UART думаю подробно рассказывать не требуется. Единственно на что стоит обратить внимание — для удобства и совместимости с не ESP реализована функция serialEvent ():

void loop(void) {
        // …
        if (Serial.available())
                serialEvent();
        // …
}


С WEB интерфейсом все гораздо интереснее. Посвятим ему отдельный раздел.

WEB интерфейс


В случае с умными устройствами, по моему мнению, WEB интерфейс самое удобное для пользователя решение.

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

Использование специфических программ для управления устройством — накладывает ограничения на пользователя, добавляет необходимость разработки и поддержки этих программ, а также требует от разработчика заботиться о доставке этих программ на терминальные устройства пользователя. По-хорошему, приложение должно быть опубликовано в магазинах приложений Google, Apple, Windows, а также доступно в репозиториях Linux в виде deb и rpm пакетов — в противном случае, для какой-то части аудитории может быть затруднен доступ к интерфейсу устройства.

Доступ к WEB интерфейсу устройства доступен с любой операционной системы — Linux, Windows, Android, MacOS, на десктопе, ноутбуке, планшете, смартфоне — лишь бы был браузер. Конечно разработчику WEB интерфейса необходимо учитывать особенности различных устройств, но это в основном касается размера и разрешения. Доступ к WEB интерфейсу умного устройства в доме/квартире/на даче легко обеспечивается извне через интернет — сейчас сложно представить дом/квартиру, в которых есть умные устройства и нет роутера и интернета, а в роутере такой доступ настраивается в несколько кликов (тем, кто совсем не в теме, помогут ключевые слова — «проброска портов» и «динамический DNS»). В случае дачи доступ можно обеспечить с помощью 3G роутера.

Для реализации WEB интерфейса необходим WEB сервер. Я использую библиотеку me-no-dev/ESPAsyncWebServer. Эта библиотека обеспечивает следующий функционал:

  • Отдачу статического контента, в т.ч. с поддержкой сжатия gzip. Поддерживаются виртуальные каталоги, с возможностью указания главного файла (который обычно index.htm) для каждого каталога.
  • Назначение callback функций на различные URL с учетом типа запроса (GET, POST, …)
  • Поддержка WEB сокетов на том же порту (это имеет значение при проброске портов).
  • BASIC авторизацию. Причем авторизация устанавливается индивидуально для каждого URL. Это важно, т.к. например Google Chrome при создании ярлыка страницы на главном экране, запрашивает иконку и файла манифеста и не передает данные авторизации. Поэтому часть файлов помещены в виртуальный каталог и для этого каталога авторизация отключена.


HTTP сервисы операционной системы


В текущем проекте все настройки операционной системы выполняются с помощью HTTP сервисов. HTTP сервис — это небольшой независимый функционал получения/изменения данных, доступный по HTTP. Далее рассмотрим список этих сервисов.

HELP


Реализацию любого списка команд я считаю правильным начать с реализации команды HELP. Ниже блок инициализации WEB сервера:

void HTTPserverClass::init()
{
        //SERVER INIT
        help_info.concat(F("/'doc_hame.ext': load file from server. Allow methods: HTTP_GET\n"));
        AsyncStaticWebHandler& handler = server.serveStatic("na/", SPIFFS, "na/");
        serveStaticHandlerNA = &handler; // виртуальный каталог в котором Нет Авторизации
        server.serveStatic("/", SPIFFS, "/"); // все остальные файлы
        
        //info
        // перед реализацией сервиса сформируем его описание
        help_info.concat(F("/info: get system info. Allow methods: HTTP_GET\n"));
        server.on("/info", HTTP_GET, handleInfo);
        
        …
        
        server.on("/help", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(200, ContentTypesStrings[ContentTypes::text_plain], help_info.c_str()); });

        // устанавливаем параметры авторизации
        setAuthentication(ConfigStore.getAdminName(), ConfigStore.getAdminPassword());
        
        DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); // разрешает кросс-доменные запросы

        // запускаем сервер
        server.begin();

        // макросы отладочной информации будут приложены отдельно
        DEBUG_PRINT(F("HTTP server started"));

}


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

http://tc-demo.vehs.ru/help

/'doc_hame.ext': load file from server. Allow methods: HTTP_GET
/info: get system info. Allow methods: HTTP_GET
/time: get time as string (eg: 20140527T123456). Allow methods: HTTP_GET
/uptime: get uptime as string (eg: 123D123456). Allow methods: HTTP_GET
/rtc: set RTC time. Allow methods: HTTP_GET, HTTP_POST
/list ? [dir=...] & [format=json]: get file list as text or json. Allow methods: HTTP_GET
/edit: edit files. Allow methods: HTTP_GET, HTTP_PUT, HTTP_DELETE, HTTP_POST
/wifi: edit wifi settings. Allow methods: HTTP_GET, HTTP_POST
/wifi-scan ? [format=json]: get wifi list as text or json. Allow methods: HTTP_GET
/wifi-info ? [format=json]: get wifi info as text or json. Allow methods: HTTP_GET
/ap: edit soft ap settings. Allow methods: HTTP_GET, HTTP_POST
/user: edit user settings. Allow methods: HTTP_GET, HTTP_POST
/user-info ? [format=json]: get user info as text or json. Allow methods: HTTP_GET
/update: update flash. Allow methods: HTTP_GET, HTTP_POST
/restart: restart system. Allow methods: HTTP_GET, HTTP_POST
/ws: web socket url. Allow methods: HTTP_GET
/help: list allow URLs. Allow methods: HTTP_GET


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

#ifdef USE_RTC_CLOCK
        help_info.concat(F("/rtc: set RTC time. Allow methods: HTTP_GET, HTTP_POST\n"));
        const char* urlNTP = "/rtc";
        server.on(urlNTP, HTTP_GET, [](AsyncWebServerRequest *request) { DEBUG_PRINT(F("/rtc")); request->send(200, ContentTypesStrings[ContentTypes::text_html], String(F("RTC time
"))); }); server.on(urlNTP, HTTP_POST, handleSetRTC_time); #endif // USE_RTC_CLOCK


cr-6napgtebfgiquecvuezhxqia.png

В последствии этот сервис используется в более симпатичном интерфейсе:

ttmn-oyhy9v3lygwrk0jk2wzp2o.png

Прикладное ПО


Наконец подошли к тому, ради чего система создавалась. А именно — к реализации прикладной задачи.

Любая прикладная программа должна получать исходные данные, обрабатывать их и выдавать результат. Так же, возможно, система должна сообщать текущее состояние.

Исходными данными для контроллера теплого пола являются:

  • Данные датчиков — система не привязана к конкретным датчикам. Для каждого датчика формируется уникальный идентификатор. Для радио-датчиков их идентификатор дополняется до 16 бит нулями, для датчиков 1Wire на основании их внутреннего идентификатора вычисляется CRC16 и используется в качестве идентификатора датчика. Таким образом, все датчики имеют идентификаторы длиной 2 байта.
  • Данные о зонах отопления — число зон не фиксировано, максимальное количество ограничено используемым модулем реле. С учетом этого ограничения так же разрабатывался WEB интерфейс.
  • Целевая температура и расписание — я постарался сделать максимально гибкие настройки, можно создать несколько схем отопления и можно даже на каждую зону назначить свою схему настроек.


Таким образом есть некоторое количество настроек, которые надо как-то задавать, и есть некоторое количество параметров, которые система сообщает как текущее состояние.
Для общения контроллера с внешним миром я реализовал командный интерпретатор, который позволил реализовать как управление контроллером, так и получать данные о состоянии. Команды передаются контроллеру в человекочитаемом виде, и могут передаваться через UART или WEB сокет (при желании можно реализовать поддержку других протоколов, например telnet).
Строка команд начинается со знака '#' и оканчивается нулевым символом либо символом перевода строки. Все команды состоят из имени команды и операнда, разделенных двоеточием. Для некоторых команд операнд не обязателен, в этом случае двоеточие и операнд не указываются. Команды в строке разделяются запятой. Например:

#ZonesInfo:1,SensorsInfo


И конечно же список команд начинается с команды Help, которая выводит список всех допустимых команд (передаваемые команды для удобства восприятия начинаются со знака '>' вместо '#'):

>help
Help
SetZonesCount
Zone
SetName
SetSensor
...
LoadCfg
SaveCfg
#Cmd:Help,CmdRes:Ok


Особенностью реализации командного интерпретатора является то, что информация о результате выполнения команды выдается так же в виде команды или набора команд:

>help
…
#Cmd:Help,CmdRes:Ok

>zone:123
#Cmd:Zone,Value:123,CmdRes:Error,Error:Zone 123 not in range 1-5

>SchemasInfo
#SchemasCount:2
#Schema:1,Name:Основная,DOWs:0b0000000
#Schema:2,Name:Гараж,DOWs:0b0000000
#Cmd:SchemasInfo,CmdRes:Ok


На стороне WEB клиента так же реализован командный интерпретатор, который принимает эти команды и преобразует их в графический вид. Например:

>zonesInfo:3
#Zone:3,Name:Спальня,Sensor:0x5680,Schema:1,DeltaT:-20
#Cmd:ZonesInfo,CmdRes:Ok


WEB интерфейс передал запрос контроллеру о зоне номер 3, и получил в ответ название зоны, идентификатор датчика, привязанного к зоне, идентификатор схемы, назначенной зоне и коррекцию температуры для зоны. Командный интерпретатор не понимает дробных чисел, поэтому температура передается в десятых долях градуса, т.е. 12.3 градуса это 123 десятых долей.

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

#sensor:0x5A20,type:w433th,battery:1,button_tx:0,channel:0,temperature:228,humidity:34,uptime_label:130308243,time_label:20180521T235126


Или о том например, что данные зоны необходимо актуализировать:

#Zone:2,TargetTemp:220,CurrentTemp:228,Error:Ok


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

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

Заключение


Использование подобного подхода, а именно:

  • Разделение ПО на операционную систему и прикладную программу
  • Реализация настроек операционной системы в виде минималистичных HTTP сервисов
  • Отделение логики системы от представления данных
  • Использование человекочитаемых протоколов обмена данными


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

5bmgmh1jpuzsrdjpreb6jw_yopy.png

Как видно, в этом проекте только первые три страницы интерфейса непосредственно связаны с прикладной задачей, а остальные практически универсальны.

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

Кому интересна данная тема — пишите, может я в чем-то не прав, а может быть какие-то детали имеет смысл описать более подробно.

Демонстрацию и описание устройства можно посмотреть на демо-сайте.

© Habrahabr.ru