From 04afd13532986d9fee4fdbc9f1d04e32da9924c8 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 24 Apr 2026 16:31:03 -0700 Subject: [PATCH] works --- .../24_nvs/lib/tbeam_display/library.json | 14 + .../lib/tbeam_display/src/TBeamDisplay.cpp | 204 ++++++++++++ .../lib/tbeam_display/src/TBeamDisplay.h | 70 ++++ exercises/24_nvs/platformio.ini | 77 +++++ exercises/24_nvs/scripts/set_build_epoch.py | 13 + exercises/24_nvs/src/main.cpp | 307 ++++++++++++++++++ 6 files changed, 685 insertions(+) create mode 100644 exercises/24_nvs/lib/tbeam_display/library.json create mode 100644 exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.cpp create mode 100644 exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.h create mode 100644 exercises/24_nvs/platformio.ini create mode 100644 exercises/24_nvs/scripts/set_build_epoch.py create mode 100644 exercises/24_nvs/src/main.cpp diff --git a/exercises/24_nvs/lib/tbeam_display/library.json b/exercises/24_nvs/lib/tbeam_display/library.json new file mode 100644 index 0000000..70a14f6 --- /dev/null +++ b/exercises/24_nvs/lib/tbeam_display/library.json @@ -0,0 +1,14 @@ +{ + "name": "tbeam_display", + "version": "0.1.0", + "description": "Reusable SH1106 OLED display service for LilyGO T-Beam Supreme exercises.", + "frameworks": "arduino", + "platforms": "espressif32", + "dependencies": [ + { + "name": "U8g2", + "owner": "olikraus", + "version": "^2.36.4" + } + ] +} diff --git a/exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.cpp b/exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.cpp new file mode 100644 index 0000000..427a060 --- /dev/null +++ b/exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.cpp @@ -0,0 +1,204 @@ +#include "TBeamDisplay.h" + +#ifndef OLED_SDA +#define OLED_SDA 17 +#endif + +#ifndef OLED_SCL +#define OLED_SCL 18 +#endif + +#ifndef OLED_ADDR +#define OLED_ADDR 0x3C +#endif + +namespace tbeam { + +TBeamDisplay::TBeamDisplay(TwoWire& wire) : wire_(wire) {} + +bool TBeamDisplay::begin(const DisplayConfig& config) { + config_ = config; + clearError(); + + if (config_.sda < 0) { + config_.sda = OLED_SDA; + } + if (config_.scl < 0) { + config_.scl = OLED_SCL; + } + if (config_.address == 0) { + config_.address = OLED_ADDR; + } + + if (config_.beginWire) { + wire_.begin(config_.sda, config_.scl); + } + + oled_.setI2CAddress(config_.address << 1); + if (!oled_.begin()) { + ready_ = false; + setError("OLED begin failed"); + return false; + } + + ready_ = true; + setPowerSave(config_.powerSave); + setFont(DisplayFont::NORMAL); + clear(); + return true; +} + +void TBeamDisplay::update() { +} + +void TBeamDisplay::clear() { + if (!ready_) { + return; + } + oled_.clearBuffer(); + oled_.sendBuffer(); +} + +void TBeamDisplay::clearBuffer() { + for (uint8_t i = 0; i < kMaxLines; ++i) { + lines_[i][0] = '\0'; + } +} + +void TBeamDisplay::setPowerSave(bool enabled) { + if (!ready_) { + return; + } + oled_.setPowerSave(enabled ? 1 : 0); + powerSave_ = enabled; +} + +void TBeamDisplay::setFont(DisplayFont font) { + font_ = font; + if (ready_) { + oled_.setFont(fontFor(font_)); + } +} + +void TBeamDisplay::showLines(const char* l1, + const char* l2, + const char* l3, + const char* l4, + const char* l5, + const char* l6) { + setLine(0, l1); + setLine(1, l2); + setLine(2, l3); + setLine(3, l4); + setLine(4, l5); + setLine(5, l6); + renderLines(); +} + +void TBeamDisplay::setLine(uint8_t index, const char* text) { + if (index >= kMaxLines) { + return; + } + strlcpy(lines_[index], text ? text : "", sizeof(lines_[index])); +} + +void TBeamDisplay::renderLines(uint8_t lineCount) { + if (!ready_) { + return; + } + + if (lineCount > kMaxLines) { + lineCount = kMaxLines; + } + + oled_.clearBuffer(); + oled_.setFont(fontFor(font_)); + + uint8_t yStart = 12; + uint8_t yStep = 12; + if (font_ == DisplayFont::SMALL) { + yStart = 10; + yStep = 10; + } else if (font_ == DisplayFont::LARGE) { + yStart = 15; + yStep = 16; + if (lineCount > 4) { + lineCount = 4; + } + } + + for (uint8_t i = 0; i < lineCount; ++i) { + if (lines_[i][0] == '\0') { + continue; + } + oled_.drawUTF8(0, yStart + (i * yStep), lines_[i]); + } + oled_.sendBuffer(); +} + +void TBeamDisplay::appendLine(const char* text) { + for (uint8_t i = 0; i < kMaxLines - 1; ++i) { + strlcpy(lines_[i], lines_[i + 1], sizeof(lines_[i])); + } + setLine(kMaxLines - 1, text); + renderLines(); +} + +void TBeamDisplay::showBoot(const char* title, const char* subtitle, const char* detail) { + setFont(DisplayFont::NORMAL); + showLines(title ? title : "T-Beam", subtitle, detail); +} + +void TBeamDisplay::showStatus(const char* title, const char* left, const char* right, const char* footer) { + if (!ready_) { + return; + } + + oled_.clearBuffer(); + oled_.setFont(u8g2_font_6x10_tf); + if (title) { + oled_.drawUTF8(0, 10, title); + } + oled_.drawHLine(0, 13, 128); + + oled_.setFont(u8g2_font_7x14B_tf); + if (left) { + oled_.drawUTF8(0, 34, left); + } + if (right) { + const int width = oled_.getUTF8Width(right); + int x = 128 - width; + if (x < 0) { + x = 0; + } + oled_.drawUTF8(x, 34, right); + } + + oled_.setFont(u8g2_font_6x10_tf); + if (footer) { + oled_.drawUTF8(0, 60, footer); + } + oled_.sendBuffer(); +} + +void TBeamDisplay::setError(const char* message) { + strlcpy(lastError_, message ? message : "", sizeof(lastError_)); +} + +void TBeamDisplay::clearError() { + lastError_[0] = '\0'; +} + +const uint8_t* TBeamDisplay::fontFor(DisplayFont font) const { + switch (font) { + case DisplayFont::SMALL: + return u8g2_font_5x8_tf; + case DisplayFont::LARGE: + return u8g2_font_7x14B_tf; + case DisplayFont::NORMAL: + default: + return u8g2_font_6x10_tf; + } +} + +} // namespace tbeam diff --git a/exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.h b/exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.h new file mode 100644 index 0000000..bfc5f8f --- /dev/null +++ b/exercises/24_nvs/lib/tbeam_display/src/TBeamDisplay.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +namespace tbeam { + +struct DisplayConfig { + int sda = -1; + int scl = -1; + uint8_t address = 0x3C; + bool beginWire = true; + bool powerSave = false; +}; + +enum class DisplayFont : uint8_t { + SMALL = 0, + NORMAL, + LARGE +}; + +class TBeamDisplay { + public: + static constexpr uint8_t kMaxLines = 6; + static constexpr uint8_t kLineBytes = 32; + + explicit TBeamDisplay(TwoWire& wire = Wire); + + bool begin(const DisplayConfig& config = DisplayConfig{}); + void update(); + + bool ready() const { return ready_; } + bool powerSave() const { return powerSave_; } + const char* lastError() const { return lastError_; } + + void clear(); + void clearBuffer(); + void setPowerSave(bool enabled); + void setFont(DisplayFont font); + void showLines(const char* l1, + const char* l2 = nullptr, + const char* l3 = nullptr, + const char* l4 = nullptr, + const char* l5 = nullptr, + const char* l6 = nullptr); + void setLine(uint8_t index, const char* text); + void renderLines(uint8_t lineCount = kMaxLines); + void appendLine(const char* text); + void showBoot(const char* title, const char* subtitle = nullptr, const char* detail = nullptr); + void showStatus(const char* title, const char* left, const char* right = nullptr, const char* footer = nullptr); + + U8G2& raw() { return oled_; } + + private: + void setError(const char* message); + void clearError(); + const uint8_t* fontFor(DisplayFont font) const; + + TwoWire& wire_; + DisplayConfig config_{}; + U8G2_SH1106_128X64_NONAME_F_HW_I2C oled_{U8G2_R0, U8X8_PIN_NONE}; + bool ready_ = false; + bool powerSave_ = false; + DisplayFont font_ = DisplayFont::NORMAL; + char lines_[kMaxLines][kLineBytes] = {}; + char lastError_[96] = {}; +}; + +} // namespace tbeam diff --git a/exercises/24_nvs/platformio.ini b/exercises/24_nvs/platformio.ini new file mode 100644 index 0000000..b60f92b --- /dev/null +++ b/exercises/24_nvs/platformio.ini @@ -0,0 +1,77 @@ +; 20260423 Codex +; Exercise 24_nvs + +[platformio] +default_envs = cy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +board_build.partitions = default_8MB.csv +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_extra_dirs = + ../lib +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + +build_flags = + -I ../lib/tbeam_display/src + -I ../../shared/boards + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"AMY\" + -D NODE_LABEL=\"Amy\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"BOB\" + -D NODE_LABEL=\"Bob\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"CY\" + -D NODE_LABEL=\"Cy\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"DAN\" + -D NODE_LABEL=\"Dan\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"ED\" + -D NODE_LABEL=\"Ed\" + +[env:flo] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"FLO\" + -D NODE_LABEL=\"Flo\" + +[env:guy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"GUY\" + -D NODE_LABEL=\"Guy\" diff --git a/exercises/24_nvs/scripts/set_build_epoch.py b/exercises/24_nvs/scripts/set_build_epoch.py new file mode 100644 index 0000000..033becd --- /dev/null +++ b/exercises/24_nvs/scripts/set_build_epoch.py @@ -0,0 +1,13 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("BUILD_EPOCH", str(epoch)), + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '\"%s\"' % utc_tag), + ] +) diff --git a/exercises/24_nvs/src/main.cpp b/exercises/24_nvs/src/main.cpp new file mode 100644 index 0000000..b0603d1 --- /dev/null +++ b/exercises/24_nvs/src/main.cpp @@ -0,0 +1,307 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "TBeamDisplay.h" + +namespace { + +#ifndef BOARD_ID +#define BOARD_ID "CY" +#endif + +#ifndef NODE_LABEL +#define NODE_LABEL "Cy" +#endif + +#ifndef BUILD_EPOCH +#define BUILD_EPOCH 0 +#endif + +using tbeam::DisplayConfig; +using tbeam::DisplayFont; +using tbeam::TBeamDisplay; + +static constexpr const char* kExerciseTitle = "Exercise 24"; +static constexpr const char* kExerciseSubtitle = "NVS Persistence Demo"; +static constexpr const char* kExerciseVersion = "Version 1"; +static constexpr const char* kNamespace = "mag"; +static constexpr const char* kBlobKey = "magcal_blob"; +static constexpr const char* kKeyX = "mag_x"; +static constexpr const char* kKeyY = "mag_y"; +static constexpr const char* kKeyZ = "mag_z"; +static constexpr const char* kKeyEpoch = "mag_epoch"; +static constexpr uint32_t kSplashMs = 15000; +static constexpr uint32_t kInitMessageMs = 2000; + +struct __attribute__((packed)) MagCal { + int16_t x; + int16_t y; + int16_t z; + uint64_t epoch; + uint16_t version; +}; + +static_assert(sizeof(MagCal) == 16, "MagCal layout changed"); + +MagCal magcalibration = {-654, 1129, 464, BUILD_EPOCH, 1}; + +enum class ValueState : uint8_t { + Missing = 0, + NullValue, + Valid, +}; + +struct DisplayField { + ValueState state = ValueState::Missing; + int16_t value = 0; +}; + +struct EpochField { + ValueState state = ValueState::Missing; + uint64_t value = 0; +}; + +struct CalibrationView { + DisplayField x{}; + DisplayField y{}; + DisplayField z{}; + EpochField epoch{}; +}; + +Preferences g_preferences; +TBeamDisplay g_display; +char g_displayLines[4][64] = {}; +uint32_t g_scrollStartMs = 0; + +bool formatEpochUtc(uint64_t epoch, char* out, size_t outSize) { + if (out == nullptr || outSize == 0) { + return false; + } + + if (epoch > static_cast(INT32_MAX)) { + return false; + } + + time_t raw = static_cast(epoch); + struct tm tmUtc; + if (gmtime_r(&raw, &tmUtc) == nullptr) { + return false; + } + + return strftime(out, outSize, "%Y%m%d_%H%M%S", &tmUtc) > 0; +} + +bool isInvalidCalibration(const MagCal& cal) { + if (cal.version == 0 || cal.epoch == 0) { + return true; + } + return cal.x == 0 && cal.y == 0 && cal.z == 0 && cal.epoch == 0 && cal.version == 0; +} + +void assignMissing(CalibrationView& view) { + view = CalibrationView{}; +} + +void assignNull(CalibrationView& view) { + view.x.state = ValueState::NullValue; + view.y.state = ValueState::NullValue; + view.z.state = ValueState::NullValue; + view.epoch.state = ValueState::NullValue; +} + +void assignValid(const MagCal& cal, CalibrationView& view) { + view.x.state = ValueState::Valid; + view.x.value = cal.x; + view.y.state = ValueState::Valid; + view.y.value = cal.y; + view.z.state = ValueState::Valid; + view.z.value = cal.z; + view.epoch.state = ValueState::Valid; + view.epoch.value = cal.epoch; +} + +void formatField(const DisplayField& field, char* out, size_t outSize) { + if (field.state == ValueState::Missing) { + strlcpy(out, "not found", outSize); + return; + } + if (field.state == ValueState::NullValue) { + strlcpy(out, "NULL", outSize); + return; + } + snprintf(out, outSize, "%d", field.value); +} + +void formatEpochField(const EpochField& field, char* out, size_t outSize) { + if (field.state == ValueState::Missing) { + strlcpy(out, "not found", outSize); + return; + } + if (field.state == ValueState::NullValue) { + strlcpy(out, "NULL", outSize); + return; + } + if (!formatEpochUtc(field.value, out, outSize)) { + strlcpy(out, "NULL", outSize); + } +} + +bool writeCalibration(const MagCal& cal) { + const size_t written = g_preferences.putBytes(kBlobKey, &cal, sizeof(cal)); + if (written != sizeof(cal)) { + return false; + } + + const size_t xWritten = g_preferences.putShort(kKeyX, cal.x); + const size_t yWritten = g_preferences.putShort(kKeyY, cal.y); + const size_t zWritten = g_preferences.putShort(kKeyZ, cal.z); + const size_t epochWritten = g_preferences.putULong64(kKeyEpoch, cal.epoch); + return xWritten > 0 && yWritten > 0 && zWritten > 0 && epochWritten > 0; +} + +CalibrationView loadCalibration(bool& initializedDefaults) { + CalibrationView view; + assignMissing(view); + initializedDefaults = false; + + const size_t len = g_preferences.getBytesLength(kBlobKey); + if (len == sizeof(MagCal)) { + MagCal cal{}; + const size_t read = g_preferences.getBytes(kBlobKey, &cal, sizeof(cal)); + if (read == sizeof(cal)) { + if (isInvalidCalibration(cal)) { + assignNull(view); + } else { + assignValid(cal, view); + } + return view; + } + } + + if (writeCalibration(magcalibration)) { + initializedDefaults = true; + assignValid(magcalibration, view); + } + return view; +} + +void showSplash() { + if (!g_display.ready()) { + return; + } + + g_display.setFont(DisplayFont::NORMAL); + g_display.showLines(kExerciseTitle, kExerciseSubtitle, kExerciseVersion); + delay(kSplashMs); +} + +void showMessage(const char* line1, const char* line2 = nullptr) { + if (!g_display.ready()) { + return; + } + + g_display.setFont(DisplayFont::NORMAL); + g_display.showLines(line1, line2); +} + +void showCalibration(const CalibrationView& view) { + char xValue[24]; + char yValue[24]; + char zValue[24]; + char epochValue[24]; + + formatField(view.x, xValue, sizeof(xValue)); + formatField(view.y, yValue, sizeof(yValue)); + formatField(view.z, zValue, sizeof(zValue)); + formatEpochField(view.epoch, epochValue, sizeof(epochValue)); + + snprintf(g_displayLines[0], sizeof(g_displayLines[0]), "magnet calibration.x = %s", xValue); + snprintf(g_displayLines[1], sizeof(g_displayLines[1]), "magnet calibration.y = %s", yValue); + snprintf(g_displayLines[2], sizeof(g_displayLines[2]), "magnet calibration.z = %s", zValue); + snprintf(g_displayLines[3], sizeof(g_displayLines[3]), "magnet calibration.date = %s", epochValue); + g_scrollStartMs = millis(); +} + +void renderCalibrationScreen() { + if (!g_display.ready()) { + return; + } + + U8G2& oled = g_display.raw(); + oled.clearBuffer(); + oled.setFont(u8g2_font_4x6_tf); + + const uint32_t elapsed = millis() - g_scrollStartMs; + const int16_t scrollStep = static_cast(elapsed / 175U); + const uint8_t yPositions[4] = {8, 22, 36, 50}; + + for (uint8_t i = 0; i < 4; ++i) { + const int width = oled.getUTF8Width(g_displayLines[i]); + int16_t x = 0; + if (width > 128) { + const int travel = width - 128 + 8; + x = -static_cast(scrollStep % travel); + } + oled.drawUTF8(x, yPositions[i], g_displayLines[i]); + } + + oled.sendBuffer(); +} + +void logCalibration(const CalibrationView& view) { + char xValue[24]; + char yValue[24]; + char zValue[24]; + char epochValue[24]; + + formatField(view.x, xValue, sizeof(xValue)); + formatField(view.y, yValue, sizeof(yValue)); + formatField(view.z, zValue, sizeof(zValue)); + formatEpochField(view.epoch, epochValue, sizeof(epochValue)); + + Serial.printf("magnet calibration.x = %s\n", xValue); + Serial.printf("magnet calibration.y = %s\n", yValue); + Serial.printf("magnet calibration.z = %s\n", zValue); + Serial.printf("magnet calibration.date = %s\n", epochValue); +} + +} // namespace + +void setup() { + Serial.begin(115200); + delay(200); + + DisplayConfig displayConfig; + displayConfig.powerSave = false; + g_display.begin(displayConfig); + + showSplash(); + + if (!g_preferences.begin(kNamespace, false)) { + showMessage("NVS open failed", kNamespace); + Serial.println("Failed to open Preferences namespace"); + return; + } + + bool initializedDefaults = false; + const CalibrationView view = loadCalibration(initializedDefaults); + + if (initializedDefaults) { + showMessage("Calibration initialized"); + delay(kInitMessageMs); + } + + showCalibration(view); + renderCalibrationScreen(); + logCalibration(view); +} + +void loop() { + renderCalibrationScreen(); + delay(250); +}