Прототип на «коленке»: cоздание приложения для мониторинга датчиков сердечного ритма в спортивном за
- среда, 4 ноября 2020 г. в 00:31:39
Однажды за утренним кофе обсуждали с приятелем современные технологии Интернета вещей и разговорились на предмет реализации системы мониторинга фитнес-оборудования в спортивном клубе. Приятель искал способ реализации своей идеи с нулевой стартовой стоимостью, а мне интересно было сделать что-то полезное и устроить себе очередную проверку знаний и творческих способностей.
В результате решили для начала создать, как говорится, «на коленке», прототип устройства, собирающего данные с пульсометров – датчиков сердечного ритма. По результатам работы я решил написать статью для обмена опытом с сообществом читателей, а еще для повышения собственного уровня в практике написания статей. В этой статье мы проследуем поэтапно от идеи до прототипа программы.
В интернете есть множество статей, в которых описываются готовые системы, но к сожалению часто в такого рода статьях опускаются подробности реализации на начальном этапе разработки. Я решил заполнить этот пробел.
Основным лейтмотивом реализации проекта служит идея совмещения низкоуровневой разработки программы управления устройством на языке C++ и быстрой высокоуровневой разработки сервиса на Python. Базовым программным обеспечением должна быть операционная система Linux. Будем использовать «Linux way» – работа системы должна быть построена на небольших независимых сервисах, работающих под управлением ОС.
Контроль состояния здоровья посетителей спортивного зала – преимущество как для клиентов (добавляет толику заботы и ощущение безопасности), так и для самой организации (повышает ее престиж и предупреждает возможные несчастные случаи). Главное условие на данный момент: стоимость стартапа не должна быть существенна; все необходимые компоненты должны находится в свободной продаже; программная часть должна быть построена на принципах свободного программного обеспечения.
Посетитель спортивного зала в начале тренировки получает нагрудный датчик сердечного ритма HRM (Heart Rate Monitor) и регистрирует его у оператора в зале. Затем он перемещается по залу, и показания его датчика автоматически поступают на сервер сбора статистики для отслеживания состояния его здоровья. Такое предложение выгодно отличается от приобретения датчика самим посетителем: данные собираются централизовано и могут быть сопоставлены с данными с различных спортивных тренажеров, а также ретроспективно проанализированы.
В статье описан первый этап создания такого решенния — программы, считывающую данные с датчика и с помощью которой можно будет в дальнейшем отправлять данные на сервер.
HRM представляет собой автономный датчик (монитор), прикрепленный на тело спортсмена, передающий данные по беспроводной сети. Большинство мониторов, предлагаемых сейчас на рынке, могут работать с использованием открытой сети с частотой 2.4ГГц по протоколам ANT+ и BLE. Показания датчика регистрируются на каком-либо программно-управляемом устройстве: мобильном телефоне или компьютере через USB приемопередатчик.
Для простоты решения задачи остановимся на протоколе ANT, так как такие датчики уже есть в наличии и протокол просто реализовать. Дальнейшее расширение системы будет производится с использованием других систем и технологий.
Основная проблема при использовании устройств ANT и BLE заключается в ограниченном радиусе действия сети (максимальный радиус в режиме минимальной мощности для ANT передатчика 1mW составляет всего 1 метр), поэтому решено создать распределенную сеть регистрирующих устройств. Для достижения этой цели выбраны бюджетные одноплатные компьютеры в качестве узлов проводной или беспроводной локальной сети. К такому маломощному компьютеру можно подсоединить одновременно несколько разнородных датчиков через USB разветвитель с дополнительным питанием и разнести на максимальную дальность действия USB кабеля (до 5 метров).
Для начала работы важно иметь все необходимые компоненты под рукой.
Перечислим то, что требуется:
Одноплатный компьютер Orange Pi Zero с ARM v7 с 2-х ядерным процессором,
256Мб ОЗУ и 2Gb Micro SD.
Приемопередатчик USB Ant+ Stick (далее USB стик)
Монитор (датчик) сердечного ритма HRM
USB — TTL Serial преобразователь интерфейсов для связи с ПК
Итак, выбор железа состоялся. Для реализации программной части будем использовать C++ для взаимодействия с железом и Python версии 3 для сервиса. Выбор базового программного обеспечения остановим на операционной системе Linux. Вариант с использованием Android тоже вполне интересен, но несет больше риска в плане реализации. Что касается Linux для Orange Pi, то это будет Raspbian, наиболее полная и стабильная ОС для этого мини-компьютера. Все необходимые программные компоненты есть в репозитории Raspbian. Впрочем, результат работы можно будет в дальнейшем портировать на другие платформы.
Собираем все вместе и начинаем «творить» прототип.
Для упрощения процесса разработки используем x86-64 машину с установленной Ubuntu Linux 18.04, а образ Orange Pi Zero загружаем с сайта https://www.armbian.com и в дальнейшем настраиваем для работы. Сборку проекта под целевую платформу будем производить непосредственно на одноплатнике.
Записываем полученный образ на SD карту, запускам плату, делаем первоначальную конфигурацию LAN / Wi-Fi. Устанавливаем Git, Python3 и GCC, остальное подгружаем по мере необходимости.
Проведем декомпозицию программного кода, для этого разделим программную часть на уровни абстракции. На нижнем уровне расположим модуль для Python, реализованный на C++, который будет отвечать за взаимодействие ПО верхнего уровня с USB приемопередатчиком. На более высоких уровнях – сетевое взаимодействие с сервером приложений. В самом простом случае это может быть WEB-сервер.
Первоначально хотел использовать готовое решение. Однако выяснилось, что большинство проектов использует библиотеку libusb, что требует изменения в образе Raspbian, в котором для данного оборудования уже есть готовый модуль ядра usb_serial_simple. Поэтому взаимодействие с железом осуществили через символьное устройство /dev/ttyUSB на скорости 115200 бод, что оказалось проще и удобнее.
Проект основан на переделке существующего открытого кода с GitHub (https://github.com/akokoshn/AntService). Код проекта был переработан и максимально упрощен для использования совместно с Python. Получившийся прототип можно найти по ссылке.
Сборка проекта будет с использованием CMake и Python Extension. На выходе получим исполняемый файл и динамическую библиотеку модуля Python.
Режим работы протокола ANT для HRM происходит в широковещательном режиме (Broadcast data) обмена данными по каналу между ведущим (master) – HRM датчиком и ведомым (slave) – USB стиком. Такой режим используется в случае, когда потеря данных не критична.
На аппаратном уровне ведомый осуществляет поиск ведущего в сети и пытается установить соединение, используя следующие характеристики: ключ сети, частоту, период опроса. Если ведущий найден, то устанавливается односторонний канал от ведущего к ведомому, приходят сообщения в виде байтовых посылок с полезной нагрузкой 8 байт на одно сообщение.
На диаграмме показан процесс установления соединения. Здесь Host – управляющий компьютер, USB_stick – приемопередатчик (ведомое устройство), HRM – нагрудный датчик (ведущее устройство)
Последовательность действий:
Код приложения будем создавать в объектно-ориентированной парадигме, поэтому первым шагом определим список объектов:
Список состояний, в которых могут находится объекты:
Список методов объектов, изменяющих состояние объектов:
По результатам анализа взаимодействия и выбора объектов для реализации построим диаграмму классов. Здесь Device будет абстрактным классом, реализующим интерфейс соединения с устройством.
Отправка сообщений происходит через метод «do_comand», первым аргументом принимающий сообщение, а вторым – обработчик результата (это может быть любой вызываемый объект).
Вот псевдокод, демонстрирующий, как использовать программу:
// Создаем объект класса Stick.
Stick stick = Stick();
// Создаем устройство TtyUsbDevice и передаем владение в объект класса Stick.
stick.AttachDevice(std::unique_ptr<Device>(new TtyUsbDevice("/dev/ttyUSB0")));
// Подключаем.
stick.Connect();
// Устанавливаем в исходное состояние.
stick.Reset();
// Инициализируем и устанавливаем соединение.
stick.Init();
// Получаем сообщение с датчика.
ExtendedMessage msg;
stick.ReadExtendedMsg(msg);
Пример использования Python модуля.
# Создаем объект класса с методом обратного вызова «__call__»
import hrm
class Callable:
def __init__(self):
self.tries = 50
def __call__(self, json):
print(json)
self.tries -= 1
if self.tries <= 0:
return False # Stop
return True # Get next value
call_back = Callable()
# Подключаем файл устройства
hrm.attach('/dev/ttyUSB0')
# Инициализируем устройство
status = hrm.init()
print(f"Initialisation status {status}")
if not status:
exit(1)
# Передаем полученный объект для обработки модулем
hrm.set_callback(call_back)
Здесь все просто и понятно, переходим к детальному описанию особенностей проекта.
При разработке приложения не следует упрощать сбор логов и статистики, поэтому используются сторонние библиотеки: Glog, Boost.Log и другие. В нашем случае сборка проекта будет происходить непосредственно на устройстве, поэтому для уменьшения количества кода решено применить собственный логер.
Для отображения точки входа в область видимости и выхода используем простой макрос, который создает объект логгера на стеке. В конструкторе выводится в лог точка входа (имя С++ файла, имя метода, номер строки), в деструкторе – точка выхода. В начало каждой интересуемой области видимости ставится макрос. Если логирование не требуется для всей программы, макрос определяется как пустой.
// Show debug info
#define DEBUG
#if defined(DEBUG)
#include <string.h>
class LogMessageObject
{
public:
LogMessageObject(std::string const &funcname, std::string const &path_to_file, unsigned line) {
auto found = path_to_file.rfind("/");
// Extra symbols make the output coloured
std::cout << "+ \x1b[31m" << funcname << " \x1b[33m["
<< (found == std::string::npos ? path_to_file : path_to_file.substr(found + 1))
<< ":" << std::dec << line << "]\x1b[0m" << std::endl;
this->funcname_ = funcname;
};
~LogMessageObject() {
std::cout << "- \x1b[31m" << this->funcname_ << "\x1b[0m" << std::endl;
};
private:
std::string funcname_;
};
#define LOG_MSG(msg) std::cout << msg << std::endl;
#define LOG_ERR(msg) std::cerr << msg << std::endl;
#define LOG_FUNC LogMessageObject lmsgo__(__func__, __FILE__, __LINE__);
#else // DEBUG
#define LOG_MSG(msg)
#define LOG_ERR(msg)
#define LOG_FUNC
#endif // DEBUG
Пример работы логгера:
Attach Ant USB Stick: /dev/ttyUSB0
+ AttachDevice [Stick.cpp:26]
- AttachDevice
+ Connect [Stick.cpp:34]
+ Connect [TtyUsbDevice.cpp:46]
- Connect
- Connect
+ reset [Stick.cpp:164]
+ Message [Common.h:88]
+ MessageChecksum [Common.h:77]
- MessageChecksum
- Message
+ do_command [Stick.cpp:140]
Write: 0xa4 0x1 0x4a 0x0 0xef
+ ReadNextMessage [Stick.cpp:72]
- ReadNextMessage
Read: 0xa4 0x1 0x6f 0x20 0xea
- do_command
- reset
+ Init [Stick.cpp:49]
+ query_info [Stick.cpp:180]
+ get_serial [Stick.cpp:199]
+ Message [Common.h:88]
+ MessageChecksum [Common.h:77]
- MessageChecksum
- Message
+ do_command [Stick.cpp:140]
Write: 0xa4 0x2 0x4d 0x0 0x61 0x8a
+ ReadNextMessage [Stick.cpp:72]
- ReadNextMessage
Read: 0xa4 0x4 0x61 0x83 0x22 0x27 0x12 0x55
- do_command
- get_serial
Для уменьшения связности создадим абстрактный класс Device и конкретный класс TtyUsbDevice. Класс Device выступает в роли интерфейса для взаимодействия кода приложения с USB. Класс TtyUsbDevice работает с модулем ядра Linux через файл символьного устройства «/dev/ttyUSB».
class Device {
public:
virtual bool Read(std::vector<uint8_t> &) = 0;
virtual bool Write(std::vector<uint8_t> const &) = 0;
virtual bool Connect() = 0;
virtual bool IsConnected() = 0;
virtual bool Disconnect() = 0;
virtual ~Device() {}
};
В качестве структуры данных для хранения сообщений используем std::vector<uint8_t>. Сообщение в формате ANT состоит из синхро-байта, однобайтного поля – размер сообщения, однобайтного идентификатора сообщения, самих данных и контрольной суммы.
inline std::vector<uint8_t> Message(ant::MessageId id, std::vector<uint8_t> const &data)
{
LOG_FUNC;
std::vector<uint8_t> yield;
yield.push_back(static_cast<uint8_t>(ant::SYNC_BYTE));
yield.push_back(static_cast<uint8_t>(data.size()));
yield.push_back(static_cast<uint8_t>(id));
yield.insert(yield.end(), data.begin(), data.end());
yield.push_back(MessageChecksum(yield));
return yield;
}
Класс Stick реализует протокол взаимодействия между хостом и USB стиком.
class Stick {
public:
void AttachDevice(std::unique_ptr<Device> && device);
bool Connect();
bool Reset();
bool Init();
bool ReadNextMessage(std::vector<uint8_t> &);
bool ReadExtendedMsg(ExtendedMessage &);
private:
ant::error do_command(const std::vector<uint8_t> &message,
std::function<ant::error (const std::vector<uint8_t>&)> process,
uint8_t wait_response_message_type);
ant::error reset();
ant::error query_info();
ant::error get_serial(unsigned &serial);
ant::error get_version(std::string &version);
ant::error get_capabilities(unsigned &max_channels, unsigned &max_networks);
ant::error check_channel_response(const std::vector<uint8_t> &response,
uint8_t channel, uint8_t cmd, uint8_t status);
ant::error set_network_key(std::vector<uint8_t> const &network_key);
ant::error set_extended_messages(bool enabled);
ant::error assign_channel(uint8_t channel_number, uint8_t network_key);
ant::error set_channel_id(uint8_t channel_number, uint32_t device_number, uint8_t device_type);
ant::error configure_channel(uint8_t channel_number, uint32_t period, uint8_t timeout, uint8_t frequency);
ant::error open_channel(uint8_t channel_number);
private:
std::unique_ptr<Device> device_ {nullptr};
std::vector<uint8_t> stored_chunk_ {};
std::string version_ {};
unsigned serial_ = 0;
unsigned channels_ = 0;
unsigned networks_ = 0;
};
Интерфейсная часть и реализация для удобства разделены семантически. Класс владеет единственным экземпляром типа «Device», владение которым передается через метод “AttachDevice”.
Отправка и обработка команд происходит через вызов метода «do_command», который в качестве первого аргумента принимает байты сообщения, вторым аргументом – обработчик, затем тип ожидаемого сообщения. Главное требование для метода «do_command» заключается в том, что он должен быть точкой входа для всех сообщений и местом синхронизации. Для возможности расширения метода потребуется инкапсулировать его аргументы в новый объект – сообщение. Код прототипа не является многопоточным, но подразумевает возможность переработки «do_command» на основе ворклетов и асинхронной обработки сообщений. Метод отбрасывает сообщения, не соответствующие ожидаемому типу. Это сделано для упрощения кода прототипа. В рабочей версии каждое сообщение будет обрабатываться асинхронно собственным обработчиком.
ant::error Stick::do_command(const std::vector<uint8_t> &message,
std::function<ant::error (const std::vector<uint8_t>&)> check_func,
uint8_t response_msg_type)
{
LOG_FUNC;
LOG_MSG("Write: " << MessageDump(message));
device_->Write(std::move(message));
std::vector<uint8_t> response_msg {};
do {
ReadNextMessage(response_msg);
} while (response_msg[2] != response_msg_type);
LOG_MSG("Read: " << MessageDump(response_msg));
ant::error status = check_func(response_msg);
if (status != ant::NO_ERROR) {
LOG_ERR("Returns with error status: " << status);
return status;
}
return ant::NO_ERROR;
}
Согласно алгоритму работы HRM датчика, данные передаются только в одну строну с использованием расширенного типа сообщения. Для прототипа используется простая схема: после открытия канала и установления соединения клиентское приложение использует метод ReadExtendedMsg для чтения расширенных сообщений.
struct ExtendedMessage {
uint8_t channel_number;
uint8_t payload[8];
uint16_t device_number;
uint8_t device_type;
uint8_t trans_type;
};
bool Stick::ReadExtendedMsg(ExtendedMessage& ext_msg)
{
/* Flagged Extended Data Message Format
*
* | 1B | 1B | 1B | 1B | 8B | 1B | 2B | 1B | 1B | 1B |
* |------|--------|-----|---------|---------|------|--------|--------|-------|-------|
* | SYNC | Msg | Msg | Channel | Payload | Flag | Device | Device | Trans | Check |
* | | Length | ID | Number | | Byte | Number | Type | Type | sum |
* | | | | | | | | | | |
* | 0 | 1 | 2 | 3 | 4-11 | 12 | 13,14 | 15 | 16 | 17 |
*/
LOG_FUNC;
std::vector<uint8_t> buff {};
device_->Read(buff);
if (buff.size() != 18 or buff[2] != 0x4e or buff[12] != 0x80) {
LOG_ERR("This message is not extended data message");
return false;
}
ext_msg.channel_number = buff[3];
for (int j=0; j<8; j++) {
ext_msg.payload[j] = buff[j+4];
};
ext_msg.device_number = (uint16_t)buff[14] << 8 | (uint16_t)buff[13];
ext_msg.device_type = buff[15];
ext_msg.trans_type = buff[16];
return true;
}
Для создания в Python модуля hrm, предназначенного для работы с ANT, воспользуемся «distutils». Создадим два файла: «setup.py» (для сборки) и hrm.cpp, в котором находится исходный код модуля.
Сборку всего модуля опишем в файле «setup.py» через создание объект типа «Extension». Для сборки вызовем функцию «setup» над этим объектом.
from distutils.core import setup, Extension
hrm = Extension('hrm',
language = "c++",
sources = ['hrm.cpp', '../src/TtyUsbDevice.cpp', '../src/Stick.cpp'],
extra_compile_args=["-std=c++17"],
include_dirs = ['../include'])
setup(
name = 'hrm',
version = '1.0',
description = 'HRM python module',
ext_modules = [hrm]
)
Объект класса Stick храним в глобальной переменной
static std::shared_ptr<Stick> stick_shared
Далее создаем две структуры типа «PyMethodDef» и «PyModuleDef» и инициализируем модуль.
Для работы с USB стиком в Python создадим три функции:
В результате разработки прототипа получилось простое приложение, с помощью которого можно производить проверку работы датчиков в тренажерном зале. Следующим шагом будет реализация технологии работы с сервером приложений.
Для проведения эксперимента по реализации бизнес-идеи не потребовалось использовать большое количество ресурсов и кода. Код приложения специально сделан упрощенным и линейным в первую очередь для уменьшения количества ошибок и демонстрации принципов работы с ANT.
Как результат работы приведу простой алгоритм моих действий для выполнения поставленной задачи:
Всем удачи во всех начинаниях!