diff --git a/platformio.ini b/platformio.ini index 34eaa3588..d10ce68e0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp index 42b9900bf..897f74d69 100644 --- a/src/buzz/buzz.cpp +++ b/src/buzz/buzz.cpp @@ -11,6 +11,10 @@ #include #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; diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index b6cd6fbaf..dcd765c9e 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -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 diff --git a/src/gps/GPS.h b/src/gps/GPS.h index 9924f73fc..a9a82795c 100644 --- a/src/gps/GPS.h +++ b/src/gps/GPS.h @@ -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); diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 50ddc495a..02d2b3d02 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -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(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) { diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index d3b061482..cf694f51a 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -249,6 +249,9 @@ class Screen : public concurrency::OSThread void setFrames(FrameFocus focus = FOCUS_DEFAULT); std::vector indicatorIcons; // Per-frame custom icon pointers +#if defined(OLED_COMPACT_UI) + std::vector frameTitles; // Per-frame short labels, parallel to indicatorIcons +#endif Screen(const Screen &) = delete; Screen &operator=(const Screen &) = delete; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 0a9ef95fd..b66a2a02a 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -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; diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 95244d099..3ed91d86b 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -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); diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp index b0b15b416..c672dd11d 100644 --- a/src/graphics/draw/ClockRenderer.cpp +++ b/src/graphics/draw/ClockRenderer.cpp @@ -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(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); diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 8e5ec52c3..b50c7081c 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -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); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index d4ff187ec..a4d0b4b5e 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -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) { diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 932dc773e..a6ae217e7 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -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 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); } diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index de8ff26d5..7d7bf5a6e 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -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; diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 74e26358f..33d36535b 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -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(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 { diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 065a7ff47..b8ead2706 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -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(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(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((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(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(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: " instead of the generic "Favorite" label. + char favTitleBuf[24]; + if (title && strcmp(title, "Favorite") == 0) { + const int favNodeIndex = static_cast(frameToHighlight) - + (static_cast(screen->frameCount) - static_cast(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 } diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index d6a55dd07..d66406abf 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -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 favoritedNodes; diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 4f09e3224..429ecd7ea 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -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; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 91a1da809..077c926dd 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -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 diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 276c382a1..98feda782 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -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"); diff --git a/src/platform/nrf52/NRF52I2SOutput.cpp b/src/platform/nrf52/NRF52I2SOutput.cpp new file mode 100644 index 000000000..4395f9bf4 --- /dev/null +++ b/src/platform/nrf52/NRF52I2SOutput.cpp @@ -0,0 +1,84 @@ +#include "NRF52I2SOutput.h" + +#if defined(HAS_I2S_SPEAKER_NRF52) + +#include + +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 diff --git a/src/platform/nrf52/NRF52I2SOutput.h b/src/platform/nrf52/NRF52I2SOutput.h new file mode 100644 index 000000000..a4309da31 --- /dev/null +++ b/src/platform/nrf52/NRF52I2SOutput.h @@ -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 + +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; diff --git a/src/platform/nrf52/NRF52RtttlPlayer.cpp b/src/platform/nrf52/NRF52RtttlPlayer.cpp new file mode 100644 index 000000000..1434c7702 --- /dev/null +++ b/src/platform/nrf52/NRF52RtttlPlayer.cpp @@ -0,0 +1,184 @@ +#include "NRF52RtttlPlayer.h" + +#if defined(HAS_I2S_SPEAKER_NRF52) + +#include "NRF52I2SOutput.h" +#include "mesh/Throttle.h" +#include + +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 diff --git a/src/platform/nrf52/NRF52RtttlPlayer.h b/src/platform/nrf52/NRF52RtttlPlayer.h new file mode 100644 index 000000000..030c0e153 --- /dev/null +++ b/src/platform/nrf52/NRF52RtttlPlayer.h @@ -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 + +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; diff --git a/variants/esp32c6/m5stack_unitc6l/variant.h b/variants/esp32c6/m5stack_unitc6l/variant.h index b75784be5..ca01d9863 100644 --- a/variants/esp32c6/m5stack_unitc6l/variant.h +++ b/variants/esp32c6/m5stack_unitc6l/variant.h @@ -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 diff --git a/variants/nrf52840/t-echo-card/variant.cpp b/variants/nrf52840/t-echo-card/variant.cpp index c1cf77693..be684c2c3 100644 --- a/variants/nrf52840/t-echo-card/variant.cpp +++ b/variants/nrf52840/t-echo-card/variant.cpp @@ -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); } diff --git a/variants/nrf52840/t-echo-card/variant.h b/variants/nrf52840/t-echo-card/variant.h index 81e8661d8..505d3d5f6 100644 --- a/variants/nrf52840/t-echo-card/variant.h +++ b/variants/nrf52840/t-echo-card/variant.h @@ -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