From 74fcb385cf6106cfcac9e8d1f73f662a5d480dab Mon Sep 17 00:00:00 2001 From: bin101 Date: Sat, 25 Jul 2026 13:55:05 +0200 Subject: [PATCH 1/3] feat: show selected channel in the idle header The group's radio channel was only visible buried in the settings menu. Show it as CHn right after the nickname on the idle screen so it's a permanent, glanceable reminder of which of the 10 channels the device is on. --- src/ui.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index ebd0b9d..6c92c0c 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -413,6 +413,16 @@ void renderIdle() { display.setFont(u8g2_font_6x10_tf); display.drawStr(0, 9, DeviceConfig::nickname()); + // Radio group channel, one blank column right of the name -- a permanent + // reminder of which of the 10 channels this device is on, since it + // otherwise only shows up buried in the settings menu. + int leftEndX = 6 * static_cast(strlen(DeviceConfig::nickname())); + char chStr[6]; + snprintf(chStr, sizeof(chStr), "CH%u", DeviceConfig::channel()); + int chX = leftEndX + 6; + display.drawStr(chX, 9, chStr); + leftEndX = chX + 6 * static_cast(strlen(chStr)); + // Right-aligned header block: "active/total" rider counter, then own battery. int rightX = 128; if (batteryAvailableForDisplay) { @@ -439,12 +449,11 @@ void renderIdle() { } if (muted) { - // Centered in whatever is free between the own name and the right block, - // nudged right of the name when the counters get wide. - int nameEndX = 6 * static_cast(strlen(DeviceConfig::nickname())); - int x = (nameEndX + rightX) / 2 - (6 * 5) / 2; - if (x < nameEndX + 3) { - x = nameEndX + 3; + // Centered in whatever is free between the name/channel block and the + // right block, nudged right when the counters get wide. + int x = (leftEndX + rightX) / 2 - (6 * 5) / 2; + if (x < leftEndX + 3) { + x = leftEndX + 3; } display.drawStr(x, 9, "MUTE"); } From e82a97fcc24483d0f19e72e29ed3a44fcdc010b4 Mon Sep 17 00:00:00 2001 From: bin101 Date: Sat, 25 Jul 2026 13:55:34 +0200 Subject: [PATCH 2/3] fix: smooth battery voltage with an EMA to stop the percent flickering The displayed battery percent was recomputed every ~1s from a raw, unfiltered INA219 voltage reading. Because the percent curve is steep in its middle segments (~0.2 %/mV), a few mV of load-induced noise per read showed up as the header percent jumping back and forth. Apply an EMA to the cached millivolts before the percent curve ever sees them (BATTERY_EMA_ALPHA_PERCENT), which also stabilizes the low-battery latch and the battery value sent to peers via the heartbeat. --- src/config.h | 8 ++++++++ src/power.cpp | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/config.h b/src/config.h index 8aaf4d7..ce1a2e5 100644 --- a/src/config.h +++ b/src/config.h @@ -253,6 +253,14 @@ static_assert((BEEP_FREQUENCY_MAX_HZ - BEEP_FREQUENCY_MIN_HZ) / BEEP_FREQUENCY_S // The INA219 is polled at most this often; everything else reads the cached // value. Keeps the shared I2C bus (OLED!) free and the main loop fast. #define BATTERY_READ_INTERVAL_MS 1000UL +// EMA weight (percent) given to each new voltage reading before it becomes +// the cached lastMillivolts. The percent-vs-voltage curve is steep in its +// middle segments (e.g. 0.2 %/mV around 3.6-3.7 V), so a few mV of +// load-induced noise per ~1 s read otherwise shows up as the displayed +// battery percent flickering back and forth. At one read per +// BATTERY_READ_INTERVAL_MS, 30% smooths over a few seconds without making +// the reading feel sluggish. Lower = smoother/slower to react. +#define BATTERY_EMA_ALPHA_PERCENT 30 // "Low" latches below LOW and only clears above CLEAR -- the 100 mV hysteresis // stops the warning from flapping while the voltage bounces around under load. // Same thresholds are used for the peers' battery level from their heartbeats. diff --git a/src/power.cpp b/src/power.cpp index 1eb3b42..03ba29d 100644 --- a/src/power.cpp +++ b/src/power.cpp @@ -43,7 +43,19 @@ void refreshIfDue() { // Out-of-range readings simply keep the last known-good value rather than // being trusted. if (busVoltage >= 0.0f && busVoltage <= 5.0f) { - lastMillivolts = static_cast(busVoltage * 1000.0f); + uint16_t raw = static_cast(busVoltage * 1000.0f); + // EMA rather than taking the raw reading verbatim: smooths out the load + // noise that otherwise turns into a flickering displayed percent (see + // BATTERY_EMA_ALPHA_PERCENT). Skip it on the very first-ever reading so + // the display doesn't ramp up from a zeroed lastMillivolts. + if (!hasEverRead) { + lastMillivolts = raw; + } else { + lastMillivolts = static_cast( + (static_cast(raw) * BATTERY_EMA_ALPHA_PERCENT + + static_cast(lastMillivolts) * (100 - BATTERY_EMA_ALPHA_PERCENT)) / + 100); + } } // Adafruit_INA219::begin() calibrates for 32V/2A internally (see init()), // so getCurrent_mA() works out of the box -- no extra setCalibration_*() From e68496c69b50ac7e3784b1b1af93744f0114fe15 Mon Sep 17 00:00:00 2001 From: bin101 Date: Sat, 25 Jul 2026 13:55:49 +0200 Subject: [PATCH 3/3] feat: show incoming warnings full-screen with rider name and large text Incoming hazard warnings need to be unmissable at a glance while riding. Rework the warning screen around the two-tone panel instead of fighting it: the affected rider's name fills the yellow strip (who), and the warning label auto-fits the largest font from a size ladder in the blue region below (what), word-wrapping to two lines when needed. Stays large for the full 15s display duration; short-click dismiss still works. --- src/ui.cpp | 114 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 6c92c0c..5da8eaf 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -573,15 +573,115 @@ void renderMenu() { display.drawStr(0, 61, "short=next long=send"); } -void renderIncoming() { - display.setFont(u8g2_font_6x10_tf); - char header[24]; - snprintf(header, sizeof(header), "WARNING from %s", incomingSenderNickname); - display.drawStr(0, 9, header); - display.drawHLine(0, 12, 128); +// Font ladder for the big warning-label auto-fit in renderIncoming(), largest +// first. The fub* faces (Bitstream Vera Bold at a few pixel sizes) are only +// used here; the 9x15 bold at the end is the pre-existing font already used +// elsewhere in this file, kept as the guaranteed-fits floor (it's what +// renderIncoming() used exclusively before this auto-fit existed). +const uint8_t *const kWarningFontLadder[] = { + u8g2_font_fub25_tf, + u8g2_font_fub20_tf, + u8g2_font_fub17_tf, + u8g2_font_9x15B_tf, +}; +const size_t kWarningFontLadderCount = + sizeof(kWarningFontLadder) / sizeof(kWarningFontLadder[0]); + +// Splits `text` into two lines at its last space or '/' (dropping the +// delimiter) -- e.g. "CAR BEHIND" -> "CAR" / "BEHIND", "BRAKING/STOP" -> +// "BRAKING" / "STOP". Returns false if there is no such break point (a +// single word, e.g. "ATTENTION!"), leaving the buffers untouched. +bool splitLabelInTwo(const char *text, char *line1, size_t line1Size, char *line2, + size_t line2Size) { + const char *breakAt = nullptr; + for (const char *p = text; *p != '\0'; ++p) { + if (*p == ' ' || *p == '/') { + breakAt = p; // keep scanning -- we want the LAST match + } + } + if (breakAt == nullptr) { + return false; + } + size_t firstLen = static_cast(breakAt - text); + size_t copyLen = firstLen < line1Size - 1 ? firstLen : line1Size - 1; + memcpy(line1, text, copyLen); + line1[copyLen] = '\0'; + strncpy(line2, breakAt + 1, line2Size - 1); + line2[line2Size - 1] = '\0'; + return true; +} +// Full-screen alert: deliberately built around the two-tone panel rather than +// fighting it. The yellow strip (rows 0-15) answers "who" -- the affected +// rider's name, centered and bold. The blue region below answers "what" -- +// the warning label, picked as large as it fits from kWarningFontLadder and +// word-wrapped to two lines when a single line would run off the sides. No +// "WARNING from" prefix: taking over the whole screen plus the beep pattern +// already make the context unambiguous, so that space goes to a bigger name. +// Stays up for the full UI_INCOMING_DISPLAY_MS (see kTimeouts); the footer's +// short-click dismiss still works throughout. +void renderIncoming() { display.setFont(u8g2_font_9x15B_tf); - display.drawStr(0, 34, Protocol::warningLabel(incomingType)); + int nameW = display.getStrWidth(incomingSenderNickname); + int nameX = (128 - nameW) / 2; + if (nameX < 0) { + nameX = 0; + } + display.drawStr(nameX, 12, incomingSenderNickname); + + const char *label = Protocol::warningLabel(incomingType); + constexpr int kMaxWidth = 124; // 2px margin either side of the 128px panel + constexpr int kBandTop = 16; // top of the panel's blue region + constexpr int kBandBottom = 51; // leaves room for the footer below + constexpr int kLineGap = 2; + + char line1Buf[24]; + char line2Buf[24]; + bool hasTwoLines = splitLabelInTwo(label, line1Buf, sizeof(line1Buf), line2Buf, sizeof(line2Buf)); + + // Default/fallback: smallest font, single line -- this is exactly what + // renderIncoming() always did before the auto-fit, so it's a safe floor if + // (unexpectedly) nothing in the ladder measures as fitting. + const uint8_t *chosenFont = kWarningFontLadder[kWarningFontLadderCount - 1]; + bool chosenTwoLines = false; + for (size_t i = 0; i < kWarningFontLadderCount; ++i) { + display.setFont(kWarningFontLadder[i]); + int ascent = display.getAscent(); + int descent = -display.getDescent(); // getDescent() is <= 0 + int lineHeight = ascent + descent; + + if (display.getStrWidth(label) <= kMaxWidth && lineHeight <= (kBandBottom - kBandTop)) { + chosenFont = kWarningFontLadder[i]; + chosenTwoLines = false; + break; + } + if (hasTwoLines) { + int twoLineHeight = 2 * lineHeight + kLineGap; + if (display.getStrWidth(line1Buf) <= kMaxWidth && display.getStrWidth(line2Buf) <= kMaxWidth && + twoLineHeight <= (kBandBottom - kBandTop)) { + chosenFont = kWarningFontLadder[i]; + chosenTwoLines = true; + break; + } + } + } + + display.setFont(chosenFont); + int ascent = display.getAscent(); + int descent = -display.getDescent(); + int lineHeight = ascent + descent; + if (chosenTwoLines) { + int blockHeight = 2 * lineHeight + kLineGap; + int top = kBandTop + (kBandBottom - kBandTop - blockHeight) / 2; + int baseline1 = top + ascent; + int baseline2 = baseline1 + lineHeight + kLineGap; + display.drawStr((128 - display.getStrWidth(line1Buf)) / 2, baseline1, line1Buf); + display.drawStr((128 - display.getStrWidth(line2Buf)) / 2, baseline2, line2Buf); + } else { + int top = kBandTop + (kBandBottom - kBandTop - lineHeight) / 2; + int baseline = top + ascent; + display.drawStr((128 - display.getStrWidth(label)) / 2, baseline, label); + } display.setFont(u8g2_font_6x10_tf); display.drawStr(0, 61, "short = dismiss");