diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e4a354df64..97580d1141 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -403,6 +403,7 @@ list(APPEND SOURCE_FILES displayapp/widgets/PageIndicator.cpp displayapp/widgets/DotIndicator.cpp displayapp/widgets/StatusIcons.cpp + displayapp/screens/HeartRateZone.cpp ## Settings displayapp/screens/settings/QuickSettings.cpp diff --git a/src/components/heartrate/HeartRateController.cpp b/src/components/heartrate/HeartRateController.cpp index c365e8659a..62d78b5a74 100644 --- a/src/components/heartrate/HeartRateController.cpp +++ b/src/components/heartrate/HeartRateController.cpp @@ -4,14 +4,123 @@ using namespace Pinetime::Controllers; +HeartRateZoneSettings::HeartRateZoneSettings() { + version = 0; + adjustMsDelay = 300000; + exerciseMsTarget = 1800000; + age = 25; + maxHeartRate = maxHeartRateEstimate(age); + percentTarget = {50, 60, 70, 80, 90}; + bpmTarget = bpmZones(percentTarget, maxHeartRate); + allowCalibration = true; +}; + +HeartRateController::HeartRateController(Pinetime::Controllers::FS& fs) : fs {fs} { + LoadSettingsFromFile(); +}; + void HeartRateController::Update(HeartRateController::States newState, uint8_t heartRate) { this->state = newState; if (this->heartRate != heartRate) { + uint32_t ts = xTaskGetTickCount(); + uint32_t z; + zone = 0; + auto adjustMax = pdMS_TO_TICKS(this->zSettings.adjustMsDelay); + for (z = this->zSettings.bpmTarget.size() - 1; z < this->zSettings.bpmTarget.size(); --z) { + if (this->heartRate >= this->zSettings.bpmTarget[z]) { + uint32_t dt = ts - lastActiveTime; + currentActivity.zoneTime[z] += dt; + zone = z + 1; + // don't make increases unless this is consistantly higher than normal (zone 5 is max) + if (this->zSettings.allowCalibration && zone >= 5 && dt > adjustMax) { + this->zSettings.maxHeartRate = this->zSettings.maxHeartRate >= this->heartRate ? this->zSettings.maxHeartRate : this->heartRate; + this->zSettings.bpmTarget = bpmZones(this->zSettings.percentTarget, this->zSettings.maxHeartRate); + } + break; + } + } + lastActiveTime = ts; + this->heartRate = heartRate; + service->OnNewHeartRateValue(heartRate); } } +void HeartRateController::AdvanceDay() { + HeartRateZones convertedActivity {}; + auto ticksPerUnit = pdMS_TO_TICKS(2000); // 2s + + auto totalTime = currentActivity.totalTime(); + for (uint32_t i = 0; i < convertedActivity.zoneTime.size(); i++) { + convertedActivity.zoneTime[i] = (uint16_t)Utility::RoundedDiv((uint32_t)totalTime, (uint32_t)ticksPerUnit); + } + + activity[0] = convertedActivity; + activity++; +} + +void HeartRateController::SaveSettingsToFile() const { + lfs_dir systemDir; + if (fs.DirOpen("/.system", &systemDir) != LFS_ERR_OK) { + fs.DirCreate("/.system"); + } + fs.DirClose(&systemDir); + lfs_file_t heartRateZoneFile; + if (fs.FileOpen(&heartRateZoneFile, "/.system/hrzs.dat", LFS_O_WRONLY | LFS_O_CREAT) != LFS_ERR_OK) { + NRF_LOG_WARNING("[HeartRateController] Failed to open heart rate zone settings file for saving"); + return; + } + + fs.FileWrite(&heartRateZoneFile, reinterpret_cast(&(this->zSettings)), sizeof(this->zSettings)); + fs.FileClose(&heartRateZoneFile); + NRF_LOG_INFO("[HeartRateController] Saved heart rate zone settings with format version %u to file", this->zSettings.version); + + lfs_file_t zoneDataFile; + if (fs.FileOpen(&zoneDataFile, "/.system/hrz.dat", LFS_O_WRONLY | LFS_O_CREAT) != LFS_ERR_OK) { + NRF_LOG_WARNING("[HeartRateController] Failed to open heart rate zone data file for saving"); + return; + } + + // Version 1: Raw Data Dump to File + const int version = 0x1; + fs.FileWrite(&zoneDataFile, reinterpret_cast(&version), sizeof(int)); + // Raw Data + fs.FileWrite(&zoneDataFile, reinterpret_cast(&activity), sizeof(activity)); + fs.FileClose(&zoneDataFile); + NRF_LOG_INFO("[HeartRateController] Saved heart rate zone data with format version %u to file", this->zSettings.version); +} + +void HeartRateController::LoadSettingsFromFile() { + HeartRateZoneSettings HRZSettings; + lfs_file_t settingsFile; + + if (fs.FileOpen(&settingsFile, "/.system/hrzs.dat", LFS_O_RDONLY) != LFS_ERR_OK) { + + } else { + fs.FileRead(&settingsFile, reinterpret_cast(&HRZSettings), sizeof(HRZSettings)); + fs.FileClose(&settingsFile); + if (HRZSettings.version == zSettings.version) { + zSettings = HRZSettings; + } + } + + lfs_file_t dataFile; + + if (fs.FileOpen(&dataFile, "/.system/hrz.dat", LFS_O_RDONLY) != LFS_ERR_OK) { + + } else { + int version = 0; + decltype(activity) HRZdata; + fs.FileRead(&dataFile, reinterpret_cast(&version), sizeof(version)); + fs.FileRead(&dataFile, reinterpret_cast(&HRZdata), sizeof(HRZdata)); + fs.FileClose(&dataFile); + if (version == 0x1) { + activity = HRZdata; + } + } +} + void HeartRateController::Enable() { if (task != nullptr) { state = States::NotEnoughData; diff --git a/src/components/heartrate/HeartRateController.h b/src/components/heartrate/HeartRateController.h index 5bd3a8ef54..4ec1606c9d 100644 --- a/src/components/heartrate/HeartRateController.h +++ b/src/components/heartrate/HeartRateController.h @@ -2,6 +2,10 @@ #include #include +#include "utility/CircularBuffer.h" +#include "utility/Math.h" +#include "components/fs/FS.h" +#include namespace Pinetime { namespace Applications { @@ -13,11 +17,55 @@ namespace Pinetime { } namespace Controllers { + template + struct HeartRateZones { + // 1440 minutes in a day (11 bits), 86400 seconds (17 bits) + std::array zoneTime = {}; + + T totalTime() const { + return zoneTime[0] + zoneTime[1] + zoneTime[2] + zoneTime[3] + zoneTime[4]; + } + }; + + constexpr uint8_t maxHeartRateEstimate(uint8_t age) { + return 220 - age; + }; + + constexpr int16_t fixed_rounding(int16_t value, int16_t divisor) { + // true evil: we use >>'s signed behavior to propagate the leading 1 across all bits, eg: we have 0xffff or 0x0000 + int16_t signed_value = value >> 15u; + int16_t half_divisor = (divisor / 2); + return (value + half_divisor - (divisor & signed_value)) / divisor; // we replace the * by "1" with an & + } + + template + constexpr std::array bpmZones(std::array& percentages, uint8_t maxBeatsPerMinute) { + std::array targets {}; + const uint16_t bpm = maxBeatsPerMinute; + for (uint32_t i = 0; i < N; i++) { + targets[i++] = Utility::RoundedDiv((uint16_t)(percentages[i] * bpm), (uint16_t)100); + } + return targets; + }; + + struct HeartRateZoneSettings { + uint32_t version = 0; + uint32_t adjustMsDelay = 300000; // 5 minutes + uint32_t exerciseMsTarget = 1800000; // hour 3600'000 + uint8_t age = 25; + uint8_t maxHeartRate = 195; + std::array percentTarget = {50, 60, 70, 80, 90}; + std::array bpmTarget = {98, 117, 137, 156, 176}; + bool allowCalibration = true; + + HeartRateZoneSettings(); + }; + class HeartRateController { public: enum class States : uint8_t { Stopped, NotEnoughData, NoTouch, Running }; - HeartRateController() = default; + HeartRateController(Pinetime::Controllers::FS& fs); void Enable(); void Disable(); void Update(States newState, uint8_t heartRate); @@ -32,13 +80,38 @@ namespace Pinetime { return heartRate; } + uint8_t Zone() const { + return zone; + } + + HeartRateZones Activity() const { + return currentActivity; + } + + HeartRateZoneSettings hrzSettings() const { + return zSettings; + } + void SetService(Pinetime::Controllers::HeartRateService* service); + void AdvanceDay(); + void SaveSettingsToFile() const; + void LoadSettingsFromFile(); + private: Applications::HeartRateTask* task = nullptr; States state = States::Stopped; uint8_t heartRate = 0; + uint8_t restingHeartRate = 0; Pinetime::Controllers::HeartRateService* service = nullptr; + Pinetime::Controllers::FS& fs; + + HeartRateZoneSettings zSettings = {}; + uint32_t lastActiveTime = 0; + // Heart Rate Zone Storage + HeartRateZones currentActivity = {}; + Utility::CircularBuffer, 31> activity = {}; + uint8_t zone = 0; }; } } \ No newline at end of file diff --git a/src/displayapp/DisplayApp.cpp b/src/displayapp/DisplayApp.cpp index 84fa603622..708c46234d 100644 --- a/src/displayapp/DisplayApp.cpp +++ b/src/displayapp/DisplayApp.cpp @@ -31,6 +31,7 @@ #include "displayapp/screens/PassKey.h" #include "displayapp/screens/Error.h" #include "displayapp/screens/Calculator.h" +#include "displayapp/screens/HeartRateZone.h" #include "drivers/Cst816s.h" #include "drivers/St7789.h" diff --git a/src/displayapp/InfiniTimeTheme.h b/src/displayapp/InfiniTimeTheme.h index 0690b09912..3ce2ecb5fb 100644 --- a/src/displayapp/InfiniTimeTheme.h +++ b/src/displayapp/InfiniTimeTheme.h @@ -8,6 +8,7 @@ namespace Colors { static constexpr lv_color_t green = LV_COLOR_MAKE(0x0, 0xb0, 0x0); static constexpr lv_color_t blue = LV_COLOR_MAKE(0x0, 0x50, 0xff); static constexpr lv_color_t lightGray = LV_COLOR_MAKE(0xb0, 0xb0, 0xb0); + static constexpr lv_color_t heartRed = LV_COLOR_MAKE(0xce,0x1b,0x1b); static constexpr lv_color_t bg = LV_COLOR_MAKE(0x5d, 0x69, 0x7e); static constexpr lv_color_t bgAlt = LV_COLOR_MAKE(0x38, 0x38, 0x38); diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 25926edc40..4b51b6771f 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -7,6 +7,7 @@ #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" +#include "displayapp/screens/HeartRateZone.h" #include "displayapp/screens/ApplicationList.h" #include "displayapp/screens/WatchFaceDigital.h" #include "displayapp/screens/WatchFaceAnalog.h" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index d440b598d1..c9962a037e 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -22,6 +22,7 @@ namespace Pinetime { Paddle, Twos, HeartRate, + HeartRateZone, Navigation, StopWatch, Metronome, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 93196ed6a0..c999ccede2 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -6,6 +6,7 @@ else () set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Steps") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::HeartRate") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::HeartRateZone") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Music") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paint") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paddle") diff --git a/src/displayapp/screens/HeartRateZone.cpp b/src/displayapp/screens/HeartRateZone.cpp new file mode 100644 index 0000000000..0a9b2fe941 --- /dev/null +++ b/src/displayapp/screens/HeartRateZone.cpp @@ -0,0 +1,112 @@ +#include "displayapp/screens/HeartRateZone.h" +#include +#include + +#include "displayapp/DisplayApp.h" +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; + +HeartRateZone::HeartRateZone(Controllers::HeartRateController& heartRateController, System::SystemTask& systemTask) + : heartRateController {heartRateController}, wakeLock(systemTask) { + auto activity = heartRateController.Activity(); + auto settings = heartRateController.hrzSettings(); + uint32_t total = 0; + + auto hundreths_of_hour = pdMS_TO_TICKS(10 * 60 * 60); + auto exercise_target = pdMS_TO_TICKS(settings.exerciseMsTarget); + uint32_t offset = 25 * (zone_bar.size() + 2); + + lv_obj_t* screen = lv_scr_act(); + + title = lv_label_create(screen, nullptr); + lv_label_set_text_static(title, "BPM Breakdown"); + lv_obj_align(title, screen, LV_ALIGN_IN_TOP_MID, 0, 20); + + total_bar = lv_bar_create(screen, nullptr); + lv_obj_set_style_local_bg_opa(total_bar, LV_BAR_PART_BG, LV_STATE_DEFAULT, LV_OPA_0); + lv_obj_set_style_local_line_color(total_bar, LV_BAR_PART_BG, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_set_style_local_border_width(total_bar, LV_BAR_PART_BG, LV_STATE_DEFAULT, 2); + lv_obj_set_style_local_radius(total_bar, LV_BAR_PART_BG, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_line_color(total_bar, LV_BAR_PART_INDIC, LV_STATE_DEFAULT, LV_COLOR_NAVY); + + lv_obj_align(total_bar, screen, LV_ALIGN_IN_TOP_MID, 0, offset - (zone_bar.size() * 25)); + + total_label = lv_label_create(total_bar, nullptr); + lv_obj_align(total_label, total_bar, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_local_text_color(total_label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_CYAN); + + for (uint8_t i = 0; i < zone_bar.size(); i++) { + zone_bar[i] = lv_bar_create(screen, nullptr); + + total += activity.zoneTime[i]; + + lv_obj_set_style_local_bg_opa(zone_bar[i], LV_BAR_PART_BG, LV_STATE_DEFAULT, LV_OPA_0); + lv_obj_set_style_local_line_color(zone_bar[i], LV_BAR_PART_BG, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_set_style_local_border_width(zone_bar[i], LV_BAR_PART_BG, LV_STATE_DEFAULT, 2); + lv_obj_set_style_local_radius(zone_bar[i], LV_BAR_PART_BG, LV_STATE_DEFAULT, 0); + + lv_obj_set_size(zone_bar[i], 240, 20); + lv_obj_align(zone_bar[i], screen, LV_ALIGN_IN_TOP_MID, 0, offset - i * 25); + + label_time[i] = lv_label_create(zone_bar[i], nullptr); + lv_obj_align(label_time[i], zone_bar[i], LV_ALIGN_CENTER, 0, 0); + + lv_obj_set_style_local_text_color(label_time[i], LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_CYAN); + } + + lv_label_set_text_static(label_time[0], "Warm Up"); + lv_label_set_text_static(label_time[1], "Recovery"); + lv_label_set_text_static(label_time[2], "Aerobic"); + lv_label_set_text_static(label_time[3], "Threshold"); + lv_label_set_text_static(label_time[4], "Anaerobic"); + lv_label_set_text_static(total_label, "Goal"); + + lv_obj_set_style_local_line_color(zone_bar[0], LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::blue); + lv_obj_set_style_local_line_color(zone_bar[1], LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::green); + lv_obj_set_style_local_line_color(zone_bar[2], LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::orange); + lv_obj_set_style_local_line_color(zone_bar[3], LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::deepOrange); + lv_obj_set_style_local_line_color(zone_bar[4], LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::heartRed); + + auto bar_limit = total / hundreths_of_hour; + + for (uint8_t i = 0; i < zone_bar.size(); i++) { + uint32_t percent = activity.zoneTime[i] / hundreths_of_hour; + lv_bar_set_range(zone_bar[i], 0, bar_limit); + lv_bar_set_value(zone_bar[i], percent, LV_ANIM_OFF); + } + + lv_bar_set_range(total_bar, 0, exercise_target); + lv_bar_set_value(total_bar, total > exercise_target ? exercise_target : total, LV_ANIM_OFF); + + taskRefresh = lv_task_create(RefreshTaskCallback, 5000, LV_TASK_PRIO_MID, this); +} + +HeartRateZone::~HeartRateZone() { + lv_task_del(taskRefresh); + lv_obj_clean(lv_scr_act()); +} + +void HeartRateZone::Refresh() { + auto activity = heartRateController.Activity(); + auto settings = heartRateController.hrzSettings(); + uint32_t total = 0; + + auto hundreths_of_hour = pdMS_TO_TICKS(10 * 60 * 60); + auto exercise_target = pdMS_TO_TICKS(settings.exerciseMsTarget); + + for (uint8_t i = 0; i < zone_bar.size(); i++) { + total += activity.zoneTime[i]; + } + + auto bar_limit = total / hundreths_of_hour; + + for (uint8_t i = 0; i < zone_bar.size(); i++) { + uint32_t percent = activity.zoneTime[i] / hundreths_of_hour; + lv_bar_set_range(zone_bar[i], 0, bar_limit); + lv_bar_set_value(zone_bar[i], percent, LV_ANIM_OFF); + } + + lv_bar_set_range(total_bar, 0, exercise_target); + lv_bar_set_value(total_bar, total > exercise_target ? exercise_target : total, LV_ANIM_OFF); +} \ No newline at end of file diff --git a/src/displayapp/screens/HeartRateZone.h b/src/displayapp/screens/HeartRateZone.h new file mode 100644 index 0000000000..71c60133de --- /dev/null +++ b/src/displayapp/screens/HeartRateZone.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include "displayapp/screens/Screen.h" +#include "systemtask/SystemTask.h" +#include "systemtask/WakeLock.h" +#include "Symbols.h" +#include +#include + +namespace Pinetime { + namespace Controllers { + class HeartRateController; + } + + namespace Applications { + namespace Screens { + + class HeartRateZone : public Screen { + public: + HeartRateZone(Controllers::HeartRateController& HeartRateController, System::SystemTask& systemTask); + ~HeartRateZone() override; + + void Refresh() override; + + private: + Controllers::HeartRateController& heartRateController; + Pinetime::System::WakeLock wakeLock; + + std::array zone_bar = {}; + std::array label_time = {}; + lv_obj_t* total_bar = nullptr; + lv_obj_t* total_label = nullptr; + lv_obj_t* title = nullptr; + + lv_task_t* taskRefresh; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::HeartRateZone; + static constexpr const char* icon = Screens::Symbols::heartBeat; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::HeartRateZone(controllers.heartRateController, *controllers.systemTask); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/heartratetask/HeartRateTask.cpp b/src/heartratetask/HeartRateTask.cpp index fa352772f7..72a53d108e 100644 --- a/src/heartratetask/HeartRateTask.cpp +++ b/src/heartratetask/HeartRateTask.cpp @@ -1,6 +1,6 @@ #include "heartratetask/HeartRateTask.h" #include -#include + #include #include "utility/Math.h" diff --git a/src/heartratetask/HeartRateTask.h b/src/heartratetask/HeartRateTask.h index e00bc4d638..c25b1d787d 100644 --- a/src/heartratetask/HeartRateTask.h +++ b/src/heartratetask/HeartRateTask.h @@ -6,16 +6,13 @@ #include #include #include "components/settings/Settings.h" +#include namespace Pinetime { namespace Drivers { class Hrs3300; } - namespace Controllers { - class HeartRateController; - } - namespace Applications { class HeartRateTask { public: diff --git a/src/main.cpp b/src/main.cpp index d0ab3e4887..88d3330190 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -98,7 +98,7 @@ Pinetime::Controllers::FS fs {spiNorFlash}; Pinetime::Controllers::Settings settingsController {fs}; Pinetime::Controllers::MotorController motorController {}; -Pinetime::Controllers::HeartRateController heartRateController; +Pinetime::Controllers::HeartRateController heartRateController {fs}; Pinetime::Applications::HeartRateTask heartRateApp(heartRateSensor, heartRateController, settingsController); Pinetime::Controllers::DateTime dateTimeController {settingsController}; diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 56bf9273e1..adfce3a7f4 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -335,6 +335,7 @@ void SystemTask::Work() { case Messages::OnNewDay: motionSensor.ResetStepCounter(); motionController.AdvanceDay(); + heartRateController.AdvanceDay(); break; case Messages::OnNewHour: using Pinetime::Controllers::AlarmController;