mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-13 23:21:19 +10:00
Full T-echo card support + Compact UI (#11342)
* T-echo card * Update NRF52I2SOutput.cpp * Update NRF52I2SOutput.h * cleanup * Update buzz.cpp * use consistent runtime compact-panel check instead of mixing with compile-time macro * Update NodeDB.cpp * Update ExternalNotificationModule.cpp * switched to Throttle::isWithinTimespanMs * Update SharedUIDisplay.h * trunk fix * last cleanup * ClockRenderer.cpp for OLED_COMPACT_UI and setup Unit C6L for new UI. * Fixed regressions in standard OLED and TFT --------- Co-authored-by: Jason P <applewiz@mac.com>
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
#include <unordered_map>
|
||||
#endif
|
||||
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
#include "platform/nrf52/NRF52I2SOutput.h"
|
||||
#endif
|
||||
|
||||
#if !defined(ARCH_PORTDUINO)
|
||||
extern "C" void delay(uint32_t dwMs);
|
||||
#endif
|
||||
@@ -112,6 +116,32 @@ void playTones(const ToneDuration *tone_durations, int size)
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
// Native I2S speaker path (no ESP AudioThread/RTTTL needed here).
|
||||
pinMode(SPEAKER_EN, OUTPUT);
|
||||
digitalWrite(SPEAKER_EN, HIGH);
|
||||
#if defined(SPEAKER_EN_2)
|
||||
pinMode(SPEAKER_EN_2, OUTPUT);
|
||||
digitalWrite(SPEAKER_EN_2, HIGH);
|
||||
#endif
|
||||
if (!nrf52I2SOutput.begin(SPEAKER_BCLK, SPEAKER_WS_LRCK, SPEAKER_DATA)) {
|
||||
digitalWrite(SPEAKER_EN, LOW);
|
||||
#if defined(SPEAKER_EN_2)
|
||||
digitalWrite(SPEAKER_EN_2, LOW);
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < size; i++) {
|
||||
const auto &tone_duration = tone_durations[i];
|
||||
nrf52I2SOutput.playTone(tone_duration.frequency_khz, tone_duration.duration_ms);
|
||||
}
|
||||
nrf52I2SOutput.end();
|
||||
digitalWrite(SPEAKER_EN, LOW);
|
||||
#if defined(SPEAKER_EN_2)
|
||||
digitalWrite(SPEAKER_EN_2, LOW);
|
||||
#endif
|
||||
return;
|
||||
#endif
|
||||
#if defined(PIN_BUZZER)
|
||||
if (!config.device.buzzer_gpio)
|
||||
config.device.buzzer_gpio = PIN_BUZZER;
|
||||
|
||||
+28
-2
@@ -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
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
#define GPS_STANDBY_ACTIVE LOW
|
||||
#endif
|
||||
|
||||
// Allow defining the polarity of an external GPS RF front-end enable. Default is active high.
|
||||
#ifndef GPS_RF_EN_ACTIVE
|
||||
#define GPS_RF_EN_ACTIVE HIGH
|
||||
#endif
|
||||
|
||||
static constexpr uint32_t GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS = 10 * 1000UL;
|
||||
static constexpr uint32_t GPS_FIX_HOLD_MAX_MS = 20000;
|
||||
|
||||
@@ -257,6 +262,10 @@ class GPS : private concurrency::OSThread
|
||||
*/
|
||||
void writePinStandby(bool standby);
|
||||
|
||||
/** Set the external RF front-end enable pin, if relevant
|
||||
*/
|
||||
void writePinRFEN(bool on);
|
||||
|
||||
/** Set GPS power with PMU, if relevant
|
||||
*/
|
||||
void setPowerPMU(bool on);
|
||||
|
||||
+72
-4
@@ -570,12 +570,11 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
#elif defined(USE_SSD1306)
|
||||
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
|
||||
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
|
||||
isI2cScreen = true;
|
||||
#if defined(OLED_Y_OFFSET_PAGES)
|
||||
// Panels whose active window does not start at GDDRAM row 0 (e.g. 72x40
|
||||
// modules on pages 3..7) need a fixed vertical page shift on every write.
|
||||
// Shift writes to the panel's visible GDDRAM pages.
|
||||
static_cast<SSD1306Wire *>(dispdev)->setYOffset(OLED_Y_OFFSET_PAGES);
|
||||
#endif
|
||||
isI2cScreen = true;
|
||||
#elif defined(USE_SPISSD1306)
|
||||
dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48);
|
||||
if (!dispdev->init()) {
|
||||
@@ -739,6 +738,10 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
|
||||
enabled = true;
|
||||
setInterval(0); // Draw ASAP
|
||||
runASAP = true;
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
if (graphics::isCompactPanel(dispdev))
|
||||
graphics::UIRenderer::notifyScreenWoke();
|
||||
#endif
|
||||
} else {
|
||||
powerMon->clearState(meshtastic_PowerMon_State_Screen_On);
|
||||
#ifdef USE_EINK
|
||||
@@ -1350,6 +1353,15 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
|
||||
|
||||
// Regenerate the normal set of frames, focusing a specific frame if requested
|
||||
// Called when a frame should be added / removed, or custom frames should be cleared
|
||||
// No-op on other boards, so this costs them no flash/RAM.
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
#define PUSH_FRAME_TITLE(x) frameTitles.push_back(x)
|
||||
#define CLEAR_FRAME_TITLES() frameTitles.clear()
|
||||
#else
|
||||
#define PUSH_FRAME_TITLE(x)
|
||||
#define CLEAR_FRAME_TITLES()
|
||||
#endif
|
||||
|
||||
void Screen::setFrames(FrameFocus focus)
|
||||
{
|
||||
// Block setFrames calls when virtual keyboard is active to prevent overlay interference
|
||||
@@ -1367,6 +1379,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
showingNormalScreen = true;
|
||||
|
||||
indicatorIcons.clear();
|
||||
CLEAR_FRAME_TITLES();
|
||||
|
||||
size_t numframes = 0;
|
||||
|
||||
@@ -1375,19 +1388,23 @@ void Screen::setFrames(FrameFocus focus)
|
||||
if (error_code) {
|
||||
normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame;
|
||||
indicatorIcons.push_back(icon_error);
|
||||
PUSH_FRAME_TITLE("Alert");
|
||||
focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame
|
||||
}
|
||||
|
||||
#if defined(DISPLAY_CLOCK_FRAME)
|
||||
if (!hiddenFrames.clock) {
|
||||
fsi.positions.clock = numframes;
|
||||
#if defined(OLED_TINY)
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
normalFrames[numframes++] = graphics::ClockRenderer::drawDigitalClockFrame;
|
||||
#elif defined(OLED_TINY)
|
||||
normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame;
|
||||
#else
|
||||
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||
: graphics::ClockRenderer::drawDigitalClockFrame;
|
||||
#endif
|
||||
indicatorIcons.push_back(digital_icon_clock);
|
||||
PUSH_FRAME_TITLE("Clock");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1395,6 +1412,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
fsi.positions.home = numframes;
|
||||
normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused;
|
||||
indicatorIcons.push_back(icon_home);
|
||||
PUSH_FRAME_TITLE("Home");
|
||||
}
|
||||
|
||||
#if BASEUI_HAS_GAMES
|
||||
@@ -1403,23 +1421,27 @@ void Screen::setFrames(FrameFocus focus)
|
||||
fsi.positions.games = numframes;
|
||||
normalFrames[numframes++] = drawGamesFrame;
|
||||
indicatorIcons.push_back(joystick_small);
|
||||
PUSH_FRAME_TITLE("Games");
|
||||
}
|
||||
#endif
|
||||
|
||||
fsi.positions.textMessage = numframes;
|
||||
normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
|
||||
indicatorIcons.push_back(icon_mail);
|
||||
PUSH_FRAME_TITLE("Messages");
|
||||
|
||||
#ifndef USE_EINK
|
||||
if (!hiddenFrames.nodelist_nodes) {
|
||||
fsi.positions.nodelist_nodes = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes;
|
||||
indicatorIcons.push_back(icon_nodes);
|
||||
PUSH_FRAME_TITLE("Nodes");
|
||||
}
|
||||
if (!hiddenFrames.nodelist_location) {
|
||||
fsi.positions.nodelist_location = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location;
|
||||
indicatorIcons.push_back(icon_list);
|
||||
PUSH_FRAME_TITLE("Node List");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1429,16 +1451,19 @@ void Screen::setFrames(FrameFocus focus)
|
||||
fsi.positions.nodelist_lastheard = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen;
|
||||
indicatorIcons.push_back(icon_nodes);
|
||||
PUSH_FRAME_TITLE("Nodes");
|
||||
}
|
||||
if (!hiddenFrames.nodelist_hopsignal) {
|
||||
fsi.positions.nodelist_hopsignal = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen;
|
||||
indicatorIcons.push_back(icon_signal);
|
||||
PUSH_FRAME_TITLE("Signal");
|
||||
}
|
||||
if (!hiddenFrames.nodelist_distance) {
|
||||
fsi.positions.nodelist_distance = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen;
|
||||
indicatorIcons.push_back(icon_distance);
|
||||
PUSH_FRAME_TITLE("Distance");
|
||||
}
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
@@ -1447,36 +1472,46 @@ void Screen::setFrames(FrameFocus focus)
|
||||
fsi.positions.nodelist_bearings = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses;
|
||||
indicatorIcons.push_back(icon_list);
|
||||
PUSH_FRAME_TITLE("Bearings");
|
||||
}
|
||||
#endif
|
||||
if (!hiddenFrames.gps) {
|
||||
fsi.positions.gps = numframes;
|
||||
normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen;
|
||||
indicatorIcons.push_back(icon_compass);
|
||||
PUSH_FRAME_TITLE("GPS");
|
||||
}
|
||||
#endif
|
||||
if (RadioLibInterface::instance && !hiddenFrames.lora) {
|
||||
fsi.positions.lora = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused;
|
||||
indicatorIcons.push_back(icon_radio);
|
||||
PUSH_FRAME_TITLE("LoRa");
|
||||
}
|
||||
if (!hiddenFrames.system) {
|
||||
fsi.positions.system = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen;
|
||||
indicatorIcons.push_back(icon_system);
|
||||
PUSH_FRAME_TITLE("System");
|
||||
}
|
||||
#if !defined(DISPLAY_CLOCK_FRAME)
|
||||
if (!hiddenFrames.clock) {
|
||||
fsi.positions.clock = numframes;
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
normalFrames[numframes++] = graphics::ClockRenderer::drawDigitalClockFrame;
|
||||
#else
|
||||
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||
: graphics::ClockRenderer::drawDigitalClockFrame;
|
||||
#endif
|
||||
indicatorIcons.push_back(digital_icon_clock);
|
||||
PUSH_FRAME_TITLE("Clock");
|
||||
}
|
||||
#endif
|
||||
if (!hiddenFrames.chirpy) {
|
||||
fsi.positions.chirpy = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy;
|
||||
indicatorIcons.push_back(chirpy_small);
|
||||
PUSH_FRAME_TITLE("Chirpy");
|
||||
}
|
||||
|
||||
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
||||
@@ -1484,6 +1519,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
fsi.positions.wifi = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawFrameWiFi;
|
||||
indicatorIcons.push_back(icon_wifi);
|
||||
PUSH_FRAME_TITLE("WiFi");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1511,6 +1547,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
fsi.positions.waypoint = numframes;
|
||||
|
||||
indicatorIcons.push_back(icon_module);
|
||||
PUSH_FRAME_TITLE("Module");
|
||||
numframes++;
|
||||
}
|
||||
}
|
||||
@@ -1539,6 +1576,7 @@ void Screen::setFrames(FrameFocus focus)
|
||||
for (const auto &f : favoriteFrames) {
|
||||
normalFrames[numframes++] = f;
|
||||
indicatorIcons.push_back(icon_node);
|
||||
PUSH_FRAME_TITLE("Favorite");
|
||||
}
|
||||
fsi.positions.lastFavorite = numframes - 1;
|
||||
} else {
|
||||
@@ -2150,6 +2188,36 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
// UP/DOWN on the compact position screen toggles compass vs coordinates+elevation
|
||||
if (graphics::isCompactPanel(dispdev) && ui->getUiState()->currentFrame == framesetInfo.positions.gps) {
|
||||
if (event->inputEvent == INPUT_BROKER_UP) {
|
||||
graphics::UIRenderer::scrollPositionUp();
|
||||
setFastFramerate();
|
||||
return 0;
|
||||
}
|
||||
if (event->inputEvent == INPUT_BROKER_DOWN) {
|
||||
graphics::UIRenderer::scrollPositionDown();
|
||||
setFastFramerate();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
// UP/DOWN on the compact favorite-node screen toggles compass+distance vs status/telemetry
|
||||
if (graphics::isCompactPanel(dispdev) && framesetInfo.positions.firstFavorite != 255 &&
|
||||
ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite &&
|
||||
ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) {
|
||||
if (event->inputEvent == INPUT_BROKER_UP) {
|
||||
graphics::UIRenderer::scrollFavoriteUp();
|
||||
setFastFramerate();
|
||||
return 0;
|
||||
}
|
||||
if (event->inputEvent == INPUT_BROKER_DOWN) {
|
||||
graphics::UIRenderer::scrollFavoriteDown();
|
||||
setFastFramerate();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Use left or right input from a keyboard to move between frames,
|
||||
// so long as a mesh module isn't using these events for some other purpose
|
||||
if (showingNormalScreen) {
|
||||
|
||||
@@ -249,6 +249,9 @@ class Screen : public concurrency::OSThread
|
||||
void setFrames(FrameFocus focus = FOCUS_DEFAULT);
|
||||
|
||||
std::vector<const uint8_t *> indicatorIcons; // Per-frame custom icon pointers
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
std::vector<const char *> frameTitles; // Per-frame short labels, parallel to indicatorIcons
|
||||
#endif
|
||||
Screen(const Screen &) = delete;
|
||||
Screen &operator=(const Screen &) = delete;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -50,7 +50,7 @@ void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale)
|
||||
|
||||
uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8;
|
||||
|
||||
uint16_t topAndBottomX = x + static_cast<uint16_t>(4 * scale);
|
||||
uint16_t topAndBottomX = x + 3;
|
||||
|
||||
uint16_t quarterCellHeight = cellHeight / 4;
|
||||
|
||||
@@ -178,7 +178,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
snprintf(secondString, sizeof(secondString), "%02d", second);
|
||||
|
||||
static bool scaleInitialized = false;
|
||||
static float scale = 0.50f;
|
||||
static float scale = 0.15f;
|
||||
static float segmentWidth = SEGMENT_WIDTH * 0.75f;
|
||||
static float segmentHeight = SEGMENT_HEIGHT * 0.75f;
|
||||
|
||||
@@ -186,7 +186,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
#ifdef DISPLAY_FORCE_SMALL_FONTS
|
||||
float screenwidth_target_ratio = 0.70f; // Target 70% of display width (adjustable)
|
||||
#elif defined(BICOLOR_OLED_DISPLAY)
|
||||
float screenwidth_target_ratio = 0.60f; // Forced for BICOLOR_OLED_DISPLAY due to two color display
|
||||
float screenwidth_target_ratio = 0.60f; // Forced for BICOLOR_OLED_DISPLAY due to two color display
|
||||
#else
|
||||
float screenwidth_target_ratio = 0.80f; // Target 80% of display width (adjustable)
|
||||
#endif
|
||||
@@ -194,11 +194,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
float step = 0.05f; // Step increment per iteration
|
||||
|
||||
float target_width = display->getWidth() * screenwidth_target_ratio;
|
||||
#if !defined(OLED_COMPACT_UI)
|
||||
float target_height =
|
||||
display->getHeight() -
|
||||
((currentResolution == ScreenResolution::High)
|
||||
? 46
|
||||
: 33); // Be careful adjusting this number, we have to account for header and the text under the time
|
||||
#else
|
||||
float target_height = display->getHeight(); // OLED compact UI has no header or footer, so we can use the full height
|
||||
#endif
|
||||
|
||||
float calculated_width_size = 0.0f;
|
||||
float calculated_height_size = 0.0f;
|
||||
@@ -208,7 +212,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
segmentHeight = SEGMENT_HEIGHT * scale;
|
||||
|
||||
calculated_width_size = segmentHeight + ((segmentWidth + (segmentHeight * 2) + 4) * 4);
|
||||
calculated_height_size = segmentHeight + ((segmentHeight + (segmentHeight * 2) + 4) * 2);
|
||||
calculated_height_size = (segmentWidth * 2) + (segmentHeight * 3) + 8;
|
||||
|
||||
if (calculated_width_size >= target_width || calculated_height_size >= target_height || scale >= max_scale) {
|
||||
break;
|
||||
@@ -229,13 +233,22 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
|
||||
// calculate hours:minutes string width
|
||||
size_t len = strlen(timeString);
|
||||
uint16_t timeStringWidth = len * 5;
|
||||
#ifdef OLED_COMPACT_UI
|
||||
#define CLOCK_CHAR_GAP 2
|
||||
#else
|
||||
#define CLOCK_CHAR_GAP 5
|
||||
#endif
|
||||
uint16_t timeStringWidth = (len - 1) * CLOCK_CHAR_GAP; // gaps sit between characters, not after the last one
|
||||
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char character = timeString[i];
|
||||
|
||||
// Must mirror the advances used by the draw loop below, or the clock won't be centered.
|
||||
if (character == ':') {
|
||||
timeStringWidth += segmentHeight;
|
||||
timeStringWidth += segmentHeight + 6;
|
||||
if (scale >= 2.0f) {
|
||||
timeStringWidth += (uint16_t)(4.5f * scale);
|
||||
}
|
||||
} else {
|
||||
timeStringWidth += segmentWidth + (segmentHeight * 2) + 4;
|
||||
}
|
||||
@@ -246,6 +259,12 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
|
||||
uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2) + 2;
|
||||
|
||||
#if !defined(OLED_COMPACT_UI)
|
||||
const uint16_t bottomRowY = hourMinuteTextY + ((uint16_t)segmentWidth * 2) + ((uint16_t)segmentHeight * 3) + 8 + 1;
|
||||
#else
|
||||
const uint16_t bottomRowY = (display->getHeight() - hourMinuteTextY) + 1;
|
||||
#endif
|
||||
|
||||
// iterate over characters in hours:minutes string and draw segmented characters
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char character = timeString[i];
|
||||
@@ -263,34 +282,23 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
|
||||
hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4;
|
||||
}
|
||||
|
||||
hourMinuteTextX += 5;
|
||||
hourMinuteTextX += CLOCK_CHAR_GAP;
|
||||
}
|
||||
|
||||
// draw seconds string + AM/PM
|
||||
display->setFont(FONT_SMALL);
|
||||
int xOffset = -1;
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
xOffset = 0;
|
||||
}
|
||||
if (hour >= 10) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
xOffset += 32;
|
||||
} else {
|
||||
xOffset += 18;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.display.use_12h_clock) {
|
||||
display->drawString(startingHourMinuteTextX + xOffset, (display->getHeight() - hourMinuteTextY) - 1, isPM ? "pm" : "am");
|
||||
#if !defined(OLED_COMPACT_UI)
|
||||
const char *period = isPM ? "pm" : "am";
|
||||
#else
|
||||
const char *period = isPM ? "p" : "a";
|
||||
#endif
|
||||
display->drawString(startingHourMinuteTextX, bottomRowY, period);
|
||||
}
|
||||
|
||||
#ifndef USE_EINK
|
||||
xOffset = (currentResolution == ScreenResolution::High) ? 18 : 10;
|
||||
if (scale >= 2.0f) {
|
||||
xOffset -= (int)(4.5f * scale);
|
||||
}
|
||||
display->drawString(startingHourMinuteTextX + timeStringWidth - xOffset, (display->getHeight() - hourMinuteTextY) - 1,
|
||||
secondString);
|
||||
display->drawString(hourMinuteTextX - CLOCK_CHAR_GAP - display->getStringWidth(secondString), bottomRowY, secondString);
|
||||
#endif
|
||||
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -69,6 +69,12 @@ void scrollDown()
|
||||
if (maxScroll < 0)
|
||||
maxScroll = 0;
|
||||
|
||||
if (graphics::isCompactPanel(screen->getDisplayDevice()) && scrollY >= maxScroll) {
|
||||
// Compact panels: scrolling past the bottom wraps back to the top.
|
||||
scrollY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
scrollY += 12;
|
||||
if (scrollY > maxScroll)
|
||||
scrollY = maxScroll;
|
||||
@@ -427,9 +433,12 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
display->clear();
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
const int navHeight = FONT_HEIGHT_SMALL;
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
// Compact panels: no bottom nav row anymore (see UIRenderer::drawNavigationBar), full height available.
|
||||
const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL;
|
||||
const int scrollBottom = SCREEN_HEIGHT - navHeight;
|
||||
const int usableHeight = scrollBottom;
|
||||
const int contentTop = compactPanel ? 0 : getTextPositions(display)[1];
|
||||
const int usableHeight = compactPanel ? scrollBottom - contentTop : scrollBottom;
|
||||
constexpr int LEFT_MARGIN = 2;
|
||||
constexpr int RIGHT_MARGIN = 2;
|
||||
constexpr int SCROLLBAR_WIDTH = 3;
|
||||
@@ -440,7 +449,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
constexpr int BUBBLE_TEXT_INDENT = 2;
|
||||
|
||||
// Check if bubbles are enabled
|
||||
const bool showBubbles = config.display.enable_message_bubbles;
|
||||
const bool showBubbles = config.display.enable_message_bubbles && !compactPanel;
|
||||
const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN;
|
||||
|
||||
// Derived widths
|
||||
@@ -616,13 +625,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
|
||||
}
|
||||
|
||||
// Shrink Sender name if needed
|
||||
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
|
||||
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " *@...");
|
||||
// Shrink Sender name if needed; compact panels put it on its own line, so no sharing with timeBuf/chanType.
|
||||
int availWidth = compactPanel ? (mine ? rightTextWidth : leftTextWidth)
|
||||
: (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
|
||||
display->getStringWidth(chanType);
|
||||
// Compact panels hard-cut (no "...") so drop its width reservation too.
|
||||
availWidth -= graphics::UIRenderer::measureStringWithEmotes(display, compactPanel ? "*@" : " *@...");
|
||||
if (availWidth < 0)
|
||||
availWidth = 0;
|
||||
char truncatedSender[64];
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth,
|
||||
compactPanel ? "" : "...");
|
||||
|
||||
// Determine signed-message prefix before building the header line, since it needs to go
|
||||
// at the front of headerStr rather than appended after (strncat only appends at the end).
|
||||
@@ -634,28 +647,58 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
}
|
||||
#endif
|
||||
|
||||
// Final header line
|
||||
char headerStr[128];
|
||||
if (mine) {
|
||||
if (currentMode == ThreadMode::ALL) {
|
||||
if (strcmp(chanType, "(DM)") == 0) {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
|
||||
if (compactPanel) {
|
||||
// Time and sender don't fit on one line at this width - time first, name below.
|
||||
allLines.push_back(timeBuf);
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(true);
|
||||
ackForLine.push_back(AckStatus::NONE); // ack mark shown on the name line instead
|
||||
|
||||
char nameLine[80] = "";
|
||||
if (mine) {
|
||||
if (currentMode == ThreadMode::ALL) {
|
||||
if (strcmp(chanType, "(DM)") == 0) {
|
||||
snprintf(nameLine, sizeof(nameLine), "to %s", truncatedSender);
|
||||
} else {
|
||||
snprintf(nameLine, sizeof(nameLine), "to %s", chanType);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
|
||||
snprintf(nameLine, sizeof(nameLine), chanType[0] ? "%s%s@%s" : "%s%s", signPrefix, truncatedSender, chanType);
|
||||
}
|
||||
|
||||
if (nameLine[0]) {
|
||||
allLines.push_back(nameLine);
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(true);
|
||||
ackForLine.push_back(m.ackStatus);
|
||||
} else {
|
||||
// Nothing to show on a second line (e.g. "mine" in ALL mode) - move the ack mark back.
|
||||
ackForLine.back() = m.ackStatus;
|
||||
}
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix, truncatedSender,
|
||||
chanType);
|
||||
}
|
||||
// Final header line
|
||||
char headerStr[128];
|
||||
if (mine) {
|
||||
if (currentMode == ThreadMode::ALL) {
|
||||
if (strcmp(chanType, "(DM)") == 0) {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
|
||||
}
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
|
||||
}
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix,
|
||||
truncatedSender, chanType);
|
||||
}
|
||||
|
||||
// Push header line
|
||||
allLines.push_back(headerStr);
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(true);
|
||||
ackForLine.push_back(m.ackStatus);
|
||||
allLines.push_back(headerStr);
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(true);
|
||||
ackForLine.push_back(m.ackStatus);
|
||||
}
|
||||
|
||||
const char *msgText = MessageStore::getText(m);
|
||||
|
||||
@@ -679,6 +722,13 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
// Cache lines and heights
|
||||
cachedLines.swap(allLines);
|
||||
cachedHeights = calculateLineHeights(cachedLines, emotes, isHeader);
|
||||
if (compactPanel) {
|
||||
for (size_t i = 0; i < cachedHeights.size(); ++i) {
|
||||
if (isHeader[i]) {
|
||||
cachedHeights[i] = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<MessageBlock> blocks = buildMessageBlocks(isHeader, isMine);
|
||||
|
||||
@@ -728,8 +778,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
#endif
|
||||
|
||||
int finalScroll = (int)scrollY;
|
||||
int yOffset = -finalScroll + getTextPositions(display)[1];
|
||||
const int contentTop = getTextPositions(display)[1];
|
||||
int yOffset = -finalScroll + contentTop;
|
||||
const int contentBottom = scrollBottom; // already excludes nav line
|
||||
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
|
||||
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
|
||||
@@ -907,18 +956,20 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
|
||||
true);
|
||||
|
||||
// Draw underline just under header text
|
||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||
if (!compactPanel) {
|
||||
// Draw underline just under header text
|
||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||
|
||||
int underlineW = w;
|
||||
int maxW = rightEdge - headerX;
|
||||
if (maxW < 0)
|
||||
maxW = 0;
|
||||
if (underlineW > maxW)
|
||||
underlineW = maxW;
|
||||
int underlineW = w;
|
||||
int maxW = rightEdge - headerX;
|
||||
if (maxW < 0)
|
||||
maxW = 0;
|
||||
if (underlineW > maxW)
|
||||
underlineW = maxW;
|
||||
|
||||
for (int px = 0; px < underlineW; ++px) {
|
||||
display->setPixel(headerX + px, underlineY);
|
||||
for (int px = 0; px < underlineW; ++px) {
|
||||
display->setPixel(headerX + px, underlineY);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw ACK/NACK mark for our own messages
|
||||
@@ -958,8 +1009,10 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
}
|
||||
|
||||
// Draw scrollbar
|
||||
drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, getTextPositions(display)[1]);
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, contentTop);
|
||||
if (!compactPanel) {
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
}
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -250,6 +250,11 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
|
||||
return;
|
||||
}
|
||||
|
||||
// Compact panels: DOWN cancels menus instead of scrolling (covers every picker below).
|
||||
if (graphics::isCompactPanel(display) && inEvent.inputEvent == INPUT_BROKER_DOWN) {
|
||||
inEvent.inputEvent = INPUT_BROKER_CANCEL;
|
||||
}
|
||||
|
||||
switch (current_notification_type) {
|
||||
case notificationTypeEnum::none:
|
||||
// Do nothing - no notification to display
|
||||
@@ -641,8 +646,12 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
|
||||
const int arrowWidth = (currentResolution == ScreenResolution::High)
|
||||
? UIRenderer::measureStringWithEmotes(display, "> <")
|
||||
: UIRenderer::measureStringWithEmotes(display, "><");
|
||||
const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth);
|
||||
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth);
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
// Compact panels: box spans the full width, so just a small edge margin.
|
||||
const int margin = compactPanel ? 4 : 28;
|
||||
const int maxTextWidth = std::max(0, display->getWidth() - margin - arrowWidth);
|
||||
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth,
|
||||
compactPanel ? "" : "...");
|
||||
}
|
||||
} else {
|
||||
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
|
||||
@@ -688,8 +697,9 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp
|
||||
const char *lineStarts[MAX_LINES + 1] = {0};
|
||||
uint16_t lineCount = 0;
|
||||
char lineBuffer[40] = {0};
|
||||
bool useTaggedTextBanner =
|
||||
(current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0 && alertBannerLineCount > 0);
|
||||
bool useTaggedTextBanner = ((current_notification_type == notificationTypeEnum::text_banner ||
|
||||
current_notification_type == notificationTypeEnum::pairing_pin) &&
|
||||
alertBannerOptions == 0 && alertBannerLineCount > 0);
|
||||
|
||||
if (useTaggedTextBanner) {
|
||||
lineCount = std::min<uint8_t>(alertBannerLineCount, MAX_LINES);
|
||||
@@ -775,7 +785,7 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp
|
||||
const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation
|
||||
|
||||
// copy the linestarts to display to the linePointers holder
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
for (uint16_t i = 0; i < lineCount && i < visibleTotalLines; i++) {
|
||||
linePointers[i] = lineStarts[i];
|
||||
}
|
||||
|
||||
@@ -835,7 +845,9 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
BannerFont lineFonts[totalLines] = {};
|
||||
uint8_t lineEffectiveHeights[totalLines] = {0};
|
||||
const char *renderLines[totalLines] = {0};
|
||||
bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner && alertBannerOptions == 0);
|
||||
bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner ||
|
||||
current_notification_type == notificationTypeEnum::pairing_pin) &&
|
||||
alertBannerOptions == 0;
|
||||
|
||||
if (maxWidth != 0)
|
||||
is_picker = true;
|
||||
@@ -937,18 +949,25 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
}
|
||||
int16_t boxTop = (display->height() / 2) - (boxHeight / 2);
|
||||
boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1;
|
||||
if (graphics::isCompactPanel(display)) {
|
||||
boxLeft = 0;
|
||||
boxTop = 0;
|
||||
boxWidth = display->width();
|
||||
boxHeight = display->height();
|
||||
} else {
|
||||
#if defined(OLED_TINY)
|
||||
if (visibleTotalLines == 1) {
|
||||
boxTop += 25;
|
||||
}
|
||||
if (alertBannerOptions < 3) {
|
||||
int missingLines = 3 - alertBannerOptions;
|
||||
int moveUp = missingLines * (effectiveLineHeight / 2);
|
||||
boxTop -= moveUp;
|
||||
if (boxTop < 0)
|
||||
boxTop = 0;
|
||||
}
|
||||
if (visibleTotalLines == 1) {
|
||||
boxTop += 25;
|
||||
}
|
||||
if (alertBannerOptions < 3) {
|
||||
int missingLines = 3 - alertBannerOptions;
|
||||
int moveUp = missingLines * (effectiveLineHeight / 2);
|
||||
boxTop -= moveUp;
|
||||
if (boxTop < 0)
|
||||
boxTop = 0;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Draw Box
|
||||
display->setColor(BLACK);
|
||||
@@ -1009,7 +1028,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
}
|
||||
#endif
|
||||
display->setColor(BLACK);
|
||||
int yOffset = 3;
|
||||
const int yOffset = graphics::isCompactPanel(display) ? 2 : 3;
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
|
||||
@@ -116,7 +116,8 @@ static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compa
|
||||
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, heading, labelRadius);
|
||||
}
|
||||
|
||||
static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
|
||||
static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading,
|
||||
bool showRing = true)
|
||||
{
|
||||
const StandardCompassNeedlePoints points =
|
||||
computeStandardCompassNeedlePoints(compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, 0.0f);
|
||||
@@ -141,7 +142,8 @@ static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16
|
||||
points.southRightY);
|
||||
#endif
|
||||
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showRing)
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading);
|
||||
}
|
||||
|
||||
@@ -350,7 +352,8 @@ static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compa
|
||||
#endif
|
||||
}
|
||||
|
||||
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
|
||||
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading,
|
||||
bool showRing = true)
|
||||
{
|
||||
// Compass colors should follow whatever background role is already active at this location.
|
||||
const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg());
|
||||
@@ -375,7 +378,8 @@ static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_
|
||||
}
|
||||
|
||||
drawStandardCompassNeedle(display, compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, compassBgColor);
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showRing)
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading);
|
||||
drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading);
|
||||
}
|
||||
@@ -392,10 +396,11 @@ static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_
|
||||
|
||||
static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
|
||||
bool showCompass, float myHeading, float bearing, const char *statusLine1,
|
||||
const char *statusLine2)
|
||||
const char *statusLine2, bool showRing = true)
|
||||
{
|
||||
// Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text.
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showRing)
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showCompass) {
|
||||
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
||||
@@ -405,17 +410,19 @@ static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, i
|
||||
}
|
||||
|
||||
static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
|
||||
bool validHeading, float heading, const char *statusLine1, const char *statusLine2)
|
||||
bool validHeading, float heading, const char *statusLine1, const char *statusLine2,
|
||||
bool showRing = true)
|
||||
{
|
||||
// Shared "position screen" compass renderer: use mono/TFT path only when heading is valid.
|
||||
if (validHeading) {
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
drawTftCompass(display, compassX, compassY, compassRadius, heading);
|
||||
drawTftCompass(display, compassX, compassY, compassRadius, heading, showRing);
|
||||
#else
|
||||
drawMonoCompass(display, compassX, compassY, compassRadius, heading);
|
||||
drawMonoCompass(display, compassX, compassY, compassRadius, heading, showRing);
|
||||
#endif
|
||||
} else {
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
if (showRing)
|
||||
display->drawCircle(compassX, compassY, compassRadius);
|
||||
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
||||
}
|
||||
}
|
||||
@@ -508,15 +515,8 @@ extern uint32_t dopThresholds[5];
|
||||
|
||||
// Draw GPS status summary (satellite icon + status text).
|
||||
// Handles all GPS states: disabled / not present / fixed position / no lock / sat count.
|
||||
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||||
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps, bool center)
|
||||
{
|
||||
// Draw satellite image
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y + 1, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
|
||||
char textString[12];
|
||||
if (config.position.fixed_position) {
|
||||
// Fixed position overrides live GPS state, regardless of gps_mode
|
||||
@@ -533,7 +533,20 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
|
||||
snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
|
||||
}
|
||||
|
||||
display->drawString(x + ((currentResolution == ScreenResolution::High) ? 18 : 11), y, textString);
|
||||
const int textOffset = (currentResolution == ScreenResolution::High) ? 18 : 11;
|
||||
if (center) {
|
||||
int contentWidth = textOffset + display->getStringWidth(textString);
|
||||
x = (SCREEN_WIDTH - contentWidth) / 2;
|
||||
}
|
||||
|
||||
// Draw satellite image
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
|
||||
display->drawString(x + textOffset, y, textString);
|
||||
}
|
||||
|
||||
void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||||
@@ -681,7 +694,7 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y,
|
||||
|
||||
// Draw nodes status
|
||||
void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset,
|
||||
bool show_total, const char *additional_words)
|
||||
bool show_total, const char *additional_words, bool center)
|
||||
{
|
||||
char usersString[20];
|
||||
int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0;
|
||||
@@ -693,6 +706,12 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
|
||||
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words);
|
||||
}
|
||||
|
||||
int string_offset = (currentResolution == ScreenResolution::High) ? 9 : 0;
|
||||
if (center) {
|
||||
int contentWidth = 10 + string_offset + display->getStringWidth(usersString);
|
||||
x = (SCREEN_WIDTH - contentWidth) / 2;
|
||||
}
|
||||
|
||||
#if (defined(USE_EINK) || defined(HAS_SPI_TFT)) && !defined(DISPLAY_FORCE_SMALL_FONTS)
|
||||
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
@@ -707,13 +726,26 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
|
||||
display->drawFastImage(x, y + 1, 8, 8, imgUser);
|
||||
}
|
||||
#endif
|
||||
int string_offset = (currentResolution == ScreenResolution::High) ? 9 : 0;
|
||||
display->drawString(x + 10 + string_offset, y - 2, usersString);
|
||||
}
|
||||
|
||||
// **********************
|
||||
// * Favorite Node Info *
|
||||
// **********************
|
||||
// Compact panels: toggle between the compass/distance view and the status/telemetry view.
|
||||
static int favoriteViewIndex = 0;
|
||||
|
||||
void UIRenderer::scrollFavoriteDown()
|
||||
{
|
||||
favoriteViewIndex = (favoriteViewIndex + 1) % 2;
|
||||
}
|
||||
|
||||
void UIRenderer::scrollFavoriteUp()
|
||||
{
|
||||
if (favoriteViewIndex > 0)
|
||||
favoriteViewIndex--;
|
||||
}
|
||||
|
||||
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
|
||||
void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
@@ -746,6 +778,158 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
|
||||
// === Draw battery/time/mail header (common across screens) ===
|
||||
graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow);
|
||||
|
||||
#if HAS_GPS && defined(OLED_COMPACT_UI)
|
||||
// Compact panels: page 0 = name/distance/compass, page 1 = status/telemetry (scroll down)
|
||||
if (graphics::isCompactPanel(display)) {
|
||||
int cline = 1;
|
||||
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
meshtastic_PositionLite nodePos, ourPos;
|
||||
const bool haveNodePos = nodeDB->copyNodePosition(node->num, nodePos);
|
||||
const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
|
||||
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
||||
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
|
||||
const bool hasFix = hasOwnPositionFix && hasNodePositionFix && haveOurPos && haveNodePos;
|
||||
|
||||
if (favoriteViewIndex == 0) {
|
||||
// --- Long name (falls back to short) ---
|
||||
const char *rawName = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : shortName;
|
||||
char nodeName[40];
|
||||
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
|
||||
UIRenderer::drawStringWithEmotes(display, 2, getTextPositions(display)[cline++], nodeName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
|
||||
// --- Compass (bearing to node), right-aligned ---
|
||||
bool showCompass = false;
|
||||
float myHeading = 0.0f, bearing = 0.0f;
|
||||
const char *statusLine1 = nullptr;
|
||||
const char *statusLine2 = nullptr;
|
||||
if (hasFix) {
|
||||
showCompass = CompassRenderer::getHeadingRadians(DegD(ourPos.latitude_i), DegD(ourPos.longitude_i), myHeading);
|
||||
if (showCompass) {
|
||||
bearing = GeoCoord::bearing(DegD(ourPos.latitude_i), DegD(ourPos.longitude_i), DegD(nodePos.latitude_i),
|
||||
DegD(nodePos.longitude_i));
|
||||
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
|
||||
} else {
|
||||
statusLine1 = "No";
|
||||
statusLine2 = "Heading";
|
||||
}
|
||||
} else {
|
||||
statusLine1 = "No";
|
||||
statusLine2 = "Fix";
|
||||
}
|
||||
|
||||
const int compassTop = getTextPositions(display)[cline];
|
||||
int availableHeight = SCREEN_HEIGHT - compassTop - 1;
|
||||
const int maxCompassDiameter = (SCREEN_WIDTH / 2 - 4 < availableHeight) ? (SCREEN_WIDTH / 2 - 4) : availableHeight;
|
||||
int compassRadius = maxCompassDiameter / 2;
|
||||
if (compassRadius < 8)
|
||||
compassRadius = 8;
|
||||
const int compassX = SCREEN_WIDTH - compassRadius - 4;
|
||||
const int compassY = compassTop + availableHeight / 2;
|
||||
drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1,
|
||||
statusLine2, /*showRing=*/false);
|
||||
|
||||
// --- Distance, directly under the name, left side only, shown when a fix is available ---
|
||||
if (hasFix) {
|
||||
char distStr[16];
|
||||
const float distanceMeters = GeoCoord::latLongToMeter(DegD(nodePos.latitude_i), DegD(nodePos.longitude_i),
|
||||
DegD(ourPos.latitude_i), DegD(ourPos.longitude_i));
|
||||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
||||
const int feet = static_cast<int>((distanceMeters * METERS_TO_FEET) + 0.5f);
|
||||
if (feet < 1000)
|
||||
snprintf(distStr, sizeof(distStr), "%dft", feet);
|
||||
else
|
||||
snprintf(distStr, sizeof(distStr), "%dmi", (feet + 2640) / 5280);
|
||||
} else {
|
||||
const int meters = static_cast<int>(distanceMeters + 0.5f);
|
||||
if (meters < 1000)
|
||||
snprintf(distStr, sizeof(distStr), "%dm", meters);
|
||||
else
|
||||
snprintf(distStr, sizeof(distStr), "%dkm", (meters + 500) / 1000);
|
||||
}
|
||||
display->drawString(2, getTextPositions(display)[cline++], distStr);
|
||||
}
|
||||
|
||||
// --- Last heard, directly under distance ---
|
||||
uint32_t seenSeconds = sinceLastSeen(node);
|
||||
if (seenSeconds != 0 && seenSeconds != UINT32_MAX) {
|
||||
uint32_t minutes = seenSeconds / 60, hours = minutes / 60, days = hours / 24;
|
||||
char seenStr[16];
|
||||
snprintf(seenStr, sizeof(seenStr), (days > 365 ? "?" : "%d%c"),
|
||||
(days ? days
|
||||
: hours ? hours
|
||||
: minutes),
|
||||
(days ? 'd'
|
||||
: hours ? 'h'
|
||||
: 'm'));
|
||||
display->drawString(2, getTextPositions(display)[cline++], seenStr);
|
||||
}
|
||||
} else {
|
||||
// --- Page 1: status, signal/hops, heard, uptime, battery ---
|
||||
meshtastic_StatusMessage cachedStatus;
|
||||
if (nodeDB && nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
|
||||
drawTruncatedStatusLine(display, x, getTextPositions(display)[cline++], cachedStatus.status);
|
||||
}
|
||||
|
||||
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
|
||||
const bool showHops = node->has_hops_away && node->hops_away > 0;
|
||||
if (isZeroHop && node->snr > -100 && node->snr != 0) {
|
||||
char sigStr[16];
|
||||
snprintf(sigStr, sizeof(sigStr), "SNR:%.1f", node->snr);
|
||||
display->drawString(x, getTextPositions(display)[cline++], sigStr);
|
||||
} else if (showHops) {
|
||||
char hopStr[16];
|
||||
snprintf(hopStr, sizeof(hopStr), "Hops:%d", node->hops_away);
|
||||
display->drawString(x, getTextPositions(display)[cline++], hopStr);
|
||||
}
|
||||
|
||||
uint32_t seconds = sinceLastSeen(node);
|
||||
if (seconds != 0 && seconds != UINT32_MAX) {
|
||||
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
|
||||
char seenStr[20];
|
||||
snprintf(seenStr, sizeof(seenStr), (days > 365 ? "Heard:?" : "Heard:%d%c ago"),
|
||||
(days ? days
|
||||
: hours ? hours
|
||||
: minutes),
|
||||
(days ? 'd'
|
||||
: hours ? 'h'
|
||||
: 'm'));
|
||||
display->drawString(x, getTextPositions(display)[cline++], seenStr);
|
||||
}
|
||||
|
||||
meshtastic_DeviceMetrics nodeMetrics;
|
||||
if (nodeDB->copyNodeTelemetry(node->num, nodeMetrics)) {
|
||||
if (nodeMetrics.has_uptime_seconds) {
|
||||
char uptimeStr[24];
|
||||
getUptimeStr(nodeMetrics.uptime_seconds * 1000, "Up:", uptimeStr, sizeof(uptimeStr));
|
||||
display->drawString(x, getTextPositions(display)[cline++], uptimeStr);
|
||||
}
|
||||
if (nodeMetrics.has_battery_level) {
|
||||
char batStr[24];
|
||||
int pct = (int)nodeMetrics.battery_level;
|
||||
if (pct > 100) {
|
||||
snprintf(batStr, sizeof(batStr), "Plugged In");
|
||||
} else {
|
||||
snprintf(batStr, sizeof(batStr), "Bat:%d%%", pct);
|
||||
}
|
||||
display->drawString(x, getTextPositions(display)[cline++], batStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two-page indicator, matching the position screen's scrollbar thumb style.
|
||||
const int scrollbarX = SCREEN_WIDTH - 2;
|
||||
const int thumbHeight = SCREEN_HEIGHT / 2;
|
||||
const int thumbY = favoriteViewIndex * (SCREEN_HEIGHT - thumbHeight);
|
||||
for (int i = 0; i < thumbHeight; i++) {
|
||||
display->setPixel(scrollbarX, thumbY + i);
|
||||
}
|
||||
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
|
||||
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
|
||||
// 2. Each row is only shown if it has valid data.
|
||||
@@ -1140,9 +1324,19 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
bool origBold = config.display.heading_bold;
|
||||
config.display.heading_bold = false;
|
||||
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
|
||||
if (!config.lora.tx_enabled) {
|
||||
const char *txdisabled = "Transmit Disabled";
|
||||
display->drawString(x, getTextPositions(display)[line], txdisabled);
|
||||
if (compactPanel) {
|
||||
int textWidth = display->getStringWidth(txdisabled);
|
||||
display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled);
|
||||
} else {
|
||||
display->drawString(x, getTextPositions(display)[line], txdisabled);
|
||||
}
|
||||
} else if (compactPanel) {
|
||||
// No room for a separate left/right column layout - center it instead.
|
||||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online", true);
|
||||
} else {
|
||||
// Display Region and Channel Utilization
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
@@ -1155,27 +1349,48 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
if (currentResolution != ScreenResolution::UltraLow) {
|
||||
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
|
||||
}
|
||||
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
|
||||
if (!compactPanel) {
|
||||
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
|
||||
} else {
|
||||
line++;
|
||||
}
|
||||
|
||||
// === Second Row: Satellites and Voltage ===
|
||||
config.display.heading_bold = false;
|
||||
|
||||
#if HAS_GPS
|
||||
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus);
|
||||
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus, compactPanel);
|
||||
#endif
|
||||
|
||||
#if defined(OLED_TINY)
|
||||
line += 1;
|
||||
|
||||
// === Node Identity ===
|
||||
int textWidth = 0;
|
||||
int nameX = 0;
|
||||
const char *shortName = owner.short_name[0] ? owner.short_name : "";
|
||||
if (compactPanel) {
|
||||
// === Channel Utilization (compact, above name) ===
|
||||
int chutil_percent = static_cast<int>(airTime->channelUtilizationPercent() + 0.5f);
|
||||
char chUtilStr[16];
|
||||
snprintf(chUtilStr, sizeof(chUtilStr), "ChUtil %d%%", chutil_percent);
|
||||
int chUtilWidth = display->getStringWidth(chUtilStr);
|
||||
display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++], chUtilStr);
|
||||
|
||||
// === ShortName Centered ===
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
|
||||
// === Node Identity: long name (falls back to short), truncated with "..." if too wide ===
|
||||
const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : "";
|
||||
const char *shortName = owner.short_name[0] ? owner.short_name : "";
|
||||
const char *rawName = longName[0] ? longName : shortName;
|
||||
char nodeName[32];
|
||||
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
|
||||
int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
|
||||
int nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], nodeName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
} else {
|
||||
// === Node Identity ===
|
||||
const char *shortName = owner.short_name[0] ? owner.short_name : "";
|
||||
int textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
int nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
}
|
||||
#else
|
||||
if (powerStatus->getHasBattery()) {
|
||||
char batStr[20];
|
||||
@@ -1532,6 +1747,20 @@ void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display,
|
||||
// ****************************
|
||||
// * My Position Screen *
|
||||
// ****************************
|
||||
// Compact panels: 0 = compass, 1 = coordinates + elevation
|
||||
static int positionViewIndex = 0;
|
||||
|
||||
void UIRenderer::scrollPositionDown()
|
||||
{
|
||||
positionViewIndex = (positionViewIndex + 1) % 2;
|
||||
}
|
||||
|
||||
void UIRenderer::scrollPositionUp()
|
||||
{
|
||||
if (positionViewIndex > 0)
|
||||
positionViewIndex--;
|
||||
}
|
||||
|
||||
void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
display->clear();
|
||||
@@ -1545,13 +1774,14 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
// === Header ===
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
const int *textPos = getTextPositions(display);
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
|
||||
// === First Row: My Location ===
|
||||
#if HAS_GPS
|
||||
bool origBold = config.display.heading_bold;
|
||||
config.display.heading_bold = false;
|
||||
|
||||
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus);
|
||||
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus, compactPanel);
|
||||
|
||||
config.display.heading_bold = origBold;
|
||||
|
||||
@@ -1594,8 +1824,61 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
|
||||
}
|
||||
}
|
||||
|
||||
// GPS data (coordinates/altitude) is available whenever GPS is enabled or a fixed position is set.
|
||||
const bool hasGpsData =
|
||||
config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED || config.position.fixed_position;
|
||||
|
||||
// Compact panels: GPS status row above is shared; scroll down for coordinates+elevation.
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
if (compactPanel) {
|
||||
if (positionViewIndex == 0) {
|
||||
// Compass comes from the IMU/magnetometer, not GPS - keep it showing even with GPS off.
|
||||
const int compassTop = textPos[line];
|
||||
int availableHeight = SCREEN_HEIGHT - compassTop - 1;
|
||||
int compassRadius = availableHeight / 2;
|
||||
if (compassRadius < 8)
|
||||
compassRadius = 8;
|
||||
if (compassRadius * 2 > SCREEN_WIDTH - 4)
|
||||
compassRadius = (SCREEN_WIDTH - 4) / 2;
|
||||
drawDetailedCompassOrStatus(display, x + SCREEN_WIDTH / 2, compassTop + availableHeight / 2, compassRadius,
|
||||
validHeading, heading, statusLine1, statusLine2, /*showRing=*/false);
|
||||
} else if (hasGpsData) {
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1");
|
||||
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
|
||||
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
|
||||
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2");
|
||||
}
|
||||
char altitudeLine[32] = {0};
|
||||
int32_t alt = geoCoord.getAltitude();
|
||||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
||||
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0fft", alt * METERS_TO_FEET);
|
||||
} else {
|
||||
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt);
|
||||
}
|
||||
display->drawString(x, textPos[line++], altitudeLine);
|
||||
} else {
|
||||
// Coordinates page needs GPS - say so instead of leaving it blank.
|
||||
const char *displayLine =
|
||||
(config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) ? "No GPS" : "GPS off";
|
||||
int textWidth = display->getStringWidth(displayLine);
|
||||
display->drawString((SCREEN_WIDTH - textWidth) / 2, textPos[line], displayLine);
|
||||
}
|
||||
|
||||
// Two-page indicator, matching NodeListRenderer::drawScrollbar's thumb style.
|
||||
const int scrollbarX = SCREEN_WIDTH - 2;
|
||||
const int thumbHeight = SCREEN_HEIGHT / 2;
|
||||
const int thumbY = positionViewIndex * (SCREEN_HEIGHT - thumbHeight);
|
||||
for (int i = 0; i < thumbHeight; i++) {
|
||||
display->setPixel(scrollbarX, thumbY + i);
|
||||
}
|
||||
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// If GPS is off or not present (and position isn't fixed), no need to display these parts
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED || config.position.fixed_position) {
|
||||
if (hasGpsData) {
|
||||
// === Second Row: Last GPS Fix ===
|
||||
if (gpsStatus->getLastFixMillis() > 0) {
|
||||
uint32_t delta = millis() - gpsStatus->getLastFixMillis();
|
||||
@@ -1751,6 +2034,12 @@ void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
static int16_t lastFrameIndex = -1;
|
||||
static uint32_t lastFrameChangeTime = 0;
|
||||
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
|
||||
constexpr uint32_t ICON_DISPLAY_DURATION_MS_COMPACT = 1000;
|
||||
|
||||
void UIRenderer::notifyScreenWoke()
|
||||
{
|
||||
lastFrameChangeTime = millis();
|
||||
}
|
||||
|
||||
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
|
||||
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||||
@@ -1776,18 +2065,79 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8;
|
||||
const int spacing = (currentResolution == ScreenResolution::High) ? 8 : 4;
|
||||
const int bigOffset = (currentResolution == ScreenResolution::High) ? 1 : 0;
|
||||
const bool compactPanel = graphics::isCompactPanel(display);
|
||||
|
||||
const size_t totalIcons = screen->indicatorIcons.size();
|
||||
if (totalIcons == 0)
|
||||
return;
|
||||
|
||||
const int navPadding = (currentResolution == ScreenResolution::High) ? 24 : 12; // padding per side
|
||||
// Compact panels: briefly show current frame's icon+title centered, then nothing.
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
if (compactPanel) {
|
||||
const bool introVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS_COMPACT;
|
||||
if (!introVisible)
|
||||
return;
|
||||
|
||||
const uint8_t *icon =
|
||||
frameToHighlight < screen->indicatorIcons.size() ? screen->indicatorIcons[frameToHighlight] : nullptr;
|
||||
const char *title = frameToHighlight < screen->frameTitles.size() ? screen->frameTitles[frameToHighlight] : "";
|
||||
|
||||
// Favorite frames: show "Fav: <name>" instead of the generic "Favorite" label.
|
||||
char favTitleBuf[24];
|
||||
if (title && strcmp(title, "Favorite") == 0) {
|
||||
const int favNodeIndex = static_cast<int>(frameToHighlight) -
|
||||
(static_cast<int>(screen->frameCount) - static_cast<int>(favoritedNodes.size()));
|
||||
if (favNodeIndex >= 0 && favNodeIndex < (int)favoritedNodes.size() && favoritedNodes[favNodeIndex]) {
|
||||
meshtastic_NodeInfoLite *favNode = favoritedNodes[favNodeIndex];
|
||||
const char *favShort = (nodeInfoLiteHasUser(favNode) && favNode->short_name[0]) ? favNode->short_name : "Node";
|
||||
const char *favRaw = (nodeInfoLiteHasUser(favNode) && favNode->long_name[0]) ? favNode->long_name : favShort;
|
||||
char favLine[40];
|
||||
snprintf(favLine, sizeof(favLine), "Fav: %s", favRaw);
|
||||
UIRenderer::truncateStringWithEmotes(display, favLine, favTitleBuf, sizeof(favTitleBuf), SCREEN_WIDTH - 4);
|
||||
title = favTitleBuf;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr int introIconSize = 16;
|
||||
display->setFont(FONT_SMALL);
|
||||
const int blockHeight = introIconSize + 2 + FONT_HEIGHT_SMALL;
|
||||
const int top = (SCREEN_HEIGHT - blockHeight) / 2;
|
||||
const int iconX = (SCREEN_WIDTH - introIconSize) / 2;
|
||||
|
||||
display->setColor(BLACK);
|
||||
display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
display->setColor(WHITE);
|
||||
|
||||
// Battery level in the top-right corner while the intro is showing.
|
||||
char battStr[8];
|
||||
int chargePercent = powerStatus->getBatteryChargePercent();
|
||||
if (chargePercent == 101) {
|
||||
snprintf(battStr, sizeof(battStr), "USB");
|
||||
} else {
|
||||
snprintf(battStr, sizeof(battStr), "%s%d%%", powerStatus->getIsCharging() ? "+" : "", chargePercent);
|
||||
}
|
||||
display->setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||
display->drawString(SCREEN_WIDTH, 0, battStr);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
|
||||
if (icon)
|
||||
NodeListRenderer::drawScaledXBitmap16x16(iconX, top, 8, 8, icon, display);
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(SCREEN_WIDTH / 2, top + introIconSize + 2, title);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
const int navPadding = compactPanel ? 8 : ((currentResolution == ScreenResolution::High) ? 24 : 12);
|
||||
|
||||
int usableWidth = SCREEN_WIDTH - (navPadding * 2);
|
||||
if (usableWidth < iconSize)
|
||||
usableWidth = iconSize;
|
||||
|
||||
const size_t iconsPerPage = usableWidth / (iconSize + spacing);
|
||||
const size_t iconsPerPage =
|
||||
compactPanel ? ((usableWidth + spacing) / (iconSize + spacing)) : (usableWidth / (iconSize + spacing));
|
||||
const size_t currentPage = frameToHighlight / iconsPerPage;
|
||||
const size_t pageStart = currentPage * iconsPerPage;
|
||||
const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons);
|
||||
@@ -1825,11 +2175,14 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
navBarPrevVisible = navBarVisible;
|
||||
#endif
|
||||
|
||||
if (compactPanel && !navBarVisible)
|
||||
return;
|
||||
|
||||
// Pre-calculate bounding rect
|
||||
const int rectX = xStart - 2 - bigOffset;
|
||||
const int rectY = y - 2;
|
||||
const int rectY = y - (compactPanel ? 1 : 2);
|
||||
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
|
||||
const int rectHeight = iconSize + 6;
|
||||
const int rectHeight = iconSize + (compactPanel ? 2 : 6);
|
||||
|
||||
// Clear background and draw border
|
||||
display->setColor(BLACK);
|
||||
@@ -1871,8 +2224,9 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2);
|
||||
display->setColor(BLACK);
|
||||
#else
|
||||
const int activePadding = compactPanel ? 1 : 2;
|
||||
display->setColor(WHITE);
|
||||
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
|
||||
display->fillRect(x - activePadding, y - activePadding, iconSize + activePadding * 2, iconSize + activePadding * 2);
|
||||
display->setColor(BLACK);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@ class UIRenderer
|
||||
public:
|
||||
// Common UI elements
|
||||
static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus,
|
||||
int node_offset = 0, bool show_total = true, const char *additional_words = "");
|
||||
int node_offset = 0, bool show_total = true, const char *additional_words = "", bool center = false);
|
||||
|
||||
// GPS status functions
|
||||
static void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
|
||||
static void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus, bool center = false);
|
||||
static void drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus,
|
||||
const char *mode = "line1");
|
||||
static void drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
|
||||
@@ -48,8 +48,14 @@ class UIRenderer
|
||||
|
||||
// Navigation bar overlay
|
||||
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
|
||||
// Compact panels: called when the screen turns back on, so the intro splash replays even
|
||||
// though drawNavigationBar itself never ran while the screen (and its OSThread) was off.
|
||||
static void notifyScreenWoke();
|
||||
|
||||
static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
// Compact panels: toggle between compass+distance view and status/telemetry view
|
||||
static void scrollFavoriteDown();
|
||||
static void scrollFavoriteUp();
|
||||
|
||||
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
|
||||
@@ -60,6 +66,9 @@ class UIRenderer
|
||||
|
||||
// Compass and location screen
|
||||
static void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
// Compact panels: toggle between compass view and coordinates+elevation view
|
||||
static void scrollPositionDown();
|
||||
static void scrollPositionUp();
|
||||
|
||||
static NodeNum currentFavoriteNodeNum;
|
||||
static std::vector<meshtastic_NodeInfoLite *> favoritedNodes;
|
||||
|
||||
@@ -58,6 +58,10 @@ ButtonThread *BackButtonThread = nullptr;
|
||||
ButtonThread *CancelButtonThread = nullptr;
|
||||
#endif
|
||||
|
||||
#if defined(DOWN_BUTTON_PIN)
|
||||
ButtonThread *DownButtonThread = nullptr;
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
InputBroker *inputBroker = nullptr;
|
||||
@@ -323,6 +327,28 @@ void InputBroker::Init()
|
||||
BackButtonThread->initButton(backConfig);
|
||||
#endif
|
||||
|
||||
#if defined(DOWN_BUTTON_PIN)
|
||||
// Sends literal INPUT_BROKER_DOWN/DOWN_LONG (needed for message/nodelist scroll),
|
||||
// unlike ALT_BUTTON_PIN which sends ALT_PRESS/ALT_LONG (treated as UP/previous).
|
||||
DownButtonThread = new ButtonThread("DownButton");
|
||||
ButtonConfig downConfig;
|
||||
downConfig.pinNumber = DOWN_BUTTON_PIN;
|
||||
downConfig.activeLow = DOWN_BUTTON_ACTIVE_LOW;
|
||||
downConfig.activePullup = DOWN_BUTTON_ACTIVE_PULLUP;
|
||||
downConfig.pullupSense = pullup_sense;
|
||||
downConfig.intRoutine = []() {
|
||||
DownButtonThread->userButton.tick();
|
||||
DownButtonThread->setIntervalFromNow(0);
|
||||
runASAP = true;
|
||||
BaseType_t higherWake = 0;
|
||||
concurrency::mainDelay.interruptFromISR(&higherWake);
|
||||
};
|
||||
downConfig.singlePress = INPUT_BROKER_DOWN;
|
||||
downConfig.longPress = INPUT_BROKER_DOWN_LONG;
|
||||
downConfig.longPressTime = 500;
|
||||
DownButtonThread->initButton(downConfig);
|
||||
#endif
|
||||
|
||||
#if defined(BUTTON_PIN)
|
||||
#if defined(USERPREFS_BUTTON_PIN)
|
||||
int _pinNum = config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN;
|
||||
|
||||
+15
-3
@@ -1112,6 +1112,10 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
config.display.wake_on_tap_or_motion = true;
|
||||
#endif
|
||||
|
||||
#if defined(T_ECHO_CARD)
|
||||
config.display.screen_on_secs = 60;
|
||||
#endif
|
||||
|
||||
#ifdef COMPASS_ORIENTATION
|
||||
config.display.compass_orientation = COMPASS_ORIENTATION;
|
||||
#endif
|
||||
@@ -1220,8 +1224,11 @@ void NodeDB::installDefaultModuleConfig()
|
||||
moduleConfig.has_store_forward = true;
|
||||
moduleConfig.has_telemetry = true;
|
||||
moduleConfig.has_external_notification = true;
|
||||
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) || \
|
||||
defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
|
||||
#if defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION)
|
||||
#define HAS_NOTIFICATION_LED
|
||||
#endif
|
||||
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_NOTIFICATION_LED) || \
|
||||
defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || defined(HAS_I2S_SPEAKER_NRF52)
|
||||
moduleConfig.external_notification.enabled = true;
|
||||
#endif
|
||||
|
||||
@@ -1229,6 +1236,10 @@ void NodeDB::installDefaultModuleConfig()
|
||||
moduleConfig.external_notification.output_buzzer = PIN_BUZZER;
|
||||
moduleConfig.external_notification.use_pwm = true;
|
||||
moduleConfig.external_notification.alert_message_buzzer = true;
|
||||
#elif defined(HAS_I2S_SPEAKER_NRF52)
|
||||
// No PWM piezo pin - alert playback goes through NRF52RtttlPlayer/I2S instead,
|
||||
// gated only on alert_message_buzzer + canBuzz(), not output_buzzer/use_pwm.
|
||||
moduleConfig.external_notification.alert_message_buzzer = true;
|
||||
#endif
|
||||
|
||||
#if defined(PIN_VIBRATION)
|
||||
@@ -1246,7 +1257,8 @@ void NodeDB::installDefaultModuleConfig()
|
||||
|
||||
#if defined(PIN_VIBRATION)
|
||||
moduleConfig.external_notification.nag_timeout = 2;
|
||||
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
|
||||
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || \
|
||||
defined(HAS_I2S_SPEAKER_NRF52)
|
||||
moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
|
||||
#endif
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ bool ascending = true;
|
||||
#define PIN_BUZZER false
|
||||
#endif
|
||||
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
#include "platform/nrf52/NRF52RtttlPlayer.h"
|
||||
#endif
|
||||
|
||||
/*
|
||||
Documentation:
|
||||
https://meshtastic.org/docs/configuration/module/external-notification
|
||||
@@ -77,6 +81,9 @@ int32_t ExternalNotificationModule::runOnce()
|
||||
#ifdef HAS_I2S
|
||||
// audioThread->isPlaying() also handles actually playing the RTTTL, needs to be called in loop
|
||||
isRtttlPlaying = isRtttlPlaying || audioThread->isPlaying();
|
||||
#endif
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying();
|
||||
#endif
|
||||
if ((nagCycleCutoff < millis()) && !isRtttlPlaying) {
|
||||
// Turn off external notification immediately when timeout is reached, regardless of song state
|
||||
@@ -145,6 +152,17 @@ int32_t ExternalNotificationModule::runOnce()
|
||||
// we need fast updates to play the RTTTL
|
||||
delay = EXT_NOTIFICATION_FAST_THREAD_MS;
|
||||
}
|
||||
#endif
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
// Play RTTTL over the I2S speaker (no piezo on this board).
|
||||
if (canBuzz() && buzzerShouldAlert) {
|
||||
if (nrf52RtttlPlayer.isPlaying()) {
|
||||
nrf52RtttlPlayer.play();
|
||||
} else if (isNagging && (nagCycleCutoff >= millis())) {
|
||||
nrf52RtttlPlayer.begin(rtttlConfig.ringtone);
|
||||
}
|
||||
delay = EXT_NOTIFICATION_FAST_THREAD_MS;
|
||||
}
|
||||
#endif
|
||||
// now let the PWM buzzer play
|
||||
if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) {
|
||||
@@ -264,6 +282,9 @@ void ExternalNotificationModule::stopNow()
|
||||
#ifdef HAS_I2S
|
||||
LOG_INFO("Stop audioThread playback");
|
||||
audioThread->stop();
|
||||
#endif
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
nrf52RtttlPlayer.stop();
|
||||
#endif
|
||||
// Turn off all outputs
|
||||
LOG_INFO("Turning off setExternalStates");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "NRF52I2SOutput.h"
|
||||
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
|
||||
#include <nrf_i2s.h>
|
||||
|
||||
NRF52I2SOutput nrf52I2SOutput;
|
||||
|
||||
bool NRF52I2SOutput::begin(uint8_t sckPin, uint8_t wsPin, uint8_t sdPin)
|
||||
{
|
||||
nrf_i2s_pins_set(NRF_I2S, sckPin, wsPin, NRF_I2S_PIN_NOT_CONNECTED, sdPin, NRF_I2S_PIN_NOT_CONNECTED);
|
||||
bool ok = nrf_i2s_configure(NRF_I2S, NRF_I2S_MODE_MASTER, NRF_I2S_FORMAT_I2S, NRF_I2S_ALIGN_LEFT, NRF_I2S_SWIDTH_16BIT,
|
||||
NRF_I2S_CHANNELS_STEREO, NRF_I2S_MCK_32MDIV8, NRF_I2S_RATIO_256X);
|
||||
started = ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void NRF52I2SOutput::fillBuffer(uint32_t frequencyHz)
|
||||
{
|
||||
size_t samplesPerCycle = kSampleRateHz / frequencyHz;
|
||||
if (samplesPerCycle < 4)
|
||||
samplesPerCycle = 4;
|
||||
if (samplesPerCycle > kMaxSamples)
|
||||
samplesPerCycle = kMaxSamples;
|
||||
|
||||
// Square wave, same value in both stereo halves (works regardless of SD_MODE strap).
|
||||
const int16_t amplitude = 12000; // headroom below full-scale
|
||||
const size_t half = samplesPerCycle / 2;
|
||||
for (size_t i = 0; i < samplesPerCycle; i++) {
|
||||
int16_t sample = (i < half) ? amplitude : (int16_t)-amplitude;
|
||||
buffer[i] = ((uint32_t)(uint16_t)sample << 16) | (uint16_t)sample;
|
||||
}
|
||||
|
||||
// Buffer pointer is never updated after START, so EasyDMA just keeps replaying it.
|
||||
nrf_i2s_transfer_set(NRF_I2S, (uint16_t)samplesPerCycle, NULL, buffer);
|
||||
}
|
||||
|
||||
void NRF52I2SOutput::playTone(uint32_t frequencyHz, uint32_t durationMs)
|
||||
{
|
||||
if (!started || durationMs == 0)
|
||||
return;
|
||||
|
||||
if (frequencyHz <= 1) {
|
||||
// Rest note.
|
||||
delay(durationMs);
|
||||
return;
|
||||
}
|
||||
|
||||
fillBuffer(frequencyHz);
|
||||
nrf_i2s_enable(NRF_I2S);
|
||||
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_START);
|
||||
|
||||
delay(durationMs);
|
||||
|
||||
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_STOP);
|
||||
nrf_i2s_disable(NRF_I2S);
|
||||
}
|
||||
|
||||
void NRF52I2SOutput::startTone(uint32_t frequencyHz)
|
||||
{
|
||||
if (!started)
|
||||
return;
|
||||
stopTone();
|
||||
if (frequencyHz <= 1)
|
||||
return;
|
||||
|
||||
fillBuffer(frequencyHz);
|
||||
nrf_i2s_enable(NRF_I2S);
|
||||
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_START);
|
||||
}
|
||||
|
||||
void NRF52I2SOutput::stopTone()
|
||||
{
|
||||
nrf_i2s_task_trigger(NRF_I2S, NRF_I2S_TASK_STOP);
|
||||
nrf_i2s_disable(NRF_I2S);
|
||||
}
|
||||
|
||||
void NRF52I2SOutput::end()
|
||||
{
|
||||
stopTone();
|
||||
started = false;
|
||||
}
|
||||
|
||||
#endif // HAS_I2S_SPEAKER_NRF52
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
// nRF52840 I2S tone driver. Drives NRF_I2S registers directly via nrf_i2s.h
|
||||
// (the framework has no nrfx_i2s.c driver, only the register HAL).
|
||||
// Plays repeating square-wave tones only - no RTTTL text parsing.
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class NRF52I2SOutput
|
||||
{
|
||||
public:
|
||||
bool begin(uint8_t sckPin, uint8_t wsPin, uint8_t sdPin);
|
||||
|
||||
// frequencyHz <= 1 = silent rest.
|
||||
void playTone(uint32_t frequencyHz, uint32_t durationMs);
|
||||
|
||||
// Non-blocking variants for callers that advance timing themselves (e.g. RTTTL player).
|
||||
void startTone(uint32_t frequencyHz);
|
||||
void stopTone();
|
||||
|
||||
void end();
|
||||
|
||||
private:
|
||||
void fillBuffer(uint32_t frequencyHz);
|
||||
|
||||
static constexpr uint32_t kSampleRateHz = 15625; // 32MHz/8/256
|
||||
static constexpr size_t kMaxSamples = 512;
|
||||
|
||||
uint32_t buffer[kMaxSamples] = {};
|
||||
bool started = false;
|
||||
};
|
||||
|
||||
extern NRF52I2SOutput nrf52I2SOutput;
|
||||
@@ -0,0 +1,184 @@
|
||||
#include "NRF52RtttlPlayer.h"
|
||||
|
||||
#if defined(HAS_I2S_SPEAKER_NRF52)
|
||||
|
||||
#include "NRF52I2SOutput.h"
|
||||
#include "mesh/Throttle.h"
|
||||
#include <cctype>
|
||||
|
||||
NRF52RtttlPlayer nrf52RtttlPlayer;
|
||||
|
||||
namespace
|
||||
{
|
||||
// Index 0 unused, then C4..B4, C5..B5, C6..B6, C7..B7 (48 notes) - same layout as
|
||||
// the NonBlockingRTTTL library's table, indexed as [(octave - 4) * 12 + note].
|
||||
const int kNoteFreq[] = {
|
||||
0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, 523, 554, 587, 622,
|
||||
659, 698, 740, 784, 831, 880, 932, 988, 1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661,
|
||||
1760, 1865, 1976, 2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951,
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void NRF52RtttlPlayer::begin(const char *songBuffer, uint8_t loopCountIn, unsigned long loopGapMs)
|
||||
{
|
||||
buffer = songBuffer;
|
||||
defaultDuration = 4;
|
||||
defaultOctave = 6;
|
||||
bpm = 63;
|
||||
playing = true;
|
||||
noteStartAt = millis();
|
||||
noteDurationMs = 0;
|
||||
loopCount = loopCountIn;
|
||||
loopGap = loopGapMs;
|
||||
|
||||
pinMode(SPEAKER_EN, OUTPUT);
|
||||
digitalWrite(SPEAKER_EN, HIGH);
|
||||
#if defined(SPEAKER_EN_2)
|
||||
pinMode(SPEAKER_EN_2, OUTPUT);
|
||||
digitalWrite(SPEAKER_EN_2, HIGH);
|
||||
#endif
|
||||
nrf52I2SOutput.begin(SPEAKER_BCLK, SPEAKER_WS_LRCK, SPEAKER_DATA);
|
||||
nrf52I2SOutput.stopTone();
|
||||
|
||||
// format: name:d=N,o=N,b=NNN:notes...
|
||||
while (*buffer != ':' && *buffer != '\0')
|
||||
buffer++;
|
||||
if (*buffer == ':')
|
||||
buffer++;
|
||||
|
||||
int num;
|
||||
if (*buffer == 'd') {
|
||||
buffer += 2; // skip "d="
|
||||
num = 0;
|
||||
while (isdigit((unsigned char)*buffer))
|
||||
num = (num * 10) + (*buffer++ - '0');
|
||||
if (num > 0)
|
||||
defaultDuration = num;
|
||||
buffer++; // skip comma
|
||||
}
|
||||
if (*buffer == 'o') {
|
||||
buffer += 2; // skip "o="
|
||||
num = *buffer++ - '0';
|
||||
if (num >= 3 && num <= 7)
|
||||
defaultOctave = num;
|
||||
buffer++; // skip comma
|
||||
}
|
||||
if (*buffer == 'b') {
|
||||
buffer += 2; // skip "b="
|
||||
num = 0;
|
||||
while (isdigit((unsigned char)*buffer))
|
||||
num = (num * 10) + (*buffer++ - '0');
|
||||
bpm = num;
|
||||
buffer++; // skip colon
|
||||
}
|
||||
if (bpm <= 0)
|
||||
bpm = 63;
|
||||
|
||||
wholeNoteMs = (60 * 1000L / bpm) * 4;
|
||||
firstNote = buffer;
|
||||
}
|
||||
|
||||
void NRF52RtttlPlayer::nextNote()
|
||||
{
|
||||
nrf52I2SOutput.stopTone();
|
||||
|
||||
int num = 0;
|
||||
while (isdigit((unsigned char)*buffer))
|
||||
num = (num * 10) + (*buffer++ - '0');
|
||||
long duration = num ? (wholeNoteMs / num) : (wholeNoteMs / defaultDuration);
|
||||
|
||||
int note = 0;
|
||||
switch (*buffer) {
|
||||
case 'c':
|
||||
note = 1;
|
||||
break;
|
||||
case 'd':
|
||||
note = 3;
|
||||
break;
|
||||
case 'e':
|
||||
note = 5;
|
||||
break;
|
||||
case 'f':
|
||||
note = 6;
|
||||
break;
|
||||
case 'g':
|
||||
note = 8;
|
||||
break;
|
||||
case 'a':
|
||||
note = 10;
|
||||
break;
|
||||
case 'b':
|
||||
note = 12;
|
||||
break;
|
||||
case 'p':
|
||||
default:
|
||||
note = 0;
|
||||
break;
|
||||
}
|
||||
buffer++;
|
||||
|
||||
if (*buffer == '#') {
|
||||
note++;
|
||||
buffer++;
|
||||
}
|
||||
if (*buffer == '.') {
|
||||
duration += duration / 2;
|
||||
buffer++;
|
||||
}
|
||||
|
||||
int scale;
|
||||
if (isdigit((unsigned char)*buffer)) {
|
||||
scale = *buffer - '0';
|
||||
buffer++;
|
||||
} else {
|
||||
scale = defaultOctave;
|
||||
}
|
||||
if (*buffer == '.') {
|
||||
duration += duration / 2;
|
||||
buffer++;
|
||||
}
|
||||
if (*buffer == ',')
|
||||
buffer++;
|
||||
|
||||
if (note && scale >= 4 && scale <= 7) {
|
||||
nrf52I2SOutput.startTone(kNoteFreq[(scale - 4) * 12 + note]);
|
||||
}
|
||||
noteStartAt = millis();
|
||||
noteDurationMs = (uint32_t)duration;
|
||||
}
|
||||
|
||||
void NRF52RtttlPlayer::play()
|
||||
{
|
||||
if (!playing)
|
||||
return;
|
||||
|
||||
if (Throttle::isWithinTimespanMs(noteStartAt, noteDurationMs))
|
||||
return;
|
||||
|
||||
if (*buffer == '\0') {
|
||||
if (--loopCount) {
|
||||
noteStartAt = millis();
|
||||
noteDurationMs = (uint32_t)loopGap;
|
||||
buffer = firstNote;
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
nextNote();
|
||||
}
|
||||
|
||||
void NRF52RtttlPlayer::stop()
|
||||
{
|
||||
if (playing) {
|
||||
nrf52I2SOutput.stopTone();
|
||||
digitalWrite(SPEAKER_EN, LOW);
|
||||
#if defined(SPEAKER_EN_2)
|
||||
digitalWrite(SPEAKER_EN_2, LOW);
|
||||
#endif
|
||||
playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
#endif // HAS_I2S_SPEAKER_NRF52
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
// Non-blocking RTTTL player for the nRF52 I2S speaker path (NRF52I2SOutput.h).
|
||||
// Same parse algorithm as the NonBlockingRTTTL library, but drives
|
||||
// NRF52I2SOutput::startTone/stopTone instead of tone()/noTone().
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class NRF52RtttlPlayer
|
||||
{
|
||||
public:
|
||||
void begin(const char *songBuffer, uint8_t loopCount = 1, unsigned long loopGapMs = 1000);
|
||||
void play(); // call every tick
|
||||
void stop();
|
||||
bool isPlaying() const { return playing; }
|
||||
|
||||
private:
|
||||
void nextNote();
|
||||
|
||||
const char *buffer = "";
|
||||
const char *firstNote = "";
|
||||
uint8_t defaultDuration = 4;
|
||||
uint8_t defaultOctave = 6;
|
||||
int bpm = 63;
|
||||
long wholeNoteMs = 0;
|
||||
uint32_t noteStartAt = 0;
|
||||
uint32_t noteDurationMs = 0;
|
||||
unsigned long loopGap = 1000;
|
||||
uint8_t loopCount = 1;
|
||||
bool playing = false;
|
||||
};
|
||||
|
||||
extern NRF52RtttlPlayer nrf52RtttlPlayer;
|
||||
@@ -15,6 +15,8 @@ void c6l_init();
|
||||
#define NEOPIXEL_TYPE (NEO_GRB + NEO_KHZ800) // type of neopixels in use
|
||||
#define ENABLE_AMBIENTLIGHTING // Turn on Ambient Lighting
|
||||
|
||||
#define OLED_COMPACT_UI
|
||||
|
||||
// #define BUTTON_PIN 9
|
||||
#define BUTTON_EXTENDER
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user