This commit is contained in:
John Poole 2026-04-24 16:31:03 -07:00
commit 04afd13532
6 changed files with 685 additions and 0 deletions

View file

@ -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"
}
]
}

View file

@ -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

View file

@ -0,0 +1,70 @@
#pragma once
#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
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

View file

@ -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\"

View file

@ -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),
]
)

View file

@ -0,0 +1,307 @@
#include <Arduino.h>
#include <Preferences.h>
#include <U8g2lib.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#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<uint64_t>(INT32_MAX)) {
return false;
}
time_t raw = static_cast<time_t>(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<int16_t>(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<int16_t>(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);
}