From 508c47bc1bbab8dcecdf4acb2c480ba05ad86867 Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Mon, 10 Aug 2026 12:37:13 +0100 Subject: [PATCH 01/14] Add TapCounter app to validate the sealed-device dev/build pipeline Skeletal user app (button + on-screen counter) built and confirmed running in InfiniSim. Proves the scaffold -> register -> launch loop works before building ClimbLogger on top of the same pattern. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/screens/TapCounter.cpp | 45 ++++++++++++++++++++++ src/displayapp/screens/TapCounter.h | 54 +++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 src/displayapp/screens/TapCounter.cpp create mode 100644 src/displayapp/screens/TapCounter.h diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 25926edc40..ad276d3570 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -15,6 +15,7 @@ #include "displayapp/screens/WatchFacePineTimeStyle.h" #include "displayapp/screens/WatchFaceTerminal.h" #include "displayapp/screens/WatchFacePrideFlag.h" +#include "displayapp/screens/TapCounter.h" namespace Pinetime { namespace Applications { diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index d440b598d1..7cc4f8f0f6 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -29,6 +29,7 @@ namespace Pinetime { Calculator, Steps, Dice, + TapCounter, Weather, PassKey, QuickSettings, diff --git a/src/displayapp/screens/TapCounter.cpp b/src/displayapp/screens/TapCounter.cpp new file mode 100644 index 0000000000..0a37d9ee79 --- /dev/null +++ b/src/displayapp/screens/TapCounter.cpp @@ -0,0 +1,45 @@ +#include "displayapp/screens/TapCounter.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + // LVGL is a C library, so events arrive via a plain function pointer. + // The standard InfiniTime pattern: stash `this` in the widget's + // user_data, then bounce the event back into a member function. + void ButtonEventHandler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + if (event == LV_EVENT_CLICKED) { + screen->OnButtonClicked(); + } + } +} + +TapCounter::TapCounter() { + // The constructor builds the UI. lv_scr_act() is the active screen; + // every widget is parented to it (or to another widget). + + counterLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_fmt(counterLabel, "Count: %d", count); + lv_label_set_align(counterLabel, LV_LABEL_ALIGN_CENTER); + lv_obj_align(counterLabel, lv_scr_act(), LV_ALIGN_IN_TOP_MID, 0, 60); + + button = lv_btn_create(lv_scr_act(), nullptr); + button->user_data = this; + lv_obj_set_event_cb(button, ButtonEventHandler); + lv_obj_set_size(button, 200, 80); + lv_obj_align(button, lv_scr_act(), LV_ALIGN_IN_BOTTOM_MID, 0, -20); + + buttonLabel = lv_label_create(button, nullptr); + lv_label_set_text_static(buttonLabel, "Tap me"); +} + +TapCounter::~TapCounter() { + // Destructor must clean up everything LVGL created, otherwise the + // widgets would leak into whatever screen is shown next. + lv_obj_clean(lv_scr_act()); +} + +void TapCounter::OnButtonClicked() { + count++; + lv_label_set_text_fmt(counterLabel, "Count: %d", count); +} diff --git a/src/displayapp/screens/TapCounter.h b/src/displayapp/screens/TapCounter.h new file mode 100644 index 0000000000..3adc2188dd --- /dev/null +++ b/src/displayapp/screens/TapCounter.h @@ -0,0 +1,54 @@ +#pragma once + +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "displayapp/screens/Symbols.h" +#include + +namespace Pinetime { + namespace Applications { + namespace Screens { + + // A minimal user app: one button, one counter label. + // An instance is created when the app is opened from the launcher, + // and destroyed when you leave the app (state is NOT preserved). + class TapCounter : public Screen { + public: + TapCounter(); + ~TapCounter() override; + + // Called from the LVGL event callback when the button is clicked + void OnButtonClicked(); + + private: + int count = 0; + + lv_obj_t* counterLabel = nullptr; + lv_obj_t* button = nullptr; + lv_obj_t* buttonLabel = nullptr; + }; + } + + // AppTraits is how InfiniTime's build-time app registry learns about + // this app: its enum id, its launcher icon, and how to construct it. + // CreateAppDescriptions() in UserApps.h reads this at compile time for + // every app listed in ENABLE_USERAPPS. + template <> + struct AppTraits { + static constexpr Apps app = Apps::TapCounter; + // Any constant from displayapp/screens/Symbols.h works here + static constexpr const char* icon = Screens::Symbols::check; + + static Screens::Screen* Create(AppControllers& /*controllers*/) { + // If your app later needs the date, heart rate, settings, etc., + // pass references from `controllers` into your constructor here. + return new Screens::TapCounter(); + } + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + } + }; + } +} From b2cd08966cca32e3a6361391d381ec701472cc5b Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Mon, 10 Aug 2026 13:27:38 +0100 Subject: [PATCH 02/14] Scaffold ClimbLogger app (Roadmap Step 1) Placeholder screen only, registered via the same AppTraits pattern TapCounter proved: enum entry, header/cpp pair, included in UserApps.h. Confirms the build->register->launch loop for the real app name before Step 2 builds the selection UI. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/screens/ClimbLogger.cpp | 14 +++++++++ src/displayapp/screens/ClimbLogger.h | 42 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 src/displayapp/screens/ClimbLogger.cpp create mode 100644 src/displayapp/screens/ClimbLogger.h diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index ad276d3570..8f2415464a 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -16,6 +16,7 @@ #include "displayapp/screens/WatchFaceTerminal.h" #include "displayapp/screens/WatchFacePrideFlag.h" #include "displayapp/screens/TapCounter.h" +#include "displayapp/screens/ClimbLogger.h" namespace Pinetime { namespace Applications { diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 7cc4f8f0f6..ad628af6bc 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -30,6 +30,7 @@ namespace Pinetime { Steps, Dice, TapCounter, + ClimbLogger, Weather, PassKey, QuickSettings, diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp new file mode 100644 index 0000000000..0b71e1c0b0 --- /dev/null +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -0,0 +1,14 @@ +#include "displayapp/screens/ClimbLogger.h" + +using namespace Pinetime::Applications::Screens; + +ClimbLogger::ClimbLogger() { + label = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(label, "ClimbLogger"); + lv_label_set_align(label, LV_LABEL_ALIGN_CENTER); + lv_obj_align(label, lv_scr_act(), LV_ALIGN_CENTER, 0, 0); +} + +ClimbLogger::~ClimbLogger() { + lv_obj_clean(lv_scr_act()); +} diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h new file mode 100644 index 0000000000..ea4067910c --- /dev/null +++ b/src/displayapp/screens/ClimbLogger.h @@ -0,0 +1,42 @@ +#pragma once + +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "displayapp/screens/Symbols.h" +#include + +namespace Pinetime { + namespace Applications { + namespace Screens { + + // Step 1 scaffold: placeholder screen only, proves the app registers + // and launches. Selection UI (area/colour/grade, result, attempts) + // is built in Step 2. + class ClimbLogger : public Screen { + public: + ClimbLogger(); + ~ClimbLogger() override; + + private: + lv_obj_t* label = nullptr; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::ClimbLogger; + static constexpr const char* icon = Screens::Symbols::shoe; + + static Screens::Screen* Create(AppControllers& /*controllers*/) { + // Step 3+ will pass filesystem/motor controllers through here once + // the catalog/log files and haptic confirmation are wired up. + return new Screens::ClimbLogger(); + } + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + } + }; + } +} From 7c1fc0e19635a60b3b6e8ad3155ae703a0cc02f6 Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Mon, 10 Aug 2026 13:35:09 +0100 Subject: [PATCH 03/14] Switch ClimbLogger icon from shoe-prints to mountain FontAwesome5 has no climbing-specific glyph (that's a v6 addition), so picked the closest already-licensed icon in the bundled woff: 'mountain' (0xf6fc). Added its codepoint to fonts.json's jetbrains_mono_bold_20 range and defined Symbols::mountain. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/fonts/fonts.json | 2 +- src/displayapp/screens/ClimbLogger.h | 2 +- src/displayapp/screens/Symbols.h | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index 3221c2f171..99c7b7b61a 100644 --- a/src/displayapp/fonts/fonts.json +++ b/src/displayapp/fonts/fonts.json @@ -7,7 +7,7 @@ }, { "file": "FontAwesome5-Solid+Brands+Regular.woff", - "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed" + "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed, 0xf6fc" } ], "bpp": 1, diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h index ea4067910c..75b6f0c92a 100644 --- a/src/displayapp/screens/ClimbLogger.h +++ b/src/displayapp/screens/ClimbLogger.h @@ -26,7 +26,7 @@ namespace Pinetime { template <> struct AppTraits { static constexpr Apps app = Apps::ClimbLogger; - static constexpr const char* icon = Screens::Symbols::shoe; + static constexpr const char* icon = Screens::Symbols::mountain; static Screens::Screen* Create(AppControllers& /*controllers*/) { // Step 3+ will pass filesystem/motor controllers through here once diff --git a/src/displayapp/screens/Symbols.h b/src/displayapp/screens/Symbols.h index 058b2d06e9..18b5190370 100644 --- a/src/displayapp/screens/Symbols.h +++ b/src/displayapp/screens/Symbols.h @@ -42,6 +42,7 @@ namespace Pinetime { static constexpr const char* sleep = "\xEE\xBD\x84"; static constexpr const char* calculator = "\xEF\x87\xAC"; static constexpr const char* backspace = "\xEF\x95\x9A"; + static constexpr const char* mountain = "\xEF\x9B\xBC"; // fontawesome_weathericons.c // static constexpr const char* sun = "\xEF\x86\x85"; From a53b43ec91f240dea8c9669cfa9e96b604352bfc Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Mon, 10 Aug 2026 13:44:42 +0100 Subject: [PATCH 04/14] Build ClimbLogger selection UI against a hard-coded catalog (Roadmap Step 2) Single Screen instance, internal Step enum (Area -> Colour -> Grade -> [Climb, if the filter doesn't land on exactly one] -> Result -> Attempts), rebuilding its lv_btnmatrix/Counter widgets per step rather than pushing separate Screen objects -- same pattern FirmwareUpdate.h uses for its own multi-step flow. - CatalogEntry + an 8-entry hard-coded fixture (2 placeholder areas, mixed boulder/route climbs) stand in for catalog.csv until Step 3. - Result capture (flash/send/fell/project) and the attempts counter reuse lv_btnmatrix and the existing Widgets::Counter, matching how Calculator and Alarm already use them elsewhere in this codebase. - Logging a climb prints a CSV-shaped line via NRF_LOG_INFO (visible on the host running the simulator) and triggers a short haptic buzz via MotorController::RunForDuration -- the "something visible/testable" placeholder Step 2 called for, ahead of Step 4's real log.csv write. - Wired dateTimeController/motorController through AppTraits::Create(). Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 339 ++++++++++++++++++++++++- src/displayapp/screens/ClimbLogger.h | 77 +++++- 2 files changed, 402 insertions(+), 14 deletions(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 0b71e1c0b0..52d2ce8c2d 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -1,14 +1,343 @@ #include "displayapp/screens/ClimbLogger.h" +#include +#include +#include "components/motor/MotorController.h" +#include "components/datetime/DateTimeController.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + struct CatalogEntry { + const char* id; + const char* area; + const char* colour; + const char* grade; + const char* style; + const char* name; + }; + + // Hard-coded catalog fixture (Roadmap Step 2). Step 3 replaces this + // with a real read of catalog.csv from LittleFS — the field shape + // matches CLAUDE.md's catalog record data model so call sites don't + // need to change. Area names are placeholders (the Castle's real + // zone list is still an open question); colours/grades are a small + // mix of boulder (Font-style V-grades) and route (French sport + // grades) climbs, enough to exercise the area -> colour -> grade + // filter with more than one option at each step. + constexpr std::array climbCatalog {{ + {"C001", "Main Hall", "Red", "6a", "Route", "12"}, + {"C002", "Main Hall", "Red", "6b", "Route", "14"}, + {"C003", "Main Hall", "Blue", "V3", "Boulder", "3"}, + {"C004", "Main Hall", "Yellow", "V4", "Boulder", "9"}, + {"C005", "Above & Beyond", "Green", "6c", "Route", "2"}, + {"C006", "Above & Beyond", "Green", "V2", "Boulder", "5"}, + {"C007", "Above & Beyond", "Blue", "6a+", "Route", "7"}, + {"C008", "Above & Beyond", "Red", "V5", "Boulder", "1"}, + }}; + } + } +} + using namespace Pinetime::Applications::Screens; -ClimbLogger::ClimbLogger() { - label = lv_label_create(lv_scr_act(), nullptr); - lv_label_set_text_static(label, "ClimbLogger"); - lv_label_set_align(label, LV_LABEL_ALIGN_CENTER); - lv_obj_align(label, lv_scr_act(), LV_ALIGN_CENTER, 0, 0); +namespace { + void ButtonMatrixEventHandler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + screen->OnButtonMatrixEvent(obj, event); + } + + void LogButtonEventHandler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + screen->OnLogButtonEvent(obj, event); + } + + // Adds `value` to `out` if it isn't already present (case-sensitive + // string compare, since catalog fields are plain C strings, not + // guaranteed to be the same literal/address across entries). + bool AddIfNew(std::array& out, size_t& count, const char* value) { + for (size_t i = 0; i < count; i++) { + if (std::strcmp(out[i], value) == 0) { + return false; + } + } + if (count < out.size()) { + out[count++] = value; + return true; + } + return false; + } +} + +ClimbLogger::ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController) + : motorController {motorController}, dateTimeController {dateTimeController} { + ShowStep(); } ClimbLogger::~ClimbLogger() { lv_obj_clean(lv_scr_act()); } + +size_t ClimbLogger::CollectAreas(std::array& out) const { + size_t count = 0; + for (const auto& entry : climbCatalog) { + AddIfNew(out, count, entry.area); + } + return count; +} + +size_t ClimbLogger::CollectColours(std::array& out) const { + size_t count = 0; + for (const auto& entry : climbCatalog) { + if (std::strcmp(entry.area, selectedArea) == 0) { + AddIfNew(out, count, entry.colour); + } + } + return count; +} + +size_t ClimbLogger::CollectGrades(std::array& out) const { + size_t count = 0; + for (const auto& entry : climbCatalog) { + if (std::strcmp(entry.area, selectedArea) == 0 && std::strcmp(entry.colour, selectedColour) == 0) { + AddIfNew(out, count, entry.grade); + } + } + return count; +} + +size_t ClimbLogger::CollectMatchingClimbs(std::array& out) const { + size_t count = 0; + for (const auto& entry : climbCatalog) { + if (std::strcmp(entry.area, selectedArea) == 0 && std::strcmp(entry.colour, selectedColour) == 0 && + std::strcmp(entry.grade, selectedGrade) == 0 && count < out.size()) { + out[count++] = &entry; + } + } + return count; +} + +void ClimbLogger::ShowStep() { + lv_obj_clean(lv_scr_act()); + titleLabel = nullptr; + buttonMatrix = nullptr; + logButton = nullptr; + + switch (step) { + case Step::Area: { + std::array options {}; + size_t count = CollectAreas(options); + ShowOptionsStep("Area", options.data(), count); + break; + } + case Step::Colour: { + std::array options {}; + size_t count = CollectColours(options); + ShowOptionsStep("Colour", options.data(), count); + break; + } + case Step::Grade: { + std::array options {}; + size_t count = CollectGrades(options); + ShowOptionsStep("Grade", options.data(), count); + break; + } + case Step::Climb: { + std::array matches {}; + size_t count = CollectMatchingClimbs(matches); + std::array options {}; + for (size_t i = 0; i < count; i++) { + options[i] = matches[i]->name; + } + ShowOptionsStep("Climb", options.data(), count); + break; + } + case Step::Result: + ShowResultStep(); + break; + case Step::Attempts: + ShowAttemptsStep(); + break; + } +} + +void ClimbLogger::ShowOptionsStep(const char* title, const char* const* options, size_t count) { + titleLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(titleLabel, title); + lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); + + size_t mapIndex = 0; + for (size_t i = 0; i < count && mapIndex + 1 < optionsMap.size(); i++) { + optionsMap[mapIndex++] = options[i]; + optionsMap[mapIndex++] = "\n"; + } + if (mapIndex > 0) { + mapIndex--; // drop the trailing row separator before the terminator + } + optionsMap[mapIndex] = ""; + + buttonMatrix = lv_btnmatrix_create(lv_scr_act(), nullptr); + buttonMatrix->user_data = this; + lv_obj_set_event_cb(buttonMatrix, ButtonMatrixEventHandler); + lv_btnmatrix_set_map(buttonMatrix, optionsMap.data()); + lv_obj_set_size(buttonMatrix, 200, 180); + lv_obj_align(buttonMatrix, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -5); +} + +void ClimbLogger::ShowResultStep() { + titleLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(titleLabel, "Result"); + lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); + + static constexpr const char* resultMap[] = {"Flash", "Send", "\n", "Fell", "Project", ""}; + + buttonMatrix = lv_btnmatrix_create(lv_scr_act(), nullptr); + buttonMatrix->user_data = this; + lv_obj_set_event_cb(buttonMatrix, ButtonMatrixEventHandler); + lv_btnmatrix_set_map(buttonMatrix, const_cast(resultMap)); + lv_obj_set_size(buttonMatrix, 200, 180); + lv_obj_align(buttonMatrix, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -5); +} + +void ClimbLogger::ShowAttemptsStep() { + titleLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(titleLabel, "Attempts"); + lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); + + // Create() (re)builds the LVGL widgets against the current screen; + // SetValue() must come after, since it writes into the label Create() + // just made, and resets the count left over from a previous climb. + attemptsCounter.Create(); + attemptsCounter.SetValue(1); + lv_obj_align(attemptsCounter.GetObject(), nullptr, LV_ALIGN_CENTER, 0, -10); + + logButton = lv_btn_create(lv_scr_act(), nullptr); + logButton->user_data = this; + lv_obj_set_event_cb(logButton, LogButtonEventHandler); + lv_obj_set_size(logButton, 120, 50); + lv_obj_align(logButton, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -10); + + lv_obj_t* logLabel = lv_label_create(logButton, nullptr); + lv_label_set_text_static(logLabel, "Log"); +} + +void ClimbLogger::OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event) { + if (obj != buttonMatrix || event != LV_EVENT_PRESSED) { + return; + } + const char* text = lv_btnmatrix_get_active_btn_text(buttonMatrix); + if (text == nullptr) { + return; + } + OnOptionSelected(text); + ShowStep(); +} + +void ClimbLogger::OnLogButtonEvent(lv_obj_t* obj, lv_event_t event) { + if (obj != logButton || event != LV_EVENT_CLICKED) { + return; + } + LogAndReset(); +} + +void ClimbLogger::OnOptionSelected(const char* text) { + switch (step) { + case Step::Area: + selectedArea = text; + step = Step::Colour; + break; + + case Step::Colour: + selectedColour = text; + step = Step::Grade; + break; + + case Step::Grade: { + selectedGrade = text; + std::array matches {}; + size_t count = CollectMatchingClimbs(matches); + if (count <= 1) { + selectedClimb = count == 1 ? matches[0] : nullptr; + step = Step::Result; + } else { + step = Step::Climb; + } + break; + } + + case Step::Climb: { + std::array matches {}; + size_t count = CollectMatchingClimbs(matches); + for (size_t i = 0; i < count; i++) { + if (std::strcmp(matches[i]->name, text) == 0) { + selectedClimb = matches[i]; + break; + } + } + step = Step::Result; + break; + } + + case Step::Result: + if (std::strcmp(text, "Flash") == 0) { + selectedResult = Result::Flash; + } else if (std::strcmp(text, "Send") == 0) { + selectedResult = Result::Send; + } else if (std::strcmp(text, "Fell") == 0) { + selectedResult = Result::Fell; + } else if (std::strcmp(text, "Project") == 0) { + selectedResult = Result::Project; + } + step = Step::Attempts; + break; + + case Step::Attempts: + break; + } +} + +const char* ClimbLogger::ToResultString(Result result) { + switch (result) { + case Result::Flash: + return "flash"; + case Result::Send: + return "send"; + case Result::Fell: + return "fell"; + case Result::Project: + return "project"; + } + return ""; +} + +void ClimbLogger::LogAndReset() { + if (selectedClimb != nullptr) { + // Step 4 replaces this stdout line with an appended row in log.csv, + // in the same field order as CLAUDE.md's denormalised log record. + NRF_LOG_INFO("ClimbLogger: %04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s,%s,%d", + dateTimeController.Year(), + static_cast(dateTimeController.Month()), + dateTimeController.Day(), + dateTimeController.Hours(), + dateTimeController.Minutes(), + dateTimeController.Seconds(), + selectedClimb->id, + selectedClimb->area, + selectedClimb->colour, + selectedClimb->grade, + selectedClimb->style, + ToResultString(selectedResult), + attemptsCounter.GetValue()); + } + + motorController.RunForDuration(30); + + step = Step::Area; + selectedArea = nullptr; + selectedColour = nullptr; + selectedGrade = nullptr; + selectedClimb = nullptr; + selectedResult = Result::Send; + + ShowStep(); +} diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h index 75b6f0c92a..da00068dee 100644 --- a/src/displayapp/screens/ClimbLogger.h +++ b/src/displayapp/screens/ClimbLogger.h @@ -1,25 +1,86 @@ #pragma once +#include +#include #include "displayapp/apps/Apps.h" #include "displayapp/screens/Screen.h" #include "displayapp/Controllers.h" #include "displayapp/screens/Symbols.h" +#include "displayapp/widgets/Counter.h" #include namespace Pinetime { + namespace Controllers { + class MotorController; + class DateTime; + } + namespace Applications { namespace Screens { + // Defined in ClimbLogger.cpp, alongside the hard-coded catalog + // fixture. Step 3 replaces the fixture with a real read of + // catalog.csv from LittleFS; this shape is expected to survive + // that move (it matches CLAUDE.md's catalog record data model). + struct CatalogEntry; - // Step 1 scaffold: placeholder screen only, proves the app registers - // and launches. Selection UI (area/colour/grade, result, attempts) - // is built in Step 2. + // Roadmap Step 2: filter (area -> colour -> grade [-> climb if the + // filter doesn't land on exactly one]) -> result -> attempts, against + // the hard-coded catalog fixture. A single Screen instance rebuilds + // its widgets per step rather than pushing separate Screen objects, + // matching the pattern FirmwareUpdate.h uses for its own multi-step + // flow with an internal `enum class States`. class ClimbLogger : public Screen { public: - ClimbLogger(); + ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController); ~ClimbLogger() override; + void OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event); + void OnLogButtonEvent(lv_obj_t* obj, lv_event_t event); + + // Max distinct options offered at any one filter step. Public so + // the free helper functions in ClimbLogger.cpp (which build those + // option lists ahead of a ClimbLogger method call) can size their + // buffers against it. + static constexpr size_t kMaxOptions = 8; + private: - lv_obj_t* label = nullptr; + enum class Step { Area, Colour, Grade, Climb, Result, Attempts }; + enum class Result { Flash, Send, Fell, Project }; + + Controllers::MotorController& motorController; + Controllers::DateTime& dateTimeController; + + Step step = Step::Area; + const char* selectedArea = nullptr; + const char* selectedColour = nullptr; + const char* selectedGrade = nullptr; + const CatalogEntry* selectedClimb = nullptr; + Result selectedResult = Result::Send; + + // Backing storage for the lv_btnmatrix map of the current step + // ("\n"-separated rows, "" terminator) — sized for kMaxOptions + // one-per-row buttons plus separators and the terminator. + std::array optionsMap {}; + + lv_obj_t* titleLabel = nullptr; + lv_obj_t* buttonMatrix = nullptr; + lv_obj_t* logButton = nullptr; + Widgets::Counter attemptsCounter = Widgets::Counter(1, 20, jetbrains_mono_42); + + void ShowStep(); + void ShowOptionsStep(const char* title, const char* const* options, size_t count); + void ShowResultStep(); + void ShowAttemptsStep(); + + size_t CollectAreas(std::array& out) const; + size_t CollectColours(std::array& out) const; + size_t CollectGrades(std::array& out) const; + size_t CollectMatchingClimbs(std::array& out) const; + + void OnOptionSelected(const char* text); + void LogAndReset(); + + static const char* ToResultString(Result result); }; } @@ -28,10 +89,8 @@ namespace Pinetime { static constexpr Apps app = Apps::ClimbLogger; static constexpr const char* icon = Screens::Symbols::mountain; - static Screens::Screen* Create(AppControllers& /*controllers*/) { - // Step 3+ will pass filesystem/motor controllers through here once - // the catalog/log files and haptic confirmation are wired up. - return new Screens::ClimbLogger(); + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::ClimbLogger(controllers.motorController, controllers.dateTimeController); } static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { From 224500d03d542c57af33c0cde36d06d06757f52a Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Tue, 11 Aug 2026 09:39:09 +0100 Subject: [PATCH 05/14] Fix cascading step-skip bug: react to VALUE_CHANGED, not PRESSED lv_btnmatrix fires PRESSED on press-down but VALUE_CHANGED once, on release, after the gesture against the current matrix has fully resolved. OnButtonMatrixEvent rebuilds the whole screen (destroying this exact matrix) in response to a selection, so reacting to PRESSED re-triggered against the freshly-created matrix at the same coordinates while the input device was still mid-gesture -- cascading through several steps on a single tap instead of stopping at the one selected. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 52d2ce8c2d..91886300b8 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -222,7 +222,14 @@ void ClimbLogger::ShowAttemptsStep() { } void ClimbLogger::OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event) { - if (obj != buttonMatrix || event != LV_EVENT_PRESSED) { + // VALUE_CHANGED (not PRESSED) matters here: lv_btnmatrix fires it once, + // on release, once the press/release gesture has fully resolved against + // the *current* button matrix. This handler rebuilds the whole screen + // (destroying this exact button matrix) in response, so reacting to + // PRESSED instead re-triggers against the freshly-created matrix at the + // same coordinates while the input device is still mid-gesture, cascading + // through several steps on a single tap. + if (obj != buttonMatrix || event != LV_EVENT_VALUE_CHANGED) { return; } const char* text = lv_btnmatrix_get_active_btn_text(buttonMatrix); From bcb00bf7b1893d1b150b5cf4b60076b1c0483b0b Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Tue, 11 Aug 2026 10:02:03 +0100 Subject: [PATCH 06/14] Fix cascade bug for real: react to CLICKED, not VALUE_CHANGED The previous fix (PRESSED -> VALUE_CHANGED) was based on a wrong assumption. lv_btnmatrix's ctrl_bits default LV_BTNMATRIX_CTRL_CLICK_TRIG to off (allocate_btn_areas_and_controls memsets ctrl_bits to 0), and with that bit off, lv_btnmatrix's own VALUE_CHANGED signal fires on *press*, same timing problem as PRESSED -- still cascaded. LV_EVENT_CLICKED, sent generically by lv_indev.c itself (the same mechanism the existing, already-correct logButton handler relies on), only fires once a press/release gesture has fully resolved against the current object -- safe to rebuild/destroy the screen in response to. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 91886300b8..57ab6c766b 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -222,14 +222,18 @@ void ClimbLogger::ShowAttemptsStep() { } void ClimbLogger::OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event) { - // VALUE_CHANGED (not PRESSED) matters here: lv_btnmatrix fires it once, - // on release, once the press/release gesture has fully resolved against - // the *current* button matrix. This handler rebuilds the whole screen - // (destroying this exact button matrix) in response, so reacting to - // PRESSED instead re-triggers against the freshly-created matrix at the - // same coordinates while the input device is still mid-gesture, cascading - // through several steps on a single tap. - if (obj != buttonMatrix || event != LV_EVENT_VALUE_CHANGED) { + // CLICKED matters here, not PRESSED or (perhaps counter-intuitively) + // VALUE_CHANGED. lv_btnmatrix's ctrl_bits default LV_BTNMATRIX_CTRL_CLICK_TRIG + // to off (see allocate_btn_areas_and_controls's memset), and with that bit + // off, lv_btnmatrix fires its own VALUE_CHANGED signal on *press*, same + // timing problem as PRESSED. CLICKED, in contrast, is sent generically by + // lv_indev.c itself only once a press/release gesture has fully resolved + // against the *current* object. This handler rebuilds the whole screen + // (destroying this exact button matrix) in response to a selection, so + // anything that can fire on press-down re-triggers against the + // freshly-created matrix at the same coordinates while the input device + // is still mid-gesture, cascading through several steps on one tap. + if (obj != buttonMatrix || event != LV_EVENT_CLICKED) { return; } const char* text = lv_btnmatrix_get_active_btn_text(buttonMatrix); From a849dc9ba252a03c879f27ffe03e41d4bbef1270 Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Tue, 11 Aug 2026 13:22:59 +0100 Subject: [PATCH 07/14] Redesign ClimbLogger taxonomy: Gym -> Style -> Type -> Grade -> Attempt Replaces the area/colour/grade catalog-matching flow with a simpler 5-step taxonomy that reflects how climbs are actually logged across two gyms: - Gym: The Castle / Climbing District / Other - Style: Boulder / Top Rope / Lead - Type: Slab / Overhang / Vertical / Mixed (wall angle, not gym area) - Grade: V-scale for Boulder, Font-scale for Top Rope/Lead (auto-picked from style, no separate scale-picking step) - Attempt: Send / Did Not Finish -- replaces the numeric attempts counter entirely, so logging now happens the instant Attempt is tapped, with no separate confirm button No more catalog/CatalogEntry -- there's no fixed route list to match against anymore, just a taxonomy of what was climbed. Also adds "remember previous selection": selectedGym/Style/Type/Grade/Attempt are no longer cleared after logging, so they double as next log's defaults, and ShowOptionsStep pre-checks the matching button (lv_btnmatrix_set_btn_ctrl + CTRL_CHECK_STATE, same API Calculator.cpp already uses for its toggle-state operator buttons) so repeating the same climb type is one confirming tap per step. In-memory only for this run of the app; real persistence is a later roadmap step. Grade ladders and gym/type lists are placeholders (still open questions per ROADMAP.md), enough to exercise the flow. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 328 ++++++------------------- src/displayapp/screens/ClimbLogger.h | 64 ++--- 2 files changed, 106 insertions(+), 286 deletions(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 57ab6c766b..9ff7c7cd9a 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -1,72 +1,28 @@ #include "displayapp/screens/ClimbLogger.h" #include +#include #include #include "components/motor/MotorController.h" #include "components/datetime/DateTimeController.h" -namespace Pinetime { - namespace Applications { - namespace Screens { - struct CatalogEntry { - const char* id; - const char* area; - const char* colour; - const char* grade; - const char* style; - const char* name; - }; - - // Hard-coded catalog fixture (Roadmap Step 2). Step 3 replaces this - // with a real read of catalog.csv from LittleFS — the field shape - // matches CLAUDE.md's catalog record data model so call sites don't - // need to change. Area names are placeholders (the Castle's real - // zone list is still an open question); colours/grades are a small - // mix of boulder (Font-style V-grades) and route (French sport - // grades) climbs, enough to exercise the area -> colour -> grade - // filter with more than one option at each step. - constexpr std::array climbCatalog {{ - {"C001", "Main Hall", "Red", "6a", "Route", "12"}, - {"C002", "Main Hall", "Red", "6b", "Route", "14"}, - {"C003", "Main Hall", "Blue", "V3", "Boulder", "3"}, - {"C004", "Main Hall", "Yellow", "V4", "Boulder", "9"}, - {"C005", "Above & Beyond", "Green", "6c", "Route", "2"}, - {"C006", "Above & Beyond", "Green", "V2", "Boulder", "5"}, - {"C007", "Above & Beyond", "Blue", "6a+", "Route", "7"}, - {"C008", "Above & Beyond", "Red", "V5", "Boulder", "1"}, - }}; - } - } -} - using namespace Pinetime::Applications::Screens; namespace { + // Placeholder option lists (Roadmap Step 2). Gyms/grade ladders are + // still open questions (see ROADMAP.md's cross-cutting open questions + // table) — these are enough to exercise the flow, not the final word. + constexpr const char* gymOptions[] = {"The Castle", "Climbing District", "Other"}; + constexpr const char* styleOptions[] = {"Boulder", "Top Rope", "Lead"}; + constexpr const char* typeOptions[] = {"Slab", "Overhang", "Vertical", "Mixed"}; + constexpr const char* vScaleGrades[] = {"V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7"}; + constexpr const char* fontScaleGrades[] = {"5+", "6a", "6a+", "6b", "6b+", "6c", "6c+", "7a"}; + constexpr const char* attemptOptions[] = {"Send", "Did Not Finish"}; + void ButtonMatrixEventHandler(lv_obj_t* obj, lv_event_t event) { auto* screen = static_cast(obj->user_data); screen->OnButtonMatrixEvent(obj, event); } - - void LogButtonEventHandler(lv_obj_t* obj, lv_event_t event) { - auto* screen = static_cast(obj->user_data); - screen->OnLogButtonEvent(obj, event); - } - - // Adds `value` to `out` if it isn't already present (case-sensitive - // string compare, since catalog fields are plain C strings, not - // guaranteed to be the same literal/address across entries). - bool AddIfNew(std::array& out, size_t& count, const char* value) { - for (size_t i = 0; i < count; i++) { - if (std::strcmp(out[i], value) == 0) { - return false; - } - } - if (count < out.size()) { - out[count++] = value; - return true; - } - return false; - } } ClimbLogger::ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController) @@ -78,98 +34,57 @@ ClimbLogger::~ClimbLogger() { lv_obj_clean(lv_scr_act()); } -size_t ClimbLogger::CollectAreas(std::array& out) const { - size_t count = 0; - for (const auto& entry : climbCatalog) { - AddIfNew(out, count, entry.area); - } - return count; -} - -size_t ClimbLogger::CollectColours(std::array& out) const { - size_t count = 0; - for (const auto& entry : climbCatalog) { - if (std::strcmp(entry.area, selectedArea) == 0) { - AddIfNew(out, count, entry.colour); - } - } - return count; -} - -size_t ClimbLogger::CollectGrades(std::array& out) const { - size_t count = 0; - for (const auto& entry : climbCatalog) { - if (std::strcmp(entry.area, selectedArea) == 0 && std::strcmp(entry.colour, selectedColour) == 0) { - AddIfNew(out, count, entry.grade); - } - } - return count; -} - -size_t ClimbLogger::CollectMatchingClimbs(std::array& out) const { - size_t count = 0; - for (const auto& entry : climbCatalog) { - if (std::strcmp(entry.area, selectedArea) == 0 && std::strcmp(entry.colour, selectedColour) == 0 && - std::strcmp(entry.grade, selectedGrade) == 0 && count < out.size()) { - out[count++] = &entry; - } - } - return count; +bool ClimbLogger::IsBoulderStyle() const { + return selectedStyle != nullptr && std::strcmp(selectedStyle, "Boulder") == 0; } void ClimbLogger::ShowStep() { lv_obj_clean(lv_scr_act()); titleLabel = nullptr; buttonMatrix = nullptr; - logButton = nullptr; switch (step) { - case Step::Area: { - std::array options {}; - size_t count = CollectAreas(options); - ShowOptionsStep("Area", options.data(), count); + case Step::Gym: + ShowOptionsStep("Gym", gymOptions, std::size(gymOptions), selectedGym); break; - } - case Step::Colour: { - std::array options {}; - size_t count = CollectColours(options); - ShowOptionsStep("Colour", options.data(), count); + case Step::Style: + ShowOptionsStep("Style", styleOptions, std::size(styleOptions), selectedStyle); break; - } - case Step::Grade: { - std::array options {}; - size_t count = CollectGrades(options); - ShowOptionsStep("Grade", options.data(), count); + case Step::Type: + ShowOptionsStep("Type", typeOptions, std::size(typeOptions), selectedType); break; - } - case Step::Climb: { - std::array matches {}; - size_t count = CollectMatchingClimbs(matches); - std::array options {}; - for (size_t i = 0; i < count; i++) { - options[i] = matches[i]->name; + case Step::Grade: + if (IsBoulderStyle()) { + ShowOptionsStep("Grade (V)", vScaleGrades, std::size(vScaleGrades), selectedGrade); + } else { + ShowOptionsStep("Grade (Font)", fontScaleGrades, std::size(fontScaleGrades), selectedGrade); } - ShowOptionsStep("Climb", options.data(), count); - break; - } - case Step::Result: - ShowResultStep(); break; - case Step::Attempts: - ShowAttemptsStep(); + case Step::Attempt: + ShowOptionsStep("Attempt", attemptOptions, std::size(attemptOptions), selectedAttempt); break; } } -void ClimbLogger::ShowOptionsStep(const char* title, const char* const* options, size_t count) { +void ClimbLogger::ShowOptionsStep(const char* title, const char* const* options, size_t count, const char* rememberedValue) { titleLabel = lv_label_create(lv_scr_act(), nullptr); lv_label_set_text_static(titleLabel, title); lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); + // One button per row reads clearly for a few options; longer lists (the + // grade ladders) go two-per-row so buttons stay a reasonable tap size. + const size_t perRow = count > 4 ? 2 : 1; + size_t mapIndex = 0; + int rememberedBtnIndex = -1; for (size_t i = 0; i < count && mapIndex + 1 < optionsMap.size(); i++) { + if (rememberedValue != nullptr && std::strcmp(options[i], rememberedValue) == 0) { + rememberedBtnIndex = static_cast(i); + } optionsMap[mapIndex++] = options[i]; - optionsMap[mapIndex++] = "\n"; + if ((i + 1) % perRow == 0 || i + 1 == count) { + optionsMap[mapIndex++] = "\n"; + } } if (mapIndex > 0) { mapIndex--; // drop the trailing row separator before the terminator @@ -182,43 +97,13 @@ void ClimbLogger::ShowOptionsStep(const char* title, const char* const* options, lv_btnmatrix_set_map(buttonMatrix, optionsMap.data()); lv_obj_set_size(buttonMatrix, 200, 180); lv_obj_align(buttonMatrix, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -5); -} - -void ClimbLogger::ShowResultStep() { - titleLabel = lv_label_create(lv_scr_act(), nullptr); - lv_label_set_text_static(titleLabel, "Result"); - lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); - - static constexpr const char* resultMap[] = {"Flash", "Send", "\n", "Fell", "Project", ""}; - - buttonMatrix = lv_btnmatrix_create(lv_scr_act(), nullptr); - buttonMatrix->user_data = this; - lv_obj_set_event_cb(buttonMatrix, ButtonMatrixEventHandler); - lv_btnmatrix_set_map(buttonMatrix, const_cast(resultMap)); - lv_obj_set_size(buttonMatrix, 200, 180); - lv_obj_align(buttonMatrix, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -5); -} - -void ClimbLogger::ShowAttemptsStep() { - titleLabel = lv_label_create(lv_scr_act(), nullptr); - lv_label_set_text_static(titleLabel, "Attempts"); - lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); - - // Create() (re)builds the LVGL widgets against the current screen; - // SetValue() must come after, since it writes into the label Create() - // just made, and resets the count left over from a previous climb. - attemptsCounter.Create(); - attemptsCounter.SetValue(1); - lv_obj_align(attemptsCounter.GetObject(), nullptr, LV_ALIGN_CENTER, 0, -10); - logButton = lv_btn_create(lv_scr_act(), nullptr); - logButton->user_data = this; - lv_obj_set_event_cb(logButton, LogButtonEventHandler); - lv_obj_set_size(logButton, 120, 50); - lv_obj_align(logButton, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -10); - - lv_obj_t* logLabel = lv_label_create(logButton, nullptr); - lv_label_set_text_static(logLabel, "Log"); + // Row separators don't consume a button index, so options[i] lines up + // directly with button index i regardless of how rows were wrapped. + if (rememberedBtnIndex >= 0) { + lv_btnmatrix_set_one_check(buttonMatrix, true); + lv_btnmatrix_set_btn_ctrl(buttonMatrix, static_cast(rememberedBtnIndex), LV_BTNMATRIX_CTRL_CHECK_STATE); + } } void ClimbLogger::OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event) { @@ -241,114 +126,59 @@ void ClimbLogger::OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event) { return; } OnOptionSelected(text); - ShowStep(); -} - -void ClimbLogger::OnLogButtonEvent(lv_obj_t* obj, lv_event_t event) { - if (obj != logButton || event != LV_EVENT_CLICKED) { - return; - } - LogAndReset(); } void ClimbLogger::OnOptionSelected(const char* text) { switch (step) { - case Step::Area: - selectedArea = text; - step = Step::Colour; + case Step::Gym: + selectedGym = text; + step = Step::Style; break; - case Step::Colour: - selectedColour = text; - step = Step::Grade; - break; - - case Step::Grade: { - selectedGrade = text; - std::array matches {}; - size_t count = CollectMatchingClimbs(matches); - if (count <= 1) { - selectedClimb = count == 1 ? matches[0] : nullptr; - step = Step::Result; - } else { - step = Step::Climb; - } + case Step::Style: + selectedStyle = text; + step = Step::Type; break; - } - case Step::Climb: { - std::array matches {}; - size_t count = CollectMatchingClimbs(matches); - for (size_t i = 0; i < count; i++) { - if (std::strcmp(matches[i]->name, text) == 0) { - selectedClimb = matches[i]; - break; - } - } - step = Step::Result; - break; - } - - case Step::Result: - if (std::strcmp(text, "Flash") == 0) { - selectedResult = Result::Flash; - } else if (std::strcmp(text, "Send") == 0) { - selectedResult = Result::Send; - } else if (std::strcmp(text, "Fell") == 0) { - selectedResult = Result::Fell; - } else if (std::strcmp(text, "Project") == 0) { - selectedResult = Result::Project; - } - step = Step::Attempts; + case Step::Type: + selectedType = text; + step = Step::Grade; break; - case Step::Attempts: + case Step::Grade: + selectedGrade = text; + step = Step::Attempt; break; - } -} -const char* ClimbLogger::ToResultString(Result result) { - switch (result) { - case Result::Flash: - return "flash"; - case Result::Send: - return "send"; - case Result::Fell: - return "fell"; - case Result::Project: - return "project"; + case Step::Attempt: + selectedAttempt = text; + LogAndReset(); + return; // LogAndReset() already redraws the next screen } - return ""; + ShowStep(); } void ClimbLogger::LogAndReset() { - if (selectedClimb != nullptr) { - // Step 4 replaces this stdout line with an appended row in log.csv, - // in the same field order as CLAUDE.md's denormalised log record. - NRF_LOG_INFO("ClimbLogger: %04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s,%s,%d", - dateTimeController.Year(), - static_cast(dateTimeController.Month()), - dateTimeController.Day(), - dateTimeController.Hours(), - dateTimeController.Minutes(), - dateTimeController.Seconds(), - selectedClimb->id, - selectedClimb->area, - selectedClimb->colour, - selectedClimb->grade, - selectedClimb->style, - ToResultString(selectedResult), - attemptsCounter.GetValue()); - } + // Step 4 replaces this stdout line with an appended row in log.csv, in + // the same field order as CLAUDE.md's denormalised log record. + NRF_LOG_INFO("ClimbLogger: %04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s", + dateTimeController.Year(), + static_cast(dateTimeController.Month()), + dateTimeController.Day(), + dateTimeController.Hours(), + dateTimeController.Minutes(), + dateTimeController.Seconds(), + selectedGym, + selectedStyle, + selectedType, + selectedGrade, + selectedAttempt); motorController.RunForDuration(30); - step = Step::Area; - selectedArea = nullptr; - selectedColour = nullptr; - selectedGrade = nullptr; - selectedClimb = nullptr; - selectedResult = Result::Send; - + // Deliberately not clearing selectedGym/selectedStyle/selectedType/ + // selectedGrade/selectedAttempt: they double as next log's remembered + // defaults, pre-checked in ShowOptionsStep. + step = Step::Gym; ShowStep(); } diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h index da00068dee..5d1b5e7f70 100644 --- a/src/displayapp/screens/ClimbLogger.h +++ b/src/displayapp/screens/ClimbLogger.h @@ -6,7 +6,6 @@ #include "displayapp/screens/Screen.h" #include "displayapp/Controllers.h" #include "displayapp/screens/Symbols.h" -#include "displayapp/widgets/Counter.h" #include namespace Pinetime { @@ -17,70 +16,61 @@ namespace Pinetime { namespace Applications { namespace Screens { - // Defined in ClimbLogger.cpp, alongside the hard-coded catalog - // fixture. Step 3 replaces the fixture with a real read of - // catalog.csv from LittleFS; this shape is expected to survive - // that move (it matches CLAUDE.md's catalog record data model). - struct CatalogEntry; - - // Roadmap Step 2: filter (area -> colour -> grade [-> climb if the - // filter doesn't land on exactly one]) -> result -> attempts, against - // the hard-coded catalog fixture. A single Screen instance rebuilds - // its widgets per step rather than pushing separate Screen objects, - // matching the pattern FirmwareUpdate.h uses for its own multi-step - // flow with an internal `enum class States`. + // Gym -> Style -> Type -> Grade -> Attempt, then logged immediately + // (no separate confirm step). A single Screen instance rebuilds its + // button matrix per step rather than pushing separate Screen + // objects, matching the pattern FirmwareUpdate.h uses for its own + // multi-step flow with an internal `enum class States`. + // + // Every step remembers the previous log's choice: the matching + // option is pre-checked when a step's screen opens, so repeating the + // same climb type is a single confirming tap per step. Remembered + // values are in-memory only for the current run of the app — real + // on-device persistence is a later roadmap step. class ClimbLogger : public Screen { public: ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController); ~ClimbLogger() override; void OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event); - void OnLogButtonEvent(lv_obj_t* obj, lv_event_t event); - // Max distinct options offered at any one filter step. Public so - // the free helper functions in ClimbLogger.cpp (which build those - // option lists ahead of a ClimbLogger method call) can size their - // buffers against it. + // Max distinct options offered at any one step. Public so the free + // helper functions in ClimbLogger.cpp (which build those option + // lists ahead of a ClimbLogger method call) can size their buffers + // against it. static constexpr size_t kMaxOptions = 8; private: - enum class Step { Area, Colour, Grade, Climb, Result, Attempts }; - enum class Result { Flash, Send, Fell, Project }; + enum class Step { Gym, Style, Type, Grade, Attempt }; Controllers::MotorController& motorController; Controllers::DateTime& dateTimeController; - Step step = Step::Area; - const char* selectedArea = nullptr; - const char* selectedColour = nullptr; + Step step = Step::Gym; + + // Current step's picks, and — since never cleared on log — also + // next time's remembered defaults. + const char* selectedGym = nullptr; + const char* selectedStyle = nullptr; + const char* selectedType = nullptr; const char* selectedGrade = nullptr; - const CatalogEntry* selectedClimb = nullptr; - Result selectedResult = Result::Send; + const char* selectedAttempt = nullptr; // Backing storage for the lv_btnmatrix map of the current step // ("\n"-separated rows, "" terminator) — sized for kMaxOptions - // one-per-row buttons plus separators and the terminator. + // buttons plus one separator per row plus the terminator. std::array optionsMap {}; lv_obj_t* titleLabel = nullptr; lv_obj_t* buttonMatrix = nullptr; - lv_obj_t* logButton = nullptr; - Widgets::Counter attemptsCounter = Widgets::Counter(1, 20, jetbrains_mono_42); void ShowStep(); - void ShowOptionsStep(const char* title, const char* const* options, size_t count); - void ShowResultStep(); - void ShowAttemptsStep(); - - size_t CollectAreas(std::array& out) const; - size_t CollectColours(std::array& out) const; - size_t CollectGrades(std::array& out) const; - size_t CollectMatchingClimbs(std::array& out) const; + void ShowOptionsStep(const char* title, const char* const* options, size_t count, const char* rememberedValue); void OnOptionSelected(const char* text); void LogAndReset(); - static const char* ToResultString(Result result); + bool IsBoulderStyle() const; }; } From 677d0c480a6ff4cae33cc95b326c0498c1b4dce6 Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Thu, 13 Aug 2026 16:44:42 +0100 Subject: [PATCH 08/14] Persist logged climbs to /climbs/log.csv on LittleFS (Roadmap Step 4) Replaces the NRF_LOG_INFO stdout placeholder from Step 2 with a real append-only write via Controllers::FS, wired through AppTraits::Create() same as motorController/dateTimeController already were. Follows AlarmController::SaveSettingsToFile's established idiom: DirOpen the containing directory and DirCreate it if that fails (first-boot case), then FileOpen with LFS_O_WRONLY | LFS_O_CREAT | LFS_O_APPEND so each log is a cheap single-line append, not a full-file rewrite. Controllers::FS has no locking/queueing -- every call is a direct synchronous lfs_* call -- so, per the existing codebase convention (AlarmController/Settings both do this), it's safe to call straight from this Screen's own event-handling code on the DisplayApp task. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 66 +++++++++++++++++++------- src/displayapp/screens/ClimbLogger.h | 8 +++- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 9ff7c7cd9a..425b7c5d9d 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -1,10 +1,12 @@ #include "displayapp/screens/ClimbLogger.h" +#include #include #include #include #include "components/motor/MotorController.h" #include "components/datetime/DateTimeController.h" +#include "components/fs/FS.h" using namespace Pinetime::Applications::Screens; @@ -25,8 +27,10 @@ namespace { } } -ClimbLogger::ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController) - : motorController {motorController}, dateTimeController {dateTimeController} { +ClimbLogger::ClimbLogger(Controllers::MotorController& motorController, + Controllers::DateTime& dateTimeController, + Controllers::FS& filesystem) + : motorController {motorController}, dateTimeController {dateTimeController}, filesystem {filesystem} { ShowStep(); } @@ -159,21 +163,7 @@ void ClimbLogger::OnOptionSelected(const char* text) { } void ClimbLogger::LogAndReset() { - // Step 4 replaces this stdout line with an appended row in log.csv, in - // the same field order as CLAUDE.md's denormalised log record. - NRF_LOG_INFO("ClimbLogger: %04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s", - dateTimeController.Year(), - static_cast(dateTimeController.Month()), - dateTimeController.Day(), - dateTimeController.Hours(), - dateTimeController.Minutes(), - dateTimeController.Seconds(), - selectedGym, - selectedStyle, - selectedType, - selectedGrade, - selectedAttempt); - + WriteLogEntry(); motorController.RunForDuration(30); // Deliberately not clearing selectedGym/selectedStyle/selectedType/ @@ -182,3 +172,45 @@ void ClimbLogger::LogAndReset() { step = Step::Gym; ShowStep(); } + +void ClimbLogger::WriteLogEntry() { + // Same field order as CLAUDE.md's denormalised log record (timestamp, + // then a copy of each catalog-ish field so the row still means + // something after Gym/Style/Type/Grade option lists change later). + char line[128]; + int len = std::snprintf(line, + sizeof(line), + "%04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s\n", + dateTimeController.Year(), + static_cast(dateTimeController.Month()), + dateTimeController.Day(), + dateTimeController.Hours(), + dateTimeController.Minutes(), + dateTimeController.Seconds(), + selectedGym, + selectedStyle, + selectedType, + selectedGrade, + selectedAttempt); + if (len <= 0) { + return; + } + const size_t writeLen = static_cast(len) < sizeof(line) ? static_cast(len) : sizeof(line) - 1; + + // Directory may not exist yet on a fresh filesystem — same + // open-then-create-on-failure idiom AlarmController uses for + // /.system before writing into it. + lfs_dir_t climbsDir; + if (filesystem.DirOpen("/climbs", &climbsDir) != LFS_ERR_OK) { + filesystem.DirCreate("/climbs"); + } + filesystem.DirClose(&climbsDir); + + lfs_file_t logFile; + if (filesystem.FileOpen(&logFile, "/climbs/log.csv", LFS_O_WRONLY | LFS_O_CREAT | LFS_O_APPEND) != LFS_ERR_OK) { + NRF_LOG_WARNING("[ClimbLogger] Failed to open log.csv for appending"); + return; + } + filesystem.FileWrite(&logFile, reinterpret_cast(line), writeLen); + filesystem.FileClose(&logFile); +} diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h index 5d1b5e7f70..8603b8198b 100644 --- a/src/displayapp/screens/ClimbLogger.h +++ b/src/displayapp/screens/ClimbLogger.h @@ -29,7 +29,9 @@ namespace Pinetime { // on-device persistence is a later roadmap step. class ClimbLogger : public Screen { public: - ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController); + ClimbLogger(Controllers::MotorController& motorController, + Controllers::DateTime& dateTimeController, + Controllers::FS& filesystem); ~ClimbLogger() override; void OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event); @@ -45,6 +47,7 @@ namespace Pinetime { Controllers::MotorController& motorController; Controllers::DateTime& dateTimeController; + Controllers::FS& filesystem; Step step = Step::Gym; @@ -69,6 +72,7 @@ namespace Pinetime { void OnOptionSelected(const char* text); void LogAndReset(); + void WriteLogEntry(); bool IsBoulderStyle() const; }; @@ -80,7 +84,7 @@ namespace Pinetime { static constexpr const char* icon = Screens::Symbols::mountain; static Screens::Screen* Create(AppControllers& controllers) { - return new Screens::ClimbLogger(controllers.motorController, controllers.dateTimeController); + return new Screens::ClimbLogger(controllers.motorController, controllers.dateTimeController, controllers.filesystem); } static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { From ed47ed0ba04cfe57695e4f392910f141a93e7cfd Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Thu, 13 Aug 2026 17:33:05 +0100 Subject: [PATCH 09/14] Print a host-visible confirmation line when a climb is logged WriteLogEntry() now logs via NRF_LOG_INFO after a successful file write, reusing the same formatted line (no trailing "\n", added separately for the file) rather than duplicating the format string -- so you can see in the terminal that something was actually appended without a make pull-log round-trip every time. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 425b7c5d9d..2ce1a8944e 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -180,7 +180,7 @@ void ClimbLogger::WriteLogEntry() { char line[128]; int len = std::snprintf(line, sizeof(line), - "%04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s\n", + "%04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s", dateTimeController.Year(), static_cast(dateTimeController.Month()), dateTimeController.Day(), @@ -212,5 +212,11 @@ void ClimbLogger::WriteLogEntry() { return; } filesystem.FileWrite(&logFile, reinterpret_cast(line), writeLen); + filesystem.FileWrite(&logFile, reinterpret_cast("\n"), 1); filesystem.FileClose(&logFile); + + // Host-visible confirmation that the write actually happened, on top of + // (not instead of) the real file write above -- helpful during sim + // development without needing a make pull-log round-trip every time. + NRF_LOG_INFO("ClimbLogger: logged %s", line); } From b86cbdf26271abb766d5edb2f1a8ea08d87e7ecb Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Thu, 13 Aug 2026 17:43:23 +0100 Subject: [PATCH 10/14] Log timestamps as UTC ISO 8601 instead of local naive datetime WriteLogEntry() now uses dateTimeController.UTCDateTime() (subtracts the BLE-reported timezone/DST offset, 0 if never set e.g. in the simulator) rather than the Year()/Hours()/etc. getters, which read `localTime` -- already offset-adjusted. Formats as "%04d-%02d-%02dT%02d:%02d:%02dZ" (ISO 8601, "Z" = UTC) instead of the previous naive "YYYY-MM-DD HH:MM:SS" with no timezone info at all. Keeps the log unambiguous regardless of whether/where a companion app ever set the watch's timezone correctly. Local-time display, if wanted, is a host-side concern for whatever ingests log.csv later (Step 6), not something to bake into the on-device file. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index 2ce1a8944e..f82e21e5c9 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -1,7 +1,9 @@ #include "displayapp/screens/ClimbLogger.h" +#include #include #include +#include #include #include #include "components/motor/MotorController.h" @@ -174,19 +176,30 @@ void ClimbLogger::LogAndReset() { } void ClimbLogger::WriteLogEntry() { + // UTC, ISO 8601 ("Z" = zero offset) -- deliberately not local time. + // dateTimeController.Year()/Hours()/etc. read `localTime`, adjusted by + // whatever timezone/DST offset the companion app last sent over BLE + // (0 if it never has, e.g. in the simulator); UTCDateTime() undoes that + // adjustment. Storing UTC keeps the log unambiguous regardless of + // where/whether that offset was ever set correctly -- local-time + // display, if wanted, is a host-side concern for whatever ingests + // log.csv later (Step 6), not something to bake into the file. + const std::time_t utcTimeT = std::chrono::system_clock::to_time_t(dateTimeController.UTCDateTime()); + const std::tm* utcTm = std::gmtime(&utcTimeT); + // Same field order as CLAUDE.md's denormalised log record (timestamp, // then a copy of each catalog-ish field so the row still means // something after Gym/Style/Type/Grade option lists change later). char line[128]; int len = std::snprintf(line, sizeof(line), - "%04d-%02d-%02d %02d:%02d:%02d,%s,%s,%s,%s,%s", - dateTimeController.Year(), - static_cast(dateTimeController.Month()), - dateTimeController.Day(), - dateTimeController.Hours(), - dateTimeController.Minutes(), - dateTimeController.Seconds(), + "%04d-%02d-%02dT%02d:%02d:%02dZ,%s,%s,%s,%s,%s", + utcTm->tm_year + 1900, + utcTm->tm_mon + 1, + utcTm->tm_mday, + utcTm->tm_hour, + utcTm->tm_min, + utcTm->tm_sec, selectedGym, selectedStyle, selectedType, From e83fe5323a109b1999645a6d83257f14e3cff06f Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Fri, 14 Aug 2026 18:08:15 +0100 Subject: [PATCH 11/14] Register ClimbLogger.cpp with the firmware build The firmware's src/CMakeLists.txt SOURCE_FILES list is explicit (no glob, unlike InfiniSim's simulator CMake, so new screens must be added here too or the real hardware build fails to link. Co-Authored-By: Claude Sonnet 5 EOF ) --- src/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e4a354df64..7655738a56 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -396,6 +396,7 @@ list(APPEND SOURCE_FILES displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp + displayapp/screens/ClimbLogger.cpp displayapp/screens/Styles.cpp displayapp/screens/WeatherSymbols.cpp displayapp/Colors.cpp From 041ba56e1c8b1d2d55ffd6cb5bb8ad84bbc5ebff Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Sat, 15 Aug 2026 20:54:46 +0100 Subject: [PATCH 12/14] Add optional build tag suffix to on-watch version display CLIMBLOGGER_BUILD_TAG (CMake cache var, empty by default) appends an "om" suffix to the version shown on the System info and post-flash Firmware validation screens, e.g. "1.16.0.om005" instead of "1.16.0" -- makes a custom ClimbLogger build unmistakable from an official InfiniTime release at a glance, without touching the numeric project version used internally by MCUboot. Wired through docker/build.sh so the top-level project's `make firmware` can set it per build. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 12 ++++++++++++ docker/build.sh | 1 + src/Version.h.in | 5 +++++ src/displayapp/screens/FirmwareValidation.cpp | 15 +++++++++++---- src/displayapp/screens/SystemInfo.cpp | 15 +++++++++++---- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d0b590932e..b90b761958 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,15 @@ set_property(CACHE TARGET_DEVICE PROPERTY STRINGS PINETIME MOY_TFK5 MOY_TIN5 MOY set(PROJECT_GIT_COMMIT_HASH "") +# Optional custom build tag (e.g. "om001"), appended to the on-watch version +# display (Settings > System info, and the post-flash validation screen) so +# a ClimbLogger dev build is unmistakable from an official InfiniTime +# release at a glance. Left empty for a plain "1.16.0"-style display when +# not set (e.g. simulator builds, or a manual cmake invocation) -- the +# top-level project's `make firmware` target sets this to an incrementing +# om value on every real hardware build. +set(CLIMBLOGGER_BUILD_TAG "" CACHE STRING "Custom build tag suffix shown on-watch (e.g. om001); empty for a plain official-style version") + execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} OUTPUT_VARIABLE PROJECT_GIT_COMMIT_HASH @@ -53,6 +62,9 @@ message(" * Mode : " ${CMAKE_BUILD_TYPE}) message(" * Version : " ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}) message(" * Toolchain : " ${ARM_NONE_EABI_TOOLCHAIN_PATH}) message(" * GitRef(S) : " ${PROJECT_GIT_COMMIT_HASH}) +if(CLIMBLOGGER_BUILD_TAG) + message(" * ClimbLogger build tag : " ${CLIMBLOGGER_BUILD_TAG}) +endif() message(" * NRF52 SDK : " ${NRF5_SDK_PATH}) message(" * Target device : " ${TARGET_DEVICE}) if(BUILD_DFU) diff --git a/docker/build.sh b/docker/build.sh index 04963ea0ab..d82f1aefa5 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -80,6 +80,7 @@ CmakeGenerate() { CMAKE_ARGS=() [ -n "$ENABLE_USERAPPS" ] && CMAKE_ARGS+=("-DENABLE_USERAPPS=$ENABLE_USERAPPS") [ -n "$ENABLE_WATCHFACES" ] && CMAKE_ARGS+=("-DENABLE_WATCHFACES=$ENABLE_WATCHFACES") + [ -n "$CLIMBLOGGER_BUILD_TAG" ] && CMAKE_ARGS+=("-DCLIMBLOGGER_BUILD_TAG=$CLIMBLOGGER_BUILD_TAG") cmake -G "Unix Makefiles" \ -S "$SOURCES_DIR" \ diff --git a/src/Version.h.in b/src/Version.h.in index 0d6219c09a..3a5009be8c 100644 --- a/src/Version.h.in +++ b/src/Version.h.in @@ -12,11 +12,16 @@ namespace Pinetime { static constexpr uint32_t Patch() {return patch;} static constexpr const char* GitCommitHash() {return commitHash;} static constexpr const char* VersionString() {return versionString;} + // Empty ("") for an official-style build; a non-empty tag (e.g. + // "om001") marks a custom ClimbLogger dev build -- see CMakeLists.txt's + // CLIMBLOGGER_BUILD_TAG. + static constexpr const char* BuildTag() {return buildTag;} private: static constexpr uint32_t major = @PROJECT_VERSION_MAJOR@; static constexpr uint32_t minor = @PROJECT_VERSION_MINOR@; static constexpr uint32_t patch = @PROJECT_VERSION_PATCH@; static constexpr const char* commitHash = "@PROJECT_GIT_COMMIT_HASH@"; static constexpr const char* versionString = "@PROJECT_VERSION_MAJOR@.@PROJECT_VERSION_MINOR@.@PROJECT_VERSION_PATCH@"; + static constexpr const char* buildTag = "@CLIMBLOGGER_BUILD_TAG@"; }; } \ No newline at end of file diff --git a/src/displayapp/screens/FirmwareValidation.cpp b/src/displayapp/screens/FirmwareValidation.cpp index 8fc9d251cb..11779e4c34 100644 --- a/src/displayapp/screens/FirmwareValidation.cpp +++ b/src/displayapp/screens/FirmwareValidation.cpp @@ -1,4 +1,5 @@ #include "displayapp/screens/FirmwareValidation.h" +#include #include #include "Version.h" #include "components/firmwarevalidator/FirmwareValidator.h" @@ -29,12 +30,18 @@ FirmwareValidation::FirmwareValidation(Pinetime::Controllers::FirmwareValidator& labelVersion = lv_label_create(lv_scr_act(), nullptr); lv_label_set_recolor(labelVersion, true); + + char versionStr[24]; + if (Version::BuildTag()[0] != '\0') { + snprintf(versionStr, sizeof(versionStr), "%lu.%lu.%lu.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); + } else { + snprintf(versionStr, sizeof(versionStr), "%lu.%lu.%lu", Version::Major(), Version::Minor(), Version::Patch()); + } + lv_label_set_text_fmt(labelVersion, - "#808080 Version# %lu.%lu.%lu\n" + "#808080 Version# %s\n" "#808080 Short Ref# %s\n", - Version::Major(), - Version::Minor(), - Version::Patch(), + versionStr, Version::GitCommitHash()); lv_obj_align(labelVersion, nullptr, LV_ALIGN_CENTER, 0, -40); lv_label_set_align(labelVersion, LV_LABEL_ALIGN_CENTER); diff --git a/src/displayapp/screens/SystemInfo.cpp b/src/displayapp/screens/SystemInfo.cpp index 2392f3be3e..6e633b626b 100644 --- a/src/displayapp/screens/SystemInfo.cpp +++ b/src/displayapp/screens/SystemInfo.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include "displayapp/screens/SystemInfo.h" #include @@ -79,17 +80,23 @@ bool SystemInfo::OnTouchEvent(Pinetime::Applications::TouchEvents event) { std::unique_ptr SystemInfo::CreateScreen1() { lv_obj_t* label = lv_label_create(lv_scr_act(), nullptr); lv_label_set_recolor(label, true); + + char versionStr[24]; + if (Version::BuildTag()[0] != '\0') { + snprintf(versionStr, sizeof(versionStr), "%ld.%ld.%ld.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); + } else { + snprintf(versionStr, sizeof(versionStr), "%ld.%ld.%ld", Version::Major(), Version::Minor(), Version::Patch()); + } + lv_label_set_text_fmt(label, "#FFFF00 InfiniTime#\n\n" - "#808080 Version# %ld.%ld.%ld\n" + "#808080 Version# %s\n" "#808080 Short Ref# %s\n" "#808080 Build date#\n" "%s\n" "%s\n\n" "#808080 Bootloader# %s", - Version::Major(), - Version::Minor(), - Version::Patch(), + versionStr, Version::GitCommitHash(), __DATE__, __TIME__, From 5bd7be715d5391b5e5d796292a0d2500571b32f5 Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Mon, 17 Aug 2026 11:52:58 +0100 Subject: [PATCH 13/14] Persist remembered selections across app exit/reload Remembered per-step defaults previously lived only in the Screen object's memory, so they were lost whenever ClimbLogger was destroyed and recreated (exiting the app, at minimum -- confirmed on real hardware). Now written to /climbs/last_selection.csv after each successful log and read back in the constructor, resolved against the current option lists rather than pointing into a stale buffer, so a changed option list degrades to no pre-check instead of a bogus one. Also fixes format-string warnings in SystemInfo/FirmwareValidation's version display (%ld/%lu vs uint32_t) surfaced while rebuilding for this change -- harmless on the 32-bit ARM firmware target but real on the simulator's 64-bit long. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/ClimbLogger.cpp | 83 +++++++++++++++++++ src/displayapp/screens/ClimbLogger.h | 10 ++- src/displayapp/screens/FirmwareValidation.cpp | 4 +- src/displayapp/screens/SystemInfo.cpp | 4 +- 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp index f82e21e5c9..ea97cb4e75 100644 --- a/src/displayapp/screens/ClimbLogger.cpp +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -23,16 +23,36 @@ namespace { constexpr const char* fontScaleGrades[] = {"5+", "6a", "6a+", "6b", "6b+", "6c", "6c+", "7a"}; constexpr const char* attemptOptions[] = {"Send", "Did Not Finish"}; + constexpr const char* kSelectionFile = "/climbs/last_selection.csv"; + void ButtonMatrixEventHandler(lv_obj_t* obj, lv_event_t event) { auto* screen = static_cast(obj->user_data); screen->OnButtonMatrixEvent(obj, event); } + + // Finds the literal pointer within `options` matching `text`, so callers + // can point straight at static storage (the same option-list literals + // ShowOptionsStep already compares against) instead of pointing into a + // buffer that won't outlive the read. Returns nullptr on no match (e.g. + // an option list changed since the remembered selection was saved). + const char* FindOption(const char* const* options, size_t count, const char* text) { + if (text == nullptr) { + return nullptr; + } + for (size_t i = 0; i < count; i++) { + if (std::strcmp(options[i], text) == 0) { + return options[i]; + } + } + return nullptr; + } } ClimbLogger::ClimbLogger(Controllers::MotorController& motorController, Controllers::DateTime& dateTimeController, Controllers::FS& filesystem) : motorController {motorController}, dateTimeController {dateTimeController}, filesystem {filesystem} { + LoadRememberedSelections(); ShowStep(); } @@ -166,6 +186,7 @@ void ClimbLogger::OnOptionSelected(const char* text) { void ClimbLogger::LogAndReset() { WriteLogEntry(); + SaveRememberedSelections(); motorController.RunForDuration(30); // Deliberately not clearing selectedGym/selectedStyle/selectedType/ @@ -233,3 +254,65 @@ void ClimbLogger::WriteLogEntry() { // development without needing a make pull-log round-trip every time. NRF_LOG_INFO("ClimbLogger: logged %s", line); } + +void ClimbLogger::SaveRememberedSelections() { + // LogAndReset() is the only caller, after all five steps are picked, so + // these should never be null here -- but guard anyway rather than write + // a line with a literal "(null)" in it if that assumption ever breaks. + if (selectedGym == nullptr || selectedStyle == nullptr || selectedType == nullptr || selectedGrade == nullptr || + selectedAttempt == nullptr) { + return; + } + + char line[96]; + int len = + std::snprintf(line, sizeof(line), "%s,%s,%s,%s,%s", selectedGym, selectedStyle, selectedType, selectedGrade, selectedAttempt); + if (len <= 0) { + return; + } + const size_t writeLen = static_cast(len) < sizeof(line) ? static_cast(len) : sizeof(line) - 1; + + // /climbs is guaranteed to already exist by the time this runs -- it's + // only ever called right after WriteLogEntry(), which creates it first. + lfs_file_t file; + if (filesystem.FileOpen(&file, kSelectionFile, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) != LFS_ERR_OK) { + NRF_LOG_WARNING("[ClimbLogger] Failed to open last_selection.csv for writing"); + return; + } + filesystem.FileWrite(&file, reinterpret_cast(line), writeLen); + filesystem.FileClose(&file); +} + +void ClimbLogger::LoadRememberedSelections() { + lfs_file_t file; + if (filesystem.FileOpen(&file, kSelectionFile, LFS_O_RDONLY) != LFS_ERR_OK) { + return; // nothing remembered yet (fresh filesystem, or never logged) -- fine, steps just start blank + } + char buf[96]; + int readLen = filesystem.FileRead(&file, reinterpret_cast(buf), sizeof(buf) - 1); + filesystem.FileClose(&file); + if (readLen <= 0) { + return; + } + buf[readLen] = '\0'; + + // strtok, not strtok_r: this runs single-threaded on the display task, so + // the non-reentrant version's shared internal state isn't a concern here. + const char* gymText = std::strtok(buf, ",\n"); + const char* styleText = gymText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + const char* typeText = styleText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + const char* gradeText = typeText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + const char* attemptText = gradeText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + + // Look up each parsed token against the current option lists rather than + // pointing straight at `buf` (a local that won't outlive this call) -- + // also means a stale value from a since-changed option list quietly + // resolves to nullptr (no pre-check) instead of a dangling/bogus pointer. + selectedGym = FindOption(gymOptions, std::size(gymOptions), gymText); + selectedStyle = FindOption(styleOptions, std::size(styleOptions), styleText); + selectedType = FindOption(typeOptions, std::size(typeOptions), typeText); + // Grade ladder depends on style, already resolved above. + selectedGrade = IsBoulderStyle() ? FindOption(vScaleGrades, std::size(vScaleGrades), gradeText) + : FindOption(fontScaleGrades, std::size(fontScaleGrades), gradeText); + selectedAttempt = FindOption(attemptOptions, std::size(attemptOptions), attemptText); +} diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h index 8603b8198b..152b7b4212 100644 --- a/src/displayapp/screens/ClimbLogger.h +++ b/src/displayapp/screens/ClimbLogger.h @@ -25,8 +25,12 @@ namespace Pinetime { // Every step remembers the previous log's choice: the matching // option is pre-checked when a step's screen opens, so repeating the // same climb type is a single confirming tap per step. Remembered - // values are in-memory only for the current run of the app — real - // on-device persistence is a later roadmap step. + // values are written to a small /climbs/last_selection.csv after + // each successful log and read back in the constructor, so they + // survive this Screen object being destroyed and recreated -- + // whether from exiting the app, screen timeout, or anything else -- + // rather than relying on in-memory state alone. See + // LoadRememberedSelections / SaveRememberedSelections. class ClimbLogger : public Screen { public: ClimbLogger(Controllers::MotorController& motorController, @@ -73,6 +77,8 @@ namespace Pinetime { void OnOptionSelected(const char* text); void LogAndReset(); void WriteLogEntry(); + void LoadRememberedSelections(); + void SaveRememberedSelections(); bool IsBoulderStyle() const; }; diff --git a/src/displayapp/screens/FirmwareValidation.cpp b/src/displayapp/screens/FirmwareValidation.cpp index 11779e4c34..7cad073f79 100644 --- a/src/displayapp/screens/FirmwareValidation.cpp +++ b/src/displayapp/screens/FirmwareValidation.cpp @@ -33,9 +33,9 @@ FirmwareValidation::FirmwareValidation(Pinetime::Controllers::FirmwareValidator& char versionStr[24]; if (Version::BuildTag()[0] != '\0') { - snprintf(versionStr, sizeof(versionStr), "%lu.%lu.%lu.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); + snprintf(versionStr, sizeof(versionStr), "%u.%u.%u.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); } else { - snprintf(versionStr, sizeof(versionStr), "%lu.%lu.%lu", Version::Major(), Version::Minor(), Version::Patch()); + snprintf(versionStr, sizeof(versionStr), "%u.%u.%u", Version::Major(), Version::Minor(), Version::Patch()); } lv_label_set_text_fmt(labelVersion, diff --git a/src/displayapp/screens/SystemInfo.cpp b/src/displayapp/screens/SystemInfo.cpp index 6e633b626b..774a509256 100644 --- a/src/displayapp/screens/SystemInfo.cpp +++ b/src/displayapp/screens/SystemInfo.cpp @@ -83,9 +83,9 @@ std::unique_ptr SystemInfo::CreateScreen1() { char versionStr[24]; if (Version::BuildTag()[0] != '\0') { - snprintf(versionStr, sizeof(versionStr), "%ld.%ld.%ld.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); + snprintf(versionStr, sizeof(versionStr), "%u.%u.%u.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); } else { - snprintf(versionStr, sizeof(versionStr), "%ld.%ld.%ld", Version::Major(), Version::Minor(), Version::Patch()); + snprintf(versionStr, sizeof(versionStr), "%u.%u.%u", Version::Major(), Version::Minor(), Version::Patch()); } lv_label_set_text_fmt(label, From 1561634459a88d91091963995290e84576db4409 Mon Sep 17 00:00:00 2001 From: Oisin Mulvihill Date: Mon, 17 Aug 2026 11:55:48 +0100 Subject: [PATCH 14/14] Fix version-display format specifiers for the real ARM build The previous %u fix compiled fine on the simulator's host toolchain (where uint32_t is unsigned int) but broke the real firmware build with -Werror=format (where uint32_t is unsigned long on this ARM ABI). Neither %u nor %lu is portable across both toolchains. Using PRIu32 from instead, which resolves correctly on either. Co-Authored-By: Claude Sonnet 5 --- src/displayapp/screens/FirmwareValidation.cpp | 11 +++++++++-- src/displayapp/screens/SystemInfo.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/displayapp/screens/FirmwareValidation.cpp b/src/displayapp/screens/FirmwareValidation.cpp index 7cad073f79..0b1421525d 100644 --- a/src/displayapp/screens/FirmwareValidation.cpp +++ b/src/displayapp/screens/FirmwareValidation.cpp @@ -1,4 +1,5 @@ #include "displayapp/screens/FirmwareValidation.h" +#include #include #include #include "Version.h" @@ -33,9 +34,15 @@ FirmwareValidation::FirmwareValidation(Pinetime::Controllers::FirmwareValidator& char versionStr[24]; if (Version::BuildTag()[0] != '\0') { - snprintf(versionStr, sizeof(versionStr), "%u.%u.%u.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); + snprintf(versionStr, + sizeof(versionStr), + "%" PRIu32 ".%" PRIu32 ".%" PRIu32 ".%s", + Version::Major(), + Version::Minor(), + Version::Patch(), + Version::BuildTag()); } else { - snprintf(versionStr, sizeof(versionStr), "%u.%u.%u", Version::Major(), Version::Minor(), Version::Patch()); + snprintf(versionStr, sizeof(versionStr), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, Version::Major(), Version::Minor(), Version::Patch()); } lv_label_set_text_fmt(labelVersion, diff --git a/src/displayapp/screens/SystemInfo.cpp b/src/displayapp/screens/SystemInfo.cpp index 774a509256..4efbddb8ee 100644 --- a/src/displayapp/screens/SystemInfo.cpp +++ b/src/displayapp/screens/SystemInfo.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include "displayapp/screens/SystemInfo.h" @@ -83,9 +84,15 @@ std::unique_ptr SystemInfo::CreateScreen1() { char versionStr[24]; if (Version::BuildTag()[0] != '\0') { - snprintf(versionStr, sizeof(versionStr), "%u.%u.%u.%s", Version::Major(), Version::Minor(), Version::Patch(), Version::BuildTag()); + snprintf(versionStr, + sizeof(versionStr), + "%" PRIu32 ".%" PRIu32 ".%" PRIu32 ".%s", + Version::Major(), + Version::Minor(), + Version::Patch(), + Version::BuildTag()); } else { - snprintf(versionStr, sizeof(versionStr), "%u.%u.%u", Version::Major(), Version::Minor(), Version::Patch()); + snprintf(versionStr, sizeof(versionStr), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, Version::Major(), Version::Minor(), Version::Patch()); } lv_label_set_text_fmt(label,