Оптимизация энергопотребления LoRa - Часть 2

Тестируя энергопотребление lora-at, я обнаружил, что плата в режиме глубокого сна потребляет около 1.7мА. Это примерно в миллион раз больше, чем теоретический минимум ESP32. Такое поведение совершенно нельзя оставлять без внимания, поэтому я потратил около трёх недель, чтобы разобраться в причинах.

Симптомы

С помощью PPK2 я установил, что после перехода в спящий режим, потребление энергии около 1.7мА:

При этом очевидно, что переход в спящий режим происходит: потребление уменьшается с ~30мА до 1.7мА. Видимо проблема где-то на самой плате.

Я также подключаю питание через JST коннектор, значит дополнительная энергия не тратится на USB-serial чипе.

Ни один светодиод не работает, значит проблема не в них.

Поиск проблемы

Обычно решение подобных проблем достаточно простое: первая ссылка в Google. Однако, в этот раз всё оказалось гораздо сложнее. Первая ссылка в Google ведёт на крайне длинное и пространное обсуждение проблемы на github. Из которого становится ясно, что:

  • Нужно явно выключить OLED экран
  • Если перепаять пару коннекторов в USB, то можно уменьшить до 4мА
  • Если в SPI протоколе пин CS (SS) в состоянии LOW (нет сигнала), то sx127x чип ожидает команду и тратит энергию
  • Меньше 1.7мА получить невозможно, так как CS пин - это не RTC пин. А значит его нельзя сделать HIGH в режиме глубокого сна
  • У кого-то получилось сделать 19мкА, но непонятно как
  • У кого-то получилось уменьшить энергопотребление выставив пинам 4-20 значение INPUT

Всё это крайне противоречиво, но раз у кого-то получилось, значит это теоретически возможно.

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

if (!LoRa.begin(BAND, false)) {
	Serial.println("init failed");
}
LoRa.sleep();
SPI.end();
pinMode(SCK, INPUT);
pinMode(MISO, INPUT);
pinMode(MOSI, INPUT);
pinMode(SS, INPUT);
pinMode(15, INPUT);
pinMode(16, INPUT);
pinMode(17, INPUT);
pinMode(26, INPUT);
pinMode(14, INPUT);
esp_deep_sleep_start();

И на удивление оно сработало! Плата стала потреблять ~13мкА. Значит проблема не в плате, а в моём коде.

Дифференциальный анализ

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

На самом деле не всё так просто. Дело в том, что первая программа:

  • написана на С++
  • использует библиотеку LoRa.cpp, которая
  • использует библиотеку SPI.cpp, которая
  • основана на arduino-espressif
  • собирается platformio

А вторая:

  • написана на С
  • использует библиотеку sx127x, которая
  • использует spi драйвер из ESP32, который
  • основан на esp-idf фреймворке
  • собирается esp-idf

И между ними нет ничего общего.

Дальше идёт много аналитики, поэтому слабонерным и кормящим мамам лучше не читать.

Итак, раз у нас есть две программы, в которых нет ничего общего, то это нужно исправить! Для этого я сделал программу, которая:

  • инициализирует spi драйвер
  • инициализирует чип с помощью библиотеки sx127x
  • переводит его в спящий режим
  • отключает spi шину и
  • переходит в спящий режим

При этом собирал и запускал я её в platformio. В результате ошибка воспроизвелась. Плата по-прежнему потребляла 1.7мА. Из этого следует, что проблема не в языке программирования и не в инструменте сборки. Уже прогресс!

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

Следующим шагом я решил глянуть в исходники SPI.cpp и сравнить с методами spi_bus_remove_device и spi_bus_free, которые ответственны за де-инициализацию spi шины.

Но здесь меня постигла неудача. Дело в том, что SPI.cpp основан на arduino-espressif. А тот работает напрямую с регистрами процессора и не использует внутри spi драйвер. В итоге я нашёл пару подозрительных мест:

  • во многих местах spi драйвера используется GPIO матрица
  • в arduino-espressif напрямую меняются регистры процессора
...
spi->dev->pin.val = 0;
spi->dev->user.val = 0;
spi->dev->user1.val = 0;
...

В попытках разобраться, что такое GPIO матрица и как на уровне железа работают регистры, я потратил пару недель. Что самое обидное - это не помогло. Да, мне пришлось заглянуть в datasheet ESP32 и прочитать про GPIO matrix, да, я теперь лучше понимаю, что это такое. Но вот потребляет ли эта матрица дополнительную энергию в спящем режиме или нет, я не знаю.

В какой-то момент я открыл несколько интересных вещей:

  • во-первых, пины, на которые припаяен чип sx127x, выходят наружу и их можно померить. То есть это не какие-то логические пины, которые спрятаны внутри самого микроконтроллера
  • а во-вторых, с помощью PPK2 можно померить работу протокола SPI!

Но и тут у меня случилась частичная неудача. Я смог посмотреть работу SPI протокола для программы на C. Выглядит, кстати, это вот так:

С помощью этого графика мне удалось увидеть, что CS пин переходит в состояние LOW в спящем режиме. Миф развеян - этот пин необязательно должен быть RTC пином.

А вот с другой стороны мне не удалось посмотреть на работу SPI протокола для С++ программы. Почему-то в arduino-espressif нельзя установить частоту шины в 50кГц. Вполне возможно это сделано для совместимости с Arduino.

В поисках идей я ещё раз прочитал даташит на sx127x, но в этот раз уделил больше внимания энергопотреблению. Там меня заинтересовала строчка: потребление тока в режиме простоя - типичное - 1.6мА, максимальное - 1.8мА. Выглядит, как будто, мой случай. Правда, это не сильно помогает: проблема может быть как и в неправильной работе SPI шины, так и в моём коде. Для сравнения в спящем режиме чип должен потреблять максимум 1мкА.

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

Следующая идея заключалась в том, что в рамках одной программы поочерёдно запускать С++ код и С код. При этом каждый из них должен сначала полностью инициализировать SPI шину и чип, а потом де-инициализировать. Это позволит понять - есть ли ещё какие-то блоки или функции, которые использует С код и не выключает. Если есть, то С++ код не будет их выключать (потому что он энергоэффективный и не использует их) и потребление тока будет 1.7мА.

В итоге оказалось, что потребление всегда большое после работы С кода. Это с одной стороны хорошо, а с другой - совершенно не помогает понять, где именно проблема.

Шла третья неделя и я уже отчаялся. Я даже задал вопрос на официальном форуме. Мне никто не ответил, тогда я решил задать вопрос ChatGPT. Разумеется он мне не помог, зато я знатно посмеялся с его ответов.

Следующая идея, которая мне пришла в голову заключалась в следующем:

  • инициализировать SPI.cpp
  • инициализировать LoRa.cpp
  • де-инициализировать. Если в этот момент перейти в режим сна, то потребление будет низким
  • инициализировать spi драйвер
  • де-инициализировать spi драйвер
  • перейти в режим сна

Таким образом я хотел проверить, есть ли что-то внутри spi драйвера, что не выключается. Оказалось, что нет! Потребление минимальное даже после включения и выключения spi драйвера. Это значит, что проблема либо в том как spi драйвер работает с чипом, либо в моей библиотеке.

Катарсис

Далее историки обычно пишут, что решение проблемы пришло во сне, либо в душе. Я почему-то решил, что проблема связана с чипом sx127x. Внутри моей библиотеки инициализация достаточно простая: прочитать и проверить версию чипа. А вот в С++ версии много чего:

  • проверка версии чипа
  • переход в спящий режим
  • установка частоты передачи сигнала
  • инициализация буфера для входящих/исходящих сообщений
  • инициализация LoRa - spreading factor, bandwidth, coding rate
  • и многое другое

Я решил полностью повторить все вызовы, даже если они не имеют никакого смысла. И в результате потребление энергии стало ~13мкА! Победа! Ну а дальше было дело техники: я убирал ненужные вызовы и смотрел на потребление энергии. В итоге осталось следующее:

  • инициализация чипа
  • переход в спящий режим
  • переход в режим ожидания
  • переход в спящий режим

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

Послесловие

Помимо перевода LoRa чипа в спящий режим, нужно выключить OLED экран. Для этого нужно сделать пины плавающими и отключить любые прерывания. Я написал следующую вспомогательную функцию:

void disable_pin(int pin) {
  gpio_config_t conf = {
      .pin_bit_mask = (1ULL << (gpio_num_t)pin), 
      .mode = GPIO_MODE_INPUT,                   
      .pull_up_en = GPIO_PULLUP_DISABLE,         
      .pull_down_en = GPIO_PULLDOWN_DISABLE,     
      .intr_type = GPIO_INTR_DISABLE};
  ESP_ERROR_CHECK(gpio_config(&conf));
}

С помощью этой функции можно выключить пины sx127x и OLED экрана:

disable_pin(SCK);
disable_pin(MISO);
disable_pin(MOSI);
disable_pin(SS);
disable_pin(4);
disable_pin(15);

В результате у меня получилось потребление энергии в режиме сна: ~13мкА. В статье по оптимизации энергопотребления для солнечной панели я брал 6мА для режима сна. И в результате у меня получалось 656.15Втч за день. Если же принять ~13мкА, то получится:

323 мин * 16 мА + 1127 мин * 0.013 мА = 5168 мАмин + 14.65 мАмин =
= 86.37 мАч * 3.3В = 0.285 Втч

Если учесть, что одна солнечная панель производит 0.396Втч, то её должно хватить на целый день!