回到首页 返回首页
回到顶部 回到顶部
返回上一页 返回上一页
best-icon

#宅家#FireBeetle 制作心率显示器 简单

头像 zoologist 2022.05.20 957 2

当前最流行的是一种称为“HIIT“的运动方式,它是High-Intensity Intervals Training的缩写,中文翻译为“高强度间歇训练”。简单的说,这种训练模式是“高强度运动+间歇性休息”相间的循环模式进行,达到短时间高效燃脂的效果。例如,快跑200m,步行100m,重复几次。动感单车快踏30秒,慢踏30秒。重复几次-跑步机上斜加速45秒,平路慢跑45秒,重复几次,上述都是典型的 HIIT 运动。

HIIT运动方式的几大好处:

1.高效。10min HIIT的效果,等同30min慢跑,甚至更好,因为训练之后还会持续燃脂;

2.避免损耗肌肉。传统的中低强度有氧运动训练,做得太久(30min以上)会使肌肉分解,不利于增肌。但HIIT时间短,对肌肉刺激大,能够维持肌肉质量;

3.提升运动表现HIIT能够提高心肺耐力,还能够提升你的运动速度和爆发力,而且做起来愉悦感更强,容易坚持;

4.改善慢性疾病改善糖尿和心血管病的效果,比传统低强度带氧运动好得多。

从上面可以看到,足够的运动强度是实现 HIIT 运动的重要手段,而评估运动强度的一个很有效的参数是心率。HIIT常见的评估指标为:运动后1分钟后达到最高值;运动后2-3分钟恢复稳定;男生一分钟之内,女生1-2分钟恢复到一般心率;部分身体欠佳的2-3分钟;运动时心率要控制在最大心率的84%以上;中间间歇时心率不要低于70%。

为了更好的进行训练,我们需要一个能够显示当前心率同时显示心率变化趋势的设备,这样便于使用者进行评估。为了采集心率经过比较,最终选择的是国产的迈金(Magene) H303心率带。这款心率带除了IP67级别防水,续航1000小时,之外比较有特色的是支持ANT+和蓝牙双协议。这两个协议涵盖了市面上的大部分接收端。此外还可以同时连接ANT+ 和蓝牙接收器,更重要的是这款心率带价格远低于同等功能的佳明等同品牌。

project-image

这次设计使用DFRobot 出品的 FireBeetle ESP32 作为接收端,同时为了更好的展示心率以及变化趋势,选了一款7寸液晶串口屏幕。为了方便移动,这次还特地设计了供电部分,使用一节18650电池提供电力,同时选用了一个移动电源板。硬件部分元件列表:

元件以及型号

用途以及优点

中显 SDWe070T06 液晶屏幕用于显示心率和心率变化趋势,串口通讯,方便控制2A 5V充放电一体模块负责整体的供电18560电池提供工作所需电力,通常18650容量在2800mah,可供设备工作超过3小时FireBeetle ESP32 开发板主控,同心率带进行通信获取心率数值亚克力外壳为整体设计提供外壳

 

设计的电路图如下:

project-image

左上角是一个假负载,因为这次使用的充放电模块当负载小于100ma时会自动关机,所以预留这个电路用于拉出足够的负载避免充放电模块自动关机,但是实践中发现工作时整体消耗会大于100ma(实际测试600ma左右),因此这部分并没有作用,实际安装时,PCB上也没有进行焊接。

project-image

这次选用下面的模块实现电源管理功能。它的核心是 IP5306 芯片,这颗芯片是为充电宝产品设计的,集成了升压转换器、锂电池充电管理、电池电量指示功能,这些也正是我们设计所需的电源管理功能。从电路上可以看出,模块从左至右有 VIN(5V用于电池充电)、GND(地)、BAT(从电池正极取电)、5Vout(升压输出)几个引脚。此外,模块上有4个LED 用于反映当前电池电力情况。图中的 R2/R4构成了一个分压电路,连接到 FireBeetle 的 IO15 Pin,可以通过 ADC 监视当前电池的电压,如果有需要可以在界面上显示电池情况。U3 是一个船型开关,用于切断屏幕和FireBeetle的供电。断开之后仍然可以通过USB TypeA 公头为电池充电。

project-image

此外就是FireBeetle 和SDWe070T06接口部分。SDWe070T06 是串口屏,通过串口和外部进行连接,这里我们连接到 FireBeetle 的 IO16、17引脚,对应着 ESP32 的 Serial2。

project-image

常见的液晶屏幕都是 SPI 或者 I2C 接口,需要显示的内容是以点阵的格式从主机传输到屏幕上的。因为数据量较大,因此对于接口速度有一定要求。这次选用的液晶屏是串口(TTL电平),传输速度有限,因此无法像其他屏幕一样将屏幕上的所有点阵信息传递给屏幕。相反的,串口屏传输的是“显示内容”而不是屏幕内容。例如,当前时间是 15:23:24,如果要在普通屏幕上显示,需要主控在内存缓冲区生成一帧内容,然后再通过SPI将这一帧的内容传递到屏幕上;而对于串口屏幕来说,只需传输15、23、24 这3字节即可。从这里也可以看出串口屏操作起来比SPI 的简单很多,对于SOC资源的依赖也少了很多。串口屏幕在使用之前需一个配置的过程,以屏幕显示时间为例。在配置过程中,我们需要设定在屏幕什么位置,以什么颜色,什么字体进行显示。之后生成一个配置文件,烧写到串口屏幕之后即可通过串口改变显示的时间。

这次的心率显示器对应的串口屏幕配置如下:一张带有“当前心率”字样的背景图,一个数据变量控件用于当前显示心率,还有一个实时曲线控件用于显示心率趋势。

project-image

界面中部显示“000”字样的控件就是变量控件,选中该控件后可以在属性页面配置控件的字体大小和颜色。

project-image

其中的变量存储地址用于区分不同变量,比如:屏幕上有多个数据变量控件,每个控件的对应的地址就会有所不同。图中数据变量地址是 0x0000,对应的如果要更改显示内容发送的命令是:

A5 5A 05 82 00 00 00 64

其中 A5 5A 是帧头,所有的命令都要以此数值作为起始;接下来的 05 是指令长度,指示后面的“82 00 00 00 11”长度是5字节;82:是写变量存储器指令;00 00 是上面提到的变量地址;最后的 00 64 是写入的数值,对应十进制100。上述指令的含义就是“给0x0000地址,赋值为 0x64”,屏幕收到数据后会根据配置文件自行解析处理,最终我们能在屏幕上数据变量对应位置看到 100。

为了显示心率的趋势,还是用了一个“实时曲线”的控件。该控件输入性如下:

project-image

其中的纵轴放大倍数是经过计算得到的,公式如下:

MUL_Y=(Ye-Ys)*256/(Vmax-Vmin)

其中 Ye 是实时曲线控件在屏幕上的Y轴最大值,这里Ye=479(SDWe070T06分辨率为 800*480, 479也就是Y的最大值); Ys是该控件的Y轴起始值,这里Ys=74;Vmax 是我们要显示的最大值,这里使用180;Vmax 是我们要显示的最小值,使用 40,就是说我们认为心率的波动范围在 40-180之间。将上述变量代入公式,最终计算出来的 MUL_Y=740;

绘制实时曲线的命令举例如下:

A5 5A 04 84 01 00 85

同样 A5 5A 是帧头;04 表示后续数据有4字节;84是曲线缓冲区写指令。对于这个屏幕,共有8个通道,可以同时在这个控件上绘制8个独立的曲线。01 表示0号通道,对应的04表示2号通道;最后的 00 85 就是我们期望绘制的曲线值0x85对应十进制为133。用户单片机通过0x84 指令,按照通道号将曲线数据发送给串口屏,当串口屏收到 0x84指令收,接收到的曲线数据总是总是靠曲线窗口右侧显示、之前的曲线会向左移动、超出窗口长度部分的曲线会移出。

最终 PCB 设计如下:

project-image

焊接完成后安装18650电池、充放电模块以及FireBeetle:

project-image

确定了硬件之后,即可着手进行代码编写。

首先介绍一下如何让串口屏幕显示的。前面介绍了界面上有2个控件,分别是数据变量和实时曲线。数据变量对应的命令是“A5 5A 05 82 00 00 00 64”,最后2个字节是要显示的数值;实时曲线对应的命令是“A5 5A 04 84 01 00 84”,同样的最后2个字节是要显示的数值。

对应在代码中,开头处定义了上述命令:

byte RateText[] = {0xA5, 0x5A, 0x05, 0x82, 0x00, 0x00, 0x00, 0x11};

byte RateChart[] = {0xA5, 0x5A, 0x04, 0x84, 0x01, 0x00, 0xE0};

通过蓝牙获得的心率带数据在pData[] 中,通过下面的方式赋值给对应的控件,然后从 Serial2 发送出去串口屏幕即可接收到:

    RateText[7] = pData[1];

    RateChart[6] = pData[1];

    for (int i = 0; i < sizeof(RateText); i++) {

      Serial2.write(RateText[i]);

    }

    delay(50);

    for (int i = 0; i < sizeof(RateChart); i++) {

      Serial2.write(RateChart[i]);

}

BLE采用了Client/Server (C/S) 架构来进行数据交互,C/S架构是一种非常常见的架构,在我们身边随处可见,比如我们经常用到的浏览器和服务器也是一种C/S架构,这其中浏览器是客户端client,服务器是服务端server。BLE与此类似,一般而言设备提供服务,因此设备是server,手机使用设备提供的服务,因此手机是client。比如蓝牙蓝牙心率带,它可以提供 “当前心率” 数据服务,因此是一个server,而手机则可以请求“体温”数据以显示在手机上,因此手机是一个client。这次的设计中,FireBeetle 是作为 Client 出现的,因此代码中能看到 Client 字样。

蓝牙部分,首先初始化 Ble  ,然后通过 NimBLEDevice::getScan() 函数监听等待 Server 的出现。如果有 Server 出现,通过回调函数进入 AdvertisedDeviceCallbacks() 函数中,在其中会检查Server 提供的服务是否有 UUID_SERVICE 。 UUID_SERVICE(0x180D)预先定义好的“心率数据服务”。Client 看到 Server 的这个服务之后就使用 connectToServer() 进行连接,连接后即可从蓝牙心率带取得需要的心率数据。最终将心率数值显示出来。

完整代码:

#include <NimBLEDevice.h>

 

#define UUID_SERVICE "180D"

#define UUID_CHARACTERISTIC "2A37"

#define INITIAL_HEART_RATE "0"

 

char heart_rate[4] = INITIAL_HEART_RATE;

 

byte RateText[] = {0xA5, 0x5A, 0x05, 0x82, 0x00, 0x00, 0x00, 0x11};

byte RateChart[] = {0xA5, 0x5A, 0x04, 0x84, 0x01, 0x00, 0xE0};

 

/* Bluetooth */

void scanEndedCB(NimBLEScanResults results);

 

static NimBLEAdvertisedDevice *advDevice;

 

static bool doConnect = false;

static uint32_t scanTime = 0; // 0 = scan forever

 

class ClientCallbacks : public NimBLEClientCallbacks {

    void onConnect(NimBLEClient *pClient) {

      Serial.println("Connected");

      pClient->updateConnParams(120, 120, 0, 60);

    }

 

    void onDisconnect(NimBLEClient *pClient) {

      Serial.print(pClient->getPeerAddress().toString().c_str());

      Serial.println(" Disconnected - Starting scan");

      NimBLEDevice::getScan()->start(scanTime, scanEndedCB);

    }

 

    /* Called when the peripheral requests a change to the connection

       parameters.

       Return true to accept and apply them or false to reject and keep

       the currently used parameters. Default will return true.

    */

    bool onConnParamsUpdateRequest(NimBLEClient *pClient,

                                   const ble_gap_upd_params *params) {

      if (params->itvl_min < 24) { // 1.25ms units

        return false;

      } else if (params->itvl_max > 40) { // 1.25ms units

        return false;

      } else if (params->latency > 2) { // Intervals allowed to skip

        return false;

      } else if (params->supervision_timeout > 100) { // 10ms units

        return false;

      }

 

      return true;

    }

};

 

/* Define a class to handle the callbacks when advertisements are received */

class AdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {

    void onResult(NimBLEAdvertisedDevice *advertisedDevice) {

      Serial.print("Advertised Device found: ");

      Serial.println(advertisedDevice->toString().c_str());

      if (advertisedDevice->isAdvertisingService(NimBLEUUID(UUID_SERVICE))) {

        Serial.println("Found Our Service");

        // Stop scan before connecting

        NimBLEDevice::getScan()->stop();

        // Save the device reference in a global for the client to use

        advDevice = advertisedDevice;

        // Ready to connect now

        doConnect = true;

      }

    }

};

 

/* Notification / Indication receiving handler callback */

void notifyCB(NimBLERemoteCharacteristic *pRemoteCharacteristic, uint8_t *pData,

              size_t length, bool isNotify) {

  if (length) {

    // 这里取得测量的心率值

    uint16_t heart_rate_measurement = pData[1];

    if (pData[0] & 1) {

      heart_rate_measurement += (pData[2] << 8);

    }

    snprintf(heart_rate, sizeof(heart_rate), "%3d", heart_rate_measurement);

 

    RateText[7] = pData[1];

    RateChart[6] = pData[1];

    for (int i = 0; i < sizeof(RateText); i++) {

      Serial2.write(RateText[i]);

    }

    delay(50);

    for (int i = 0; i < sizeof(RateChart); i++) {

      Serial2.write(RateChart[i]);

    }

  }

}

 

/* Callback to process the results of the last scan or restart it */

void scanEndedCB(NimBLEScanResults results) {

  Serial.println("Scan Ended");

}

 

/* Create a single global instance of the callback class to be used by all

   clients

*/

static ClientCallbacks clientCB;

 

/* Handles the provisioning of clients and connects / interfaces with the

   server

*/

bool connectToServer() {

  NimBLEClient *pClient = nullptr;

 

  // Check if we have a client we should reuse first

  if (NimBLEDevice::getClientListSize()) {

    /* Special case when we already know this device, we send false as the

       second argument in connect() to prevent refreshing the service

       database. This saves considerable time and power.

    */

    pClient = NimBLEDevice::getClientByPeerAddress(advDevice->getAddress());

    if (pClient) {

      if (!pClient->connect(advDevice, false)) {

        Serial.println("Reconnect failed");

        return false;

      }

      Serial.println("Reconnected client");

    } else {

      /* We don't already have a client that knows this device,

         we will check for a client that is disconnected that we can use.

      */

      pClient = NimBLEDevice::getDisconnectedClient();

    }

  }

 

  // No client to reuse? Create a new one.

  if (!pClient) {

    if (NimBLEDevice::getClientListSize() >= NIMBLE_MAX_CONNECTIONS) {

      Serial.println("Max clients reached. No more connections possible");

      return false;

    }

 

    pClient = NimBLEDevice::createClient();

 

    Serial.println("New client created");

 

    pClient->setClientCallbacks(&clientCB, false);

    pClient->setConnectionParams(12, 12, 0, 51);

    pClient->setConnectTimeout(5);

 

    if (!pClient->connect(advDevice)) {

      /* Created a client but failed to connect, don't need to keep it

         as it has no data

      */

      NimBLEDevice::deleteClient(pClient);

      Serial.println("Failed to connect, deleted client");

      return false;

    }

  }

 

  if (!pClient->isConnected()) {

    if (!pClient->connect(advDevice)) {

      Serial.println("Failed to connect");

      return false;

    }

  }

 

  Serial.print("Connected to: ");

  Serial.println(pClient->getPeerAddress().toString().c_str());

  Serial.print("RSSI: ");

  Serial.println(pClient->getRssi());

 

  /* Now we can read/write/subscribe the charateristics of the services we

     are interested in

  */

  NimBLERemoteService *pSvc = nullptr;

  NimBLERemoteCharacteristic *pChr = nullptr;

  NimBLERemoteDescriptor *pDsc = nullptr;

 

  pSvc = pClient->getService(UUID_SERVICE);

  if (pSvc) { // Make sure it's not null

    pChr = pSvc->getCharacteristic(UUID_CHARACTERISTIC);

  }

 

  if (pChr) { // Make sure it's not null

    if (pChr->canRead()) {

      Serial.print(pChr->getUUID().toString().c_str());

      Serial.print(" Value: ");

      Serial.println(pChr->readValue().c_str());

    }

 

    if (pChr->canNotify()) {

      if (!pChr->subscribe(true, notifyCB)) {

        // Disconnect if subscribe failed

        pClient->disconnect();

        return false;

      }

    } else if (pChr->canIndicate()) {

      if (!pChr->subscribe(false, notifyCB)) {

        // Disconnect if subscribe failed

        pClient->disconnect();

        return false;

      }

    }

  } else {

    Serial.println("Service not found.");

  }

 

  Serial.println("Done with this device!");

  return true;

}

 

void setup() {

  Serial.begin(115200);

  Serial.println("Starting NimBLE Client");

 

  Serial2.begin(115200);

 

  NimBLEDevice::init("");

 

  NimBLEScan *pScan = NimBLEDevice::getScan();

 

  pScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallbacks());

  pScan->setInterval(45);

  pScan->setWindow(15);

  pScan->setActiveScan(true);

  pScan->start(scanTime, scanEndedCB);

}

 

void loop() {

  // 等待连接

  while (!doConnect) {

    delay(1);

  }

 

  doConnect = false;

 

  // Found a device we want to connect to, do it now

  if (connectToServer()) {

    Serial.println("Success, scanning for more...");

  } else {

    Serial.println("Failed to connect, starting scan...");

  }

 

  NimBLEDevice::getScan()->start(scanTime, scanEndedCB);

}

为了便于使用,使用亚克力制作外壳,拐角处使用六面体作为连接

project-image

成品:

project-image

评论

user-avatar
  • 三春牛-创客

    三春牛-创客2023.02.17

    0
    • 三春牛-创客

      三春牛-创客2023.02.17

      厉害

      0