Прокачиваем беговую дорожку
Недавно я решился на очень странную для себя покупку. Да, я купил себе беговую дорожку.
И вскоре ко мне пришло осознание, что не хватает подробной статистики как при катании на велосипеде. В случае с велосипедом приложение на телефоне пишет и мою скорость, и пульс, и частоту вращения педалей, и подъём. Очень любопытно контролировать все эти параметры во время тренировки, иметь возможность смотреть графики и сравнивать свои результаты время от времени.
Так я решил сделать что-то подобное и с беговой дорожкой: подключить её к смартфону или планшету, чтобы собирать и отображать статистику.
Как обычно, моё повествование и в виде традиционной текстовой статьи, и посредством видео. Кому как больше нравится.
Видео
Статья
Конструкция
Ещё в момент, когда я собирал беговую дорожку, я заметил, что пульт и само беговое полотно соединяют всего навсего четыре провода. Судя по всему, часть из них используется для питания пульта, ведь к сети 220 вольт подключается само полотно, а остальные провода нужны для передачи контрольных сигналов в обратную сторону — от пульта к полотну, они управляют скоростью и углом наклона дорожки.
Я подключил осциллограф параллельно этим проводам, пробуя разные комбинации.
В итоге выяснил, что всё примерно так, как я и предполагал. Один из проводов — это земля, и ещё один — питание 12 вольт. Остальные передают цифровые данные.
В одном из них сигнал меняется при переключении скорости и угла наклона. Это именно то, что мне нужно! Амплитуда сигнала — около четырёх вольт. Только вот протокол не похож на что-то стандартное, и сигнал ну очень шумный, когда дорожка включена, надо его как-то фильтровать.
По последнему же проводу просто идут импульсы с постоянной частотой. Судя по всему, чтобы пульт видел подключение к беговому полотну. Если этот провод отключить, пульт сразу выдаёт ошибку.
Показания с датчика пульса по этим проводам явно не передаются, но оно и не нужно. Лучше подключить отдельный нагрудный датчик, который я уже давно использую при езде на велосипеде. К тому же выяснилось, что датчик пульса на самой беговой дорожке сильно врёт, занижая показания.
Сборка устройства
Итак, дальше стоит задача собрать плату, которая подключается параллельно этим проводам, считывает текущие показания скорости и угла наклона и каким-то образом передаёт их по беспроводному соединению в планшет или смартфон.
В очередной раз я решил использовать одноплатный компьютер Onion Omega2. Он должен отлично справиться с поставленной задачей. Нужно только понизить напряжение питания до 3.3 вольт и отфильтровать данные от помех.
Для понижения напряжения я сейчас использую вот такие вот готовые платы с DC-DC конвертером. Они стоят какие-то копейки, выдерживают до пары ампер, а выходное напряжение настраивается крутилкой.
При этом эта плата имеет выводы, чтобы паяться прямо на другую плату, очень удобно. Главное — не свернуть крутилку напряжения после установки в схему.
Для фильтрации помех на линии данных я сделал обычный RC-фильтр: резистор на 2.2 килоома и конденсатор на 22 пикофарада. Это должно отфильтровать высокочастотные помехи, оставив низкочастотный сигнал.
Получилась достаточно маленькая платка.
Я подключил её к проводам беговой дорожки, чтобы посмотреть, на сколько хорошо фильтруется сигнал, когда она включена и, судя по всему, форма сигнала стала почти идеальной.
Модуль ядра
Однако, так просто работоспособность железа не проверить. Как мы увидели
ранее на осциллографе, сигналы идут очень быстро, а у нас используется не микроконтроллер, а одноплатный компьютер Omega2 с Linux на борту. Под Linux мы не сможем так быстро обрабатывать сигналы из юзерспейса. А вот из ядра сможем! Поэтому самое время написать модуль ядра Linux!
Для этого нужно скачать исходники ядра Linux, в нашем случае это сборка OpenWRT под Omega2, и создать в них директорию с исходным кодом нашего модуля.
Написание кода модуля во многом похоже на программирование микроконтроллера. Тоже пишем на Си, тоже всё низкоуровневое, тоже работаем с прерываниями и тоже обращаемся к выводам GPIO. Только вот помимо всего перечисленного ещё взаимодействуем с пространством пользователя посредством псевдофайла. Таким образом наш модуль ядра становится чем-то вроде переходника между железом и обычными приложениями. Собственно, это и называется драйвером.
Поначалу я не знал, как нужно декодировать сигналы, поэтому выводил просто их длительность.
Вскоре стало ясно, что сигналы кодируются длительностью высокого уровня. Он то длиною в 600 микросекунд, то в 1200 микросекунд. Низкий же уровень всегда длиною 600 микросекунд кроме начальной последовательности.
Всего получается 17 таких таких перепадов вниз-вверх. Судя по всему, это 16 бит данных плюс начальная последовательность. Я сделал их декодирование, взяв за основу, что длинные высокие перепады — это логический ноль, а короткие — логическая единица и вывел то, что получилось. Сразу стало видно необходимые мне данные!
16 бит — это, как известно, два байта. Первый байт говорит о типе передаваемых данных: угол наклона или скорость, а второй байт — сами данные. Драйвер получается предельно простой.
Единственным параметром драйвера является номер порта.
/* Module parameters */
static u8 receive_pin = 11;
module_param(receive_pin, byte, S_IRUGO);
MODULE_PARM_DESC(receive_pin,"Treadmill receiver pin number (default 11)");
При инициализации настраиваем его на вход и задаём прерывание, которое будет срабатывать при каждом изменении уровня на нём.
/* Allocate and init the timer */
data_recv_timer = kzalloc(sizeof(struct hrtimer), GFP_KERNEL);
if (!data_recv_timer) {
pr_err("treadmill: can't allocate memory for timer\n");
treadmill_free();
return -1;
}
hrtimer_init(data_recv_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
data_recv_timer->function = recv_timer_callback;
В этом прерывании первым делом смотрим текущее время. Далее используем это значение, чтобы вычислить, сколько же времени прошло с прошлого срабатывания прерывания и занести это в массив. Само собой, запоминаем текущее время для вычисления в следующий раз. Помимо этого надо перезапустить специальный таймер.
/* IRQ fired every rising/falling edge of receiver pin */
static irq_handler_t treadmill_irq_handler(unsigned int irq,
void *dev_id, struct pt_regs *regs)
{
u64 now = ktime_to_us(ktime_get_boottime());
u8 value = gpio_get_value(receive_pin);
u64 time_passed;
reset_recv_timer();
if ((timings_pos & 1) == value)
{
time_passed = now - last_time;
if (timings_pos < TIMINGS_BUFFER_SIZE)
{
timings[timings_pos] = time_passed;
timings_pos++;
}
last_time = now;
}
/* Announce that the IRQ has been handled correctly */
return (irq_handler_t) IRQ_HANDLED;
}
Фишка в том, что если таймер всё-таки сработает, значит перепадов уровня на пине не было достаточно давно, а соответственно пора обработать собранную информацию. В функции, которую вызывает таймер, проверяется, что было ровно 34 перепада, после чего смотрим, какой длительности был каждый интервал. Если там то 600 миллисекунд, то 1200 миллисекунд, то возьмём за границу 900. Если интервал меньше, то пишем в результат единицу, сдвигая его на один бит. После обработки каждого интервала, отправляем результат в открытые псевдофайлы, передавая таким образом данные в юзерспейс.
/* Timer */
static enum hrtimer_restart recv_timer_callback(struct hrtimer *timer)
{
int i, p;
u16 data;
if (timings_pos != 34) {
pr_debug("treadmill: invalid edges count: %d", timings_pos);
timings_pos = 0;
return HRTIMER_NORESTART;
}
data = 0;
for (i = 2; i < timings_pos; i += 2)
{
data >>= 1;
if (timings[i] < 900) // 600us = 1, 1200 us = 0
data |= 0x8000;
}
for (p = 0; p < 2; p++) {
for (i = 0; i < treadmill_number_opens; i++) {
if (!(opened_files[i]->f_mode & FMODE_READ)) continue;
((struct cfile_t*)opened_files[i]->private_data)->receiver_buffer[
((struct cfile_t*)opened_files[i]->private_data)->receiver_write_pos++
% RECEIVER_BUFFER_SIZE] = (data >> (8 * p)) & 0xFF;
}
};
wake_up_interruptible(&wq_data);
timings_pos = 0;
return HRTIMER_NORESTART;
}
Сервер на Python и определение скорости
Дальше остаётся написать скрипт на Python, который будет читать их из псевдофайла и отправлять по сети уже в виде JSON строки. Казалось бы, тут всё достаточно прямолинейно. Однако, если с углом наклона всё просто, и значение во втором байте точно соответствует углу наклона в процентах, то со скоростью всё оказалось гораздо запутаннее.
Значение 9 соответствует одному километру в час, а значение 160 соответствует 18 километрам в час. То есть зависимость данных от реальной скорости совсем не очевидная. Я выписал все значения вручную, вбил их в Excel, построил график и получил весьма неровную кривую.
Причём есть скорости, когда показания на пульте разные, а данные и скорость самой дорожки остаются одинаковыми! Например, 5.2 км/час и 5.3 км/час — это на самом деле одинаковые скорости. Везде обман. Интересно, какая там скорость на самом деле? Замерить бы как-то, но оставлю это на потом.
Если не считать этого перевода попугаев в километры в час, то скрипт получился предельно простым. Читаем данные из псевдофайла Linux, декодируем, принимаем подключения по сети и передаём данные подключившимся по сети клиентам в виде JSON строки.
class TreadmillServer:
def __init__(self, device = "/dev/treadmill", port = 11010, interface = '0.0.0.0'):
self._device = device
self._port = port
self._interface = interface
self._working = False
self._clients = []
self._server_sock = None
self.incline = 0
self.speed = 0
def start(self):
self._working = True
self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._server_sock.bind((self._interface, self._port))
self._server_sock.listen(10)
print("Listening port", self._port)
Thread(target=self._port_listener, name="Treadmill port listener", daemon=True).start()
Thread(target=self._device_listener, name="Treadmill device listener", daemon=True).start()
def stop(self):
self._working = False
if self._server_sock != None:
try:
self._server_sock.close()
except:
pass
self._server_sock = None
def __del__(self):
self.stop()
def _port_listener(self):
while self._working and self._server_sock:
try:
conn, addr = self._server_sock.accept()
print('Connected: {0}'.format(addr))
TreadmillClientConnection(self, conn, addr)
except Exception as e:
print("Error:", e)
Думаю, никакая авторизация и безопасность тут не нужны. Состояние беговой дорожки — это не те данные, которые мне хотелось бы защищать от хакеров.
Ставим этот скрипт в автозагрузку и убираем плату внутрь беговой дорожки. Увы, она поместилась только в металлическую трубы, соединяющую пульт с беговым полотном.
Как известно, металл экранирует радиосигнал, поэтому Wi-Fi антенну я вынес наружу трубы, но под пластмассовый кожух, который прячет провода.
На этом непосредственно «умная» беговая дорожка уже готова. Она уже умеет раздавать статистику по сети. Осталось только написать для неё клиент!
Клиент на Android
Чем по моему представлению должен быть такой клиент. Это приложение под Android, которое я буду запускать на планшете или смартфоне и класть поверх дисплея самой беговой дорожки, соответственно оно должно выводить на экран всю информацию по упражнениям, заменяя собой дисплей самой беговой дорожки. Приложение должно уметь работать в фоне, чтобы я мог без проблем смотреть видео во время пробежек. Также оно оно должно вести подробную статистику по пробежкам, синхронизируя всё с облаком и рисуя графики зависимости пульса от скорости и угла наклона.
Сердцем такого приложения должен быть сервис, который работает в фоне, в бесконечном цикле подключается к беговой дорожке, получает данные и декодирует их. В этом особых сложностей нет.
Датчик пульса
Самым сложным внезапно оказалась работа с датчиком пульса. Обнаружилось множество подводных камней. Есть у меня вот такой вот нагрудный пульсометр:
Я им уже давно пользуюсь, когда катаюсь на велосипеде. Он вполне стандартный, работает по BLE а-ля Bluetooth Low Enegy, без проблем спаривается и с телефоном, и с навигатором от Garmin. Я и подумать не мог, что работать с ним из своего приложения будет так неочевидно. У подобных датчиков есть стандартные GUID идентификаторы для разных показаний.
Чтобы начать получать пульс, нужно сначала настроить пульсометр на периодическую отправку показаний. Я смог это сделать только изучением нерабочих примеров и методом тыка.
В итоге я написал класс для работы с датчиком пульса, который автоматически пытается подключиться к нему и периодически сообщает текущий пульс.
Samsung Health SDK
Что же касается статистики и графиков. Я решил не изобретать велосипед, а использовать то, что я уже использую при покатушках на велосипеде, а именно как-то подружиться с замечательным приложением Samsung Health.
Сейчас наверное опять будет выглядеть, будто я Samsung рекламирую. Но на велосипеде это приложение себя действительно отлично зарекомендовало. К моему удивлению оно без проблем подключается ко всем датчикам, показывает и частоту вращения педалей, и обороты колеса, и надиктовывает статистику в наушники, и ту же статистику с графиками показывает, и ачивки выдаёт, и в облаке всё хранит.
Поиск показал, что у Samsung Health есть свой SDK, который хоть и совсем внятно, но всё-таки документирован: img-developer.samsung.com/onlinedocs/health/android/data/index.html
Работа с ним — это по сути работа с базой данных, в которой хранятся самые разные показания от пройденных шагов и измерений пульса до сахара в крови и фаз сна. Но нас сейчас интересуют записи об упражнениях, которые включают в себя как скалярные значения вроде типа упражнения, времени, расстояния, длительности, сожженных калориях, так и массивы живых данных вроде истории пульса, скорости и координат.
Все эти данные нужно корректно сохранить и подготовить. Некоторые надо вычислить.
Подсчет высоты
Например, высота подъёма. От беговой дорожки нам известен угол подъёма в каждый момент времени, который измеряется в процентах. Процент угла высоты — это отношение пройденного расстояния к подъёму. Выходит, вертикальная скорость равна обычной скорости умноженной на уклон в процентах и деленной на сто. Зная вертикальную скорость мы можем подсчитать текущую высоту в каждый момент времени. В результате её нужно занести в текущие координаты, несмотря на то, что во время упражнения они не меняются и не учитываются.
В ответ на эти данные приложение Samsung Health будет показывать, на сколько я якобы поднялся, а также вертикальную скорость в каждый момент тренировки.
Подсчёт калорий
Но как считать калории? Причём подсчёт калорий является обязательным для Samsung Health. При этом сожжённые калории — это ну очень неточный показатель, который зависит от множества самых разных факторов. Не уверен, есть ли смысл в их подсчёте.
Я не стал придумывать что-то своё и просто нагуглил калькулятор (https://42.195km.net/e/treadsim/) и скопировал к себе алгоритм из его джаваскрипта (https://42.195km.net/e/treadsim/treadsim107.js). На входе он принимает пройденное расстояние, угол подъёма и… вес.
Я мог бы задавать свой вес вручную, но раз уж мы работаем с Samsung Health, я могу брать свой текущий вес оттуда. Ведь я использую умные весы от Xiaomi, которые синхронизируются с Google Fit у меня на телефоне, Google FIt через отдельное приложение синхронизируется с Samsung Health, Samsung Health через облако синхронизируется с собой же на планшете, где его получает уже моё приложение.
Внешний вид приложения
Визуально у приложения задача — это крупно отображать основные показания: скорость, угол, пульс, расстояние, калории. Лучше делать это белым цветом на чёрном фоне, чтобы расход заряда аккумулятора при использовании AMOLED экрана был минимальным, ведь мы конечно же указываем, что при отображении нашего активити экран должен быть включен постоянно.
Кнопки автоматически скрываем, когда беговая дорожка активна. Запустить и остановить тренировку можно только при нулевой скорости.
И конечно же надо сделать поддержку режима «картинка в картинке». Делается это всего в несколько строк. Надо просто указать в манифесте, что активити поддерживает этот режим, а в коде переходить в него при сворачивании приложения. В результате можно смотреть, например, YouTube и видеть в уголке экрана показания беговой дорожки. Получилось очень удобно.
Но на этом этапе меня окончательно настигла боль разработчика под Android, ведь у меня уже получается четыре разных размера экрана: телефон и планшет в обычном режиме и они же в режиме «картинка в картинке». И так получилось, что если я подбираю нормальный размер шрифтов под один размер экрана, то в других случаях всё то слишком мелко, то слишком крупно.
При разработке под Android есть несколько категорий экранов, и можно сделать, чтобы разные параметры применялись под них автоматически, но в моём случае этого оказалось недостаточно.
В итоге пришлось рассчитывать и задавать размеры шрифтов в коде, что я считаю весьма неправильным. Однако, работает в итоге идеально.
Результат
И вот результат. Открываем приложение, ждём соединения с беговой дорожкой и датчиком пульса, запускаем тренировку и пользуемся беговой дорожкой как обычно.
По окончании тренировки останавливаем беговую дорожку. По достижении нулевой скорости появится кнопка «завершить тренировку». Нажимаем её, и статистика отправляется в Samsung Health. Открываем его и видим все данные.
Можно посмотреть график пульса, скорости и подъёма, сравнить свои успехи в разные промежутки времени, всё это хранится в облаке и доступно со всех устройств.
Можно и с Google Fit это синхронизировать. Красота. Я доволен результатом. Теперь главное — не забрасывать занятия. Можно добавить в функционал приложения, чтобы оно напоминало о тренировках, если я долго ленюсь. Но эту функцию мне делать уже лень.