Прототип на «коленке»: 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. Протестировать проект.

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

ссылка на оригинал статьи https://habr.com/ru/company/auriga/blog/526090/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *