Акселерометр и гироскоп BMI160: скролл-шутер на ESP32

          BMI160 – это 6-осевой инерциальный измерительный модуль (Inertial Measurement Unit – IMU), который объединяет в одном корпусе трёхосевой акселерометр и трёхосевой гироскоп. Его главной особенностью и конкурентным преимуществом является низкое энергопотребление и набор встроенных аппаратных алгоритмов (распознаёт касания, определяет ориентацию в пространстве, фиксирует свободное падение и удары, а также наличие отдельного детектора шагов). Благодаря всему этому данный модуль является идеальным вариантом для портативных приложений (смартфоны и «умные» часы, фитнес-браслеты, беспроводные игровые контроллеры, интерактивные детские игрушки, а также стабилизаторы фото- и видеокамер), где важна автономная работа без постоянного участия внешнего процессора.

          В рамках данной статьи мы подробно рассмотрим данный сенсор: его устройство, характеристики, настройку и потенциальные возможности. А в качестве эксперимента соберём простую портативную игровую консоль на базе ESP32. В ней с помощью BMI160 мы сделаем управление космическим кораблём на TFT-дисплее – прямо как в классических скролл-шутерах из начала 80-х.



Содержание


Общее описание

          BMI160 – это SoC-микросхема (System on Chip) датчика линейного ускорения и угловой скорости, разработанная компанией Bosch Sensortec. Чип построен по технологии MEMS (Micro-Electro-Mechanical Systems, микроэлектромеханическая система). Если нужно освежить в памяти, то подробное описание принципа работы акселерометра представлено в статье про шагомер на базе ADXL345, а принцип работы гироскопа рассмотрен в статье про цифровой угломер на базе MPU-6050.

          Встроенный акселерометр определяет линейное ускорение по трём осям (X, Y, Z), а гироскоп – угловую скорость вращения вокруг тех же осей, и оба имеют 16-битное разрешение. Также в состав чипа входит датчик температуры с рабочим диапазоном от -40 °C до +85 °C, который применяется для компенсации температурных изменений параметров основных MEMS-сенсоров.

          Ключевым преимуществом BMI160 является встроенный блок управления питанием (Power Management Unit – PMU) и аппаратный FIFO-буфер (First In First Out) на 1024 байта. Это позволяет накапливать измеренные значения и вызывать прерыванием основной внешний контроллер для передачи пакета данных только при наступлении определённого заданного события (шаг, поворот, падение), что значительно снижает общее энергопотребление системы.

          Рассмотрим в общих чертах устройство микросхемы. На рисунке ниже представлена блок-схема из официальной документации:

          Чип включает в себя следующие основные блоки:

– чувствительные элементы в виде акселерометра (Accel) и гироскопа (Gyro), которые работают с фиксированной внутренней частотой дискретизации 1600 и 6400 Гц соответственно;

– 24-битный таймер для датчиков (Sensortime), который синхронизирован с обновлением регистров данных и записью в FIFO-буфер;

– аналого-цифровые преобразователи (ADC) для каждого чувствительного элемента;

– блок цифровой обработки сигнала (Digital Signal Conditioning), предназначенный для компенсации влияния температуры, фильтрации шумов, а также для удаления смещения, выполняя расчёты с применением калибровочных коэффициентов, хранящихся в энергонезависимой памяти;

– вторичный интерфейс (Secondary Digital Interface) для подключения внешнего магнитометра (I2C) или контроллера видеокамеры (SPI), что обеспечивает оптическую стабилизацию изображения;

– блок, который на рисунке выглядит как трапеция со стрелочкой «Select», включает в себя фильтр нижних частот (Low-Pass Filter) и блок децимации (Down-Sampling), который снижает выходную частоту данных до величины, которая задаётся пользователем;

– блок регистров (Sensor Data and Sensortime Register), где хранятся последние измеренные значения акселерометра/гироскопа/магнитометра, а также временные точки, когда эти данные были получены;

– аппаратный накопитель данных (FIFO Engine) на 1024 байта со всех чувствительных элементов;

– аппаратный анализатор данных (Interrupt Engine), который постоянно мониторит данные от сенсоров и генерирует прерывания для ведущего внешнего контроллера (каналы INT1 и INT2), сообщая о наступлении заданных событий. Данный блок включает в себя детектор событий (Legacy Interrupts) и специализированный аппаратный детектор одиночного шага человека (Step Detector);

– отдельный аппаратный 16-разрядный накопитель числа шагов (Step Counter), который собирает данные от Step Detector. Он работает автономно от ведущего внешнего контроллера; – основная шина связи (I2C или SPI) микросхемы с внешним ведущим устройством (Primary Digital Interface), посредством которой производится конфигурация модуля и чтение с него данных.

          В отличие от MPU-6050, BMI160 не имеет встроенного цифрового процессора движения (Digital Motion Processor – DMP). Вместо этого сделан упор на аппаратные методы фиксации событий, что значительно уменьшило потребление энергии. В частности, разработчики предусмотрели довольно внушительный набор алгоритмов, которые удобно применять на практике в конкретных ситуациях:

1) встроенный детектор (Step Detector) и счётчик (Step Counter) шагов;

2) детектор значимого движения (Significant Motion), то есть вызов прерывания по какому-то конкретно заданному типу движения (например, устройство поднято со стола или резко поменяло ориентацию);

3) детектор любого движения (Any-motion), срабатывающий при превышении пороговой величины ускорения;

4) отсутствие или очень медленно движение в течение заданного времени (No-motion и Slow-motion);

5) одно и двойное касание (Tap и Double-Tap);

6) ориентация (Orientation) в пространстве, которое часто используется в смартфонах для определения экрана в вертикальном или горизонтальном положении, или же телефон лежит экраном вверх или вниз;

7) горизонтальное положение (Flat Detection), при котором устройство лежит на плоской

8) фиксация свободного падения (Low-g / Free-fall);

9) фиксация сильного удара (High-g);

10) генерация прерывания при готовности новых данных с чувствительных элементов (Data Ready);

11) прерывание при полном заполнении накопителя данных FIFO (FIFO full) или достижения заданного порога (FIFO watermark) для своевременного считывания данных;

12) автоматическое изменение режима питания датчика угловой скорости (PMU trigger).

          Благодаря такому набору алгоритмов, BMI160 способен самостоятельно реализовать множество полезных рабочих сценариев (повороты, удары, падения, отсутствие движения) и «будить» ведущий внешний контроллер только тогда, когда это действительно нужно. В этом и заключается его главное конкурентное преимущество.

          Ещё одной важной особенностью чипа является наличие вторичного интерфейса. Это дополнительный коммуникационный канал для подключения внешних сенсоров или периферии, что значительно расширяет функционал всей системы. Вторичный интерфейс может работать в двух режимах:

– MAG-Interface (I2C): для подключения внешнего магнитометра (например, Bosch BMM150), превращая 6-осевой IMU в 9-осевую систему, что даёт полное видение вектора движения в пространстве;

– OIS-Interface (SPI): для подключения к контроллеру оптической стабилизации изображения в камерах (например, LC898113). Гироскоп напрямую отдаёт данные контроллеру камеры на высокой скорости (до 10 МГц по SPI).

          Для наглядности ниже приведены блок-схемы систем, расширенных посредством вторичного интерфейса:

          На сегодняшний день 160-я модель считается актуальной, но устаревающей, поэтому Bosch Sensortec предлагает новые решения из линейки BMI:

– прямой наследник  BMI270, который имеет такую же распиновку, но вовсе не совместим программно. Ключевые отличия заключаются в улучшенных алгоритмах распознавания жестов, а также в увеличенной ёмкости FIFO-буфера (до 2048 байт);

– бюджетный вариант и последователь BMI323, который спроектирован для простых и массовых устройств, где не нужны сложные алгоритмы, как у BMI270, но важно низкое энергопотребление и современные скоростные интерфейсы (I3C до 12,5 МГц). Также имеется увеличенный накопитель FIFO (до 2048 байт) и предусмотрена полная аппаратная совместимость.

          Основными внешними конкурентами для BMI160 на рынке являются:

– MPU-6050 (TDK InvenSense): устаревшая модель, но всё ещё очень популярная среди DIY-энтузиастов благодаря простоте и большому количеству справочной информации и готовых проектов;

ICM-20602 (TDK InvenSense): похож по характеристикам, без специальных алгоритмов;

– LSM6DS3 (STMicroelectronics): тоже без встроенных алгоритмов, зато есть аппаратная обработка данных (по аналогии с MPU-6050).


Параметры и характеристики


BMI160 в составе платы (модуля) GY-BMI160

          Для экспериментов применим популярную отладочную плату GY-BMI160, на которой распаяна микросхема инерциального модуля:

          Данная плата содержит всю необходимую обвязку для работы с чипом, включая подтягивающие резисторы на 10 кОм, фильтрующий конденсаторы, а также понижающий линейный стабилизатор напряжения ME6211C33M5G на 3,3 В.

          Ниже представлены таблица распиновки и принципиальная схема платы GY-BMI160:

Принципиальная схема модуля GY-BMI160

Режимы работы

          BMI160 включает в себя два чувствительных элемента – акселерометр и гироскоп. Каждый из них имеет свой набор режимов работы, которые настраиваются отдельно, но при этом они могут быть взаимосвязаны. Такой раздельный подход позволяет более гибко управлять энергопотреблением микросхемы. Например, полностью отключить за ненадобностью датчик угловой скорости и использовать чип только в состоянии низкого потребления энергии. Или же включить оба сенсора с максимальной производительностью.

          Ниже представлена сводная таблица режимов работы акселерометра и гироскопа BMI160:

          Блок управления питанием (PMU), который автономно управляет режимами работы сенсоров без прямого участия ведущего внешнего контроллера, используя только заранее предустановленные правила в процессе конфигурирования чипа. Именно блок PMU определяет, когда нужно «разбудить» гироскоп при определённом событии на основании данных от датчика линейного ускорения. За счёт этого экономится энергия и не затрачиваются ресурсы внешнего ведущего устройства.

          В качестве примеров кода мы будем сами писать функции, используя встроенную в Arduino IDE библиотеку Wire для работы с I2C-интерфейсом. Конечно, можно воспользоваться популярной библиотекой DFRobot_BMI160. Но наш подход с самописными функциями поможет лучше понять принципы настройки модуля, а также позволит применить функционал, который недоступен в DFRobot_BMI160.

          Командный регистр (Command Register – 0x7E) является одним из основных. Он предназначен для запуска какого-либо действия. Cперва что-то настроили, а потом это надо активировать, и вот как раз через Command Register это и осуществляется. Кстати, пока чип выполняет какую-то команду, он новые задачи не воспринимает (установится ошибка drop_cmd_err в регистре ERR-REG (0x02)).

          Вот список команд, которые можно запускать через Command Register:


Настройка акселерометра

          Для работы с датчиком линейного ускорения используется 2 регистра:

– ACC_CONF (0x40), который настраивает скорость, режим работы и фильтрацию;

– ACC_RANGE (0x41), предназначенный для выбора чувствительности (в каком диапазон величин ускорения g реагировать на движения).

          ACC_CONF (0x40) представляет собой 1 байт и имеет следующую побитовую структуру:

          ACC_RANGE (0x41) представляет собой 1 байт, но значимые – только 4 младших бита:

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

1) writeReg(адрес_регистра, значение)

void writeReg(byte reg, byte val) {
  Wire.beginTransmission(0x69); // обращение к BMI160 с I2C-адресом 0x69
  Wire.write(reg); // обращаемся к регистру
  Wire.write(val); // отправляем значение (настройки)
  Wire.endTransmission(); // завершение передачи данных
}

2) sendCmd(команда)

void sendCmd(byte cmd) {
  Wire.beginTransmission(0x69); // обращение к BMI160 с I2C-адресом 0x69
  Wire.write(0x7E); // обращаемся к Command Register
  Wire.write(cmd); // отправляем значение (управление каким-то модулем)
  Wire.endTransmission(); // завершение передачи данных
}

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

1) Обычный/Стандартный (100 Гц, ±8g)

writeReg(0x40, 0x28);   // Normal, 100 Гц
writeReg(0x41, 0x08);   // ±8g
sendCmd(0x11);          // включить акселерометр

2) Экономный (25 Гц, 4 усреднения, ±8g)

writeReg(0x40, 0x96);   // Low Power, 25 Гц, 4 AVG
writeReg(0x41, 0x08);   // ±8g
sendCmd(0x11);          // включить (сам перейдёт в Low Power)

3) Максимальная экономия (0,78 Гц, 128 усреднений, ±16g)

writeReg(0x40, 0xDE);   // Low Power, 0.78 Гц, 128 AVG
writeReg(0x41, 0x0C);   // ±16g
sendCmd(0x11);

4) Максимальная скорость (1600 Гц, ±2g)

writeReg(0x40, 0x2C);   // Normal, 1600 Гц
writeReg(0x41, 0x03);   // ±2g
sendCmd(0x11);

          Теперь посмотрим, как получить «сырые» данных с акселерометра со всех трёх осей X/Y/Z. Данные по каждой оси хранятся в своих регистрах по два байта:

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

int16_t read16(byte addr) {
  Wire.beginTransmission(0x69);
  Wire.write(addr);           // начинаем с LSB
  Wire.endTransmission(false);
  
  Wire.requestFrom(0x69, 2);  // просим 2 байта
  uint8_t lsb = Wire.read();   // сначала LSB
  uint8_t msb = Wire.read();   // потом MSB
  
  return (int16_t)((msb << 8) | lsb);  // склеиваем и делаем знаковым
}

          Соответственно, для всех трёх осей это будет выглядеть так:

int16_t rawX = read16(0x12);   // ось X
int16_t rawY = read16(0x14);   // ось Y
int16_t rawZ = read16(0x16);   // ось Z

          Для понимания общей картины, вот коротенький пример с настройкой и сбором данных:

#include <Wire.h>
#define BMI160_ADDR 0x69 // BMI160 адрес

// Функция записи в регистр
void writeReg(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
}

// Функция отправки команды
void sendCmd(uint8_t cmd) {
  writeReg(0x7E, cmd);
}

// Функция чтения 16-битного значения
int16_t read16(uint8_t reg) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(BMI160_ADDR, 2);
  if(Wire.available() < 2) return 0;
  uint8_t lsb = Wire.read();
  uint8_t msb = Wire.read();
  return (int16_t)((msb << 8) | lsb);
}

// Глобальная переменная диапазона
int16_t range = 0x08; // ACC_RANGE = ±8g

void setup() {
  Serial.begin(115200);
  Wire.begin();
  
  // Настройка: ±8g, Normal mode, 100 Гц
  writeReg(0x40, 0x28);   // ACC_CONF
  writeReg(0x41, range);   // ACC_RANGE = ±8g
  sendCmd(0x11);          // включить акселерометр
  delay(4);
  
  Serial.println("BMI160 Accelerometer Ready");
  Serial.println("Range: ±8g");
}

void loop() {
  // Читаем сырые данные
  int16_t rawX = read16(0x12);
  int16_t rawY = read16(0x14);
  int16_t rawZ = read16(0x16);
  
  float sensitivity;
  
  // Определяем чувствительность по текущему диапазону
  switch(range) {
    case 0x03: sensitivity = 16384.0; break;   // ±2g
    case 0x05: sensitivity = 8192.0;  break;   // ±4g
    case 0x08: sensitivity = 4096.0;  break;   // ±8g
    case 0x0C: sensitivity = 2048.0;  break;   // ±16g
    default:   sensitivity = 4096.0;  break;   // по умолчанию ±8g
  }
  
  // Преобразуем в g
  float gX = rawX / sensitivity;
  float gY = rawY / sensitivity;
  float gZ = rawZ / sensitivity;
  
  // Выводим
  Serial.print("X: "); Serial.print(gX, 3);
  Serial.print(" Y: "); Serial.print(gY, 3);
  Serial.print(" Z: "); Serial.println(gZ, 3);
  
  delay(100);
}

Настройка гироскопа

          По аналогии с датчиком линейного ускорения, настройка гироскопа осуществляется также по двум регистрам:

Читайте также:  ESP32-C3 и TFT-дисплей: вывод времени по Wi-Fi

– GYR_CONF (0x42), отвечает за частоту опроса и фильтрацию;

– GYR_RANGE (0x43), определяет чувствительность.

          GYR_CONF (0x42) состоит из 8 битов со следующей структурой:

          GYR_RANGE (0x43) представляет собой 1 байт, но значимыми битами являются только 3 младших:

          Функции для записи и активации настроек аналогичные: writeReg и sendCmd.

          Далее приведены примеры готовых настроек для датчика угловой скорости:

1) Стандартный (100 Гц, ±250 °/с)

writeReg(0x42, 0b00010101);  // gyr_bwp=10 (Normal), gyr_odr=101 (100 Гц)
writeReg(0x43, 0b00000011);  // ±250°/с (диапазон 011)
sendCmd(0x15);               // включить гироскоп
delay(80);

2) Высокая точность (25 Гц, ±125 °/с)

writeReg(0x42, 0b00010011);  // gyr_bwp=10 (Normal), gyr_odr=011 (25 Гц)
writeReg(0x43, 0b00000100);  // ±125°/с (диапазон 100)
sendCmd(0x15);               // включить гироскоп
delay(80);

3) Максимальная скорость (1600 Гц, ±2000 °/с)

writeReg(0x42, 0b00010001);  // gyr_bwp=10 (Normal), gyr_odr=001 (1600 Гц)
writeReg(0x43, 0b00000000);  // ±2000°/с (диапазон 000)
sendCmd(0x15);
delay(80);

4) Экономичный режим (200 Гц, ±250 °/с, фильтр OSR4)

writeReg(0x42, 0b00000100);  // gyr_bwp=00 (OSR4), gyr_odr=100 (50 Гц)
writeReg(0x43, 0b00000011);  // ±250°/с
sendCmd(0x15);
delay(80);

          Получение «сырых» данных со всех трёх осей:

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

int16_t read16(byte addr) {
  Wire.beginTransmission(0x69);
  Wire.write(addr);           // начинаем с LSB
  Wire.endTransmission(false);
  
  Wire.requestFrom(0x69, 2);  // просим 2 байта
  uint8_t lsb = Wire.read();   // сначала LSB
  uint8_t msb = Wire.read();   // потом MSB
  
  return (int16_t)((msb << 8) | lsb);
}

          Чтение всех трёх осей выглядит так:

int16_t rawX = read16(0x0C);   // ось X
int16_t rawY = read16(0x0E);   // ось Y
int16_t rawZ = read16(0x10);   // ось Z

          Полный пример с установкой параметров и чтением данных с датчика угловой скорости:

#include <Wire.h>

// BMI160 адрес
#define BMI160_ADDR 0x69

// Функция записи в регистр
void writeReg(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
}

// Функция отправки команды
void sendCmd(uint8_t cmd) {
  writeReg(0x7E, cmd);
}

// Функция чтения 16-битного значения
int16_t read16(uint8_t reg) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(BMI160_ADDR, 2);
  if(Wire.available() < 2) return 0;
  uint8_t lsb = Wire.read();
  uint8_t msb = Wire.read();
  return (int16_t)((msb << 8) | lsb);
}

// Глобальная переменная диапазона
int16_t range = 0x03; // GYR_RANGE: ±250°/с

void setup() {
  Serial.begin(115200);
  Wire.begin();
  

// GYR_CONF (0x42) для Normal mode:
// 0x15 = 100 Гц
// 0x16 = 200 Гц
// 0x17 = 400 Гц
// 0x14 = 50 Гц
// 0x13 = 25 Гц


  // Настройка гироскопа
  writeReg(0x42, 0x16);   // GYR_CONF: Normal, ODR=110 -> 200 Гц
  writeReg(0x43, range);   // GYR_RANGE: ±250°/с
  sendCmd(0x15);          // включить гироскоп
  delay(80);              // ждём запуска
  
  Serial.println("BMI160 Gyroscope Ready");
  Serial.println("Range: ±250°/s");
}

void loop() {
  // Читаем сырые данные
  int16_t rawX = read16(0x0C);
  int16_t rawY = read16(0x0E);
  int16_t rawZ = read16(0x10);
  
  float sensitivity;
  
  // Определяем чувствительность по текущему диапазону
  switch(range) {
    case 0x00: sensitivity = 16.4;   break;   // ±2000°/с
    case 0x01: sensitivity = 32.8;   break;   // ±1000°/с
    case 0x02: sensitivity = 65.6;   break;   // ±500°/с
    case 0x03: sensitivity = 131.2;  break;   // ±250°/с
    case 0x04: sensitivity = 262.4;  break;   // ±125°/с
    default:   sensitivity = 131.2;  break;   // по умолчанию ±250°/с
  }
  
  // Преобразуем в °/с
  float gyroX = rawX / sensitivity;
  float gyroY = rawY / sensitivity;
  float gyroZ = rawZ / sensitivity;
  
  // Выводим
  Serial.print("X: "); Serial.print(gyroX, 1);
  Serial.print(" °/s\tY: "); Serial.print(gyroY, 1);
  Serial.print(" °/s\tZ: "); Serial.println(gyroZ, 1);
  
  delay(100);
}

          У гироскопа нет режима пониженного энергопотребления. Он либо полностью включен в обычном режиме (Normal), либо выключен (Suspend).

          После подачи команды sendCmd(0x15) на включение ему требуется до 80 мс на запуск, это заметно дольше, чем у акселерометра (4 мс).

          Диапазон измерений напрямую влияет на точность: для медленных и плавных вращений лучше выбрать узкий предел в ±125 °/c, а для резких движений – более широкий ±500 °/c.

          Имейте в виду, что датчик угловой скорости имеет небольшой дрейф нуля и даже в состоянии покоя может колебаться в пределах ±3 °/с.

          И последнее: если Вы изменили параметры диапазона, следует сразу после этого один раз считать данные, чтобы сбросить старый флаг готовности DRDY.


Датчик температуры

          Встроенный в чип температурный сенсор способен измерять в диапазоне от -40 °C до +85 °C, обеспечивая точность ±2 °C на всём диапазоне. Он предназначен для термокомпенсации гироскопа.

          Важно отметить, что термосенсор работает в двух режимах:

– полноценное 16-разрядное измерение абсолютной величины температуры, при которой должен быть активирован гироскоп;

– экономичный режим, при котором датчик угловой скорости отключен, а данные извлекаются 8-разрядные, по которым невозможно измерить абсолютное значение. Это нужно только для определения величины, на которую изменилась текущая температуры (то есть, к примеру, температура поднялась на 0,5 градуса). Данный функционал применяется в качестве своеобразного детектора изменения окружающей среды. В зависимости от обстоятельств, это изменение можно интерпретировать как сигнал на включение «спящего» сенсора угловой скорости.

          Чтение данных производится из двух регистров:

– TEMP_LSB (0x20), младший байт;

– TEMP_MSB (0x21), старший байт.            

          Если гироскоп включен, то считываются коды с двух регистров в полном 16-разрядном виде. Для понимания ниже приведена таблица из даташита с примерами данных:

          Допустим, мы прочитали 16-битное «сырое» значение, представленное в виде знакового числа в десятичной системе: rawTemp = 2001.

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

Температура (°C) = 23 + (rawTemp / 512).

          Величина «512» определяется чувствительностью термосенсора, которая по даташиту равна 0,002 °C/LSB:

1 / 0,002 = 500 LSB на 1 °C.

          Вместо логично ожидаемой величины «500» применяется «512» по той причине, что так температура определяется точнее примерно на 1,5 %.

          Слагаемое «23» является опорной точкой (+23 °C), что соответствует 0x0000.

          Подставим наше «сырое» значение и получим искомую температуру:

Температура (°C) = 23 + (2001 / 512) = 26,908.

          Проверим правильность формулы, взяв из таблицы известную величину 0x7FFF = 32767:

Температура (°C) = 23 + (32767 / 512) = 86,998.

          Полный пример с установкой параметров гироскопа для чтения данных с термодатчика в полном 16-разрядном формате:

#include <Wire.h>

#define BMI160_ADDR 0x69

// Функция чтения 16-битного значения (автоинкремент: LSB, затем MSB)
int16_t read16(uint8_t reg) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(BMI160_ADDR, 2);
  if(Wire.available() < 2) return 0;
  uint8_t lsb = Wire.read();
  uint8_t msb = Wire.read();
  return (int16_t)((msb << 8) | lsb);  // MSB первым, LSB вторым (по даташиту)
}

void writeReg(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
}

void sendCmd(uint8_t cmd) {
  writeReg(0x7E, cmd);
  delay(10);
}

void setup() {
  Serial.begin(115200);
  Wire.begin();

  // Включаем гироскоп (обязательно для 16-бит температуры)
  sendCmd(0x15);      // активация гироскопа в нормальном режиме
  delay(80);          // ждём запуска гироскопа

  Serial.println("BMI160 Temperature Sensor Ready");
  Serial.println("Gyroscope is ON (required for 16-bit temperature mode)");
}

void loop() {
  // Читаем сырые данные температуры (адреса 0x20 и 0x21)
  int16_t rawTemp = read16(0x20);

  // Переводим в градусы Цельсия (по даташиту)
  // Формула: T(°C) = 23 + (rawTemp / 512)
  float tempC = 23.0f + (rawTemp / 512.0f);

  Serial.print("Raw: ");
  Serial.print(rawTemp);
  Serial.print(" -> Temp: ");
  Serial.print(tempC, 2);
  Serial.println(" °C");

  delay(1000);
}

Встроенный шагомер

          Данный модуль является отдельным аппаратным блоком, который работает независимо от гироскопа. Ему нужен только акселерометр, причём в любом режиме, даже в состоянии пониженного энергопотребления (Low Power). Потребление блока составляет приблизительно 30 мкА при частоте опроса 50 Гц.

          Данный аппаратный блок может работать по двум сценариям:

– в качестве детектора (Step Detector), который генерирует прерывание на каждый отдельно взятый шаг;

– в качестве cчётчика (Step Counter), который накапливает общее количество шагов во внутреннем регистре и предоставляет к нему доступ.

          Для настройки и чтения данных используется два регистра:

– STEP_CONF (0x7A, 0x7B): два байта для установки параметров чувствительности и включения счётчика;

– STEP_CNT (0x78, 0x79): два байта только для чтения накопленного числа шагов.

          В даташите приводятся типичные значения битов для трёх предустановленных степеней чувствительности. Обратите внимание: биты в таблице даташита для STEP_CONF[1] даны с выключенным счётчиком (step_cnt_en = 0). Для работы счётчика необходимо установить бит step_cnt_en.

          Как видно, значение для включения счётчика получается путём добавлением бита 0x08 к значению из даташита с помощью побитового ИЛИ.

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

// Режим «Normal mode»
writeReg(0x7A, 0x15);
writeReg(0x7B, 0x0B);

// Режим «Sensitive mode»
writeReg(0x7A, 0x2D);
writeReg(0x7B, 0x08);

// Режим «Robust mode»
writeReg(0x7A, 0x1D);
writeReg(0x7B, 0x0F);

          Управление питанием датчика линейного ускорения и сброс счётчика осуществляются через регистр CMD (0x7E), команды в который записываются с помощью функции sendCmd():

– sendCmd(0x11) ВКЛючает акселерометр (переводит из спящего режима в нормальный). Это не включает сам шагомер, а лишь обеспечивает его питанием;

– sendCmd(0xB2) сбрасывает счётчик в ноль, но не влияет на его активность и конфигурацию.

          Для считывания данных используется следующая функция:

#define BMI160_ADDR 0x69 // если пин SA0 подтянут к VCC

uint16_t readStepCount() {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(0x78);
  Wire.endTransmission(false);
  
  Wire.requestFrom(BMI160_ADDR, 2);
  uint8_t lsb = Wire.read();
  uint8_t msb = Wire.read();
  
  return (uint16_t)((msb << 8) | lsb);
}

          Таким образом, корректная последовательность для инициализации и работы со счётчиком выглядит так:

// Шаг №1: ВКЛЮЧЕНИЕ акселерометра (обязательно до настройки)
sendCmd(0x11);          // перевести акселерометр в нормальный режим
delay(10);              // дать время на выход из спящего режима

// Шаг №2: Настройка параметров акселерометра
writeReg(0x40, 0x28);   // ACC_CONF: частота опроса 100 Гц, фильтр normal
writeReg(0x41, 0x08);   // ACC_RANGE: диапазон ±8g

// Шаг №3: Настройка и АКТИВАЦИЯ счётчика шагов (Normal mode)
writeReg(0x7A, 0x15);   // STEP_CONF[0] = Normal mode
writeReg(0x7B, 0x0B);   // STEP_CONF[1] = step_cnt_en=1, step_conf=0b011
delay(10);              // небольшая задержка для активации

// Шаг №4: Чтение значения 
uint32_t realSteps = readStepCount();  // реальные шаги

// Сброс (вызывается отдельно, когда нужно обнулить счётчик)
// sendCmd(0xB2);

          Ниже представлен самодостаточный код для работы встроенного шагомера. ESP32 и инерциальный модуль, размещённые на макетной плате, питаются от аккумулятора. Поэтому данные передаются по Bluetooth на смартфон, и количество шагов отображается в терминале приложения (например, Bluetooth Terminal). В представленном коде ESP32 выступает как Bluetooth-устройство под названием «BMI160_StepCounter».

#include <Wire.h>
#include <BluetoothSerial.h>

#define BMI160_ADDR 0x69

BluetoothSerial SerialBT;

// Переменная для хранения предыдущего значения счётчика
uint16_t lastSteps = 0;

void writeReg(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
  delay(1);
}

void sendCmd(uint8_t cmd) {
  writeReg(0x7E, cmd);
  delay(10);
}

uint8_t readReg(uint8_t reg) {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(BMI160_ADDR, 1);
  if (Wire.available()) {
    return Wire.read();
  }
  return 0;
}

uint16_t readStepCount() {
  Wire.beginTransmission(BMI160_ADDR);
  Wire.write(0x78);
  Wire.endTransmission(false);
  Wire.requestFrom(BMI160_ADDR, 2);
  if (Wire.available() < 2) return 0;
  uint8_t lsb = Wire.read();
  uint8_t msb = Wire.read();
  return (msb << 8) | lsb;
}

void setup() {
  Serial.begin(115200);

  // Bluetooth
  SerialBT.begin("BMI160_StepCounter");

  // I2C
  Wire.begin();
  Wire.setClock(400000);

  // Проверка датчика по CHIPID
  Wire.beginTransmission(BMI160_ADDR);
  if (Wire.endTransmission() != 0) {
    SerialBT.println("ERROR: BMI160 not found on I2C bus!");
    while (1) delay(1000);
  }

  uint8_t chipId = readReg(0x00);
  if (chipId != 0xD1) {
    SerialBT.print("ERROR: Wrong Chip ID! Expected 0xD1, got 0x");
    SerialBT.println(chipId, HEX);
    while (1) delay(1000);
  }
  SerialBT.println("BMI160 found and verified!");

  // === ИНИЦИАЛИЗАЦИЯ ШАГОМЕРА ===

  // 1. Программный сброс устройства
  sendCmd(0xB6);
  delay(100);

  // 2. Включение акселерометра в нормальный режим
  sendCmd(0x11);  // acc_set_pmu_mode = normal
  delay(10);

  // 3. Настройка акселерометра
  writeReg(0x40, 0x28);  // ACC_CONF: ODR = 100Hz, BW = normal, no undersampling
  writeReg(0x41, 0x08);  // ACC_RANGE: ±8g
  delay(5);

  // 4. Настройка шагомера в Normal mode и его включение
  writeReg(0x7A, 0x15);  // STEP_CONF[0] = 0x15 (Normal mode)
  writeReg(0x7B, 0x0B);  // STEP_CONF[1] = step_conf[10:8]=0b011, step_cnt_en=1
  delay(10);

  // 5. Сброс счётчика шагов
  sendCmd(0xB2);
  delay(50);

  // Запоминаем начальное значение (должно быть 0 после сброса)
  lastSteps = readStepCount();

  SerialBT.println("Step counter initialized (Normal mode)");
}

void loop() {
  // Читаем реальные шаги
  uint32_t realSteps = readStepCount();

  if (realSteps != lastSteps) {
    SerialBT.print("Steps: ");
    SerialBT.println(realSteps);
    lastSteps = realSteps;
  }
  delay(100);
}

          Если вам нужно получать сигнал на каждый шаг, используя Step Detector, необходимо настроить прерывание. В отличие от счётчика, который просто накапливает отсчёты, детектор аппаратно фиксирует факт совершения целевого действия и может сигнализировать об этом на один из внешних пинов INT.

Читайте также:  Установка среды разработки Espressif IDE

          Для конфигурации используются следующие регистры:

INT_EN[2] (0x52),бит 3(int_step_det_en) включает прерывание от детектора шагов;

INT_MAP[2] (0x57) назначает детектор на один из двух выводов микросхемы (INT1 или INT2);

INT_OUT_CTRL (0x53) настраивает поведение выходного пина (активный уровень, тип выходного каскада);

INT_STATUS[0] (0x1C), бит 0 (step_det_int) работает как флаг, который взводится аппаратно при обнаружении шага.

          Пример кода для настройки прерывания от детектора на вывод INT1:

// 1. Включение прерывания от детектора шагов
// Регистр INT_EN[2] (адрес 0x52), бит 3 = 1
writeReg(0x52, 0x08);

// 2. Назначение детектора шага на пин INT1
// Регистр INT_MAP[2] (адрес 0x57), бит 0 = 1 (Step Detector -> INT1)
writeReg(0x57, 0x01);

// 3. Настройка выходного сигнала на пине INT1
// Регистр INT_OUT_CTRL (адрес 0x53):
//   бит 3 (int1_output_en) = 1 — выход включён
//   бит 2 (int1_od)       = 0 — двухтактный выход (push-pull)
//   бит 1 (int1_lvl)      = 1 — активный высокий уровень
//   бит 0 (int1_edge)     = 0 — уровень, а не фронт
writeReg(0x53, 0x0A);

          Чтение и сброс флага прерывания выполняется через регистр статуса:

// Читаем регистр INT_STATUS[0] (адрес 0x1C)
Wire.beginTransmission(BMI160_ADDR);
Wire.write(0x1C);
Wire.endTransmission(false);
Wire.requestFrom(BMI160_ADDR, 1);
byte int_status = Wire.read();

// Проверяем бит 0 (step_det_int)
if (int_status & 0x01) {
  Serial.println("Шаг обнаружен!");
}

          Обратите внимание, что флаг step_det_int сбрасывается автоматически при чтении регистра INT_STATUS[0].

          Несколько важных моментов и советов по работе со встроенным шагомером:

1) акселерометр должен быть включён, поскольку на его основе работает счётчик;

2) в состоянии пониженного энергопотребления (Low Power) шагомер продолжает работать и потребляет всего около 30 мкА;

3) для получения максимальной точности рекомендуется использовать состояние Normal. Варианты Sensitive и Robust предназначены для коррекции поведения при специфических условиях ходьбы или манеры пользователя;

4) счётчик НЕ сбрасывается при выключении питания. При следующем включении он продолжит отсчёт с той же величины. Поэтому при старте приложения имеет смысл принудительно отправить команду сброса, если нужно начинать учёт с нуля;

5) если нужно ловить каждый шаг в реальном времени, настройте прерывание. Если нужно просто знать общее количество, то достаточно периодически читать регистр STEP_CNT.


Схема подключения BMI160 к ESP32

          В качестве базы для нашей маленькой игровой консоли мы применим отладочную плату NodeMCU-32S (38 pin), которая построена на базе модуля ESP-WROOM-32.

          Для отображения видеоигры возьмём TFT-дисплей под управлением контроллера ST7789 с диагональю экрана 2 дюйма и разрешением 240 × 320 точек:

          Данный модуль дисплея очень прост в подключении и не требует внешнего токоограничивающего резистора для подсветки.

          Ниже представлена таблица и схема подключений инерциального модуля и дисплея к отладочной плате:

Схема подключения BMI160 и ST7789 к ESP32

          Необходимо обеспечить хотя бы минимальную эргономику для «портативной консоли», поэтому опытный образец было решено собрать на беспаечной макетной плате MB-102 на 830 точек. С правого края размещена ESP32, чтобы удобно было подключать USB-кабель. Дисплей и сенсор расположены примерно по центру платы. Если взять макет двумя руками как геймпад, то управлять игрой будет интуитивно понятно.

          Обратите внимание на расположение BMI160 на плате и как размещён дисплей. В силу особенности модуля GY-BMI160, его размещение на беспаечной макетной плате определяет вектор направления каждой оси: X направлена поперёк платы (смотрит вперёд/назад), Y направлена вдоль платы (смотрит влево/вправо), Z перпендикулярно плоскости платы.

          Соответственно, всё это нам нужно будет учитывать в коде, чтобы задавать направление полёта корабля по плоскости экрана. Конкретно:

– наклоны платы влево/вправо (ось Y) перемещает корабль по горизонтали (узкой стороне) экрана;

– наклоны вперёд/назад (ось X) двигает корабль по вертикали (по длинной стороне) экрана.


Программный код (скетч) для скролл-шутера

          Для разработки проекта была выбрана среда программирования PlatformIO (расширение для редактора кода Visual Studio Code) с использованием фреймворка Arduino.

          Поскольку мы намереваемся создать довольно «подвижную» игру, то нам необходима максимальная скорость отрисовки на TFT-дисплее, чтобы картинка обновлялась без особого дискомфорта. Для этой задачи применим библиотеку tft_eSPI, поскольку она, в отличие от Adafruit_ST7735_and_ST7789_Library, имеет следующие преимущества:

– быстрая отрисовка примитивных фигур (треугольники, прямоугольники) засчёт аппаратно-оптимизированных функций;

– оптимизация под конкретный драйвер дисплея (в нашем случае – ST7789);

– стабильная работа на высокой частоте SPI-интерфейса.

          Всё вместе даёт выигрыш в частоте смены кадров и критично важно для плавности анимации.

          О том, как подготовить проект в среде PlatformIO и как настроить библиотеку tft_eSPI, написана очень подробная инструкция в статье про двухосевой джойстик KY-023.

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

– выбор драйвера. На этом этапе, может быть, Вам потребуется поэкспериментировать конкретно с Вашим экземпляром дисплея. Дело в том, что я, к примеру, предполагал, что для моего экрана нужно выбрать драйвер ST7789. Но по факту интерфейс отображался некорректно: я настраиваю надписи красного цвета, а в реале вижу синие буквы. Поэтому попробовал выбрать драйвер ST7789_2, и всё заработало как надо. Поэтому Вы тоже можете поэкспериментировать, если с первого раза экран покажет что-то не то, что ожидалось.

– разрешение экрана

– пины подключения к отладочной плате

          Если модуль дисплея будет питаться от встроенного в плату ESP32 стабилизатора напряжения (как на представленной выше схеме), то перед процессом перепрошивки отключите питание от дисплея, чтобы не возникло проблем из-за просадки питающего напряжения (это критично для загрузчика):

          Итак, перейдём непосредственно к коду нашей игры. Это классический скролл-шутер, в котором мы управляем космическим кораблём на дисплее, наклоняя макетную плату с датчиком линейного ускорения.

          Управление следующее:

– наклоняя плату вперёд/назад, корабль летит вверх/вниз по дисплею;

– делая наклон влево/вправо, корабль двигается в соответствующие стороны;

– одно нажатие кнопки BOOT на отладочной плате (справа от microUSB-разъёма) производит один выстрел;

– кнопка EN (RESET) на плате (слева от USB) перезагружает контроллер и, соответственно, саму игру.

          От степени наклона зависит скорость движения корабля.

          В верхней части экрана представлено меню на синем фоне. Слева отображается суммарный счёт (SCORE), где за каждого подстреленного врага даётся 10 очков. Справа указывается количество жизней (LIVES), которых на каждую игровую сессию выдаётся 3 штуки. При столкновении корабля с врагом жизнь отнимается. Когда жизни заканчиваются, на экране отображается сообщение об окончании сессии «GAME OVER» и суммарный счёт. Для перезапуска нажмите кнопку RESET на отладочной плате.



          Ниже представлен очень подробно прокомментированный код игры:

#include <Wire.h> // Библиотека для работы с I2C-интерфейсом
#include <TFT_eSPI.h> // Библиотека для работы с TFT-дисплеем

// Создаём объект для работы с дисплеем
TFT_eSPI tft = TFT_eSPI();

// НАСТРОЙКИ ДАТЧИКА BMI160
// I2C адрес датчика BMI160 (0x69 - альтернативный адрес, когда ножка SDO подтянута к питанию)
#define BMI160_ADDR 0x69
// Множитель для перевода сырых данных акселерометра в G при диапазоне ±8G (4096 LSB = 1G)
#define ACC_SCALE_8G 4096.0

// ИГРОВЫЕ КОНСТАНТЫ
#define SHIP_W 10     // Ширина корабля в пикселях
#define SHIP_H 12     // Высота корабля в пикселях
#define BULLET_SZ 4   // Размер пули (квадрат 4x4 пикселя)
#define ENEMY_SZ 10   // Размер врага (квадрат 10x10 пикселей)
#define BUTTON_PIN 0  // Номер пина GPIO, к которому подключена кнопка стрельбы (GPIO0)

// ИГРОВЫЕ ПЕРЕМЕННЫЕ
float shipX = 115, shipY = 305;   // Начальная позиция корабля (центр экрана по X, низ по Y)
float filteredHorizontal = 0, filteredVertical = 0; // Фильтрованные значения наклона (горизонталь и вертикаль)
int score = 0, lives = 3;         // Текущие очки и количество жизней
int lastScore = -1, lastLives = -1; // Предыдущие значения очков и жизней для оптимизации перерисовки UI

// Массив пуль (структура содержит координаты и флаг активности)
struct Bullet { int x, y; bool active; } bullets[20];
// Массив врагов
struct Enemy { int x, y; bool active; } enemies[12];

unsigned long lastShot = 0;        // Время последнего выстрела (для задержки между выстрелами)
unsigned long lastEnemySpawn = 0;  // Время последнего появления врага
bool lastButtonState = HIGH;       // Последнее состояние кнопки (HIGH - не нажата, LOW - нажата)



// Функция записи значения в регистр BMI160
void writeReg(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(BMI160_ADDR); // Начинаем передачу датчику по I2C
  Wire.write(reg);                     // Отправляем адрес регистра
  Wire.write(val);                     // Отправляем значение для записи
  Wire.endTransmission();              // Завершаем передачу
}

// Функция чтения 16-битного значения из двух последовательных регистров (младший байт, затем старший)
int16_t read16(uint8_t reg) {
  Wire.beginTransmission(BMI160_ADDR); // Начинаем передачу
  Wire.write(reg);                     // Указываем начальный регистр для чтения
  Wire.endTransmission(false);         // Завершаем передачу, но удерживаем шину (repeated start)
  Wire.requestFrom(BMI160_ADDR, 2);    // Запрашиваем 2 байта данных
  if(Wire.available() < 2) return 0;   // Если данных нет, возвращаем 0
  uint8_t lsb = Wire.read();           // Читаем младший байт
  uint8_t msb = Wire.read();           // Читаем старший байт
  return (int16_t)((msb << 8) | lsb);  // Собираем 16-битное целое со знаком и возвращаем
}



// Отрисовка статической части интерфейса (фоновая панель с надписями)
void drawUI() {
  // Закрашиваем верхнюю полосу высотой 20 пикселей тёмно-синим цветом
  tft.fillRect(0, 0, 240, 20, TFT_NAVY);
  // Устанавливаем белый цвет текста и размер шрифта 1
  tft.setTextColor(TFT_WHITE); tft.setTextSize(1);
  // Устанавливаем курсор и выводим текст "Счёт"
  tft.setCursor(5, 5); tft.print("SCORE: ");
  // Устанавливаем курсор и выводим текст "Количество жизней"
  tft.setCursor(170, 5); tft.print("LIVES: ");
}

// Обновление только изменившихся значений счёта и жизней (для экономии ресурсов)
void updateUI() {
  // Если текущий счёт отличается от предыдущего
  if(score != lastScore) {
    lastScore = score;                     // Обновляем сохранённое значение
    tft.fillRect(55, 5, 80, 10, TFT_NAVY); // Затираем старую цифру фоновым цветом
    tft.setCursor(55, 5); tft.print(score); // Печатаем новое значение
  }
  // Если текущее количество жизней отличается от предыдущего
  if(lives != lastLives) {
    lastLives = lives;                     // Обновляем сохранённое значение
    tft.fillRect(220, 5, 30, 10, TFT_NAVY); // Затираем старую цифру
    tft.setCursor(220, 5); tft.print(lives); // Печатаем новое значение
  }
}



// Создание новой пули (поиск неактивной пули в массиве и её активация)
void shoot() {
  for(int i = 0; i < 20; i++) {
    if(!bullets[i].active) {               // Нашли свободную пулю
      bullets[i].x = shipX + SHIP_W/2 - 2; // Координата X: центр корабля минус половина ширины пули
      bullets[i].y = shipY - 5;            // Координата Y: чуть выше корабля
      bullets[i].active = true;            // Делаем пулю активной
      break;                               // Выходим из цикла
    }
  }
}

// Попытка выстрелить с учётом задержки (не чаще 1 раза в 200 мс)
void tryShoot() {
  if(millis() - lastShot > 200) { // Если прошло больше 200 мс после последнего выстрела
    shoot();                       // Создаём новую пулю
    lastShot = millis();           // Запоминаем время этого выстрела
  }
}

// Создание нового врага (поиск неактивного врага в массиве и его активация наверху экрана)
void spawnEnemy() {
  for(int i = 0; i < 12; i++) {
    if(!enemies[i].active) {               // Нашли свободного врага
      enemies[i].x = random(10, 230);      // Случайная X координата (с отступом от краёв)
      enemies[i].y = 21;                   // Появляется сразу под верхней панелью (Y = 21)
      enemies[i].active = true;            // Активируем врага
      break;
    }
  }
}

// Полная перерисовка всех игровых объектов (фон, корабль, пули, враги)
void drawGame() {
  // Очищаем игровое поле (всё, что ниже верхней панели) чёрным цветом
  tft.fillRect(0, 20, 240, 295, TFT_BLACK);
  
  // Отрисовка корабля
  int sx = shipX, sy = shipY;
  // Рисуем треугольный нос корабля (верхняя часть)
  tft.fillTriangle(sx + SHIP_W/2, sy - 4,   // вершина (центр, выше)
                   sx, sy + 4,             // левая нижняя точка
                   sx + SHIP_W, sy + 4,    // правая нижняя точка
                   TFT_CYAN);              // цвет - голубой
  // Рисуем прямоугольную нижнюю часть (корпус)
  tft.fillRect(sx + 2, sy, 6, 8, TFT_CYAN);
  
  // Отрисовка пуль
  for(int i = 0; i < 20; i++) 
    if(bullets[i].active) 
      tft.fillRect(bullets[i].x, bullets[i].y, BULLET_SZ, BULLET_SZ, TFT_YELLOW);
  
  // Отрисовка врагов
  for(int i = 0; i < 12; i++) 
    if(enemies[i].active) {
      // Основной красный квадрат врага
      tft.fillRect(enemies[i].x, enemies[i].y, ENEMY_SZ, ENEMY_SZ, TFT_RED);
      // Два белых пикселя в верхней части — "глаза"
      tft.fillRect(enemies[i].x + 2, enemies[i].y + 2, 2, 2, TFT_WHITE);
      tft.fillRect(enemies[i].x + 6, enemies[i].y + 2, 2, 2, TFT_WHITE);
    }
}

// Экран окончания игры
void gameOver() {
  tft.fillScreen(TFT_BLACK);                        // Чёрный фон
  tft.setTextColor(TFT_RED);                        // Красный текст
  tft.setTextSize(2); tft.setCursor(70, 140);       // Крупный шрифт, курсор в центр
  tft.print("GAME OVER");                           // Выводим надпись GAME OVER
  tft.setTextSize(1); tft.setCursor(90, 180);       // Обычный шрифт
  tft.print("Score: "); tft.print(score);           // Выводим набранные очки
  tft.setCursor(70, 200);                           // Курсор ниже
  tft.print("Press EN to reset");                   // Инструкция по сбросу (кнопка RESET на отладочной плате)
  while(1) {}                                       // Бесконечный цикл (зависаем)
}

// ФУНКЦИЯ НАЧАЛЬНОЙ НАСТРОЙКИ
void setup() {
  Serial.begin(115200);            // Инициализация Serial-порта для отладки (скорость 115200)
  pinMode(BUTTON_PIN, INPUT_PULLUP); // Настраиваем пин кнопки на вход с подтяжкой к питанию
  
  tft.init();                      // Инициализация дисплея
  tft.setRotation(0);              // Устанавливаем ориентацию экрана (0 = портретная)
  tft.fillScreen(TFT_BLACK);       // Заливаем экран чёрным цветом
  
  Wire.begin();                    // Инициализация I2C шины (Master mode)
  
  // Настройка BMI160
  writeReg(0x7E, 0x11);            // Команда 0x11: перевести акселерометр в обычный (normal) режим
  delay(10);                       // Небольшая задержка для стабилизации
  
  writeReg(0x40, 0x28);            // ACC_CONF: ODR = 100 Гц, обычный режим фильтра
  writeReg(0x41, 0x08);            // ACC_RANGE: устанавливаем диапазон ±8G (0x08)
  
  // Инициализация массивов пуль и врагов (все объекты неактивны)
  for(int i = 0; i < 20; i++) bullets[i].active = false;
  for(int i = 0; i < 12; i++) enemies[i].active = false;
  
  drawUI();                        // Рисуем статическую часть интерфейса
}

// ОСНОВНОЙ ЦИКЛ ПРОГРАММЫ
void loop() {
  // Обработка кнопки стрельбы
  bool btn = digitalRead(BUTTON_PIN);          // Читаем текущее состояние кнопки
  // Если кнопка была не нажата, а теперь нажата (передний фронт)
  if(btn == LOW && lastButtonState == HIGH) tryShoot();
  lastButtonState = btn;                       // Сохраняем текущее состояние для следующего кадра
  
  // Управление кораблём по наклону датчика
  // Читаем сырые значения ускорения по оси X и Y (регистры 0x12 и 0x14 для младшего байта)
  int16_t ax = read16(0x12);
  int16_t ay = read16(0x14);
  
  // Переводим LSB в ускорение в G
  float acc_x = ax / ACC_SCALE_8G;
  float acc_y = ay / ACC_SCALE_8G;
  
  // Ось Y управляет движением по горизонтали (влево-вправо)
  float rawHorizontal = constrain(acc_y * 30, -12, 12);
  // Применяем цифровой фильтр (сглаживание) для устранения шумов
  // Коэффициенты подобраны под частоту 50 Гц (delay 20 мс)
  // При изменении частоты обновления (в конце цикла loop) коэффициенты нужно скорректировать.
  filteredHorizontal = 0.3 * rawHorizontal + 0.7 * filteredHorizontal;
  // Смещаем корабль по горизонтали в зависимости от фильтрованного значения
  shipX += filteredHorizontal * 1.8;
  // Не даём кораблю выходить за левый и правый края игрового поля
  shipX = constrain(shipX, 5, 235 - SHIP_W);
  
  // Ось X управляет движением по вертикали (вверх-вниз)
  float rawVertical = constrain(acc_x * 25, -10, 10);
  filteredVertical = 0.3 * rawVertical + 0.7 * filteredVertical;
  // Смещаем корабль по вертикали
  shipY += filteredVertical * 1.5;
  // Ограничиваем движение по вертикали (ниже верхней панели и не ниже низа экрана)
  shipY = constrain(shipY, 21, 305);
  
  // Спавн врагов (каждые 500 мс)
  if(millis() - lastEnemySpawn > 500) {
    spawnEnemy();
    lastEnemySpawn = millis();
  }
  
  // Обновление позиций пуль
  for(int i = 0; i < 20; i++) 
    if(bullets[i].active) {
      bullets[i].y -= 6;                    // Пуля летит вверх
      if(bullets[i].y < 20) bullets[i].active = false; // Если улетела за верхнюю панель — деактивируем
    }
  
  // Обновление врагов и проверка столкновений
  for(int i = 0; i < 12; i++) 
    if(enemies[i].active) {
      enemies[i].y += 3;                    // Враг движется вниз
      
      // Столкновение врага с кораблём
      // Проверяем пересечение прямоугольников врага и корабля
      if(abs(enemies[i].x - shipX) < ENEMY_SZ && abs(enemies[i].y - shipY) < ENEMY_SZ) {
        enemies[i].active = false;           // Уничтожаем врага
        if(--lives <= 0) gameOver();         // Уменьшаем жизни, если стало 0 — вызываем экран "Game Over"
        updateUI();                          // Обновляем отображение жизней
      }
      
      // Если враг ушёл ниже нижней границы экрана — просто деактивируем его
      if(enemies[i].y > 305) {
        enemies[i].active = false;
        continue;                            // Пропускаем проверку попаданий для этого врага
      }
      
      // Проверка попадания пули во врага
      for(int j = 0; j < 20; j++) 
        if(bullets[j].active && 
           abs(bullets[j].x - enemies[i].x) < ENEMY_SZ && 
           abs(bullets[j].y - enemies[i].y) < ENEMY_SZ) {
          bullets[j].active = false;         // Пуля исчезает
          enemies[i].active = false;         // Враг исчезает
          score += 10;                       // Увеличиваем счёт на 10
          updateUI();                        // Обновляем отображение счёта
          break;                             // Выходим из цикла проверки пуль (враг уже уничтожен)
        }
    }
  
  // Отрисовка и завершение цикла
  drawGame();       // Перерисовываем все игровые объекты
  delay(20);        // Задержка ~20 мс = частота обновления ~50 кадров в секунду
}

          Геймплей будет выглядеть примерно так:

          Плавность движения корабля и высокая частота кадров (Frames Per Second – FPS) в этой игре обеспечивается за счёт нескольких технических приёмов:

Читайте также:  Магнитометр HMC5883L (QMC5883L): компас на ESP32

1. Высокая частота обновления экрана

          В основном бесконечном цикле loop программы установлена задержка между кадрами величиной 20 миллисекунд, что соответствует частоте 50 кадров в секунду. Этого достаточно, чтобы глаз не замечал явной дискретности смены кадров, а игра оставалась отзывчивой.

          Плавность движения также обеспечивается за счёт частичной, а не полной перерисовки экрана. То есть, к примеру, конкретно только числовые данные в информационной панели (в верхней части экрана, где отображены суммарный счёт SCORE и количество жизней LIVES) перерисовывается только тогда (функция updateUI), когда игрок попадает пулей во врага или когда корабль сталкивается с пришельцем.

2. Фильтрация данных с акселерометра

          Сырые данные с датчика могут содержать шум, из-за чего корабль на экране будет дёргаться. Для сглаживания помех в коде применён сглаживающий фильтр:

  // Ось Y управляет движением по горизонтали (влево-вправо)
  float rawHorizontal = constrain(acc_y * 30, -12, 12);
  // Применяем цифровой фильтр (сглаживание) для устранения шумов
  // Коэффициенты подобраны под частоту 50 Гц (delay 20 мс)
  // При изменении частоты обновления (в конце цикла loop) коэффициенты нужно скорректировать.
  filteredHorizontal = 0.3 * rawHorizontal + 0.7 * filteredHorizontal;
  // Смещаем корабль по горизонтали в зависимости от фильтрованного значения
  shipX += filteredHorizontal * 1.8;
  // Не даём кораблю выходить за левый и правый края игрового поля
  shipX = constrain(shipX, 5, 235 - SHIP_W);

          Немного подробнее рассмотрим код с фильтрацией. Сперва нужно усилить и ограничить сигнал, используя функцию constrain:

float rawHorizontal = constrain(acc_y * 30, -12, 12);

          Переменная acc_y – это ускорение по оси Y в долях G (обычно от -1 до +1). Умножение на 30 делает наклон более заметным: небольшой наклон платы превращается в ощутимое смещение корабля. Функция constrain обрезает результат до диапазона -12…+12, чтобы даже при резком рывке корабль не улетел за край экрана.

          Далее выполняем сглаживание, чтобы не было дрожи:

filteredHorizontal = 0.3 * rawHorizontal + 0.7 * filteredHorizontal;

          Для движения корабля новая величина скорости формируется на 30 % от текущей (которая зашумлена), а также на 70 % от предыдущей величины скорости (которая уже сглажена). За счёт этого случайные выбросы гасятся, и корабль явно не дёргается.

3. Плавность перемещения

          Нам нужно создать эффект плавного перемещения, а не мгновенного скачка:

shipX += filteredHorizontal * 1.8;

          Оператор += означает прибавление к переменной shipX (позиция по оси X) величины перемещения. За счёт этого корабль не прыгает мгновенно в новую позицию, а чуть-чуть смещается. Это создаёт эффект инерции: наклонили плату – корабль разгоняется, вернули в ровное положение – плавное торможение.

          Чтобы корабль не вылетел за пределы экрана, мы создаём границы: слева отступаем 5 пикселей и справа на ширину корабля, отнимая от оставшейся ширины дисплея:

shipX = constrain(shipX, 5, 235 - SHIP_W);

Заключение

          В данной статье мы подробно познакомились с инерциальным модулем BMI160 от Bosch Sensortec, который на сегодняшний день является одним из самых популярных и доступных микросхем 6-осевого акселерометра и гироскопа. Чип широко используется в игровых контроллерах, смартфонах, системах оптической стабилизации на фото- и видеокамерах, а также в может применяться в различных носимых устройствах (фитнес-трекеры, умные очки, устройства дополненной/виртуальной реальности и так далее).

          Модуль BMI160 часто сравнивают с MPU6050. Главное отличие Bosch’евской микросхемы от конкурента заключается в том, что в ней нет встроенного цифрового процессора движения DMP, который способен обрабатывать сырые данные от сенсоров и самостоятельно производить вычисление углов. Но у рассмотренного нами чипа это всё компенсируется за счёт широкого набора зашитых в него алгоритмов, при помощи которых можно реализовать множество сценариев работы устройства (детектор удара или падения, двойное прикосновение и так далее).

          Основными достоинствами BMI160 являются:

– очень низкое потребление энергии: за счёт встроенного блока управления питанием (PMU) можно отключать лишние блоки, что позволяет достичь уровня потребления 5…30 микроампер (шагомер), в то время как при работе «на полную мощность» потребление составляет чуть менее 1 миллиампера;

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

– разнообразная аппаратная периферия: разработчики добавили в микросхему 24-битный аппаратный таймер с разрешением 39 мкс, 1024-байтный FIFO-буфер, два канала прерываний INT. Также добавлен отдельный вторичный интерфейс: либо I2C для возможности подключения внешнего магнитометра, либо OIS (SPI) для подключения к контроллеру оптической стабилизации изображения в видеокамерах. Всё это позволяет сделать из BMI160 9-осевой контроллер для систем оптической стабилизации изображения.

          На практике мы убедились, что рассмотренный инерциальный модуль отлично подходит для создания игровых контроллеров. Собранная на базе ESP32 портативная консоль для скролл-шутер наглядно продемонстрировала, как с помощью простого сглаживающего фильтра можно превратить зашумлённые «сырые» данные датчика линейного ускорения в плавное и отзывчивое управление.

          Если же Вам нужна не просто фиксация событий, а полноценный расчёт ориентации движения, то можно рассмотреть более современную модель – BMI270.