Full T-echo card support + Compact UI (#11342)

* T-echo card

* Update NRF52I2SOutput.cpp

* Update NRF52I2SOutput.h

* cleanup

* Update buzz.cpp

* use consistent runtime compact-panel check instead of mixing with compile-time macro

* Update NodeDB.cpp

* Update ExternalNotificationModule.cpp

* switched to Throttle::isWithinTimespanMs

* Update SharedUIDisplay.h

* trunk fix

* last cleanup

* ClockRenderer.cpp for OLED_COMPACT_UI and setup Unit C6L for new UI.

* Fixed regressions in standard OLED and TFT

---------

Co-authored-by: Jason P <applewiz@mac.com>
This commit is contained in:
HarukiToreda
2026-08-04 14:27:35 +00:00
committed by GitHub
co-authored by Jason P
parent d89f3eb8fc
commit 39e7aa6a6c
26 changed files with 1231 additions and 186 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ monitor_speed = 115200
monitor_filters = direct
lib_deps =
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/9d9ba7e43ed1a5e1865fc9686513eab47fb079ff.zip
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/846e0b424cd5405f65bbe4168aa2dcfe1cbfb3ad.zip
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
+30
View File
@@ -11,6 +11,10 @@
#include <unordered_map>
#endif
#if defined(HAS_I2S_SPEAKER_NRF52)
#include "platform/nrf52/NRF52I2SOutput.h"
#endif
#if !defined(ARCH_PORTDUINO)
extern "C" void delay(uint32_t dwMs);
#endif
@@ -112,6 +116,32 @@ void playTones(const ToneDuration *tone_durations, int size)
return;
}
#endif
#if defined(HAS_I2S_SPEAKER_NRF52)
// Native I2S speaker path (no ESP AudioThread/RTTTL needed here).
pinMode(SPEAKER_EN, OUTPUT);
digitalWrite(SPEAKER_EN, HIGH);
#if defined(SPEAKER_EN_2)
pinMode(SPEAKER_EN_2, OUTPUT);
digitalWrite(SPEAKER_EN_2, HIGH);
#endif
if (!nrf52I2SOutput.begin(SPEAKER_BCLK, SPEAKER_WS_LRCK, SPEAKER_DATA)) {
digitalWrite(SPEAKER_EN, LOW);
#if defined(SPEAKER_EN_2)
digitalWrite(SPEAKER_EN_2, LOW);
#endif
return;
}
for (int i = 0; i < size; i++) {
const auto &tone_duration = tone_durations[i];
nrf52I2SOutput.playTone(tone_duration.frequency_khz, tone_duration.duration_ms);
}
nrf52I2SOutput.end();
digitalWrite(SPEAKER_EN, LOW);
#if defined(SPEAKER_EN_2)
digitalWrite(SPEAKER_EN_2, LOW);
#endif
return;
#endif
#if defined(PIN_BUZZER)
if (!config.device.buzzer_gpio)
config.device.buzzer_gpio = PIN_BUZZER;
+28 -2
View File
@@ -1183,6 +1183,7 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinEN(true); // Power (EN pin): on
setPowerPMU(true); // Power (PMU): on
writePinRFEN(true); // External RF front-end: on
writePinStandby(false); // Standby (pin): awake (not standby)
setPowerUBLOX(true); // Standby (UBLOX): awake
break;
@@ -1191,15 +1192,17 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
powerMon->clearState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinEN(true); // Power (EN pin): on
setPowerPMU(true); // Power (PMU): on
writePinRFEN(false); // External RF front-end: off
writePinStandby(true); // Standby (pin): asleep (not awake)
setPowerUBLOX(false, sleepTime); // Standby (UBLOX): asleep, timed
break;
case GPS_HARDSLEEP:
powerMon->clearState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinRFEN(false); // External RF front-end: off
writePinStandby(true); // Standby (pin): asleep (not awake)
writePinEN(false); // Power (EN pin): off
setPowerPMU(false); // Power (PMU): off
writePinStandby(true); // Standby (pin): asleep (not awake)
setPowerUBLOX(false, sleepTime); // Standby (UBLOX): asleep, timed
#ifdef GNSS_AIROHA
digitalWrite(PIN_GPS_EN, LOW);
@@ -1209,9 +1212,10 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
case GPS_OFF:
assert(sleepTime == 0); // This is an indefinite sleep
powerMon->clearState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinRFEN(false); // External RF front-end: off
writePinStandby(true); // Standby (pin): asleep
writePinEN(false); // Power (EN pin): off
setPowerPMU(false); // Power (PMU): off
writePinStandby(true); // Standby (pin): asleep
setPowerUBLOX(false, 0); // Standby (UBLOX): asleep, indefinitely
#ifdef GNSS_AIROHA
digitalWrite(PIN_GPS_EN, LOW);
@@ -1261,6 +1265,21 @@ void GPS::writePinStandby(bool standby)
#endif
}
// Set the external RF front-end enable pin, if relevant
void GPS::writePinRFEN(bool on)
{
#ifdef PIN_GPS_RF_EN
bool val = on ? GPS_RF_EN_ACTIVE : !GPS_RF_EN_ACTIVE;
pinMode(PIN_GPS_RF_EN, OUTPUT);
digitalWrite(PIN_GPS_RF_EN, val);
#ifdef GPS_DEBUG
LOG_DEBUG("Pin RF EN %s", val == HIGH ? "HI" : "LOW");
#endif
#else
(void)on;
#endif
}
// Enable / Disable GPS with PMU, if present
void GPS::setPowerPMU(bool on)
{
@@ -1390,6 +1409,13 @@ void GPS::down()
if (IS_ONE_OF(gnssModel, GNSS_MODEL_UBLOX6, GNSS_MODEL_UBLOX7, GNSS_MODEL_UBLOX8, GNSS_MODEL_UBLOX9, GNSS_MODEL_UBLOX10))
softsleepSupported = true;
#ifdef GPS_FORCE_SOFT_SLEEP
if (softsleepSupported) {
setPowerState(GPS_SOFTSLEEP, sleepTime);
return;
}
#endif
if (softsleepSupported) {
// How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than
// GPS_SOFTSLEEP? Heuristic equation. A compromise manually fitted to power observations from U-blox NEO-6M
+9
View File
@@ -27,6 +27,11 @@
#define GPS_STANDBY_ACTIVE LOW
#endif
// Allow defining the polarity of an external GPS RF front-end enable. Default is active high.
#ifndef GPS_RF_EN_ACTIVE
#define GPS_RF_EN_ACTIVE HIGH
#endif
static constexpr uint32_t GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS = 10 * 1000UL;
static constexpr uint32_t GPS_FIX_HOLD_MAX_MS = 20000;
@@ -257,6 +262,10 @@ class GPS : private concurrency::OSThread
*/
void writePinStandby(bool standby);
/** Set the external RF front-end enable pin, if relevant
*/
void writePinRFEN(bool on);
/** Set GPS power with PMU, if relevant
*/
void setPowerPMU(bool on);
+72 -4
View File
@@ -570,12 +570,11 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
#elif defined(USE_SSD1306)
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
isI2cScreen = true;
#if defined(OLED_Y_OFFSET_PAGES)
// Panels whose active window does not start at GDDRAM row 0 (e.g. 72x40
// modules on pages 3..7) need a fixed vertical page shift on every write.
// Shift writes to the panel's visible GDDRAM pages.
static_cast<SSD1306Wire *>(dispdev)->setYOffset(OLED_Y_OFFSET_PAGES);
#endif
isI2cScreen = true;
#elif defined(USE_SPISSD1306)
dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48);
if (!dispdev->init()) {
@@ -739,6 +738,10 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
enabled = true;
setInterval(0); // Draw ASAP
runASAP = true;
#if defined(OLED_COMPACT_UI)
if (graphics::isCompactPanel(dispdev))
graphics::UIRenderer::notifyScreenWoke();
#endif
} else {
powerMon->clearState(meshtastic_PowerMon_State_Screen_On);
#ifdef USE_EINK
@@ -1350,6 +1353,15 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
// Regenerate the normal set of frames, focusing a specific frame if requested
// Called when a frame should be added / removed, or custom frames should be cleared
// No-op on other boards, so this costs them no flash/RAM.
#if defined(OLED_COMPACT_UI)
#define PUSH_FRAME_TITLE(x) frameTitles.push_back(x)
#define CLEAR_FRAME_TITLES() frameTitles.clear()
#else
#define PUSH_FRAME_TITLE(x)
#define CLEAR_FRAME_TITLES()
#endif
void Screen::setFrames(FrameFocus focus)
{
// Block setFrames calls when virtual keyboard is active to prevent overlay interference
@@ -1367,6 +1379,7 @@ void Screen::setFrames(FrameFocus focus)
showingNormalScreen = true;
indicatorIcons.clear();
CLEAR_FRAME_TITLES();
size_t numframes = 0;
@@ -1375,19 +1388,23 @@ void Screen::setFrames(FrameFocus focus)
if (error_code) {
normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame;
indicatorIcons.push_back(icon_error);
PUSH_FRAME_TITLE("Alert");
focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame
}
#if defined(DISPLAY_CLOCK_FRAME)
if (!hiddenFrames.clock) {
fsi.positions.clock = numframes;
#if defined(OLED_TINY)
#if defined(OLED_COMPACT_UI)
normalFrames[numframes++] = graphics::ClockRenderer::drawDigitalClockFrame;
#elif defined(OLED_TINY)
normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame;
#else
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
: graphics::ClockRenderer::drawDigitalClockFrame;
#endif
indicatorIcons.push_back(digital_icon_clock);
PUSH_FRAME_TITLE("Clock");
}
#endif
@@ -1395,6 +1412,7 @@ void Screen::setFrames(FrameFocus focus)
fsi.positions.home = numframes;
normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused;
indicatorIcons.push_back(icon_home);
PUSH_FRAME_TITLE("Home");
}
#if BASEUI_HAS_GAMES
@@ -1403,23 +1421,27 @@ void Screen::setFrames(FrameFocus focus)
fsi.positions.games = numframes;
normalFrames[numframes++] = drawGamesFrame;
indicatorIcons.push_back(joystick_small);
PUSH_FRAME_TITLE("Games");
}
#endif
fsi.positions.textMessage = numframes;
normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
indicatorIcons.push_back(icon_mail);
PUSH_FRAME_TITLE("Messages");
#ifndef USE_EINK
if (!hiddenFrames.nodelist_nodes) {
fsi.positions.nodelist_nodes = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes;
indicatorIcons.push_back(icon_nodes);
PUSH_FRAME_TITLE("Nodes");
}
if (!hiddenFrames.nodelist_location) {
fsi.positions.nodelist_location = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location;
indicatorIcons.push_back(icon_list);
PUSH_FRAME_TITLE("Node List");
}
#endif
@@ -1429,16 +1451,19 @@ void Screen::setFrames(FrameFocus focus)
fsi.positions.nodelist_lastheard = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen;
indicatorIcons.push_back(icon_nodes);
PUSH_FRAME_TITLE("Nodes");
}
if (!hiddenFrames.nodelist_hopsignal) {
fsi.positions.nodelist_hopsignal = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen;
indicatorIcons.push_back(icon_signal);
PUSH_FRAME_TITLE("Signal");
}
if (!hiddenFrames.nodelist_distance) {
fsi.positions.nodelist_distance = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen;
indicatorIcons.push_back(icon_distance);
PUSH_FRAME_TITLE("Distance");
}
#endif
#if HAS_GPS
@@ -1447,36 +1472,46 @@ void Screen::setFrames(FrameFocus focus)
fsi.positions.nodelist_bearings = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses;
indicatorIcons.push_back(icon_list);
PUSH_FRAME_TITLE("Bearings");
}
#endif
if (!hiddenFrames.gps) {
fsi.positions.gps = numframes;
normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen;
indicatorIcons.push_back(icon_compass);
PUSH_FRAME_TITLE("GPS");
}
#endif
if (RadioLibInterface::instance && !hiddenFrames.lora) {
fsi.positions.lora = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused;
indicatorIcons.push_back(icon_radio);
PUSH_FRAME_TITLE("LoRa");
}
if (!hiddenFrames.system) {
fsi.positions.system = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen;
indicatorIcons.push_back(icon_system);
PUSH_FRAME_TITLE("System");
}
#if !defined(DISPLAY_CLOCK_FRAME)
if (!hiddenFrames.clock) {
fsi.positions.clock = numframes;
#if defined(OLED_COMPACT_UI)
normalFrames[numframes++] = graphics::ClockRenderer::drawDigitalClockFrame;
#else
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
: graphics::ClockRenderer::drawDigitalClockFrame;
#endif
indicatorIcons.push_back(digital_icon_clock);
PUSH_FRAME_TITLE("Clock");
}
#endif
if (!hiddenFrames.chirpy) {
fsi.positions.chirpy = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy;
indicatorIcons.push_back(chirpy_small);
PUSH_FRAME_TITLE("Chirpy");
}
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
@@ -1484,6 +1519,7 @@ void Screen::setFrames(FrameFocus focus)
fsi.positions.wifi = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawFrameWiFi;
indicatorIcons.push_back(icon_wifi);
PUSH_FRAME_TITLE("WiFi");
}
#endif
@@ -1511,6 +1547,7 @@ void Screen::setFrames(FrameFocus focus)
fsi.positions.waypoint = numframes;
indicatorIcons.push_back(icon_module);
PUSH_FRAME_TITLE("Module");
numframes++;
}
}
@@ -1539,6 +1576,7 @@ void Screen::setFrames(FrameFocus focus)
for (const auto &f : favoriteFrames) {
normalFrames[numframes++] = f;
indicatorIcons.push_back(icon_node);
PUSH_FRAME_TITLE("Favorite");
}
fsi.positions.lastFavorite = numframes - 1;
} else {
@@ -2150,6 +2188,36 @@ int Screen::handleInputEvent(const InputEvent *event)
return 0;
}
}
#if defined(OLED_COMPACT_UI)
// UP/DOWN on the compact position screen toggles compass vs coordinates+elevation
if (graphics::isCompactPanel(dispdev) && ui->getUiState()->currentFrame == framesetInfo.positions.gps) {
if (event->inputEvent == INPUT_BROKER_UP) {
graphics::UIRenderer::scrollPositionUp();
setFastFramerate();
return 0;
}
if (event->inputEvent == INPUT_BROKER_DOWN) {
graphics::UIRenderer::scrollPositionDown();
setFastFramerate();
return 0;
}
}
// UP/DOWN on the compact favorite-node screen toggles compass+distance vs status/telemetry
if (graphics::isCompactPanel(dispdev) && framesetInfo.positions.firstFavorite != 255 &&
ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite &&
ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) {
if (event->inputEvent == INPUT_BROKER_UP) {
graphics::UIRenderer::scrollFavoriteUp();
setFastFramerate();
return 0;
}
if (event->inputEvent == INPUT_BROKER_DOWN) {
graphics::UIRenderer::scrollFavoriteDown();
setFastFramerate();
return 0;
}
}
#endif
// Use left or right input from a keyboard to move between frames,
// so long as a mesh module isn't using these events for some other purpose
if (showingNormalScreen) {
+3
View File
@@ -249,6 +249,9 @@ class Screen : public concurrency::OSThread
void setFrames(FrameFocus focus = FOCUS_DEFAULT);
std::vector<const uint8_t *> indicatorIcons; // Per-frame custom icon pointers
#if defined(OLED_COMPACT_UI)
std::vector<const char *> frameTitles; // Per-frame short labels, parallel to indicatorIcons
#endif
Screen(const Screen &) = delete;
Screen &operator=(const Screen &) = delete;
+17 -1
View File
@@ -117,6 +117,11 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
const int screenW = display->getWidth();
const int screenH = display->getHeight();
// Compact panels: no persistent header, see UIRenderer::drawNavigationBar instead.
if (isCompactPanel(display)) {
display->setColor(WHITE); // Reset for other UI - normally done at the end of this function
return;
}
const int headerHeight = highlightHeight + 2;
// Color TFT headers use a fixed dark background + white glyphs.
// Keep legacy inverted bitmap behavior only for monochrome displays.
@@ -537,7 +542,13 @@ const int *getTextPositions(OLEDDisplay *display)
{
static int textPositions[7]; // Static array that persists beyond function scope
if (currentResolution == ScreenResolution::High) {
if (isCompactPanel(display)) {
// No header on compact panels - pack rows as tight as the font allows.
for (int i = 0; i < 7; ++i) {
const int bodyLine = (i > 0) ? i - 1 : 0;
textPositions[i] = bodyLine * (FONT_HEIGHT_SMALL - 6);
}
} else if (currentResolution == ScreenResolution::High) {
textPositions[0] = textZeroLine;
textPositions[1] = textFirstLine_medium;
textPositions[2] = textSecondLine_medium;
@@ -562,6 +573,11 @@ const int *getTextPositions(OLEDDisplay *display)
// *************************
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
{
if (isCompactPanel(display)) {
display->setColor(WHITE); // Reset for other UI - normally done at the end of this function
return;
}
if (!isAPIConnected(service->api_state))
return;
+12
View File
@@ -59,6 +59,18 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
// Shared battery/time/mail header
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
// Inline so non-compact boards fold this to a constant false at every call site, cost-free.
static inline bool isCompactPanel(OLEDDisplay *display)
{
#if defined(OLED_COMPACT_UI)
// Covers both known compact panels (72x40 and 64x48).
return display->getWidth() <= 80 && display->getHeight() <= 48;
#else
(void)display;
return false;
#endif
}
const int *getTextPositions(OLEDDisplay *display);
bool isAllowedPunctuation(char c);
+33 -25
View File
@@ -50,7 +50,7 @@ void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale)
uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8;
uint16_t topAndBottomX = x + static_cast<uint16_t>(4 * scale);
uint16_t topAndBottomX = x + 3;
uint16_t quarterCellHeight = cellHeight / 4;
@@ -178,7 +178,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
snprintf(secondString, sizeof(secondString), "%02d", second);
static bool scaleInitialized = false;
static float scale = 0.50f;
static float scale = 0.15f;
static float segmentWidth = SEGMENT_WIDTH * 0.75f;
static float segmentHeight = SEGMENT_HEIGHT * 0.75f;
@@ -186,7 +186,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
#ifdef DISPLAY_FORCE_SMALL_FONTS
float screenwidth_target_ratio = 0.70f; // Target 70% of display width (adjustable)
#elif defined(BICOLOR_OLED_DISPLAY)
float screenwidth_target_ratio = 0.60f; // Forced for BICOLOR_OLED_DISPLAY due to two color display
float screenwidth_target_ratio = 0.60f; // Forced for BICOLOR_OLED_DISPLAY due to two color display
#else
float screenwidth_target_ratio = 0.80f; // Target 80% of display width (adjustable)
#endif
@@ -194,11 +194,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
float step = 0.05f; // Step increment per iteration
float target_width = display->getWidth() * screenwidth_target_ratio;
#if !defined(OLED_COMPACT_UI)
float target_height =
display->getHeight() -
((currentResolution == ScreenResolution::High)
? 46
: 33); // Be careful adjusting this number, we have to account for header and the text under the time
#else
float target_height = display->getHeight(); // OLED compact UI has no header or footer, so we can use the full height
#endif
float calculated_width_size = 0.0f;
float calculated_height_size = 0.0f;
@@ -208,7 +212,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
segmentHeight = SEGMENT_HEIGHT * scale;
calculated_width_size = segmentHeight + ((segmentWidth + (segmentHeight * 2) + 4) * 4);
calculated_height_size = segmentHeight + ((segmentHeight + (segmentHeight * 2) + 4) * 2);
calculated_height_size = (segmentWidth * 2) + (segmentHeight * 3) + 8;
if (calculated_width_size >= target_width || calculated_height_size >= target_height || scale >= max_scale) {
break;
@@ -229,13 +233,22 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
// calculate hours:minutes string width
size_t len = strlen(timeString);
uint16_t timeStringWidth = len * 5;
#ifdef OLED_COMPACT_UI
#define CLOCK_CHAR_GAP 2
#else
#define CLOCK_CHAR_GAP 5
#endif
uint16_t timeStringWidth = (len - 1) * CLOCK_CHAR_GAP; // gaps sit between characters, not after the last one
for (size_t i = 0; i < len; i++) {
char character = timeString[i];
// Must mirror the advances used by the draw loop below, or the clock won't be centered.
if (character == ':') {
timeStringWidth += segmentHeight;
timeStringWidth += segmentHeight + 6;
if (scale >= 2.0f) {
timeStringWidth += (uint16_t)(4.5f * scale);
}
} else {
timeStringWidth += segmentWidth + (segmentHeight * 2) + 4;
}
@@ -246,6 +259,12 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2) + 2;
#if !defined(OLED_COMPACT_UI)
const uint16_t bottomRowY = hourMinuteTextY + ((uint16_t)segmentWidth * 2) + ((uint16_t)segmentHeight * 3) + 8 + 1;
#else
const uint16_t bottomRowY = (display->getHeight() - hourMinuteTextY) + 1;
#endif
// iterate over characters in hours:minutes string and draw segmented characters
for (size_t i = 0; i < len; i++) {
char character = timeString[i];
@@ -263,34 +282,23 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4;
}
hourMinuteTextX += 5;
hourMinuteTextX += CLOCK_CHAR_GAP;
}
// draw seconds string + AM/PM
display->setFont(FONT_SMALL);
int xOffset = -1;
if (currentResolution == ScreenResolution::High) {
xOffset = 0;
}
if (hour >= 10) {
if (currentResolution == ScreenResolution::High) {
xOffset += 32;
} else {
xOffset += 18;
}
}
if (config.display.use_12h_clock) {
display->drawString(startingHourMinuteTextX + xOffset, (display->getHeight() - hourMinuteTextY) - 1, isPM ? "pm" : "am");
#if !defined(OLED_COMPACT_UI)
const char *period = isPM ? "pm" : "am";
#else
const char *period = isPM ? "p" : "a";
#endif
display->drawString(startingHourMinuteTextX, bottomRowY, period);
}
#ifndef USE_EINK
xOffset = (currentResolution == ScreenResolution::High) ? 18 : 10;
if (scale >= 2.0f) {
xOffset -= (int)(4.5f * scale);
}
display->drawString(startingHourMinuteTextX + timeStringWidth - xOffset, (display->getHeight() - hourMinuteTextY) - 1,
secondString);
display->drawString(hourMinuteTextX - CLOCK_CHAR_GAP - display->getStringWidth(secondString), bottomRowY, secondString);
#endif
graphics::drawCommonFooter(display, x, y);
+35 -10
View File
@@ -161,13 +161,15 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
int nameX = (SCREEN_WIDTH - textWidth);
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
// === Second Row: Role ===
auto role = DisplayFormatters::getDeviceRole(config.device.role);
char device_role[25];
snprintf(device_role, sizeof(device_role), "Role: %s", role);
textWidth = display->getStringWidth(device_role);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], device_role);
if (!graphics::isCompactPanel(display)) {
// === Second Row: Role ===
auto role = DisplayFormatters::getDeviceRole(config.device.role);
char device_role[25];
snprintf(device_role, sizeof(device_role), "Role: %s", role);
textWidth = display->getStringWidth(device_role);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], device_role);
}
// === Third Row: Radio Preset ===
// For custom modem settings show the actual parameters; for presets use the preset name.
@@ -423,10 +425,14 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
display->setTextAlignment(TEXT_ALIGN_LEFT);
// System Uptime
if (line < 2) {
if (graphics::isCompactPanel(display)) {
line += 1;
} else {
if (line < 2) {
line += 1;
}
line += 1;
}
line += 1;
char appversionstr[35];
char appversionstr_formatted[40];
@@ -461,7 +467,8 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
display->drawString(nameX, getTextPositions(display)[line++], appversionstr);
if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show uptime if the screen can show it
if (!graphics::isCompactPanel(display) &&
(SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5))) { // Only show uptime if the screen can show it
char uptimeStr[32] = "";
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
textWidth = display->getStringWidth(uptimeStr);
@@ -471,6 +478,23 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show API state if the screen can show it
char api_state[32] = "";
#if defined(OLED_COMPACT_UI)
const char *connection = "None";
if (service->api_state == service->STATE_BLE) {
connection = "BLE";
} else if (service->api_state == service->STATE_WIFI) {
connection = "WiFi";
} else if (service->api_state == service->STATE_SERIAL) {
connection = "USB";
} else if (service->api_state == service->STATE_PACKET) {
connection = "Local";
} else if (service->api_state == service->STATE_HTTP) {
connection = "HTTP";
} else if (service->api_state == service->STATE_ETH) {
connection = "Eth";
}
snprintf(api_state, sizeof(api_state), "App: %s", connection);
#else
const char *clientWord = nullptr;
// Determine if narrow or wide screen
@@ -494,6 +518,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
} else if (service->api_state == service->STATE_ETH) {
snprintf(api_state, sizeof(api_state), "%s Connected (Ethernet)", clientWord);
}
#endif
if (api_state[0] != '\0') {
display->drawString((SCREEN_WIDTH - display->getStringWidth(api_state)) / 2, getTextPositions(display)[line++],
api_state);
+9 -2
View File
@@ -677,16 +677,19 @@ void menuHandler::TZPicker()
void menuHandler::clockMenu()
{
enum optionsNumbers { Back = 0, Clock, Time, Timezone };
#if defined(OLED_TINY)
static const char *optionsArray[] = {"Back", "Time Format", "Timezone"};
static const int optionsEnumArray[] = {Back, Time, Timezone};
#else
static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"};
static const int optionsEnumArray[] = {Back, Clock, Time, Timezone};
#endif
enum optionsNumbers { Back = 0, Clock = 1, Time = 2, Timezone = 3 };
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Clock Action";
bannerOptions.optionsArrayPtr = optionsArray;
bannerOptions.optionsCount = 4;
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.optionsCount = sizeof(optionsArray) / sizeof(optionsArray[0]);
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Clock) {
menuHandler::menuQueue = menuHandler::ClockFacePicker;
@@ -1475,7 +1478,11 @@ void menuHandler::nodeListMenu()
static int optionsEnumArray[enumEnd] = {Back};
int options = 1;
#if defined(OLED_TINY)
optionsArray[options] = "Node Action";
#else
optionsArray[options] = "Node Actions / Settings";
#endif
optionsEnumArray[options++] = NodePicker;
if (currentResolution != ScreenResolution::UltraLow) {
+91 -38
View File
@@ -69,6 +69,12 @@ void scrollDown()
if (maxScroll < 0)
maxScroll = 0;
if (graphics::isCompactPanel(screen->getDisplayDevice()) && scrollY >= maxScroll) {
// Compact panels: scrolling past the bottom wraps back to the top.
scrollY = 0;
return;
}
scrollY += 12;
if (scrollY > maxScroll)
scrollY = maxScroll;
@@ -427,9 +433,12 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
display->clear();
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
const int navHeight = FONT_HEIGHT_SMALL;
const bool compactPanel = graphics::isCompactPanel(display);
// Compact panels: no bottom nav row anymore (see UIRenderer::drawNavigationBar), full height available.
const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL;
const int scrollBottom = SCREEN_HEIGHT - navHeight;
const int usableHeight = scrollBottom;
const int contentTop = compactPanel ? 0 : getTextPositions(display)[1];
const int usableHeight = compactPanel ? scrollBottom - contentTop : scrollBottom;
constexpr int LEFT_MARGIN = 2;
constexpr int RIGHT_MARGIN = 2;
constexpr int SCROLLBAR_WIDTH = 3;
@@ -440,7 +449,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
constexpr int BUBBLE_TEXT_INDENT = 2;
// Check if bubbles are enabled
const bool showBubbles = config.display.enable_message_bubbles;
const bool showBubbles = config.display.enable_message_bubbles && !compactPanel;
const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN;
// Derived widths
@@ -616,13 +625,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
}
// Shrink Sender name if needed
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " *@...");
// Shrink Sender name if needed; compact panels put it on its own line, so no sharing with timeBuf/chanType.
int availWidth = compactPanel ? (mine ? rightTextWidth : leftTextWidth)
: (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
display->getStringWidth(chanType);
// Compact panels hard-cut (no "...") so drop its width reservation too.
availWidth -= graphics::UIRenderer::measureStringWithEmotes(display, compactPanel ? "*@" : " *@...");
if (availWidth < 0)
availWidth = 0;
char truncatedSender[64];
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth,
compactPanel ? "" : "...");
// Determine signed-message prefix before building the header line, since it needs to go
// at the front of headerStr rather than appended after (strncat only appends at the end).
@@ -634,28 +647,58 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
}
#endif
// Final header line
char headerStr[128];
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
} else {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
if (compactPanel) {
// Time and sender don't fit on one line at this width - time first, name below.
allLines.push_back(timeBuf);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(AckStatus::NONE); // ack mark shown on the name line instead
char nameLine[80] = "";
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
snprintf(nameLine, sizeof(nameLine), "to %s", truncatedSender);
} else {
snprintf(nameLine, sizeof(nameLine), "to %s", chanType);
}
}
} else {
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
snprintf(nameLine, sizeof(nameLine), chanType[0] ? "%s%s@%s" : "%s%s", signPrefix, truncatedSender, chanType);
}
if (nameLine[0]) {
allLines.push_back(nameLine);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
} else {
// Nothing to show on a second line (e.g. "mine" in ALL mode) - move the ack mark back.
ackForLine.back() = m.ackStatus;
}
} else {
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix, truncatedSender,
chanType);
}
// Final header line
char headerStr[128];
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
} else {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
}
} else {
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
}
} else {
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix,
truncatedSender, chanType);
}
// Push header line
allLines.push_back(headerStr);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
allLines.push_back(headerStr);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
}
const char *msgText = MessageStore::getText(m);
@@ -679,6 +722,13 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
// Cache lines and heights
cachedLines.swap(allLines);
cachedHeights = calculateLineHeights(cachedLines, emotes, isHeader);
if (compactPanel) {
for (size_t i = 0; i < cachedHeights.size(); ++i) {
if (isHeader[i]) {
cachedHeights[i] = 10;
}
}
}
std::vector<MessageBlock> blocks = buildMessageBlocks(isHeader, isMine);
@@ -728,8 +778,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
#endif
int finalScroll = (int)scrollY;
int yOffset = -finalScroll + getTextPositions(display)[1];
const int contentTop = getTextPositions(display)[1];
int yOffset = -finalScroll + contentTop;
const int contentBottom = scrollBottom; // already excludes nav line
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
@@ -907,18 +956,20 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
true);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
if (!compactPanel) {
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
int underlineW = w;
int maxW = rightEdge - headerX;
if (maxW < 0)
maxW = 0;
if (underlineW > maxW)
underlineW = maxW;
int underlineW = w;
int maxW = rightEdge - headerX;
if (maxW < 0)
maxW = 0;
if (underlineW > maxW)
underlineW = maxW;
for (int px = 0; px < underlineW; ++px) {
display->setPixel(headerX + px, underlineY);
for (int px = 0; px < underlineW; ++px) {
display->setPixel(headerX + px, underlineY);
}
}
// Draw ACK/NACK mark for our own messages
@@ -958,8 +1009,10 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
}
// Draw scrollbar
drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, getTextPositions(display)[1]);
graphics::drawCommonHeader(display, x, y, titleStr);
drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, contentTop);
if (!compactPanel) {
graphics::drawCommonHeader(display, x, y, titleStr);
}
graphics::drawCommonFooter(display, x, y);
}
+20 -12
View File
@@ -84,7 +84,6 @@ void scrollDown()
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
{
(void)display;
(void)columnWidth;
auto fallbackId = [&] {
@@ -119,10 +118,14 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
}
#endif
// If we didn't compose from status, use normal long/short selection
// Compact panels always prefer the long name, ignoring use_long_node_name.
if (!raw) {
if (nodeInfoLiteHasUser(node)) {
raw = config.display.use_long_node_name ? node->long_name : node->short_name;
if (isCompactPanel(display)) {
raw = (node->long_name[0]) ? node->long_name : node->short_name;
} else {
raw = config.display.use_long_node_name ? node->long_name : node->short_name;
}
}
}
@@ -242,7 +245,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
nameMaxWidth, graphics::isCompactPanel(display) ? "" : "...");
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
@@ -304,7 +307,7 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
nameMaxWidth, graphics::isCompactPanel(display) ? "" : "...");
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
@@ -392,7 +395,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
nameMaxWidth, graphics::isCompactPanel(display) ? "" : "...");
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
@@ -504,7 +507,7 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
nameMaxWidth, graphics::isCompactPanel(display) ? "" : "...");
#if GRAPHICS_TFT_COLORING_ENABLED
applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
#endif
@@ -604,7 +607,8 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon)
{
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
// Compact panels: 4 rows fit (0,9,18,27), a 5th pages instead of cramming in.
const int rowYOffset = graphics::isCompactPanel(display) ? (FONT_HEIGHT_SMALL - 4) : (FONT_HEIGHT_SMALL - 3);
bool locationScreen = false;
if (strcmp(title, "Bearings") == 0)
@@ -616,13 +620,16 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
// Draw the battery/time header
graphics::drawCommonHeader(display, x, y, title);
// Space below header
y += COMMON_HEADER_HEIGHT;
// Compact panels have no header (see drawCommonHeader) - don't reserve space for one.
if (!graphics::isCompactPanel(display))
y += COMMON_HEADER_HEIGHT;
firstRowY = y;
int totalColumns = 1; // Default to 1 column
if (config.display.use_long_node_name) {
if (graphics::isCompactPanel(display)) {
totalColumns = 1; // Too narrow to split - use the full line per entry.
} else if (config.display.use_long_node_name) {
if (SCREEN_WIDTH <= 240) {
totalColumns = 1;
} else if (SCREEN_WIDTH > 240) {
@@ -669,7 +676,8 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
}
if (scrollIndex > maxScroll)
scrollIndex = maxScroll;
// Compact panels: scrolling past the last page wraps back to the top.
scrollIndex = graphics::isCompactPanel(display) ? 0 : maxScroll;
int startIndex = scrollIndex * visibleNodeRows * totalColumns;
int endIndex = min(startIndex + visibleNodeRows * totalColumns, totalEntries);
int yOffset = 0;
+36 -17
View File
@@ -250,6 +250,11 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
return;
}
// Compact panels: DOWN cancels menus instead of scrolling (covers every picker below).
if (graphics::isCompactPanel(display) && inEvent.inputEvent == INPUT_BROKER_DOWN) {
inEvent.inputEvent = INPUT_BROKER_CANCEL;
}
switch (current_notification_type) {
case notificationTypeEnum::none:
// Do nothing - no notification to display
@@ -641,8 +646,12 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
const int arrowWidth = (currentResolution == ScreenResolution::High)
? UIRenderer::measureStringWithEmotes(display, "> <")
: UIRenderer::measureStringWithEmotes(display, "><");
const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth);
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth);
const bool compactPanel = graphics::isCompactPanel(display);
// Compact panels: box spans the full width, so just a small edge margin.
const int margin = compactPanel ? 4 : 28;
const int maxTextWidth = std::max(0, display->getWidth() - margin - arrowWidth);
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth,
compactPanel ? "" : "...");
}
} else {
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
@@ -688,8 +697,9 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp
const char *lineStarts[MAX_LINES + 1] = {0};
uint16_t lineCount = 0;
char lineBuffer[40] = {0};
bool useTaggedTextBanner =
(current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0 && alertBannerLineCount > 0);
bool useTaggedTextBanner = ((current_notification_type == notificationTypeEnum::text_banner ||
current_notification_type == notificationTypeEnum::pairing_pin) &&
alertBannerOptions == 0 && alertBannerLineCount > 0);
if (useTaggedTextBanner) {
lineCount = std::min<uint8_t>(alertBannerLineCount, MAX_LINES);
@@ -775,7 +785,7 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp
const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation
// copy the linestarts to display to the linePointers holder
for (int i = 0; i < lineCount; i++) {
for (uint16_t i = 0; i < lineCount && i < visibleTotalLines; i++) {
linePointers[i] = lineStarts[i];
}
@@ -835,7 +845,9 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
BannerFont lineFonts[totalLines] = {};
uint8_t lineEffectiveHeights[totalLines] = {0};
const char *renderLines[totalLines] = {0};
bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0);
bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner ||
current_notification_type == notificationTypeEnum::pairing_pin) &&
alertBannerOptions == 0;
if (maxWidth != 0)
is_picker = true;
@@ -937,18 +949,25 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
int16_t boxTop = (display->height() / 2) - (boxHeight / 2);
boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1;
if (graphics::isCompactPanel(display)) {
boxLeft = 0;
boxTop = 0;
boxWidth = display->width();
boxHeight = display->height();
} else {
#if defined(OLED_TINY)
if (visibleTotalLines == 1) {
boxTop += 25;
}
if (alertBannerOptions < 3) {
int missingLines = 3 - alertBannerOptions;
int moveUp = missingLines * (effectiveLineHeight / 2);
boxTop -= moveUp;
if (boxTop < 0)
boxTop = 0;
}
if (visibleTotalLines == 1) {
boxTop += 25;
}
if (alertBannerOptions < 3) {
int missingLines = 3 - alertBannerOptions;
int moveUp = missingLines * (effectiveLineHeight / 2);
boxTop -= moveUp;
if (boxTop < 0)
boxTop = 0;
}
#endif
}
// Draw Box
display->setColor(BLACK);
@@ -1009,7 +1028,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
#endif
display->setColor(BLACK);
int yOffset = 3;
const int yOffset = graphics::isCompactPanel(display) ? 2 : 3;
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
+393 -39
View File
@@ -116,7 +116,8 @@ static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compa
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, heading, labelRadius);
}
static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading,
bool showRing = true)
{
const StandardCompassNeedlePoints points =
computeStandardCompassNeedlePoints(compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, 0.0f);
@@ -141,7 +142,8 @@ static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16
points.southRightY);
#endif
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading);
}
@@ -350,7 +352,8 @@ static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compa
#endif
}
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading,
bool showRing = true)
{
// Compass colors should follow whatever background role is already active at this location.
const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg());
@@ -375,7 +378,8 @@ static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_
}
drawStandardCompassNeedle(display, compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, compassBgColor);
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading);
drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading);
}
@@ -392,10 +396,11 @@ static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_
static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
bool showCompass, float myHeading, float bearing, const char *statusLine1,
const char *statusLine2)
const char *statusLine2, bool showRing = true)
{
// Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text.
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
if (showCompass) {
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
@@ -405,17 +410,19 @@ static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, i
}
static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
bool validHeading, float heading, const char *statusLine1, const char *statusLine2)
bool validHeading, float heading, const char *statusLine1, const char *statusLine2,
bool showRing = true)
{
// Shared "position screen" compass renderer: use mono/TFT path only when heading is valid.
if (validHeading) {
#if GRAPHICS_TFT_COLORING_ENABLED
drawTftCompass(display, compassX, compassY, compassRadius, heading);
drawTftCompass(display, compassX, compassY, compassRadius, heading, showRing);
#else
drawMonoCompass(display, compassX, compassY, compassRadius, heading);
drawMonoCompass(display, compassX, compassY, compassRadius, heading, showRing);
#endif
} else {
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
}
}
@@ -508,15 +515,8 @@ extern uint32_t dopThresholds[5];
// Draw GPS status summary (satellite icon + status text).
// Handles all GPS states: disabled / not present / fixed position / no lock / sat count.
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps, bool center)
{
// Draw satellite image
if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y + 1, imgGPS_width, imgGPS_height, imgGPS, display);
} else {
display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS);
}
char textString[12];
if (config.position.fixed_position) {
// Fixed position overrides live GPS state, regardless of gps_mode
@@ -533,7 +533,20 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
}
display->drawString(x + ((currentResolution == ScreenResolution::High) ? 18 : 11), y, textString);
const int textOffset = (currentResolution == ScreenResolution::High) ? 18 : 11;
if (center) {
int contentWidth = textOffset + display->getStringWidth(textString);
x = (SCREEN_WIDTH - contentWidth) / 2;
}
// Draw satellite image
if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
} else {
display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
}
display->drawString(x + textOffset, y, textString);
}
void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
@@ -681,7 +694,7 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y,
// Draw nodes status
void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset,
bool show_total, const char *additional_words)
bool show_total, const char *additional_words, bool center)
{
char usersString[20];
int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0;
@@ -693,6 +706,12 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words);
}
int string_offset = (currentResolution == ScreenResolution::High) ? 9 : 0;
if (center) {
int contentWidth = 10 + string_offset + display->getStringWidth(usersString);
x = (SCREEN_WIDTH - contentWidth) / 2;
}
#if (defined(USE_EINK) || defined(HAS_SPI_TFT)) && !defined(DISPLAY_FORCE_SMALL_FONTS)
if (currentResolution == ScreenResolution::High) {
@@ -707,13 +726,26 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
display->drawFastImage(x, y + 1, 8, 8, imgUser);
}
#endif
int string_offset = (currentResolution == ScreenResolution::High) ? 9 : 0;
display->drawString(x + 10 + string_offset, y - 2, usersString);
}
// **********************
// * Favorite Node Info *
// **********************
// Compact panels: toggle between the compass/distance view and the status/telemetry view.
static int favoriteViewIndex = 0;
void UIRenderer::scrollFavoriteDown()
{
favoriteViewIndex = (favoriteViewIndex + 1) % 2;
}
void UIRenderer::scrollFavoriteUp()
{
if (favoriteViewIndex > 0)
favoriteViewIndex--;
}
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
@@ -746,6 +778,158 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
// === Draw battery/time/mail header (common across screens) ===
graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow);
#if HAS_GPS && defined(OLED_COMPACT_UI)
// Compact panels: page 0 = name/distance/compass, page 1 = status/telemetry (scroll down)
if (graphics::isCompactPanel(display)) {
int cline = 1;
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite nodePos, ourPos;
const bool haveNodePos = nodeDB->copyNodePosition(node->num, nodePos);
const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
const bool hasFix = hasOwnPositionFix && hasNodePositionFix && haveOurPos && haveNodePos;
if (favoriteViewIndex == 0) {
// --- Long name (falls back to short) ---
const char *rawName = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : shortName;
char nodeName[40];
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
UIRenderer::drawStringWithEmotes(display, 2, getTextPositions(display)[cline++], nodeName, FONT_HEIGHT_SMALL, 1,
false);
// --- Compass (bearing to node), right-aligned ---
bool showCompass = false;
float myHeading = 0.0f, bearing = 0.0f;
const char *statusLine1 = nullptr;
const char *statusLine2 = nullptr;
if (hasFix) {
showCompass = CompassRenderer::getHeadingRadians(DegD(ourPos.latitude_i), DegD(ourPos.longitude_i), myHeading);
if (showCompass) {
bearing = GeoCoord::bearing(DegD(ourPos.latitude_i), DegD(ourPos.longitude_i), DegD(nodePos.latitude_i),
DegD(nodePos.longitude_i));
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
} else {
statusLine1 = "No";
statusLine2 = "Heading";
}
} else {
statusLine1 = "No";
statusLine2 = "Fix";
}
const int compassTop = getTextPositions(display)[cline];
int availableHeight = SCREEN_HEIGHT - compassTop - 1;
const int maxCompassDiameter = (SCREEN_WIDTH / 2 - 4 < availableHeight) ? (SCREEN_WIDTH / 2 - 4) : availableHeight;
int compassRadius = maxCompassDiameter / 2;
if (compassRadius < 8)
compassRadius = 8;
const int compassX = SCREEN_WIDTH - compassRadius - 4;
const int compassY = compassTop + availableHeight / 2;
drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1,
statusLine2, /*showRing=*/false);
// --- Distance, directly under the name, left side only, shown when a fix is available ---
if (hasFix) {
char distStr[16];
const float distanceMeters = GeoCoord::latLongToMeter(DegD(nodePos.latitude_i), DegD(nodePos.longitude_i),
DegD(ourPos.latitude_i), DegD(ourPos.longitude_i));
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
const int feet = static_cast<int>((distanceMeters * METERS_TO_FEET) + 0.5f);
if (feet < 1000)
snprintf(distStr, sizeof(distStr), "%dft", feet);
else
snprintf(distStr, sizeof(distStr), "%dmi", (feet + 2640) / 5280);
} else {
const int meters = static_cast<int>(distanceMeters + 0.5f);
if (meters < 1000)
snprintf(distStr, sizeof(distStr), "%dm", meters);
else
snprintf(distStr, sizeof(distStr), "%dkm", (meters + 500) / 1000);
}
display->drawString(2, getTextPositions(display)[cline++], distStr);
}
// --- Last heard, directly under distance ---
uint32_t seenSeconds = sinceLastSeen(node);
if (seenSeconds != 0 && seenSeconds != UINT32_MAX) {
uint32_t minutes = seenSeconds / 60, hours = minutes / 60, days = hours / 24;
char seenStr[16];
snprintf(seenStr, sizeof(seenStr), (days > 365 ? "?" : "%d%c"),
(days ? days
: hours ? hours
: minutes),
(days ? 'd'
: hours ? 'h'
: 'm'));
display->drawString(2, getTextPositions(display)[cline++], seenStr);
}
} else {
// --- Page 1: status, signal/hops, heard, uptime, battery ---
meshtastic_StatusMessage cachedStatus;
if (nodeDB && nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
drawTruncatedStatusLine(display, x, getTextPositions(display)[cline++], cachedStatus.status);
}
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
const bool showHops = node->has_hops_away && node->hops_away > 0;
if (isZeroHop && node->snr > -100 && node->snr != 0) {
char sigStr[16];
snprintf(sigStr, sizeof(sigStr), "SNR:%.1f", node->snr);
display->drawString(x, getTextPositions(display)[cline++], sigStr);
} else if (showHops) {
char hopStr[16];
snprintf(hopStr, sizeof(hopStr), "Hops:%d", node->hops_away);
display->drawString(x, getTextPositions(display)[cline++], hopStr);
}
uint32_t seconds = sinceLastSeen(node);
if (seconds != 0 && seconds != UINT32_MAX) {
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
char seenStr[20];
snprintf(seenStr, sizeof(seenStr), (days > 365 ? "Heard:?" : "Heard:%d%c ago"),
(days ? days
: hours ? hours
: minutes),
(days ? 'd'
: hours ? 'h'
: 'm'));
display->drawString(x, getTextPositions(display)[cline++], seenStr);
}
meshtastic_DeviceMetrics nodeMetrics;
if (nodeDB->copyNodeTelemetry(node->num, nodeMetrics)) {
if (nodeMetrics.has_uptime_seconds) {
char uptimeStr[24];
getUptimeStr(nodeMetrics.uptime_seconds * 1000, "Up:", uptimeStr, sizeof(uptimeStr));
display->drawString(x, getTextPositions(display)[cline++], uptimeStr);
}
if (nodeMetrics.has_battery_level) {
char batStr[24];
int pct = (int)nodeMetrics.battery_level;
if (pct > 100) {
snprintf(batStr, sizeof(batStr), "Plugged In");
} else {
snprintf(batStr, sizeof(batStr), "Bat:%d%%", pct);
}
display->drawString(x, getTextPositions(display)[cline++], batStr);
}
}
}
// Two-page indicator, matching the position screen's scrollbar thumb style.
const int scrollbarX = SCREEN_WIDTH - 2;
const int thumbHeight = SCREEN_HEIGHT / 2;
const int thumbY = favoriteViewIndex * (SCREEN_HEIGHT - thumbHeight);
for (int i = 0; i < thumbHeight; i++) {
display->setPixel(scrollbarX, thumbY + i);
}
graphics::drawCommonFooter(display, x, y);
return;
}
#endif
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
// 2. Each row is only shown if it has valid data.
@@ -1140,9 +1324,19 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
const bool compactPanel = graphics::isCompactPanel(display);
if (!config.lora.tx_enabled) {
const char *txdisabled = "Transmit Disabled";
display->drawString(x, getTextPositions(display)[line], txdisabled);
if (compactPanel) {
int textWidth = display->getStringWidth(txdisabled);
display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled);
} else {
display->drawString(x, getTextPositions(display)[line], txdisabled);
}
} else if (compactPanel) {
// No room for a separate left/right column layout - center it instead.
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online", true);
} else {
// Display Region and Channel Utilization
if (currentResolution == ScreenResolution::UltraLow) {
@@ -1155,27 +1349,48 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
if (currentResolution != ScreenResolution::UltraLow) {
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
}
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
if (!compactPanel) {
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
} else {
line++;
}
// === Second Row: Satellites and Voltage ===
config.display.heading_bold = false;
#if HAS_GPS
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus);
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus, compactPanel);
#endif
#if defined(OLED_TINY)
line += 1;
// === Node Identity ===
int textWidth = 0;
int nameX = 0;
const char *shortName = owner.short_name[0] ? owner.short_name : "";
if (compactPanel) {
// === Channel Utilization (compact, above name) ===
int chutil_percent = static_cast<int>(airTime->channelUtilizationPercent() + 0.5f);
char chUtilStr[16];
snprintf(chUtilStr, sizeof(chUtilStr), "ChUtil %d%%", chutil_percent);
int chUtilWidth = display->getStringWidth(chUtilStr);
display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++], chUtilStr);
// === ShortName Centered ===
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
// === Node Identity: long name (falls back to short), truncated with "..." if too wide ===
const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : "";
const char *shortName = owner.short_name[0] ? owner.short_name : "";
const char *rawName = longName[0] ? longName : shortName;
char nodeName[32];
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
int nameX = (SCREEN_WIDTH - textWidth) / 2;
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], nodeName, FONT_HEIGHT_SMALL, 1,
false);
} else {
// === Node Identity ===
const char *shortName = owner.short_name[0] ? owner.short_name : "";
int textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
int nameX = (SCREEN_WIDTH - textWidth) / 2;
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
false);
}
#else
if (powerStatus->getHasBattery()) {
char batStr[20];
@@ -1532,6 +1747,20 @@ void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display,
// ****************************
// * My Position Screen *
// ****************************
// Compact panels: 0 = compass, 1 = coordinates + elevation
static int positionViewIndex = 0;
void UIRenderer::scrollPositionDown()
{
positionViewIndex = (positionViewIndex + 1) % 2;
}
void UIRenderer::scrollPositionUp()
{
if (positionViewIndex > 0)
positionViewIndex--;
}
void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
display->clear();
@@ -1545,13 +1774,14 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
// === Header ===
graphics::drawCommonHeader(display, x, y, titleStr);
const int *textPos = getTextPositions(display);
const bool compactPanel = graphics::isCompactPanel(display);
// === First Row: My Location ===
#if HAS_GPS
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus);
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus, compactPanel);
config.display.heading_bold = origBold;
@@ -1594,8 +1824,61 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
}
}
// GPS data (coordinates/altitude) is available whenever GPS is enabled or a fixed position is set.
const bool hasGpsData =
config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED || config.position.fixed_position;
// Compact panels: GPS status row above is shared; scroll down for coordinates+elevation.
#if defined(OLED_COMPACT_UI)
if (compactPanel) {
if (positionViewIndex == 0) {
// Compass comes from the IMU/magnetometer, not GPS - keep it showing even with GPS off.
const int compassTop = textPos[line];
int availableHeight = SCREEN_HEIGHT - compassTop - 1;
int compassRadius = availableHeight / 2;
if (compassRadius < 8)
compassRadius = 8;
if (compassRadius * 2 > SCREEN_WIDTH - 4)
compassRadius = (SCREEN_WIDTH - 4) / 2;
drawDetailedCompassOrStatus(display, x + SCREEN_WIDTH / 2, compassTop + availableHeight / 2, compassRadius,
validHeading, heading, statusLine1, statusLine2, /*showRing=*/false);
} else if (hasGpsData) {
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1");
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2");
}
char altitudeLine[32] = {0};
int32_t alt = geoCoord.getAltitude();
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0fft", alt * METERS_TO_FEET);
} else {
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt);
}
display->drawString(x, textPos[line++], altitudeLine);
} else {
// Coordinates page needs GPS - say so instead of leaving it blank.
const char *displayLine =
(config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) ? "No GPS" : "GPS off";
int textWidth = display->getStringWidth(displayLine);
display->drawString((SCREEN_WIDTH - textWidth) / 2, textPos[line], displayLine);
}
// Two-page indicator, matching NodeListRenderer::drawScrollbar's thumb style.
const int scrollbarX = SCREEN_WIDTH - 2;
const int thumbHeight = SCREEN_HEIGHT / 2;
const int thumbY = positionViewIndex * (SCREEN_HEIGHT - thumbHeight);
for (int i = 0; i < thumbHeight; i++) {
display->setPixel(scrollbarX, thumbY + i);
}
graphics::drawCommonFooter(display, x, y);
return;
}
#endif
// If GPS is off or not present (and position isn't fixed), no need to display these parts
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED || config.position.fixed_position) {
if (hasGpsData) {
// === Second Row: Last GPS Fix ===
if (gpsStatus->getLastFixMillis() > 0) {
uint32_t delta = millis() - gpsStatus->getLastFixMillis();
@@ -1751,6 +2034,12 @@ void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *sta
static int16_t lastFrameIndex = -1;
static uint32_t lastFrameChangeTime = 0;
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
constexpr uint32_t ICON_DISPLAY_DURATION_MS_COMPACT = 1000;
void UIRenderer::notifyScreenWoke()
{
lastFrameChangeTime = millis();
}
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
@@ -1776,18 +2065,79 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8;
const int spacing = (currentResolution == ScreenResolution::High) ? 8 : 4;
const int bigOffset = (currentResolution == ScreenResolution::High) ? 1 : 0;
const bool compactPanel = graphics::isCompactPanel(display);
const size_t totalIcons = screen->indicatorIcons.size();
if (totalIcons == 0)
return;
const int navPadding = (currentResolution == ScreenResolution::High) ? 24 : 12; // padding per side
// Compact panels: briefly show current frame's icon+title centered, then nothing.
#if defined(OLED_COMPACT_UI)
if (compactPanel) {
const bool introVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS_COMPACT;
if (!introVisible)
return;
const uint8_t *icon =
frameToHighlight < screen->indicatorIcons.size() ? screen->indicatorIcons[frameToHighlight] : nullptr;
const char *title = frameToHighlight < screen->frameTitles.size() ? screen->frameTitles[frameToHighlight] : "";
// Favorite frames: show "Fav: <name>" instead of the generic "Favorite" label.
char favTitleBuf[24];
if (title && strcmp(title, "Favorite") == 0) {
const int favNodeIndex = static_cast<int>(frameToHighlight) -
(static_cast<int>(screen->frameCount) - static_cast<int>(favoritedNodes.size()));
if (favNodeIndex >= 0 && favNodeIndex < (int)favoritedNodes.size() && favoritedNodes[favNodeIndex]) {
meshtastic_NodeInfoLite *favNode = favoritedNodes[favNodeIndex];
const char *favShort = (nodeInfoLiteHasUser(favNode) && favNode->short_name[0]) ? favNode->short_name : "Node";
const char *favRaw = (nodeInfoLiteHasUser(favNode) && favNode->long_name[0]) ? favNode->long_name : favShort;
char favLine[40];
snprintf(favLine, sizeof(favLine), "Fav: %s", favRaw);
UIRenderer::truncateStringWithEmotes(display, favLine, favTitleBuf, sizeof(favTitleBuf), SCREEN_WIDTH - 4);
title = favTitleBuf;
}
}
constexpr int introIconSize = 16;
display->setFont(FONT_SMALL);
const int blockHeight = introIconSize + 2 + FONT_HEIGHT_SMALL;
const int top = (SCREEN_HEIGHT - blockHeight) / 2;
const int iconX = (SCREEN_WIDTH - introIconSize) / 2;
display->setColor(BLACK);
display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
display->setColor(WHITE);
// Battery level in the top-right corner while the intro is showing.
char battStr[8];
int chargePercent = powerStatus->getBatteryChargePercent();
if (chargePercent == 101) {
snprintf(battStr, sizeof(battStr), "USB");
} else {
snprintf(battStr, sizeof(battStr), "%s%d%%", powerStatus->getIsCharging() ? "+" : "", chargePercent);
}
display->setTextAlignment(TEXT_ALIGN_RIGHT);
display->drawString(SCREEN_WIDTH, 0, battStr);
display->setTextAlignment(TEXT_ALIGN_LEFT);
if (icon)
NodeListRenderer::drawScaledXBitmap16x16(iconX, top, 8, 8, icon, display);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(SCREEN_WIDTH / 2, top + introIconSize + 2, title);
display->setTextAlignment(TEXT_ALIGN_LEFT);
return;
}
#endif
const int navPadding = compactPanel ? 8 : ((currentResolution == ScreenResolution::High) ? 24 : 12);
int usableWidth = SCREEN_WIDTH - (navPadding * 2);
if (usableWidth < iconSize)
usableWidth = iconSize;
const size_t iconsPerPage = usableWidth / (iconSize + spacing);
const size_t iconsPerPage =
compactPanel ? ((usableWidth + spacing) / (iconSize + spacing)) : (usableWidth / (iconSize + spacing));
const size_t currentPage = frameToHighlight / iconsPerPage;
const size_t pageStart = currentPage * iconsPerPage;
const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons);
@@ -1825,11 +2175,14 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
navBarPrevVisible = navBarVisible;
#endif
if (compactPanel && !navBarVisible)
return;
// Pre-calculate bounding rect
const int rectX = xStart - 2 - bigOffset;
const int rectY = y - 2;
const int rectY = y - (compactPanel ? 1 : 2);
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
const int rectHeight = iconSize + 6;
const int rectHeight = iconSize + (compactPanel ? 2 : 6);
// Clear background and draw border
display->setColor(BLACK);
@@ -1871,8 +2224,9 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2);
display->setColor(BLACK);
#else
const int activePadding = compactPanel ? 1 : 2;
display->setColor(WHITE);
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
display->fillRect(x - activePadding, y - activePadding, iconSize + activePadding * 2, iconSize + activePadding * 2);
display->setColor(BLACK);
#endif
}
+11 -2
View File
@@ -35,10 +35,10 @@ class UIRenderer
public:
// Common UI elements
static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus,
int node_offset = 0, bool show_total = true, const char *additional_words = "");
int node_offset = 0, bool show_total = true, const char *additional_words = "", bool center = false);
// GPS status functions
static void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
static void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus, bool center = false);
static void drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus,
const char *mode = "line1");
static void drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
@@ -48,8 +48,14 @@ class UIRenderer
// Navigation bar overlay
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
// Compact panels: called when the screen turns back on, so the intro splash replays even
// though drawNavigationBar itself never ran while the screen (and its OSThread) was off.
static void notifyScreenWoke();
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// Compact panels: toggle between compass+distance view and status/telemetry view
static void scrollFavoriteDown();
static void scrollFavoriteUp();
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
@@ -60,6 +66,9 @@ class UIRenderer
// Compass and location screen
static void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// Compact panels: toggle between compass view and coordinates+elevation view
static void scrollPositionDown();
static void scrollPositionUp();
static NodeNum currentFavoriteNodeNum;
static std::vector<meshtastic_NodeInfoLite *> favoritedNodes;
+26
View File
@@ -58,6 +58,10 @@ ButtonThread *BackButtonThread = nullptr;
ButtonThread *CancelButtonThread = nullptr;
#endif
#if defined(DOWN_BUTTON_PIN)
ButtonThread *DownButtonThread = nullptr;
#endif
#endif
InputBroker *inputBroker = nullptr;
@@ -323,6 +327,28 @@ void InputBroker::Init()
BackButtonThread->initButton(backConfig);
#endif
#if defined(DOWN_BUTTON_PIN)
// Sends literal INPUT_BROKER_DOWN/DOWN_LONG (needed for message/nodelist scroll),
// unlike ALT_BUTTON_PIN which sends ALT_PRESS/ALT_LONG (treated as UP/previous).
DownButtonThread = new ButtonThread("DownButton");
ButtonConfig downConfig;
downConfig.pinNumber = DOWN_BUTTON_PIN;
downConfig.activeLow = DOWN_BUTTON_ACTIVE_LOW;
downConfig.activePullup = DOWN_BUTTON_ACTIVE_PULLUP;
downConfig.pullupSense = pullup_sense;
downConfig.intRoutine = []() {
DownButtonThread->userButton.tick();
DownButtonThread->setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
concurrency::mainDelay.interruptFromISR(&higherWake);
};
downConfig.singlePress = INPUT_BROKER_DOWN;
downConfig.longPress = INPUT_BROKER_DOWN_LONG;
downConfig.longPressTime = 500;
DownButtonThread->initButton(downConfig);
#endif
#if defined(BUTTON_PIN)
#if defined(USERPREFS_BUTTON_PIN)
int _pinNum = config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN;
+15 -3
View File
@@ -1112,6 +1112,10 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.display.wake_on_tap_or_motion = true;
#endif
#if defined(T_ECHO_CARD)
config.display.screen_on_secs = 60;
#endif
#ifdef COMPASS_ORIENTATION
config.display.compass_orientation = COMPASS_ORIENTATION;
#endif
@@ -1220,8 +1224,11 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.has_store_forward = true;
moduleConfig.has_telemetry = true;
moduleConfig.has_external_notification = true;
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) || \
defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
#if defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION)
#define HAS_NOTIFICATION_LED
#endif
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_NOTIFICATION_LED) || \
defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || defined(HAS_I2S_SPEAKER_NRF52)
moduleConfig.external_notification.enabled = true;
#endif
@@ -1229,6 +1236,10 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.external_notification.output_buzzer = PIN_BUZZER;
moduleConfig.external_notification.use_pwm = true;
moduleConfig.external_notification.alert_message_buzzer = true;
#elif defined(HAS_I2S_SPEAKER_NRF52)
// No PWM piezo pin - alert playback goes through NRF52RtttlPlayer/I2S instead,
// gated only on alert_message_buzzer + canBuzz(), not output_buzzer/use_pwm.
moduleConfig.external_notification.alert_message_buzzer = true;
#endif
#if defined(PIN_VIBRATION)
@@ -1246,7 +1257,8 @@ void NodeDB::installDefaultModuleConfig()
#if defined(PIN_VIBRATION)
moduleConfig.external_notification.nag_timeout = 2;
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || \
defined(HAS_I2S_SPEAKER_NRF52)
moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
#endif
@@ -40,6 +40,10 @@ bool ascending = true;
#define PIN_BUZZER false
#endif
#if defined(HAS_I2S_SPEAKER_NRF52)
#include "platform/nrf52/NRF52RtttlPlayer.h"
#endif
/*
Documentation:
https://meshtastic.org/docs/configuration/module/external-notification
@@ -77,6 +81,9 @@ int32_t ExternalNotificationModule::runOnce()
#ifdef HAS_I2S
// audioThread->isPlaying() also handles actually playing the RTTTL, needs to be called in loop
isRtttlPlaying = isRtttlPlaying || audioThread->isPlaying();
#endif
#if defined(HAS_I2S_SPEAKER_NRF52)
isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying();
#endif
if ((nagCycleCutoff < millis()) && !isRtttlPlaying) {
// Turn off external notification immediately when timeout is reached, regardless of song state
@@ -145,6 +152,17 @@ int32_t ExternalNotificationModule::runOnce()
// we need fast updates to play the RTTTL
delay = EXT_NOTIFICATION_FAST_THREAD_MS;
}
#endif
#if defined(HAS_I2S_SPEAKER_NRF52)
// Play RTTTL over the I2S speaker (no piezo on this board).
if (canBuzz() && buzzerShouldAlert) {
if (nrf52RtttlPlayer.isPlaying()) {
nrf52RtttlPlayer.play();
} else if (isNagging && (nagCycleCutoff >= millis())) {
nrf52RtttlPlayer.begin(rtttlConfig.ringtone);
}
delay = EXT_NOTIFICATION_FAST_THREAD_MS;
}
#endif
// now let the PWM buzzer play
if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) {
@@ -264,6 +282,9 @@ void ExternalNotificationModule::stopNow()
#ifdef HAS_I2S
LOG_INFO("Stop audioThread playback");
audioThread->stop();
#endif
#if defined(HAS_I2S_SPEAKER_NRF52)
nrf52RtttlPlayer.stop();
#endif
// Turn off all outputs
LOG_INFO("Turning off setExternalStates");
+84
View File
@@ -0,0 +1,84 @@
#include "NRF52I2SOutput.h"
#if defined(HAS_I2S_SPEAKER_NRF52)
#include <nrf_i2s.h>
NRF52I2SOutput nrf52I2SOutput;
bool NRF52I2SOutput::begin(uint8_t sckPin, uint8_t wsPin, uint8_t sdPin)
{
nrf_i2s_pins_set(NRF_I2S, sckPin, wsPin, NRF_I2S_PIN_NOT_CONNECTED, sdPin, NRF_I2S_PIN_NOT_CONNECTED);
bool ok = nrf_i2s_configure(NRF_I2S, NRF_I2S_MODE_MASTER, NRF_I2S_FORMAT_I2S, NRF_I2S_ALIGN_LEFT, NRF_I2S_SWIDTH_16BIT,
NRF_I2S_CHANNELS_STEREO, NRF_I2S_MCK_32MDIV8, NRF_I2S_RATIO_256X);
started = ok;
return ok;
}
void NRF52I2SOutput::fillBuffer(uint32_t frequencyHz)
{
size_t samplesPerCycle = kSampleRateHz / frequencyHz;
if (samplesPerCycle < 4)
samplesPerCycle = 4;
if (samplesPerCycle > kMaxSamples)
samplesPerCycle = kMaxSamples;
// Square wave, same value in both stereo halves (works regardless of SD_MODE strap).
const int16_t amplitude = 12000; // headroom below full-scale
const size_t half = samplesPerCycle / 2;
for (size_t i = 0; i < samplesPerCycle; i++) {
int16_t sample = (i < half) ? amplitude : (int16_t)-amplitude;
buffer[i] = ((uint32_t)(uint16_t)sample << 16) | (uint16_t)sample;
}
// Buffer pointer is never updated after START, so EasyDMA just keeps replaying it.
nrf_i2s_transfer_set(NRF_I2S, (uint16_t)samplesPerCycle, NULL, buffer);
}
void NRF52I2SOutput::playTone(uint32_t frequencyHz, uint32_t durationMs)
{
if (!started || durationMs == 0)
return;
if (frequencyHz <= 1) {
// Rest note.
delay(durationMs);
return;
}
fillBuffer(frequencyHz);
nrf_i2s_enable(NRF_I2S);
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_START);
delay(durationMs);
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_STOP);
nrf_i2s_disable(NRF_I2S);
}
void NRF52I2SOutput::startTone(uint32_t frequencyHz)
{
if (!started)
return;
stopTone();
if (frequencyHz <= 1)
return;
fillBuffer(frequencyHz);
nrf_i2s_enable(NRF_I2S);
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_START);
}
void NRF52I2SOutput::stopTone()
{
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_STOP);
nrf_i2s_disable(NRF_I2S);
}
void NRF52I2SOutput::end()
{
stopTone();
started = false;
}
#endif // HAS_I2S_SPEAKER_NRF52
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// nRF52840 I2S tone driver. Drives NRF_I2S registers directly via nrf_i2s.h
// (the framework has no nrfx_i2s.c driver, only the register HAL).
// Plays repeating square-wave tones only - no RTTTL text parsing.
#include <Arduino.h>
class NRF52I2SOutput
{
public:
bool begin(uint8_t sckPin, uint8_t wsPin, uint8_t sdPin);
// frequencyHz <= 1 = silent rest.
void playTone(uint32_t frequencyHz, uint32_t durationMs);
// Non-blocking variants for callers that advance timing themselves (e.g. RTTTL player).
void startTone(uint32_t frequencyHz);
void stopTone();
void end();
private:
void fillBuffer(uint32_t frequencyHz);
static constexpr uint32_t kSampleRateHz = 15625; // 32MHz/8/256
static constexpr size_t kMaxSamples = 512;
uint32_t buffer[kMaxSamples] = {};
bool started = false;
};
extern NRF52I2SOutput nrf52I2SOutput;
+184
View File
@@ -0,0 +1,184 @@
#include "NRF52RtttlPlayer.h"
#if defined(HAS_I2S_SPEAKER_NRF52)
#include "NRF52I2SOutput.h"
#include "mesh/Throttle.h"
#include <cctype>
NRF52RtttlPlayer nrf52RtttlPlayer;
namespace
{
// Index 0 unused, then C4..B4, C5..B5, C6..B6, C7..B7 (48 notes) - same layout as
// the NonBlockingRTTTL library's table, indexed as [(octave - 4) * 12 + note].
const int kNoteFreq[] = {
0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, 523, 554, 587, 622,
659, 698, 740, 784, 831, 880, 932, 988, 1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661,
1760, 1865, 1976, 2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951,
};
} // namespace
void NRF52RtttlPlayer::begin(const char *songBuffer, uint8_t loopCountIn, unsigned long loopGapMs)
{
buffer = songBuffer;
defaultDuration = 4;
defaultOctave = 6;
bpm = 63;
playing = true;
noteStartAt = millis();
noteDurationMs = 0;
loopCount = loopCountIn;
loopGap = loopGapMs;
pinMode(SPEAKER_EN, OUTPUT);
digitalWrite(SPEAKER_EN, HIGH);
#if defined(SPEAKER_EN_2)
pinMode(SPEAKER_EN_2, OUTPUT);
digitalWrite(SPEAKER_EN_2, HIGH);
#endif
nrf52I2SOutput.begin(SPEAKER_BCLK, SPEAKER_WS_LRCK, SPEAKER_DATA);
nrf52I2SOutput.stopTone();
// format: name:d=N,o=N,b=NNN:notes...
while (*buffer != ':' && *buffer != '\0')
buffer++;
if (*buffer == ':')
buffer++;
int num;
if (*buffer == 'd') {
buffer += 2; // skip "d="
num = 0;
while (isdigit((unsigned char)*buffer))
num = (num * 10) + (*buffer++ - '0');
if (num > 0)
defaultDuration = num;
buffer++; // skip comma
}
if (*buffer == 'o') {
buffer += 2; // skip "o="
num = *buffer++ - '0';
if (num >= 3 && num <= 7)
defaultOctave = num;
buffer++; // skip comma
}
if (*buffer == 'b') {
buffer += 2; // skip "b="
num = 0;
while (isdigit((unsigned char)*buffer))
num = (num * 10) + (*buffer++ - '0');
bpm = num;
buffer++; // skip colon
}
if (bpm <= 0)
bpm = 63;
wholeNoteMs = (60 * 1000L / bpm) * 4;
firstNote = buffer;
}
void NRF52RtttlPlayer::nextNote()
{
nrf52I2SOutput.stopTone();
int num = 0;
while (isdigit((unsigned char)*buffer))
num = (num * 10) + (*buffer++ - '0');
long duration = num ? (wholeNoteMs / num) : (wholeNoteMs / defaultDuration);
int note = 0;
switch (*buffer) {
case 'c':
note = 1;
break;
case 'd':
note = 3;
break;
case 'e':
note = 5;
break;
case 'f':
note = 6;
break;
case 'g':
note = 8;
break;
case 'a':
note = 10;
break;
case 'b':
note = 12;
break;
case 'p':
default:
note = 0;
break;
}
buffer++;
if (*buffer == '#') {
note++;
buffer++;
}
if (*buffer == '.') {
duration += duration / 2;
buffer++;
}
int scale;
if (isdigit((unsigned char)*buffer)) {
scale = *buffer - '0';
buffer++;
} else {
scale = defaultOctave;
}
if (*buffer == '.') {
duration += duration / 2;
buffer++;
}
if (*buffer == ',')
buffer++;
if (note && scale >= 4 && scale <= 7) {
nrf52I2SOutput.startTone(kNoteFreq[(scale - 4) * 12 + note]);
}
noteStartAt = millis();
noteDurationMs = (uint32_t)duration;
}
void NRF52RtttlPlayer::play()
{
if (!playing)
return;
if (Throttle::isWithinTimespanMs(noteStartAt, noteDurationMs))
return;
if (*buffer == '\0') {
if (--loopCount) {
noteStartAt = millis();
noteDurationMs = (uint32_t)loopGap;
buffer = firstNote;
} else {
stop();
}
return;
}
nextNote();
}
void NRF52RtttlPlayer::stop()
{
if (playing) {
nrf52I2SOutput.stopTone();
digitalWrite(SPEAKER_EN, LOW);
#if defined(SPEAKER_EN_2)
digitalWrite(SPEAKER_EN_2, LOW);
#endif
playing = false;
}
}
#endif // HAS_I2S_SPEAKER_NRF52
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// Non-blocking RTTTL player for the nRF52 I2S speaker path (NRF52I2SOutput.h).
// Same parse algorithm as the NonBlockingRTTTL library, but drives
// NRF52I2SOutput::startTone/stopTone instead of tone()/noTone().
#include <Arduino.h>
class NRF52RtttlPlayer
{
public:
void begin(const char *songBuffer, uint8_t loopCount = 1, unsigned long loopGapMs = 1000);
void play(); // call every tick
void stop();
bool isPlaying() const { return playing; }
private:
void nextNote();
const char *buffer = "";
const char *firstNote = "";
uint8_t defaultDuration = 4;
uint8_t defaultOctave = 6;
int bpm = 63;
long wholeNoteMs = 0;
uint32_t noteStartAt = 0;
uint32_t noteDurationMs = 0;
unsigned long loopGap = 1000;
uint8_t loopCount = 1;
bool playing = false;
};
extern NRF52RtttlPlayer nrf52RtttlPlayer;
@@ -15,6 +15,8 @@ void c6l_init();
#define NEOPIXEL_TYPE (NEO_GRB + NEO_KHZ800) // type of neopixels in use
#define ENABLE_AMBIENTLIGHTING // Turn on Ambient Lighting
#define OLED_COMPACT_UI
// #define BUTTON_PIN 9
#define BUTTON_EXTENDER
+6 -10
View File
@@ -36,13 +36,13 @@ void initVariant()
// No plain GPIO LEDs on this board (only WS2812 addressable LEDs, not driven here).
}
// Reproduces the vendor firmware's boot sequence from
// examples/original_test/original_test.ino. Runs before Meshtastic touches
// PIN_POWER_EN, so the RT9080 LDO gets a clean reset pulse and peripherals
// whose EN pins must be LOW at boot (GPS_EN, GPS_RF_EN, BUZZER) aren't left
// floating while the 3V3 rail is ramping.
// Runs before Meshtastic touches PIN_POWER_EN. Hold the GPS RF front end
// disabled before toggling the RT9080 rail.
void earlyInitVariant()
{
pinMode(PIN_GPS_RF_EN, OUTPUT);
digitalWrite(PIN_GPS_RF_EN, LOW);
// 3.3V rail: toggle RT9080_EN HIGH → LOW → HIGH with 100 ms dwell so the
// LDO enters enable from a known state. The single-shot HIGH in main.cpp
// is not enough on this hardware - if the chip was in a half-enabled
@@ -55,12 +55,8 @@ void earlyInitVariant()
digitalWrite(PIN_POWER_EN, HIGH);
delay(100);
// Park peripherals with active-high enables LOW so they don't sink
// Park the remaining peripherals with active-high enables LOW so they don't sink
// current while the rest of setup() runs.
pinMode(PIN_GPS_STANDBY, OUTPUT);
digitalWrite(PIN_GPS_STANDBY, LOW);
pinMode(PIN_GPS_RESET, OUTPUT);
digitalWrite(PIN_GPS_RESET, LOW);
pinMode(PIN_BUZZER, OUTPUT);
digitalWrite(PIN_BUZZER, LOW);
}
+27 -20
View File
@@ -54,6 +54,15 @@ extern "C" {
// Buttons
#define PIN_BUTTON1 (32 + 10) // KEY_1: P1.10
// Second button, shares the bootloader's DFU pin (nRF52840_BOOT/P0.24) - safe to read
// as a normal GPIO once the app is running. Wired as literal DOWN (see DOWN_BUTTON_PIN
// in InputBroker.cpp), not ALT_BUTTON_PIN, since message/nodelist scroll needs UP/DOWN.
// Not named PIN_BUTTON2 - configuration.h auto-maps that to ALT_BUTTON_PIN (conflict).
#define PIN_BUTTON_DOWN (0 + 24) // nRF52840_BOOT
#define DOWN_BUTTON_PIN PIN_BUTTON_DOWN
#define DOWN_BUTTON_ACTIVE_LOW true
#define DOWN_BUTTON_ACTIVE_PULLUP true
#define BUTTON_CLICK_MS 400
// Analog pins
@@ -79,6 +88,10 @@ static const uint8_t A0 = PIN_A0;
#define PIN_WIRE_SDA (32 + 4) // IIC_1_SDA: P1.4
#define PIN_WIRE_SCL (32 + 2) // IIC_1_SCL: P1.2
// ICM20948 IMU + magnetometer, same I2C bus as the OLED. Skips the generic auto-probe
// heuristic (register value can be misread as BMI270/MPU6050) and forces the known chip.
#define HAS_ICM20948
// External serial flash ZD25WQ32CEIGR
// QSPI Pins
#define PIN_QSPI_SCK (0 + 4)
@@ -104,25 +117,19 @@ static const uint8_t A0 = PIN_A0;
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
// ───────────────────────────────────────────────────────────────────────────
// OLED display: SSD1315 on I2C @ 0x3C (IIC_1). SSD1315 is register-compatible
// with SSD1306, so USE_SSD1306 initializes the controller correctly.
// OLED display: SSD1315 on I2C @ 0x3C (IIC_1).
//
// Viewport: the physical panel is 72×40, mapped into the SSD1315's 128×64
// GDDRAM at columns 28..99, pages 3..7 (rows 24..63). The firmware handles
// this by:
// * asking the library for GEOMETRY_72_40, which sets the framebuffer to
// 72×40 and emits the right SETMULTIPLEX (39) / SETCOMPINS at init;
// * relying on SSD1306Wire's built-in horizontal auto-centering
// ((128 - width) / 2 = 28), so no horizontal shim is needed;
// * calling SSD1306Wire::setYOffset(3) in Screen.cpp when
// OLED_Y_OFFSET_PAGES is defined - this shifts every PAGEADDR write by
// three pages (24 rows) so data lands on the visible rows.
// GDDRAM at columns 28..99, pages 3..7. The shared OLED driver centers the
// 72×40 framebuffer and applies the page offset.
// ───────────────────────────────────────────────────────────────────────────
#define HAS_SCREEN 1
#define USE_SSD1306
#define OLED_GEOMETRY_OVERRIDE GEOMETRY_72_40
#define OLED_Y_OFFSET_PAGES 3
#define SSD1306_WIRE_I2C_FREQUENCY 100000
#define OLED_TINY
#define OLED_COMPACT_UI // No header/nav bar - centered icon+title splash, full-screen content
// Controls power 3V3 for all peripherals (GPS + LoRa + Sensor)
#define PIN_POWER_EN (0 + 30) // RT9080_EN
@@ -137,13 +144,15 @@ static const uint8_t A0 = PIN_A0;
#define GPS_BAUDRATE 9600
#define HAS_GPS 1
#define PIN_GPS_EN (32 + 15) // GPS_EN: P1.15 - GPS power enable
#define GPS_EN_ACTIVE 1
#define PIN_GPS_EN (32 + 15) // GPS_EN: P1.15 - GPS power enable
#define GPS_EN_ACTIVE LOW // Active-low on this board, was wrongly HIGH
#define PIN_GPS_STANDBY (0 + 25) // GPS_WAKE_UP: P0.25 - wakeup pin
#define GPS_FORCE_SOFT_SLEEP // Vendor firmware uses L76K standby rather than cycling its main rail
#define PIN_GPS_PPS (0 + 23) // GPS_1PPS: P0.23
#define GPS_RX_PIN (0 + 19) // MCU RX ← GPS's TX (vendor GPS_UART_TX / P0.19)
#define GPS_TX_PIN (0 + 21) // MCU TX → GPS's RX (vendor GPS_UART_RX / P0.21)
#define PIN_GPS_RESET (0 + 29) // GPS_RF_EN: GPS RF enable / reset
#define PIN_GPS_RF_EN (0 + 29) // GPS_RF_EN: P0.29 - RF front-end enable, not a reset line
#define GPS_RF_EN_ACTIVE HIGH
#define GPS_THREAD_INTERVAL 50
@@ -167,13 +176,11 @@ static const uint8_t A0 = PIN_A0;
#define VBAT_AR_INTERNAL AR_INTERNAL_3_0
#define ADC_MULTIPLIER (2.0F)
// Buzzer (PWM output, passive piezo)
#define PIN_BUZZER (32 + 6) // BUZZER_DATA: P1.6
// No charge IC on this board - use native USB state as a charging proxy.
#define NRF_APM
// ───────────────────────────────────────────────────────────────────────────
// I²S speaker (MAX98357 Class-D amp). Stereo I²S data path.
// Not supported on nrf52. These defines exist for out-of-tree code only.
// ───────────────────────────────────────────────────────────────────────────
// No piezo on this board - sound goes via I2S speaker only.
#define HAS_I2S_SPEAKER_NRF52 // MAX98357 amp, driven by NRF52I2SOutput (not HAS_I2S/ESP8266Audio)
#define SPEAKER_EN (32 + 11) // P1.11 - amp main enable
#define SPEAKER_EN_2 (0 + 3) // P0.3 - secondary enable (vendor firmware toggles both)
#define SPEAKER_BCLK (0 + 16) // P0.16 - I2S bit clock