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_*() diff --git a/src/ui.cpp b/src/ui.cpp index ebd0b9d..5da8eaf 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"); } @@ -564,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");