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


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


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


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


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

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

    Auriga
    Аурига — это люди

    Комментарии 3

      0
      Очень сомнительное изобретение, вернее это клон того, что уже давно придумали, сейчас каждый хорошие часы или комп для бега/велоспорта, нагрудным датчиком оснащены по типу такого lisoped.ua/product/datchik-serdechnogo-ritma-green-cycle-gc-h2-22943507
      Даже бойцы UFC их юзают. Сам на веле с таким катаюсь, профит хороший, но мешает дико, данные тоже точные, если они найдут способ как это мерять при помощи часов или какой то присоски, без пояса — то это явно будет прорыв, первый в очереди на покупку буду :)
        0
        Здесь главное — цена вопроса, тренажеры оснащенные дополнительными функциями стоят существенно дороже, а требования к оснащению зала с точки зрения конкурентных преимуществ высокие.
        0
        Мне кажется, что сейчас есть путаница в терминологии. Измерять пульс и сердечный ритм, это, как говориться, две большие разницы.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое