From f2b1383db7f28cfc7cf3fd01cb4c2ffacd9db576 Mon Sep 17 00:00:00 2001 From: mark9064 <30447455+mark9064@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:09:57 +0100 Subject: [PATCH 1/2] Background heartrate measurement Co-Authored-By: Patric Gruber --- src/CMakeLists.txt | 1 + .../heartrate/HeartRateController.cpp | 8 +- .../heartrate/HeartRateController.h | 6 +- src/components/heartrate/Ppg.cpp | 5 + src/components/heartrate/Ppg.h | 1 + src/components/settings/Settings.h | 20 +- src/displayapp/DisplayApp.cpp | 4 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/screens/HeartRate.cpp | 4 +- .../screens/settings/SettingHeartRate.cpp | 71 ++++++ .../screens/settings/SettingHeartRate.h | 44 ++++ src/displayapp/screens/settings/Settings.h | 11 +- src/heartratetask/HeartRateTask.cpp | 224 +++++++++++++----- src/heartratetask/HeartRateTask.h | 24 +- src/main.cpp | 6 +- 15 files changed, 343 insertions(+), 87 deletions(-) create mode 100644 src/displayapp/screens/settings/SettingHeartRate.cpp create mode 100644 src/displayapp/screens/settings/SettingHeartRate.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 78ff2d6a1b..86cf4eede6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -412,6 +412,7 @@ list(APPEND SOURCE_FILES displayapp/screens/settings/SettingWeatherFormat.cpp displayapp/screens/settings/SettingWakeUp.cpp displayapp/screens/settings/SettingDisplay.cpp + displayapp/screens/settings/SettingHeartRate.cpp displayapp/screens/settings/SettingSteps.cpp displayapp/screens/settings/SettingSetDateTime.cpp displayapp/screens/settings/SettingSetDate.cpp diff --git a/src/components/heartrate/HeartRateController.cpp b/src/components/heartrate/HeartRateController.cpp index e0d692729e..c365e8659a 100644 --- a/src/components/heartrate/HeartRateController.cpp +++ b/src/components/heartrate/HeartRateController.cpp @@ -12,17 +12,17 @@ void HeartRateController::Update(HeartRateController::States newState, uint8_t h } } -void HeartRateController::Start() { +void HeartRateController::Enable() { if (task != nullptr) { state = States::NotEnoughData; - task->PushMessage(Pinetime::Applications::HeartRateTask::Messages::StartMeasurement); + task->PushMessage(Pinetime::Applications::HeartRateTask::Messages::Enable); } } -void HeartRateController::Stop() { +void HeartRateController::Disable() { if (task != nullptr) { state = States::Stopped; - task->PushMessage(Pinetime::Applications::HeartRateTask::Messages::StopMeasurement); + task->PushMessage(Pinetime::Applications::HeartRateTask::Messages::Disable); } } diff --git a/src/components/heartrate/HeartRateController.h b/src/components/heartrate/HeartRateController.h index f66c79f830..5bd3a8ef54 100644 --- a/src/components/heartrate/HeartRateController.h +++ b/src/components/heartrate/HeartRateController.h @@ -15,11 +15,11 @@ namespace Pinetime { namespace Controllers { class HeartRateController { public: - enum class States { Stopped, NotEnoughData, NoTouch, Running }; + enum class States : uint8_t { Stopped, NotEnoughData, NoTouch, Running }; HeartRateController() = default; - void Start(); - void Stop(); + void Enable(); + void Disable(); void Update(States newState, uint8_t heartRate); void SetHeartRateTask(Applications::HeartRateTask* task); diff --git a/src/components/heartrate/Ppg.cpp b/src/components/heartrate/Ppg.cpp index efbed852f5..25be6237d2 100644 --- a/src/components/heartrate/Ppg.cpp +++ b/src/components/heartrate/Ppg.cpp @@ -155,8 +155,12 @@ int8_t Ppg::Preprocess(uint16_t hrs, uint16_t als) { int Ppg::HeartRate() { if (dataIndex < dataLength) { + if (!enoughData) { + return -2; + } return 0; } + enoughData = true; int hr = 0; hr = ProcessHeartRate(resetSpectralAvg); resetSpectralAvg = false; @@ -171,6 +175,7 @@ int Ppg::HeartRate() { void Ppg::Reset(bool resetDaqBuffer) { if (resetDaqBuffer) { dataIndex = 0; + enoughData = false; } avgIndex = 0; dataAverage.fill(0.0f); diff --git a/src/components/heartrate/Ppg.h b/src/components/heartrate/Ppg.h index 373e7985c5..7893538207 100644 --- a/src/components/heartrate/Ppg.h +++ b/src/components/heartrate/Ppg.h @@ -71,6 +71,7 @@ namespace Pinetime { uint16_t dataIndex = 0; float peakLocation; bool resetSpectralAvg = true; + bool enoughData = false; int ProcessHeartRate(bool init); float HeartRateAverage(float hr); diff --git a/src/components/settings/Settings.h b/src/components/settings/Settings.h index 093a3ac6ef..9133d3fea1 100644 --- a/src/components/settings/Settings.h +++ b/src/components/settings/Settings.h @@ -1,6 +1,8 @@ #pragma once #include #include +#include +#include #include "components/brightness/BrightnessController.h" #include "components/fs/FS.h" #include "displayapp/apps/Apps.h" @@ -334,10 +336,25 @@ namespace Pinetime { return (settings.dfuAndFsEnabledOnBoot ? DfuAndFsMode::Enabled : DfuAndFsMode::Disabled); }; + std::optional GetHeartRateBackgroundMeasurementInterval() const { + if (settings.heartRateBackgroundPeriod == std::numeric_limits::max()) { + return std::nullopt; + } + return settings.heartRateBackgroundPeriod; + } + + void SetHeartRateBackgroundMeasurementInterval(std::optional newIntervalInSeconds) { + newIntervalInSeconds = newIntervalInSeconds.value_or(std::numeric_limits::max()); + if (newIntervalInSeconds != settings.heartRateBackgroundPeriod) { + settingsChanged = true; + } + settings.heartRateBackgroundPeriod = newIntervalInSeconds.value(); + } + private: Pinetime::Controllers::FS& fs; - static constexpr uint32_t settingsVersion = 0x0009; + static constexpr uint32_t settingsVersion = 0x000a; struct SettingsData { uint32_t version = settingsVersion; @@ -365,6 +382,7 @@ namespace Pinetime { Controllers::BrightnessController::Levels brightLevel = Controllers::BrightnessController::Levels::Medium; bool dfuAndFsEnabledOnBoot = false; + uint16_t heartRateBackgroundPeriod = std::numeric_limits::max(); // Disabled by default }; SettingsData settings; diff --git a/src/displayapp/DisplayApp.cpp b/src/displayapp/DisplayApp.cpp index 5c50e85f71..f3e6af2d77 100644 --- a/src/displayapp/DisplayApp.cpp +++ b/src/displayapp/DisplayApp.cpp @@ -48,6 +48,7 @@ #include "displayapp/screens/settings/SettingSteps.h" #include "displayapp/screens/settings/SettingSetDateTime.h" #include "displayapp/screens/settings/SettingChimes.h" +#include "displayapp/screens/settings/SettingHeartRate.h" #include "displayapp/screens/settings/SettingShakeThreshold.h" #include "displayapp/screens/settings/SettingBluetooth.h" #include "displayapp/screens/settings/SettingOTA.h" @@ -603,6 +604,9 @@ void DisplayApp::LoadScreen(Apps app, DisplayApp::FullRefreshDirections directio case Apps::SettingWakeUp: currentScreen = std::make_unique(settingsController); break; + case Apps::SettingHeartRate: + currentScreen = std::make_unique(settingsController); + break; case Apps::SettingDisplay: currentScreen = std::make_unique(settingsController); break; diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 0e94d32a9e..d440b598d1 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -36,6 +36,7 @@ namespace Pinetime { SettingWatchFace, SettingTimeFormat, SettingWeatherFormat, + SettingHeartRate, SettingDisplay, SettingWakeUp, SettingSteps, diff --git a/src/displayapp/screens/HeartRate.cpp b/src/displayapp/screens/HeartRate.cpp index 1a84d34928..14c873e201 100644 --- a/src/displayapp/screens/HeartRate.cpp +++ b/src/displayapp/screens/HeartRate.cpp @@ -98,12 +98,12 @@ void HeartRate::Refresh() { void HeartRate::OnStartStopEvent(lv_event_t event) { if (event == LV_EVENT_CLICKED) { if (heartRateController.State() == Controllers::HeartRateController::States::Stopped) { - heartRateController.Start(); + heartRateController.Enable(); UpdateStartStopButton(heartRateController.State() != Controllers::HeartRateController::States::Stopped); wakeLock.Lock(); lv_obj_set_style_local_text_color(label_hr, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::highlight); } else { - heartRateController.Stop(); + heartRateController.Disable(); UpdateStartStopButton(heartRateController.State() != Controllers::HeartRateController::States::Stopped); wakeLock.Release(); lv_obj_set_style_local_text_color(label_hr, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); diff --git a/src/displayapp/screens/settings/SettingHeartRate.cpp b/src/displayapp/screens/settings/SettingHeartRate.cpp new file mode 100644 index 0000000000..a45dc835ae --- /dev/null +++ b/src/displayapp/screens/settings/SettingHeartRate.cpp @@ -0,0 +1,71 @@ +#include "displayapp/screens/settings/SettingHeartRate.h" +#include +#include "displayapp/screens/Styles.h" +#include "displayapp/screens/Symbols.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + void EventHandler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + screen->UpdateSelected(obj, event); + } +} + +SettingHeartRate::SettingHeartRate(Pinetime::Controllers::Settings& settingsController) : settingsController {settingsController} { + lv_obj_t* container = lv_cont_create(lv_scr_act(), nullptr); + + lv_obj_set_style_local_bg_opa(container, LV_CONT_PART_MAIN, LV_STATE_DEFAULT, LV_OPA_TRANSP); + lv_obj_set_style_local_pad_all(container, LV_CONT_PART_MAIN, LV_STATE_DEFAULT, 5); + lv_obj_set_style_local_pad_inner(container, LV_CONT_PART_MAIN, LV_STATE_DEFAULT, 5); + lv_obj_set_style_local_border_width(container, LV_CONT_PART_MAIN, LV_STATE_DEFAULT, 0); + + lv_obj_set_pos(container, 10, 60); + lv_obj_set_width(container, LV_HOR_RES - 20); + lv_obj_set_height(container, LV_VER_RES - 50); + lv_cont_set_layout(container, LV_LAYOUT_PRETTY_TOP); + + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(title, "Backg. Interval"); + lv_label_set_text(title, "Backg. Interval"); + lv_label_set_align(title, LV_LABEL_ALIGN_CENTER); + lv_obj_align(title, lv_scr_act(), LV_ALIGN_IN_TOP_MID, 10, 15); + + lv_obj_t* icon = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(icon, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_RED); + lv_label_set_text_static(icon, Symbols::heartBeat); + lv_label_set_align(icon, LV_LABEL_ALIGN_CENTER); + lv_obj_align(icon, title, LV_ALIGN_OUT_LEFT_MID, -10, 0); + + std::optional currentInterval = settingsController.GetHeartRateBackgroundMeasurementInterval(); + + for (std::size_t i = 0; i < options.size(); i++) { + cbOption[i] = lv_checkbox_create(container, nullptr); + lv_checkbox_set_text(cbOption[i], options[i].name); + cbOption[i]->user_data = this; + lv_obj_set_event_cb(cbOption[i], EventHandler); + SetRadioButtonStyle(cbOption[i]); + + if (options[i].intervalInSeconds == currentInterval) { + lv_checkbox_set_checked(cbOption[i], true); + } + } +} + +SettingHeartRate::~SettingHeartRate() { + lv_obj_clean(lv_scr_act()); + settingsController.SaveSettings(); +} + +void SettingHeartRate::UpdateSelected(lv_obj_t* object, lv_event_t event) { + if (event == LV_EVENT_CLICKED) { + for (std::size_t i = 0; i < options.size(); i++) { + if (object == cbOption[i]) { + lv_checkbox_set_checked(cbOption[i], true); + settingsController.SetHeartRateBackgroundMeasurementInterval(options[i].intervalInSeconds); + } else { + lv_checkbox_set_checked(cbOption[i], false); + } + } + } +} diff --git a/src/displayapp/screens/settings/SettingHeartRate.h b/src/displayapp/screens/settings/SettingHeartRate.h new file mode 100644 index 0000000000..736f2b10d7 --- /dev/null +++ b/src/displayapp/screens/settings/SettingHeartRate.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include + +#include "components/settings/Settings.h" +#include "displayapp/screens/Screen.h" + +namespace Pinetime { + + namespace Applications { + namespace Screens { + class SettingHeartRate : public Screen { + public: + explicit SettingHeartRate(Pinetime::Controllers::Settings& settings); + ~SettingHeartRate() override; + + void UpdateSelected(lv_obj_t* object, lv_event_t event); + + private: + struct Option { + std::optional intervalInSeconds; + const char* name; + }; + + Pinetime::Controllers::Settings& settingsController; + + static constexpr std::array options = {{ + {.intervalInSeconds = std::nullopt, .name = " Off"}, + {.intervalInSeconds = 0, .name = "Cont"}, + {.intervalInSeconds = 30, .name = " 30s"}, + {.intervalInSeconds = 60, .name = " 1m"}, + {.intervalInSeconds = 5 * 60, .name = " 5m"}, + {.intervalInSeconds = 10 * 60, .name = " 10m"}, + {.intervalInSeconds = 30 * 60, .name = " 30m"}, + }}; + + lv_obj_t* cbOption[options.size()]; + }; + } + } +} diff --git a/src/displayapp/screens/settings/Settings.h b/src/displayapp/screens/settings/Settings.h index 370e83f474..32ac3ca943 100644 --- a/src/displayapp/screens/settings/Settings.h +++ b/src/displayapp/screens/settings/Settings.h @@ -38,23 +38,18 @@ namespace Pinetime { {Symbols::home, "Watch face", Apps::SettingWatchFace}, {Symbols::shoe, "Steps", Apps::SettingSteps}, + {Symbols::heartBeat, "Heartrate", Apps::SettingHeartRate}, {Symbols::clock, "Date & Time", Apps::SettingSetDateTime}, {Symbols::cloudSunRain, "Weather", Apps::SettingWeatherFormat}, - {Symbols::batteryHalf, "Battery", Apps::BatteryInfo}, + {Symbols::batteryHalf, "Battery", Apps::BatteryInfo}, {Symbols::clock, "Chimes", Apps::SettingChimes}, {Symbols::tachometer, "Shake Calib.", Apps::SettingShakeThreshold}, {Symbols::check, "Firmware", Apps::FirmwareValidation}, - {Symbols::shieldAlt, "Over-the-air", Apps::SettingOTA}, + {Symbols::shieldAlt, "Over-the-air", Apps::SettingOTA}, {Symbols::bluetooth, "Bluetooth", Apps::SettingBluetooth}, {Symbols::list, "About", Apps::SysInfo}, - - // {Symbols::none, "None", Apps::None}, - // {Symbols::none, "None", Apps::None}, - // {Symbols::none, "None", Apps::None}, - // {Symbols::none, "None", Apps::None}, - }}; ScreenList screens; }; diff --git a/src/heartratetask/HeartRateTask.cpp b/src/heartratetask/HeartRateTask.cpp index 8a5a871b41..d23feeda98 100644 --- a/src/heartratetask/HeartRateTask.cpp +++ b/src/heartratetask/HeartRateTask.cpp @@ -1,12 +1,56 @@ #include "heartratetask/HeartRateTask.h" #include #include -#include using namespace Pinetime::Applications; -HeartRateTask::HeartRateTask(Drivers::Hrs3300& heartRateSensor, Controllers::HeartRateController& controller) - : heartRateSensor {heartRateSensor}, controller {controller} { +namespace { + constexpr TickType_t backgroundMeasurementTimeLimit = 30 * configTICK_RATE_HZ; +} + +std::optional HeartRateTask::BackgroundMeasurementInterval() const { + auto interval = settings.GetHeartRateBackgroundMeasurementInterval(); + if (!interval.has_value()) { + return std::nullopt; + } + return interval.value() * configTICK_RATE_HZ; +} + +bool HeartRateTask::BackgroundMeasurementNeeded() const { + auto backgroundPeriod = BackgroundMeasurementInterval(); + if (!backgroundPeriod.has_value()) { + return false; + } + return xTaskGetTickCount() - lastMeasurementTime >= backgroundPeriod.value(); +}; + +TickType_t HeartRateTask::CurrentTaskDelay() const { + auto backgroundPeriod = BackgroundMeasurementInterval(); + TickType_t currentTime = xTaskGetTickCount(); + switch (state) { + case States::Disabled: + return portMAX_DELAY; + case States::Waiting: + // Sleep until a new event if background measuring disabled + if (!backgroundPeriod.has_value()) { + return portMAX_DELAY; + } + // Sleep until the next background measurement + if (currentTime - lastMeasurementTime < backgroundPeriod.value()) { + return backgroundPeriod.value() - (currentTime - lastMeasurementTime); + } + // If one is due now, go straight away + return 0; + case States::BackgroundMeasuring: + case States::ForegroundMeasuring: + return Pinetime::Controllers::Ppg::deltaTms; + } +} + +HeartRateTask::HeartRateTask(Drivers::Hrs3300& heartRateSensor, + Controllers::HeartRateController& controller, + Controllers::Settings& settings) + : heartRateSensor {heartRateSensor}, controller {controller}, settings {settings} { } void HeartRateTask::Start() { @@ -24,79 +68,68 @@ void HeartRateTask::Process(void* instance) { } void HeartRateTask::Work() { - int lastBpm = 0; + // measurementStartTime is always initialised before use by StartMeasurement + // Need to initialise lastMeasurementTime so that the first background measurement happens at a reasonable time + lastMeasurementTime = xTaskGetTickCount(); + valueCurrentlyShown = false; + while (true) { + TickType_t delay = CurrentTaskDelay(); Messages msg; - uint32_t delay; - if (state == States::Running) { - if (measurementStarted) { - delay = ppg.deltaTms; - } else { - delay = 100; - } - } else { - delay = portMAX_DELAY; - } + States newState = state; - if (xQueueReceive(messageQueue, &msg, delay)) { + if (xQueueReceive(messageQueue, &msg, delay) == pdTRUE) { switch (msg) { case Messages::GoToSleep: - StopMeasurement(); - state = States::Idle; - break; - case Messages::WakeUp: - state = States::Running; - if (measurementStarted) { - lastBpm = 0; - StartMeasurement(); - } - break; - case Messages::StartMeasurement: - if (measurementStarted) { + // Ignore power state changes when disabled + if (state == States::Disabled) { break; } - lastBpm = 0; - StartMeasurement(); - measurementStarted = true; + // State is necessarily ForegroundMeasuring + // As previously screen was on and measurement is enabled + if (BackgroundMeasurementNeeded()) { + newState = States::BackgroundMeasuring; + } else { + newState = States::Waiting; + } break; - case Messages::StopMeasurement: - if (!measurementStarted) { + case Messages::WakeUp: + // Ignore power state changes when disabled + if (state == States::Disabled) { break; } - StopMeasurement(); - measurementStarted = false; + newState = States::ForegroundMeasuring; + break; + case Messages::Enable: + // Can only be enabled when the screen is on + // If this constraint is somehow violated, the unexpected state + // will self-resolve at the next screen on event + newState = States::ForegroundMeasuring; + valueCurrentlyShown = false; + break; + case Messages::Disable: + newState = States::Disabled; break; } } + if (newState == States::Waiting && BackgroundMeasurementNeeded()) { + newState = States::BackgroundMeasuring; + } else if (newState == States::BackgroundMeasuring && !BackgroundMeasurementNeeded()) { + newState = States::Waiting; + } - if (measurementStarted) { - auto sensorData = heartRateSensor.ReadHrsAls(); - int8_t ambient = ppg.Preprocess(sensorData.hrs, sensorData.als); - int bpm = ppg.HeartRate(); - - // If ambient light detected or a reset requested (bpm < 0) - if (ambient > 0) { - // Reset all DAQ buffers - ppg.Reset(true); - // Force state to NotEnoughData (below) - lastBpm = 0; - bpm = 0; - } else if (bpm < 0) { - // Reset all DAQ buffers except HRS buffer - ppg.Reset(false); - // Set HR to zero and update - bpm = 0; - controller.Update(Controllers::HeartRateController::States::Running, bpm); - } - - if (lastBpm == 0 && bpm == 0) { - controller.Update(Controllers::HeartRateController::States::NotEnoughData, bpm); - } + // Apply state transition (switch sensor on/off) + if ((newState == States::ForegroundMeasuring || newState == States::BackgroundMeasuring) && + (state == States::Waiting || state == States::Disabled)) { + StartMeasurement(); + } else if ((newState == States::Waiting || newState == States::Disabled) && + (state == States::ForegroundMeasuring || state == States::BackgroundMeasuring)) { + StopMeasurement(); + } + state = newState; - if (bpm != 0) { - lastBpm = bpm; - controller.Update(Controllers::HeartRateController::States::Running, lastBpm); - } + if (state == States::ForegroundMeasuring || state == States::BackgroundMeasuring) { + HandleSensorData(); } } } @@ -111,6 +144,8 @@ void HeartRateTask::StartMeasurement() { heartRateSensor.Enable(); ppg.Reset(true); vTaskDelay(100); + measurementSucceeded = false; + measurementStartTime = xTaskGetTickCount(); } void HeartRateTask::StopMeasurement() { @@ -118,3 +153,70 @@ void HeartRateTask::StopMeasurement() { ppg.Reset(true); vTaskDelay(100); } + +void HeartRateTask::HandleSensorData() { + auto sensorData = heartRateSensor.ReadHrsAls(); + int8_t ambient = ppg.Preprocess(sensorData.hrs, sensorData.als); + int bpm = ppg.HeartRate(); + + // Ambient light detected + if (ambient > 0) { + // Reset all DAQ buffers + ppg.Reset(true); + controller.Update(Controllers::HeartRateController::States::NotEnoughData, bpm); + bpm = 0; + valueCurrentlyShown = false; + } + + // Reset requested, or not enough data + if (bpm == -1) { + // Reset all DAQ buffers except HRS buffer + ppg.Reset(false); + // Set HR to zero and update + bpm = 0; + controller.Update(Controllers::HeartRateController::States::Running, bpm); + valueCurrentlyShown = false; + } else if (bpm == -2) { + // Not enough data + bpm = 0; + if (!valueCurrentlyShown) { + controller.Update(Controllers::HeartRateController::States::NotEnoughData, bpm); + } + } + + if (bpm != 0) { + // Maintain constant frequency acquisition in background mode + // If the last measurement time is set to the start time, then the next measurement + // will start exactly one background period after this one + // Avoid this if measurement exceeded the time limit (which happens with background intervals <= limit) + if (state == States::BackgroundMeasuring && xTaskGetTickCount() - measurementStartTime < backgroundMeasurementTimeLimit) { + lastMeasurementTime = measurementStartTime; + } else { + lastMeasurementTime = xTaskGetTickCount(); + } + measurementSucceeded = true; + valueCurrentlyShown = true; + controller.Update(Controllers::HeartRateController::States::Running, bpm); + return; + } + // If been measuring for longer than the time limit, set the last measurement time + // This allows giving up on background measurement after a while + // and also means that background measurement won't begin immediately after + // an unsuccessful long foreground measurement + if (xTaskGetTickCount() - measurementStartTime > backgroundMeasurementTimeLimit) { + // When measuring, propagate failure if no value within the time limit + // Prevents stale heart rates from being displayed for >1 background period + // Or more than the time limit after switching to screen on (where the last background measurement was successful) + // Note: Once a successful measurement is recorded in screen on it will never be cleared + // without some other state change e.g. ambient light reset + if (!measurementSucceeded) { + controller.Update(Controllers::HeartRateController::States::Running, 0); + valueCurrentlyShown = false; + } + if (state == States::BackgroundMeasuring) { + lastMeasurementTime = xTaskGetTickCount() - backgroundMeasurementTimeLimit; + } else { + lastMeasurementTime = xTaskGetTickCount(); + } + } +} diff --git a/src/heartratetask/HeartRateTask.h b/src/heartratetask/HeartRateTask.h index 5bbfb9fb3e..9478d0d4da 100644 --- a/src/heartratetask/HeartRateTask.h +++ b/src/heartratetask/HeartRateTask.h @@ -1,8 +1,11 @@ #pragma once #include +#include +#include #include #include #include +#include "components/settings/Settings.h" namespace Pinetime { namespace Drivers { @@ -16,26 +19,37 @@ namespace Pinetime { namespace Applications { class HeartRateTask { public: - enum class Messages : uint8_t { GoToSleep, WakeUp, StartMeasurement, StopMeasurement }; - enum class States { Idle, Running }; + enum class Messages : uint8_t { GoToSleep, WakeUp, Enable, Disable }; - explicit HeartRateTask(Drivers::Hrs3300& heartRateSensor, Controllers::HeartRateController& controller); + explicit HeartRateTask(Drivers::Hrs3300& heartRateSensor, + Controllers::HeartRateController& controller, + Controllers::Settings& settings); void Start(); void Work(); void PushMessage(Messages msg); private: + enum class States : uint8_t { Disabled, Waiting, BackgroundMeasuring, ForegroundMeasuring }; static void Process(void* instance); + void HandleSensorData(); void StartMeasurement(); void StopMeasurement(); + [[nodiscard]] bool BackgroundMeasurementNeeded() const; + [[nodiscard]] std::optional BackgroundMeasurementInterval() const; + [[nodiscard]] TickType_t CurrentTaskDelay() const; + TaskHandle_t taskHandle; QueueHandle_t messageQueue; - States state = States::Running; + bool valueCurrentlyShown; + bool measurementSucceeded; + States state = States::Disabled; Drivers::Hrs3300& heartRateSensor; Controllers::HeartRateController& controller; + Controllers::Settings& settings; Controllers::Ppg ppg; - bool measurementStarted = false; + TickType_t lastMeasurementTime; + TickType_t measurementStartTime; }; } diff --git a/src/main.cpp b/src/main.cpp index 24f13caddd..9f412c5b87 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -93,13 +93,13 @@ TimerHandle_t debounceChargeTimer; Pinetime::Controllers::Battery batteryController; Pinetime::Controllers::Ble bleController; -Pinetime::Controllers::HeartRateController heartRateController; -Pinetime::Applications::HeartRateTask heartRateApp(heartRateSensor, heartRateController); - Pinetime::Controllers::FS fs {spiNorFlash}; Pinetime::Controllers::Settings settingsController {fs}; Pinetime::Controllers::MotorController motorController {}; +Pinetime::Controllers::HeartRateController heartRateController; +Pinetime::Applications::HeartRateTask heartRateApp(heartRateSensor, heartRateController, settingsController); + Pinetime::Controllers::DateTime dateTimeController {settingsController}; Pinetime::Drivers::Watchdog watchdog; Pinetime::Controllers::NotificationManager notificationManager; From 21f9dfb4489cfc781a77cc547ea46d3bcaf0d774 Mon Sep 17 00:00:00 2001 From: mark9064 <30447455+mark9064@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:03:36 +0100 Subject: [PATCH 2/2] Isochronous PPG sampling --- src/heartratetask/HeartRateTask.cpp | 49 +++++++++++++++++++++++++++-- src/heartratetask/HeartRateTask.h | 3 +- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/heartratetask/HeartRateTask.cpp b/src/heartratetask/HeartRateTask.cpp index d23feeda98..e9bc11a30a 100644 --- a/src/heartratetask/HeartRateTask.cpp +++ b/src/heartratetask/HeartRateTask.cpp @@ -1,11 +1,18 @@ #include "heartratetask/HeartRateTask.h" #include #include +#include using namespace Pinetime::Applications; namespace { constexpr TickType_t backgroundMeasurementTimeLimit = 30 * configTICK_RATE_HZ; + + // dividend + (divisor / 2) must be less than the max T value + template + constexpr T RoundedDiv(T dividend, T divisor) { + return (dividend + (divisor / static_cast(2))) / divisor; + } } std::optional HeartRateTask::BackgroundMeasurementInterval() const { @@ -24,9 +31,40 @@ bool HeartRateTask::BackgroundMeasurementNeeded() const { return xTaskGetTickCount() - lastMeasurementTime >= backgroundPeriod.value(); }; -TickType_t HeartRateTask::CurrentTaskDelay() const { +TickType_t HeartRateTask::CurrentTaskDelay() { auto backgroundPeriod = BackgroundMeasurementInterval(); TickType_t currentTime = xTaskGetTickCount(); + auto CalculateSleepTicks = [&]() { + TickType_t elapsed = currentTime - measurementStartTime; + + // Target system tick is the elapsed sensor ticks multiplied by the sensor tick duration (i.e. the elapsed time) + // multiplied by the system tick rate + // Since the sensor tick duration is a whole number of milliseconds, we compute in milliseconds and then divide by 1000 + // To avoid the number of milliseconds overflowing a u32, we take a factor of 2 out of the divisor and dividend + // (1024 / 2) * 65536 * 100 = 3355443200 which is less than 2^32 + + // Guard against future tick rate changes + static_assert((configTICK_RATE_HZ / 2ULL) * (std::numeric_limits::max() + 1ULL) * + static_cast((Pinetime::Controllers::Ppg::deltaTms)) < + std::numeric_limits::max(), + "Overflow"); + TickType_t elapsedTarget = RoundedDiv(static_cast(configTICK_RATE_HZ / 2) * (static_cast(count) + 1U) * + static_cast((Pinetime::Controllers::Ppg::deltaTms)), + static_cast(1000 / 2)); + + // On count overflow, reset both count and start time + // Count is 16bit to avoid overflow in elapsedTarget + // Count overflows every 100ms * u16 max = ~2 hours, much more often than the tick count (~48 days) + // So no need to check for tick count overflow + if (count == std::numeric_limits::max()) { + count = 0; + measurementStartTime = currentTime; + } + if (elapsedTarget > elapsed) { + return elapsedTarget - elapsed; + } + return static_cast(0); + }; switch (state) { case States::Disabled: return portMAX_DELAY; @@ -43,8 +81,11 @@ TickType_t HeartRateTask::CurrentTaskDelay() const { return 0; case States::BackgroundMeasuring: case States::ForegroundMeasuring: - return Pinetime::Controllers::Ppg::deltaTms; + return CalculateSleepTicks(); } + // Needed to keep dumb compiler happy, this is unreachable + // Any new additions to States will cause the above switch statement not to compile, so this is safe + return portMAX_DELAY; } HeartRateTask::HeartRateTask(Drivers::Hrs3300& heartRateSensor, @@ -57,7 +98,7 @@ void HeartRateTask::Start() { messageQueue = xQueueCreate(10, 1); controller.SetHeartRateTask(this); - if (pdPASS != xTaskCreate(HeartRateTask::Process, "Heartrate", 500, this, 0, &taskHandle)) { + if (pdPASS != xTaskCreate(HeartRateTask::Process, "Heartrate", 500, this, 1, &taskHandle)) { APP_ERROR_HANDLER(NRF_ERROR_NO_MEM); } } @@ -130,6 +171,7 @@ void HeartRateTask::Work() { if (state == States::ForegroundMeasuring || state == States::BackgroundMeasuring) { HandleSensorData(); + count++; } } } @@ -145,6 +187,7 @@ void HeartRateTask::StartMeasurement() { ppg.Reset(true); vTaskDelay(100); measurementSucceeded = false; + count = 0; measurementStartTime = xTaskGetTickCount(); } diff --git a/src/heartratetask/HeartRateTask.h b/src/heartratetask/HeartRateTask.h index 9478d0d4da..e00bc4d638 100644 --- a/src/heartratetask/HeartRateTask.h +++ b/src/heartratetask/HeartRateTask.h @@ -37,13 +37,14 @@ namespace Pinetime { [[nodiscard]] bool BackgroundMeasurementNeeded() const; [[nodiscard]] std::optional BackgroundMeasurementInterval() const; - [[nodiscard]] TickType_t CurrentTaskDelay() const; + TickType_t CurrentTaskDelay(); TaskHandle_t taskHandle; QueueHandle_t messageQueue; bool valueCurrentlyShown; bool measurementSucceeded; States state = States::Disabled; + uint16_t count; Drivers::Hrs3300& heartRateSensor; Controllers::HeartRateController& controller; Controllers::Settings& settings;