Оптимизируем ESP8266/ESPHOME для работы на батарейках (и делаем попутно мониторинг температуры воды в ванной)

— Товарищ генерал-лейтенант. Я давно хотел спросить. А как с йети быть?
— Йети? Надо чаще мыть.
(c) Х/Ф “ДМБ”

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

Что за повод? А история такая. Тут дернула меня нелёгкая начать закаливаться в летнюю жару, и не душем а сразу ледяной ванной (по-научному CWI, Cold Water Immersion). Дело мне это понравилось, и я дотянул спокойно аж до ноября, благо погода сама понижала температуру холодной воды от прохладной и приятной до “охтыжблинхолод”, и делала это неспешно.

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

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

Форм-фактор устройства: кирпич обыкновенный

Железо

Аппаратуру для этого проекта я взял весьма тупую. Плату от другого проекта я только частично запаял, оставив только самое необходимое: esp8266 wroom, DC-DC дающий 3.3 вольта, dht11 снятый с ещё одного проекта и изрядно когда-то оплавленный, но все ещё работающий, ну и гвоздь программы – ds18b20 на длинной кишке. А ещё прикрутил держатель для 18650 аккумулятора, куда и воткнул 18650 аккумулятор. Вот и весь колхоз. Разве что корпус добавил, наверное слишком здоровый для этой поделки, но все равно без дела валялся.

Аккумуляторы я взял не совсем простые. Лет семь назад нечистый на руку китаец прислал мне совсем-совсем дерьмовые аккумуляторы. Заявленных 4000mAh там даже рядом не валялось. Большую часть я выкинул, парочка у меня случайно остались. Быстрый тест зарядником показал, что в них есть целых 400mAh емкости. Сойдёт для сельской местности.

Чего будем мониторить и как?

Нам потребуется два основных параметра. Температура воды (с датчика ds18b20) и температура воздуха (dht11). Разница температур в основном должна показывать насколько нам “по ощущениям” холодно, и это хотелось проверить. Влажность воздуха получаем бесплатным довеском к DHT11, хотя практического примменения этому я не нашел. Наличие воды в ванной можно проверять по разнице температур воды и воздуха. Больше 4 градусов – наверное вода есть. Это нам потребуется немного позже, для энергосбережения. Желательно, когда готовишь ванну и плавно опускаешь темперауру иметь обратную связь почаще, а вот в остальное время эти данные вообще бесполезны.

Первый тест: энергосбережение отключено.

Для первого теста я использовал свой обычный конфиг esphome, для подключения к Hass использовал API, значения с датчиков отправлялись в веб интерфейс раз в минуту. Эта штука прожила 6 часов, просадила батарейку до 2.5 вольт и вырубилась. Те самые 70mA о которых писали умельцы как раз и получилсь, если пересчитать все аккуратно.

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

Переходим на MQTT

У esphome есть два способа связываться с HomeAssistant: API и MQTT.

  • API – Home Assistant периодически пытается подключиться к устройству сам, держит соединение, получает данные.
  • MQTT – ESP8266 сам цепляется к MQTT серверу, и плюется в него данными по необходимости.

Понятно, что второй вариант для нас тут будет предпочтительнее. Потому смело меняем строчку:

api:

на строчки:

mqtt:
  id: mqtt_cli
  broker: homeblade.home
  username: xxxx
  password: xxxx
  birth_message:
  will_message:

Так как нам совершенно не хочется, чтобы устройство выглядено как Unavailable пока оно находится в режиме сна, то мы устанавливаем birth_message/will_message в пустые значения. Так мы всегда будем определяться как онлайн.

Включаем правильный глубокий сон

Для этого я запаял GPIO16 на RESET на своей плате каплей припоя, и открыл документацию и интернет. Основной способ добавить глубокий сон – это вот такие магические строчки в конфиге.

deep_sleep:
  run_duration: 10s
  sleep_duration: 10min

Идея в том, что мы будем 10 секунд работать, и 10 минут спать. Неплохо, конечно, но сколько реально нам требуется времени чтобы соединиться с WiFi, получить данные с датчиков и отправить их? Больше или меньше? А ведь время это может плавать в зависимости от того, как загружена точка доступа. Потому вместо этого способа, я решил сделать кое что не очень хорошо документированное, через директивы lambda, которые дают нам возможность втыкать свой C++ код в конфиг. Секция глубокого сна у меня стала выглядеть так:

deep_sleep:
  id: sleepy

После этого я добавил секцию globals и script, в которой описал последовательность действий, которые мы должны делать при пробуждении. А еще прописал во все датчики update_interval: never, так как обновлять теперь данные буду руками при пробуждении.

globals:
  - id: updates
    type: int
    restore_value: no
    initial_value: '0'
script:
  - id: check_stuff
    then:
    - lambda: |-
        id(updates) = 0; 
        id(bat).update();
        id(dallashub).update();
        id(dhts).update();
    - wait_until:
        lambda: |-
          return (id(updates) >= 4);
    - deep_sleep.enter:
        id: sleepy
        sleep_duration: 5m
dallas:
  - pin: GPIO12
    id: dallashub
    update_interval: never
sensor:
  - platform: dht
    pin: GPIO0
    model: DHT11
    id: dhts
    temperature:
      name: "$devicename Temperature"
      id: dht_temp
      on_value: 
        lambda: |-
          id(updates)++;
    humidity:
      name: "$devicename Humidity"
      on_value: 
        lambda: |-
          id(updates)++;
    update_interval: never
  - platform: adc
    id: bat
    pin: GPIO17
    name: "$devicename Battery Voltage"
    filters:
      - multiply: 6
    update_interval: never
    on_value: 
      lambda: |-
        id(updates)++;
  - platform: dallas
    index: 0
    id: water_temp
    name: "$devicename Water Temperature"
    on_value: 
      lambda: |-
        id(updates)++;

В чем тут прикол? В том, что ручной вызов update(), как я понял, на датчике может не сразу обновить значение, а только запустить процесс, и получить данные асинхронно. Потому я посчитал сколько у меня датчиков, считая батарейку, и сделал чтобы каждое полученное значение инкрементировало глобальную переменную updates. И в скрипте мы можем просто дождаться условия, что она станет больше 4х (то есть все обновили) и уже тогда двигаться дальше, переходя в сон на 5 минут. Трюк отлично сработал, но остались еще мелкие доработки, о которых речь пойдет дальше.

Оптимизируем потребление WiFi

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

  • Включил fast_connect. То есть мы не делаем полного сканирования, а цепляемся сразу к сети, которую увидели первой, пофиг на сигнал. Если будем работать в 802.11r окружении, то тут надо будет еще и прописать канал.
  • Поставил энергосбережение в LIGHT (power_save_mode: LIGHT). Можно, наверное и больше, но мне и этого более чем хватило,
  • Отказался от DHCP в пользу статического IP (быстрее установим адрес, и сможем делать свою работу!)
  • Указал IP адрес MQTT сервера вместо dns имени, чтобы не тратить время на запросы к DNS

Вышло как-то так:

wifi:
  power_save_mode: LIGHT
  fast_connect: true
  networks:
  - ssid: xxxxx
    password: xxxxx
  manual_ip:
    static_ip: 192.168.3.31
    gateway: 192.168.3.1
    subnet: 255.255.255.0
    dns1: 192.168.3.1

Делаем интервал сна динамическим.

Это очень полезно. Когда наливаешь ванну и хочется посмотреть температуру, что получилось, нужно обновлять данные чаще. А остаток дня надо жрать как можно меньше батарейки, только главное не пропустить момент когда пользователь решит “нырнуть”. В итоге я выбрал интервал 7 минут для сна, когда воды нет, и одна минута, когда вода есть. А как будем определять наличие воды? А просто, если разница между водой и воздухом больше 4 градусов, то вода скорее всего есть!

Потому в скрипт выше, я добавил еще одну лямбду, а в globals еще одну переменную:

globals:
  - id: naptime
    type: int
    restore_value: no
    initial_value: '300'
script:
  ...
  - lambda: |-
          float df = abs(id(dht_temp).state - id(water_temp).state);
          if (df > 4.0) {
            id(water_presense).publish_state(1);
            id(naptime) = 60;
          } else {
            id(water_presense).publish_state(0);
            id(naptime) = 420;
          }

Инструкция же для перехода в сон стала выглядеть таким образом:

          - deep_sleep.enter:
              id: sleepy
              sleep_duration: !lambda |- 
                  return id(naptime) * 1000;

Добавляем наш скрипт в автостарт

Изначально я сделал switch по которой запускал этот скрипт, чтобы не поломать апдейты по воздуху и проверяя, что по кнопке в Home Assistant железка обновляет датчики, уходит в сон, а а потом просыпается. Но когда оно заработало, наступило самое время добавить его в автостарт.

esphome:
  name: $devicename
  platform: ESP8266
  board: esp_wroom_02
  build_path: build/esp8266auto
  on_boot:
    - priority: -300
      then:
        - script.execute: check_stuff

Выключаем дебаг

Чем меньше мы будем делать пустые действия над текстом, тем лучше.

logger:
  baud_rate: 0
  level: NONE

Добавляем OTA обновления

Когда железка постоянно спит, то обновления делать тяжело. Либо тащить UART, либо делать какие-то костыли. Основная рекомендация из разных статей была такой:

mqtt:
  id: mqtt_cli
  broker: homeblade.home
  birth_message:
  will_message:
  on_message:
    - topic: $devicename/ota
      payload: 'ON'
      then:
        - lambda: |-
            id(ota_mode) = 1;

То есть мы мониторим MQTT топик имя_девайса/ota, и если там приходит ON, то выставляем переменную, которая блокирует сон. Но вот незадача – у меня даже если в MQTT уже стояло это значение (с retain флагом, чтобы дошло до устройства сразу), оно просто не успевало тут отработать. Потому пришлось сделать иначе.

Сначала я открыл конфиг своего Home Assistant (ВАЖНО! Не конфиг esphome!) и добавил два выключателя:

switch:
  - platform: mqtt
    command_topic: "cryotool/ota"
    state_topic: "cryotool/ota"
    unique_id: cryotool_ota
    name: "Cryotool OTA Mode"
    icon: "mdi:upload"
    retain: true
  - platform: mqtt
    command_topic: "cryotool/session"
    state_topic: "cryotool/session"
    unique_id: cryotool_session
    name: "Start cryotool session"
    icon: "mdi:content-save"
    retain: true

Первый для режима обновления прошивки, второй для сохранения сессии (о нем позже!). Из важного, state_topic и command_topic у меня здесь одинаковые, и стоят с флагом retain. То есть как только esphome подпишется на тему, он сразу получит текущее значение.

А в жуткий скрипт check_stuff, я добавил еще больше лямбд.

    - lambda: |-
        id(mqtt_cli).subscribe("$devicename/ota", [=](const std::string &topic, const std::string &payload) {
          id(ota_mode) = (payload.compare("ON") == 0);
        });
        id(mqtt_cli).subscribe("$devicename/session", [=](const std::string &topic, const std::string &payload) {
          id(session) = (payload.compare("ON") == 0);
        });

Это выполняется до начала апдейта данных с датчиков, потому к моменту, когда эти данные потребуются, MQTT выплюнет нам уже нужные значения. Если там пришло ON, то мы отключаем режим сна вообще, и ждем новой прошивки. А когда она пришла, сразу сами же и отключаем этот режим, чтобы заснуть.

script:
...
    - if:
        condition:
          lambda: 'return id(ota_mode) == 0;'
        then:
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: !lambda |- 
                  return id(naptime) * 1000;
ota:
  safe_mode: True
  password: xxxxxx
  on_end:
     then:
      - lambda: |-
          id(ota_mode) = 0;
      - mqtt.publish:
          topic: "$devicename/ota"
          payload: "OFF"
          retain: true

Прочие плюшки

Попутно я добавил две копии значений температуры, влажности. Первая это на текущую сессию. Они обновляются тогда, когда есть вода. И данные “последней сессии”, которые обновляются если мы щелкнем в интерфейсе Home Assistant сохранить сессию. Таким образом можно сравнивать данные со вчерашними, а потом при желании посмотреть прикольные графики превращениия себя любимого в Йети.

Бодрит, правда?

Итоги и замеры

Собранный девайс я оставил валяться в ванной, пока недельку отсутствовал дома, и через некоторое время выдернул посмотреть график. После всех шаманств, 400 китайских mAh хватило на почти 9 дней работы (25е ноября – первое значение, 3е декабря – последнее. К сожалению, Home Assistant не расставляет даты на графике). Сами же датчики отваливаются немного раньше, так как ds1b20 по даташиту может работать начиная с 3.0 вольт, а вот esp8266 от 2.5 и выше. Но под конец работы напряжение уже падает очень стремительно и существенной разницы в срок службы это не вносит.

Типичная емкость CR2032 – где-то 200mAh. Понятно, что от такой батарейки оно будет работать несколько дней, не больше. Конечно, можно оптимизировать потребление дальше: отпаять пару светодиодов, поменять DC-DC на менее жрущий, выкинуть электролиты, или вообще поставить для 2032 более оптимальный повышающий преобразователь, который выжмет из батарейки все соки. Но для данного проекта, наверное самое простое будет просто тупо воткнуть нормальную 18650 батарейку, которой хватит на несколько месяцев.

Добавляем защиту от переразряда аккумулятора (типа того)

Работать на напряжениях питания ниже 3 бесполезно и даже вредно для аккумуляторов. Потому когда заряд кончается, надо выключаться. В идеале выставлять статус ‘Unavailable’ в Home Assistant который быстро намекнет нам, что батарейки сдохли. Типичное напряжение, при котором батарея отключается встроенной защитой 2.75V, но это очень низко. Потому лучше сделать порог где-нибудь от 3.0 до 3.3 вольт. Так как небольшой ток все равно потечет через DC-DC, у электролитов есть ток утечки.

У особо избранных батареек от элитных продавцов с алиэкспресса Battery Management System (BMS) вообще нет (мой случай!). А даже если есть, как показывает практика порог срабатывания достаточно низкий. Да и сам аккумулятор, вне зависимости от BMS умеет саморазряжаться – потому надо дать время пользователю заметить проблему и переткнуть аккумулятор в зарядник раньше, чем он испустит дух.

Напряжение батарейки я и так меряю простым делителем напряжения и единственным каналом АЦП, потому условие вышло простое.

    - if:
        condition:
          lambda: 'return (id(bat).state < 3.2);'
        then:
          - mqtt.publish:
                  topic: "$devicename/status"
                  payload: "offline"
                  retain: true
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: 0s

Заметили строчку про MQTT сообщение? Это наша замета birth_message/will_message, которые мы установили в пустое значение ранее. Именно оно пометит нам устройсто как “Недоступно” в интерфейсе Home Assistant.

Чтобы выйти из этого состояния в самом начале надо отправить такую вот магию: (или закомментировать пустое birth_message):

    - mqtt.publish:
            topic: "$devicename/status"
            payload: "online"
            retain: true

Приложение 1: Полные исходники моей прошивки

esphome:
  name: $devicename
  platform: ESP8266
  board: esp_wroom_02
  build_path: build/esp8266auto
  on_boot:
    - priority: -300
      then:
        - script.execute: check_stuff

substitutions:
  devicename: "cryotool"

logger:
  baud_rate: 0
  level: NONE

wifi:
  power_save_mode: LIGHT
  fast_connect: true
  networks:
  - ssid: frostgate
    password: neverland2
  manual_ip:
    static_ip: 192.168.143.31
    gateway: 192.168.143.1
    subnet: 255.255.255.0
    dns1: 192.168.143.1

ota:
  safe_mode: True
  password: updateme
  on_end:
     then:
      - lambda: |-
          id(ota_mode) = 0;
      - mqtt.publish:
          topic: "$devicename/ota"
          payload: "OFF"
          retain: true

status_led:
  pin: GPIO15

dallas:
  - pin: GPIO12
    id: dallashub
    update_interval: never

#api: 

mqtt:
  id: mqtt_cli
  broker: homeblade.home
  birth_message:
  will_message:

deep_sleep:
  id: sleepy


globals:
  - id: updates
    type: int
    restore_value: no
    initial_value: '0'
  - id: ota_mode
    type: int
    restore_value: no
    initial_value: '0'
  - id: session
    type: int
    restore_value: no
    initial_value: '0'
  - id: naptime
    type: int
    restore_value: no
    initial_value: '300'

script:
  - id: check_stuff
    then:
    - mqtt.publish:
            topic: "$devicename/status"
            payload: "online"
            retain: true
    - lambda: |-
        id(mqtt_cli).subscribe("$devicename/ota", [=](const std::string &topic, const std::string &payload) {
          id(ota_mode) = (payload.compare("ON") == 0);
        });
        id(mqtt_cli).subscribe("$devicename/session", [=](const std::string &topic, const std::string &payload) {
          id(session) = (payload.compare("ON") == 0);
        });
    - lambda: |-
        id(updates) = 0; 
        id(bat).update();
        id(dallashub).update();
        id(dhts).update();
    - wait_until:
        lambda: |-
          return (id(updates) >= 4);
    - lambda: |-
          float df = abs(id(dht_temp).state - id(water_temp).state);
          id(water_air_diff).publish_state(df);
          if (df > 4.0) {
            id(water_presense).publish_state(1);
            id(naptime) = 60;
            id(bath_temperature_current).publish_state(id(water_temp).state);
            id(bath_water_air_difference_current).publish_state(df);
          } else {
            id(water_presense).publish_state(0);
            id(naptime) = 420;
          }
    - if:
        condition:
          lambda: 'return (id(session) == 1);'
        then:
          - lambda: |-
              id(bath_temperature_last).publish_state(id(bath_temperature_current).state);
              id(bath_water_air_difference_last).publish_state(id(bath_water_air_difference_current).state);
          - mqtt.publish:
              topic: "$devicename/session"
              payload: "OFF"
              retain: true

    - logger.log:
        format: "Sleeping for %d | OTA: %d SESSION : %d"
        args: [ 'id(naptime)', 'id(ota_mode)', id(session) ]

    - if:
        condition:
          lambda: 'return (id(bat).state < 3.2);'
        then:
          - mqtt.publish:
                  topic: "$devicename/status"
                  payload: "offline"
                  retain: true
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: 0s

    - if:
        condition:
          lambda: 'return id(ota_mode) == 0;'
        then:
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: !lambda |- 
                  return id(naptime) * 1000;

binary_sensor:
  - platform: template
    id: water_presense
    name: "$devicename Bath Has Water"

sensor:
  - platform: dht
    pin: GPIO0
    model: DHT11
    id: dhts
    temperature:
      name: "$devicename Temperature"
      id: dht_temp
      on_value: 
        lambda: |-
          id(updates)++;
    humidity:
      name: "$devicename Humidity"
      on_value: 
        lambda: |-
          id(updates)++;
    update_interval: never
  - platform: adc
    id: bat
    pin: GPIO17
    name: "$devicename Battery Voltage"
    filters:
      - multiply: 6
    update_interval: never
    on_value: 
      lambda: |-
        id(updates)++;
  - platform: dallas
    index: 0
    id: water_temp
    name: "$devicename Water Temperature"
    on_value: 
      lambda: |-
        id(updates)++;
  - platform: template
    id: water_air_diff
    icon: "mdi:coolant-temperature"
    name: "$devicename Temperature Difference"
    unit_of_measurement: "°C"
    update_interval: never

  - platform: template
    id: bath_temperature_current
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Temperature"
    unit_of_measurement: "°C"
    update_interval: never
  - platform: template
    id: bath_water_air_difference_current
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Water/Air Difference"
    unit_of_measurement: "°C"
    update_interval: never
  - platform: template
    id: bath_temperature_last
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Temperature (Last)"
    unit_of_measurement: "°C"
    update_interval: never
  - platform: template
    id: bath_water_air_difference_last
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Water/Air Difference (Last)"
    unit_of_measurement: "°C"
    update_interval: never

Приложение 2: Занятные статьи по CWI

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

https://physoc.onlinelibrary.wiley.com/doi/full/10.1113/EP086283

https://pubmed.ncbi.nlm.nih.gov/30896355/

https://pubmed.ncbi.nlm.nih.gov/2736003/

One thought on “Оптимизируем ESP8266/ESPHOME для работы на батарейках (и делаем попутно мониторинг температуры воды в ванной)

  1. Добрый день!
    1. Как устроен делитель напряжения ?
    2. Обязательно ли соединять D0 RESET ?

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

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.