— Товарищ генерал-лейтенант. Я давно хотел спросить. А как с йети быть?
— Йети? Надо чаще мыть.
(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
Добрый день!
1. Как устроен делитель напряжения ?
2. Обязательно ли соединять D0 RESET ?