Как стать автором
Обновить

Прототип на «коленке»: cоздание приложения для мониторинга датчиков сердечного ритма в спортивном зале

Время на прочтение13 мин
Количество просмотров3.3K


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


В результате решили для начала создать, как говорится, «на коленке», прототип устройства, собирающего данные с пульсометров – датчиков сердечного ритма. По результатам работы я решил написать статью для обмена опытом с сообществом читателей, а еще для повышения собственного уровня в практике написания статей. В этой статье мы проследуем поэтапно от идеи до прототипа программы.


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


Основным лейтмотивом реализации проекта служит идея совмещения низкоуровневой разработки программы управления устройством на языке 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 датчиком


Режим работы протокола ANT для HRM происходит в широковещательном режиме (Broadcast data) обмена данными по каналу между ведущим (master) – HRM датчиком и ведомым (slave) – USB стиком. Такой режим используется в случае, когда потеря данных не критична.


На аппаратном уровне ведомый осуществляет поиск ведущего в сети и пытается установить соединение, используя следующие характеристики: ключ сети, частоту, период опроса. Если ведущий найден, то устанавливается односторонний канал от ведущего к ведомому, приходят сообщения в виде байтовых посылок с полезной нагрузкой 8 байт на одно сообщение.


На диаграмме показан процесс установления соединения. Здесь Host – управляющий компьютер, USB_stick – приемопередатчик (ведомое устройство), HRM – нагрудный датчик (ведущее устройство)



Последовательность действий:


  • Сброс устройства в первоначальное состояние
    • Настройка соединения
    • Активация канала
    • Периодическое чтение буфера для получения данных

Код приложения будем создавать в объектно-ориентированной парадигме, поэтому первым шагом определим список объектов:


  • Device – обеспечивает соединение с драйвером операционной системы, работающим с USB приемо-передатчиком;
    • Stick – реализует взаимодействие по протоколу ANT.

Список состояний, в которых могут находится объекты:


  • Device: подключен / не подключен;
  • Stick: подключен / не подключен / неопределенное состояние / инициализирован / не инициализирован.

Список методов объектов, изменяющих состояние объектов:


  • Device: подключить / отключить / отправить данные в устройство / получить данные из устройства;
  • Stick: инициализировать / установить соединение / отправить сообщение / обработать сообщение / выполнить команду.

По результатам анализа взаимодействия и выбора объектов для реализации построим диаграмму классов. Здесь 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;
}

Структура ExtendedMessage, чтение расширенных сообщений.


Согласно алгоритму работы 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;
}

Модуль hrm


Для создания в 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 создадим три функции:


  • attach – для подключения файла символьного устройства;
    • init – для инициализации соединения;
    • set_callback – для установки функции обратного вызова обработки расширенных сообщения.

Теперь можно обобщить и сделать некоторые выводы


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


Для проведения эксперимента по реализации бизнес-идеи не потребовалось использовать большое количество ресурсов и кода. Код приложения специально сделан упрощенным и линейным в первую очередь для уменьшения количества ошибок и демонстрации принципов работы с ANT.


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


  1. Понять суть задачи, сформулировать цели, подготовить техническое задание.
  2. Выполнить поиск готовых проектов, разобраться с лицензиями. Найти документацию о протоколах и стандартах. Понять алгоритм работы устройства.
  3. Найти необходимое оборудование, исходя из цены, доступности и технических возможностей.
  4. Продумать архитектуру приложения, выбрать среду разработки.
  5. Реализовать код приложения, заранее продумать критерии, например такие:
    ◦ код прототипа сделать однопоточным;
    ◦ использовать последний стандарт C++ 17 и стандартную библиотеку, использовать RAII;
    ◦ разделить интерфейс и реализацию семантически: методы, относящиеся к интерфейсу, называть в стиле «CamelCase», а имена методов, отвечающих за реализацию, в стиле «under_score», поля класса – в стиле «underscore»;
    ◦ логирование.
  6. Протестировать проект.

Всем удачи во всех начинаниях!

Теги:
Хабы:
+7
Комментарии3

Публикации

Информация

Сайт
hr.auriga.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия