mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-13 23:21:19 +10:00
+19








4c91beeda9
* Don't ever define PIN_LED or BLE_LED_INVERTED (#9494) * Don't ever define PIN_LED * Deprecate BLE_LED_INVERTED * Add StatusMessage module and config overrides (#9351) * Add StatusMessage module and config overrides * Trunk * Don't reboot node simply for a StatusMessage config update * Missed in reviews - fixing send bubble (#9505) * Prefer EXT_PWR_DETECT pin over chargingVolt to detect power unplugged (#9511) * Make sure we always return a value in NodeDB::restorePreferences() (#9516) In case FScom is not defined there is no return statement. This moves the return outside of the ifdef to make sure a defined value is returned. * Inkhud battery icon improvements. (#9513) * Inkhud battery icon improvements. Fixes the battery icon draining from the flat side towards the bump, which is backwards from general design language seen on most devices By request of kr0n05_ on discord, adds the ability to mirror the battery icon which fixes that issue in another way, and is also a common design seen on other devices. * Remove option for icon mirroring * Add border + dither to battery to prevent font overlap * Fix trunk format * Code cleanup, courtesy of Xaositek. * Add reply bot module with DM-only responses and rate limiting (#9456) * Implement Meshtastic reply bot module with ping and status features Adds a reply bot module that listens for /ping, /hello, and /test commands received via direct messages or broadcasts on the primary channel. The module always replies via direct message to the sender only, reporting hop count, RSSI, and SNR. Per-sender cooldowns are enforced to reduce network spam, and the module can be excluded at build time via a compile flag. Updates include the new module source files and required build configuration changes. * Update ReplyBotModule.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/ReplyBotModule.h Match the existing MESHTASTIC_EXCLUDE_* guard pattern so the module is excluded by default. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/ReplyBotModule.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Tidying up --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * HotFix for ReplyBot - Modules.cpp included and moved configuration.h (#9532) * Undefine LED_BUILTIN (#9531) Keep variant in sync with https://github.com/meshtastic/firmware/commit/df40085 Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * Add agc reset attempt (#8163) * Add agc reset attempt * Add radioLibInterface include * Trunk * AGC reset don't crash, don't naively call * Update src/main.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Use Throttle function --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove unused hmx variable (#9529) The variable is not used at all in the function, remove it to silence the compiler warning. Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * Rename LED_PIN to LED_POWER, move handling out of main to dedicated module (#9512) * Rename LED_PIN to LED_POWER, move handling out of main to dedicated module * Misc * Remove errant endif * Fix hop_limit upgrade detection (#9550) Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * meshtasticd: Fix install on Fedora 43 (#9556) * RPM: Include meshtasticd-start.sh (#9561) * Add Slash Key to VirtualKeyboard (#9563) Addition of ? and / to the virtual Keyboard via short and long press * Add support for CW2015 LiPo battery fuel gauge (#9564) * Add support for CW2015 LiPo battery fuel gauge * Address Copilot's concerns, minor fixups * Make LED_POWER blip even in critical battery (#9545) * Enable FORTIFY and SP for native builds (#9537) * Enable FORITFY and NX for native builds meshtasticd does have an executable stack and is not built with fortify, which makes exploitation of memory corruption bugs easier than it has to be. This enables fortify and a non-executable stack. This gives the following improvements on Debian Trixie: $ checksec --file=./.pio/build/native/meshtasticd RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 13516 Symbols No 0 17 ./.pio/build/native/meshtasticd $ checksec --file=./.pio/build/native/meshtasticd RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 13519 Symbols Yes 12 20 ./.pio/build/native/meshtasticd Tested with --sim mode I do not get any crashes or similar. * Enable FORTIFY and NX for native builds meshtasticd does have an executable stack and is not built with fortify, which makes exploitation of memory corruption bugs easier than it has to be. This enables fortify and a non-executable stack. This gives the following improvements on Debian Trixie: $ checksec --file=./.pio/build/native/meshtasticd RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 13516 Symbols No 0 17 ./.pio/build/native/meshtasticd $ checksec --file=./.pio/build/native/meshtasticd RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 13519 Symbols Yes 12 20 ./.pio/build/native/meshtasticd Tested with --sim mode I do not get any crashes or similar. * Enable FORTIFY and SP for native builds meshtasticd does have a stack canaries and is not built with fortify, which makes exploitation of memory corruption bugs easier than it has to be. This enables fortify and stack canaries. This gives the following improvements on Debian Trixie: $ checksec --file=./.pio/build/native/meshtasticd RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 13516 Symbols No 0 17 ./.pio/build/native/meshtasticd $ checksec --file=./.pio/build/native/meshtasticd RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 13519 Symbols Yes 12 20 ./.pio/build/native/meshtasticd Tested with --sim mode I do not get any crashes or similar. --------- Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * Update built-in documentation for current method of implementation (#9592) * Refactor logging in ProtobufModule to ensure message details are logged after successful decoding (#9536) * Automated version bumps (#9604) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Add missing openocd_target to custom nrf52 boards (#9603) This stops platformio complaining about `Missing target configuration for me25ls01-4y10td` etc when trying to flash with nrfutil. * Add sdl libs for native builds (#9595) * Add sdl libs for native builds * Alpine try again * fix some random compiler warnings (#9596) * Modify the dependency library of v4-tft (#9507) * BaseUI: Favorite Screen Signal Quality improvement (#9566) * Favorite Signal Quality improvement * Show Voltage if node shares it. * Trunk Fix * Change Favorite tittle to encase name with Asterisks * Add Pluggin In condition for Battery Line * Adjust getUptimeStr Prefixes * Create isAPIConnected for SharedCommon usage * Correct leftSideSpacing to account for isAPIConnected --------- Co-authored-by: Jason P <applewiz@mac.com> * ExternalNotification and StatusLED now call AmbientLighting to update… (#9554) * ExternalNotification and StatusLED now call AmbientLighting to update RGB LEDs. Add optional heartbeat * Don't overwrite RGB state if heartbeat is disabled. * Use the right define * Remove another .h and make rgb static * move rgb objects into AmbientLighting class * Straighten out AmbientLighting Thread object * Use %f for floats * Fixes on SCD4X admin comands (#9607) * Fixes on SCD4X admin comands * Minor fix in logs for SEN5X --------- Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * feat/add sfa30 (#9372) * Move PMSA003I to separate class and update AQ telemetry * AirQualityTelemetry module not depend on PM sensor presence * Remove commented line * Fixes on PMS class * Add missing warmup period to wakeUp function * Fixes on compilation for different variants * Add functions to check for I2C bus speed and set it * Initial implementation for SFA30Sensor * Move PMSA003I to separate class and update AQ telemetry * AirQualityTelemetry module not depend on PM sensor presence * Remove commented line * Fixes on PMS class * Add missing warmup period to wakeUp function * Fixes on compilation for different variants * Add functions to check for I2C bus speed and set it * Add ScreenFonts.h Co-authored-by: Hannes Fuchs <hannes.fuchs+git@0xef.de> * PMSA003I 1st round test * Fix I2C scan speed * Fix minor issues and bring back I2C SPEED def * Remove PMSA003I library as its no longer needed * Add functional SCD4X * Fix screen frame for CO2 * Add admin commands to SCD4X class * Add further admin commands and fixes. * Remove unused I2C speed functions and cleanup * Cleanup of SEN5X specific code added from switching branches * Remove SCAN_I2C_CLOCK_SPEED block as its not needed * Remove associated functions for setting I2C speed * Unify build epoch to add flag in platformio-custom.py (#7917) * Unify build_epoch replacement logic in platformio-custom * Missed one * Fix build error in rak_wismesh_tap_v2 (#7905) In the logs was: "No screen resolution defined in build_flags. Please define DISPLAY_SIZE." set according to similar devices. * Put guards in place around debug heap operations (#7955) * Put guards in place around debug heap operations * Add macros to clean up code * Add pointer as well * Cleanup * Fix memory leak in NextHopRouter: always free packet copy when removing from pending * Formatting * Only queue 2 client notification * Merge pull request #7965 from compumike/compumike/fix-nrf52-bluetooth-memory-leak Fix memory leak in `NRF52Bluetooth`: allocate `BluetoothStatus` on stack, not heap * Merge pull request #7964 from compumike/compumike/fix-nimble-bluetooth-memory-leak Fix memory leak in `NimbleBluetooth`: allocate `BluetoothStatus` on stack, not heap * Update protobufs (#7973) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * T-Lora Pager: Support LR1121 and SX1280 models (#7956) * T-Lora Pager: Support LR1121 and SX1280 models * Remove ifdefs * Trunk * Trunk * Static memory pool allocation (#7966) * Static memory pool * Initializer * T-Lora Pager: Support LR1121 and SX1280 models (#7956) * T-Lora Pager: Support LR1121 and SX1280 models * Remove ifdefs --------- Co-authored-by: WillyJL <me@willyjl.dev> * Portduino dynamic alloc * Missed * Drop the limit * Update meshtastic-esp8266-oled-ssd1306 digest to 0cbc26b (#7977) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Fix json report crashes on esp32 (#7978) * Tweak maximums * Fix DRAM overflow on old esp32 targets * Guard bad time warning logs using GPS_DEBUG (#7897) In 2.7.7 / 2.7.8 we introduced some new checks for time accuracy. In combination, these result in a spamming of the logs when a bad time is found When the GPS is active, we're calling the GPS thread every 0.2secs. So this log could be printed 4,500 times in a no-lock scenario :) Reserve this experience for developers using GPS_DEBUG. Fixes https://github.com/meshtastic/firmware/issues/7896 * Scale probe buffer size based on current baud rate (#7975) * Scale probe buffer size based on current baud rate * Throttle bad time validation logging and fix time comparison logic * Remove comment * Missed the other instances * Copy pasta * Fix GPS gm_mktime memory leak (#7981) * Fix overflow of time value (#7984) * Fix overflow of time value * Revert "Fix overflow of time value" This reverts commit 084796920179e80a7500d36c25fd4d82b3ef4214. * That got boogered up * Remove PMSA003 include from modules * Add flag to exclude air quality module * Rework PMSA003I to align with new I2C scanner * Reworks AQ telemetry to match new dynamic allocation method * Adds VBLE_I2C_CLOCK_SPEED build flag for sensors with different I2C speed requirements * Reworks PMSA003I * Move add sensor template to separate file * Split telemetry on screen options * Add variable I2C clock compile flag * Added to Seeed Xiao S3 as demo * Fix drawFrame in AQ module * Module settings override to i2cScan module function * Move to CAN_RECLOCK_I2C per architecture * Add reclock function in TelemetrySensor.cpp * Add flag in ESP32 common * Minor fix * Move I2C reclock function to src/detect * Fix uninitMemberVar errors and compile issue * Make sleep, wakeUp functions generic * Fix STM32 builds * Add exclude AQ sensor to builds that have environmental sensor excludes * Add includes to AddI2CSensorTemplate.h * SEN5X first pass * WIP Sen5X functions * Further (non-working) progress in SEN5X * WIP Sen5X functions * Changes on SEN5X library - removing pm_env as well * Small cleanup of SEN5X sensors * Minor change for SEN5X detection * Remove dup code * Enable PM sensor before sending telemetry. This enables the PM sensor for a predefined period to allow for warmup. Once telemetry is sent, the sensor shuts down again. * Small cleanups in SEN5X sensor * Add dynamic measurement interval for SEN5X * Only disable SEN5X if enough time after reading. * Idle for SEN5X on communication error * Cleanup of logs and remove unnecessary delays * Small TODO * Settle on uint16_t for SEN5X PM data * Make AQTelemetry sensors non-exclusive * Implementation of cleaning in FS prefs and cleanup * Remove unnecessary LOGS * Add cleaning date storage in FS * Report non-cumulative PN * Bring back detection code for SEN5X after branch rebase * Add placeholder for admin message * Add VOC measurements and persistence (WIP) * Adds VOC measurements and state * Still not working on VOC Index persistence * Should it stay in continuous mode? * Add one-shot mode config flag to SEN5X * Add nan checks on sensor data from SEN5X * Working implementation on VOCState * Adds initial timer for SEN55 to not sleep if VOCstate is not stable (1h) * Adds conditions for stability and sensor state * Fixes on VOC state and mode swtiching * Adds a new RHT/Gas only mode, with 3600s stabilization time * Fixes the VOCState buffer mismatch * Fixes SEN50/54/55 model mistake * Adapt SEN5X to new sensor list structure. Improve reclock. * Improve reClockI2C conditions for different variants * Add sleep, wakeUp, pendingForReady, hasSleep functions to PM sensors to save battery * Add SEN5X * Fix merge errors * Small reordering in PMS class for consistency * If one sensor fails, AQ telemetry still reports data * Small formatting fix * Add SEN5X to AQI in ScanI2C * SCD4X now part of AQ module with template list * Fixes difference between idle and sleep * In LowPower, sleep is disabled * Requires testing for I2C clock comms for commands * Remove unnecessary import * Add co2 to serialized AQ metrics * Add SFA30 with new sensor template in AQ module * Update library dependencies in platformio.ini * Fix unitialized variables in SEN5X constructor * Fix missing import * Fix uninitMemberVars * Fix import error for SCD4X * Fix I2CClock logic * Fix not reclocking back to 700000Hz * Fix multiple sensors being read simultaneously * The logic in AQ module is different to the one in EnvironmentTelemetryModule. In Env module, if any sensor fails to getMetrics, no valid flag for the module. This prevents other sensors to report data. * Fix pending clock change in PMSA003 * Cleanup of SEN5X class * Exclude AQ sensor from wio-e5 due to flash limitations * Fix I2C clock change logic * Make sure clock is always set to needed value * Fix returns * Fix trunk * Fix on condition in reclock * Fix trunk * Final SFA30 class implementation * Add HCHO to screen and improve logs * Add metrics to mesh packet serializer * Minor fixes in logs * OCD tidy up of logs * Fix sleep function * Remove old I2C_CLOCK_SPEED code --------- Co-authored-by: nikl <nikl174@mailbox.org> Co-authored-by: Hannes Fuchs <hannes.fuchs+git@0xef.de> Co-authored-by: Nashui-Yan <yannashui10@gmail.com> Co-authored-by: Ben Meadors <benmmeadors@gmail.com> Co-authored-by: Tom Fifield <tom@tomfifield.net> Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> Co-authored-by: WillyJL <me@willyjl.dev> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Log rxBad PacketHeaders with more info (id, relay_node) like printPacket, so we can try to match RX errors to other packets in the logs. (#9614) * Exclude status message module * fix: zero entire public key array instead of only first byte (#9619) * Update protobufs (#9621) * Xiao NRF - define suitable i2c pins for the sub-variants (#8866) Co-authored-by: Christian Walther <cwalther@gmx.ch> * Update src/detect/ScanI2C.cpp Co-authored-by: Wessel <wessel@weebl.me> * Update src/modules/Telemetry/Sensor/SFA30Sensor.cpp Co-authored-by: Wessel <wessel@weebl.me> * Update src/modules/Telemetry/Sensor/SFA30Sensor.cpp Co-authored-by: Wessel <wessel@weebl.me> * Update src/modules/Telemetry/Sensor/SFA30Sensor.cpp Co-authored-by: Wessel <wessel@weebl.me> * Update src/mesh/NodeDB.cpp Co-authored-by: Wessel <wessel@weebl.me> * convert GPS global and some new in gps.cpp to unique_ptr (#9628) Trying this to see if anything bad happen if I were to replace most raw pointers with unique_ptr. I didn't used std::make_unique since it is only supported in C++14 and onwards but until we update esp32 to arduino 3.x the ESP32 xtensa chips use a C++11 std. * replace delete in RedirectablePrint.cpp with std::unique_ptr (#9642) Is part of the unique_ptr modernization effort. * replace delete in EInkDynamicDisplay.{cpp,h} with std::unique_ptr (#9643) Is part of the unique_ptr modernization effort. * Undefine LED_BUILTIN for Heltec v2 variant (#9647) * Undefine LED_BUILTIN for Heltec v2 variant * Undefine LED_BUILTIN for Heltec v2.1 variant --------- Co-authored-by: Jorropo <jorropo.pgm@gmail.com> * replace delete in RadioInterface.cpp with std::unique_ptr (#9645) Is part of the unique_ptr modernization effort. * fix typo in PIN_GPS_SWITCH (#9648) Wasn't caught by CI. * replace delete in CryptoEngine.{cpp,h} with std::unique_ptr (#9649) Is part of the unique_ptr modernization effort. * workaround NCP5623 and LP5562 I2C builds (#9652) Theses two appear to be buggy on r1-neo and nomadstar meteor pro, they rely on Wire.h being included previously to their import. Idk why other platforms using the same smart LEDs are working while theses ones don't. This should make CI green on the dev branch. * replace delete in AudioThread.h with std::unique_ptr (#9651) Is part of the unique_ptr modernization effort. * Add USB_MODE=1 for Station G2 (#9660) * InkHUD: Favorite Map Applet (#9654) * fix a lot of low level cppcheck warnings (#9623) * simplify the observer pattern, since all the called functions are const getters. * use arduino macro over std: for numerical values and refactor local variables in drawScrollbar() * oh, so Cppcheck actually complained about const pointers not being const. * slowly getting out of ifdef hell * fix inkHUD warnings as well * last 2 check warnings * git checks should fail on low defects from now on * Feat/add scd30 (#9609) * Merge develop into SCD30 * Add SCD30 class * Fix logging and admin commands * Minor cleanup and logging improvements * Minor formatting issue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improvements on setTemperature * Fix casting float-uint16_t * Pass 100 for resetting temperature offset * Fix issues pointed out by copilot * Add quick reboot to set interval quicker on scd30 * Change saveState to only happen after boot and minor log changes * Fix missing semicolon in one shot mode log --------- Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors <benmmeadors@gmail.com> * fix: respect DontMqttMeBro flag regardless of channel PSK (#9626) The previous PSK check was broken from its introduction in #4643 — memcmp was used in boolean context without comparing to 0, inverting the condition. Since no one noticed for over a year, the PSK-based filtering provided no practical value. Simplifying to always respect the sender's preference is both more correct and easier to reason about. * our firmware action is too clever Update pio_target and add pio_opts for checks. * fix detection of SCD30 by checking if the size of the return from a 2 byte register read is correct (#9664) * fix detection of SCD30 by checking if thee size of the return from a 2 byte register read is correct fix signedness warning in PMSA003 sensor code. * Add alternate path for LPS22HB Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * check EndTransmission for errors and compare returned length to expected value Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * #9623 resolved a local shadow of next_key by converting it to int. (#9665) * zip a few gitrefs down (#9672) * InkHUD: Allow non-system applets to subscribe to input events (#9514) * Allow inkhud user applets to consume inputs with opt-in system Adds a way for applets to subscribe to input events while keeping it off by default to preserve compatibility and expected behaviours. Adds example for use as well. * Add check for nullptr on getActiveApplet uses * Remove redundant includes * Move subscribedInputs to protected * More consistent naming scheme * Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 (#9663) * BSEC2 Replacement - add approximation for IAQ to non-BSEC2 platforms. - Re-add this sensor to ESP32 targets, and refactor env_extra includes. - Fix C++ 11 compatibility * Check for gas resistance 0 * ULED_BUILTIN for 9m2ibr_aprs_lora_tracker (#9685) --------- Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz> Co-authored-by: Jason P <applewiz@mac.com> Co-authored-by: Eric Sesterhenn <eric.sesterhenn@x41-dsec.de> Co-authored-by: Vortetty <33466216+Vortetty@users.noreply.github.com> Co-authored-by: Mattatat25 <108779801+mattatat25@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Max <rekin.m@gmail.com> Co-authored-by: Colby Dillion <colby.dillion@pacshealth.com> Co-authored-by: Austin <vidplace7@gmail.com> Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Chloe Bethel <chloe@9net.org> Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com> Co-authored-by: Quency-D <55523105+Quency-D@users.noreply.github.com> Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Co-authored-by: oscgonfer <oscgonfer@users.noreply.github.com> Co-authored-by: nikl <nikl174@mailbox.org> Co-authored-by: Hannes Fuchs <hannes.fuchs+git@0xef.de> Co-authored-by: Nashui-Yan <yannashui10@gmail.com> Co-authored-by: Tom Fifield <tom@tomfifield.net> Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu> Co-authored-by: WillyJL <me@willyjl.dev> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Wessel <wessel@weebl.me> Co-authored-by: Christian Walther <cwalther@gmx.ch> Co-authored-by: Jorropo <jorropo.pgm@gmail.com> Co-authored-by: Eric Barch <ericb@ericbarch.com> Co-authored-by: Clive Blackledge <clive@ansible.org>
1564 lines
63 KiB
C++
1564 lines
63 KiB
C++
#include "configuration.h"
|
||
#if HAS_SCREEN
|
||
#include "CompassRenderer.h"
|
||
#include "GPSStatus.h"
|
||
#include "MeshService.h"
|
||
#include "NodeDB.h"
|
||
#include "NodeListRenderer.h"
|
||
#include "UIRenderer.h"
|
||
#include "airtime.h"
|
||
#include "gps/GeoCoord.h"
|
||
#include "graphics/SharedUIDisplay.h"
|
||
#include "graphics/TimeFormatters.h"
|
||
#include "graphics/images.h"
|
||
#include "main.h"
|
||
#include "target_specific.h"
|
||
#include <OLEDDisplay.h>
|
||
#include <RTC.h>
|
||
#include <cstring>
|
||
|
||
// External variables
|
||
extern graphics::Screen *screen;
|
||
#if defined(M5STACK_UNITC6L)
|
||
static uint32_t lastSwitchTime = 0;
|
||
#endif
|
||
namespace graphics
|
||
{
|
||
NodeNum UIRenderer::currentFavoriteNodeNum = 0;
|
||
std::vector<meshtastic_NodeInfoLite *> graphics::UIRenderer::favoritedNodes;
|
||
|
||
static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
|
||
{
|
||
int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1;
|
||
if (currentResolution == ScreenResolution::High) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||
} else {
|
||
display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||
}
|
||
}
|
||
|
||
void graphics::UIRenderer::rebuildFavoritedNodes()
|
||
{
|
||
favoritedNodes.clear();
|
||
size_t total = nodeDB->getNumMeshNodes();
|
||
for (size_t i = 0; i < total; i++) {
|
||
meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||
if (!n || n->num == nodeDB->getNodeNum())
|
||
continue;
|
||
if (n->is_favorite)
|
||
favoritedNodes.push_back(n);
|
||
}
|
||
|
||
std::sort(favoritedNodes.begin(), favoritedNodes.end(),
|
||
[](const meshtastic_NodeInfoLite *a, const meshtastic_NodeInfoLite *b) { return a->num < b->num; });
|
||
}
|
||
|
||
#if !MESHTASTIC_EXCLUDE_GPS
|
||
// GeoCoord object for coordinate conversions
|
||
extern GeoCoord geoCoord;
|
||
|
||
// Threshold values for the GPS lock accuracy bar display
|
||
extern uint32_t dopThresholds[5];
|
||
|
||
// Draw GPS status summary
|
||
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
// Draw satellite image
|
||
if (currentResolution == ScreenResolution::High) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||
} else {
|
||
display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||
}
|
||
char textString[10];
|
||
|
||
if (config.position.fixed_position) {
|
||
// GPS coordinates are currently fixed
|
||
snprintf(textString, sizeof(textString), "Fixed");
|
||
}
|
||
if (!gps->getIsConnected()) {
|
||
snprintf(textString, sizeof(textString), "No Lock");
|
||
}
|
||
if (!gps->getHasLock()) {
|
||
// Draw "No sats" to the right of the icon with slightly more gap
|
||
snprintf(textString, sizeof(textString), "No Sats");
|
||
} else {
|
||
snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
|
||
}
|
||
if (currentResolution == ScreenResolution::High) {
|
||
display->drawString(x + 18, y, textString);
|
||
} else {
|
||
display->drawString(x + 11, y, textString);
|
||
}
|
||
}
|
||
|
||
// Draw status when GPS is disabled or not present
|
||
void UIRenderer::drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
const char *displayLine;
|
||
int pos;
|
||
if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
||
pos = display->getWidth() - display->getStringWidth(displayLine);
|
||
} else {
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present"
|
||
: "GPS is disabled";
|
||
pos = (display->getWidth() - display->getStringWidth(displayLine)) / 2;
|
||
}
|
||
display->drawString(x + pos, y, displayLine);
|
||
}
|
||
|
||
void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
char displayLine[32];
|
||
if (!gps->getIsConnected() && !config.position.fixed_position) {
|
||
// displayLine = "No GPS Module";
|
||
// display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
||
} else if (!gps->getHasLock() && !config.position.fixed_position) {
|
||
// displayLine = "No GPS Lock";
|
||
// display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
||
} else {
|
||
geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude()));
|
||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
||
snprintf(displayLine, sizeof(displayLine), "Altitude: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET);
|
||
else
|
||
snprintf(displayLine, sizeof(displayLine), "Altitude: %.0im", geoCoord.getAltitude());
|
||
display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
||
}
|
||
}
|
||
|
||
// Draw GPS status coordinates
|
||
void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps,
|
||
const char *mode)
|
||
{
|
||
auto gpsFormat = uiconfig.gps_format;
|
||
char displayLine[32];
|
||
|
||
if (!gps->getIsConnected() && !config.position.fixed_position) {
|
||
if (strcmp(mode, "line1") == 0) {
|
||
strcpy(displayLine, "No GPS present");
|
||
display->drawString(x, y, displayLine);
|
||
}
|
||
} else if (!gps->getHasLock() && !config.position.fixed_position) {
|
||
if (strcmp(mode, "line1") == 0) {
|
||
strcpy(displayLine, "No GPS Lock");
|
||
display->drawString(x, y, displayLine);
|
||
}
|
||
} else {
|
||
|
||
geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude()));
|
||
|
||
if (gpsFormat != meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS) {
|
||
char coordinateLine_1[22];
|
||
char coordinateLine_2[22];
|
||
if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "Lat: %f", geoCoord.getLatitude() * 1e-7);
|
||
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "Lon: %f", geoCoord.getLongitude() * 1e-7);
|
||
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%2i%1c %06u E", geoCoord.getUTMZone(),
|
||
geoCoord.getUTMBand(), geoCoord.getUTMEasting());
|
||
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "%07u N", geoCoord.getUTMNorthing());
|
||
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%2i%1c %1c%1c", geoCoord.getMGRSZone(),
|
||
geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k());
|
||
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "%05u E %05u N", geoCoord.getMGRSEasting(),
|
||
geoCoord.getMGRSNorthing());
|
||
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC) { // Open Location Code
|
||
geoCoord.getOLCCode(coordinateLine_1);
|
||
coordinateLine_2[0] = '\0';
|
||
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference
|
||
if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') { // OSGR is only valid around the UK region
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%s", "Out of Boundary");
|
||
coordinateLine_2[0] = '\0';
|
||
} else {
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%1c%1c", geoCoord.getOSGRE100k(),
|
||
geoCoord.getOSGRN100k());
|
||
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "%05u E %05u N", geoCoord.getOSGREasting(),
|
||
geoCoord.getOSGRNorthing());
|
||
}
|
||
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) { // Maidenhead Locator System
|
||
double lat = geoCoord.getLatitude() * 1e-7;
|
||
double lon = geoCoord.getLongitude() * 1e-7;
|
||
|
||
// Normalize
|
||
if (lat > 90.0)
|
||
lat = 90.0;
|
||
if (lat < -90.0)
|
||
lat = -90.0;
|
||
while (lon < -180.0)
|
||
lon += 360.0;
|
||
while (lon >= 180.0)
|
||
lon -= 360.0;
|
||
|
||
double adjLon = lon + 180.0;
|
||
double adjLat = lat + 90.0;
|
||
|
||
char maiden[10]; // enough for 8-char + null
|
||
|
||
// Field (2 letters)
|
||
int lonField = int(adjLon / 20.0);
|
||
int latField = int(adjLat / 10.0);
|
||
adjLon -= lonField * 20.0;
|
||
adjLat -= latField * 10.0;
|
||
|
||
// Square (2 digits)
|
||
int lonSquare = int(adjLon / 2.0);
|
||
int latSquare = int(adjLat / 1.0);
|
||
adjLon -= lonSquare * 2.0;
|
||
adjLat -= latSquare * 1.0;
|
||
|
||
// Subsquare (2 letters)
|
||
double lonUnit = 2.0 / 24.0;
|
||
double latUnit = 1.0 / 24.0;
|
||
int lonSub = int(adjLon / lonUnit);
|
||
int latSub = int(adjLat / latUnit);
|
||
|
||
snprintf(maiden, sizeof(maiden), "%c%c%c%c%c%c", 'A' + lonField, 'A' + latField, '0' + lonSquare, '0' + latSquare,
|
||
'A' + lonSub, 'A' + latSub);
|
||
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "MH: %s", maiden);
|
||
coordinateLine_2[0] = '\0'; // only need one line
|
||
}
|
||
|
||
if (strcmp(mode, "line1") == 0) {
|
||
display->drawString(x, y, coordinateLine_1);
|
||
} else if (strcmp(mode, "line2") == 0) {
|
||
display->drawString(x, y, coordinateLine_2);
|
||
} else if (strcmp(mode, "combined") == 0) {
|
||
display->drawString(x, y, coordinateLine_1);
|
||
if (coordinateLine_2[0] != '\0') {
|
||
display->drawString(x + display->getStringWidth(coordinateLine_1), y, coordinateLine_2);
|
||
}
|
||
}
|
||
|
||
} else {
|
||
char coordinateLine_1[22];
|
||
char coordinateLine_2[22];
|
||
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "Lat: %2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(),
|
||
geoCoord.getDMSLatMin(), geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP());
|
||
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "Lon: %3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(),
|
||
geoCoord.getDMSLonMin(), geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP());
|
||
if (strcmp(mode, "line1") == 0) {
|
||
display->drawString(x, y, coordinateLine_1);
|
||
} else if (strcmp(mode, "line2") == 0) {
|
||
display->drawString(x, y, coordinateLine_2);
|
||
} else { // both
|
||
display->drawString(x, y, coordinateLine_1);
|
||
display->drawString(x, y + 10, coordinateLine_2);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#endif // !MESHTASTIC_EXCLUDE_GPS
|
||
|
||
// 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)
|
||
{
|
||
char usersString[20];
|
||
int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0;
|
||
|
||
snprintf(usersString, sizeof(usersString), "%d %s", nodes_online, additional_words);
|
||
|
||
if (show_total) {
|
||
int nodes_total = (nodeStatus->getNumTotal() > 0) ? nodeStatus->getNumTotal() + node_offset : 0;
|
||
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words);
|
||
}
|
||
|
||
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
|
||
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS) || \
|
||
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796)) && \
|
||
!defined(DISPLAY_FORCE_SMALL_FONTS)
|
||
|
||
if (currentResolution == ScreenResolution::High) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
|
||
} else {
|
||
display->drawFastImage(x, y + 3, 8, 8, imgUser);
|
||
}
|
||
#else
|
||
if (currentResolution == ScreenResolution::High) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
|
||
} else {
|
||
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 *
|
||
// **********************
|
||
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
|
||
void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
if (favoritedNodes.empty())
|
||
return;
|
||
|
||
// --- Only display if index is valid ---
|
||
int nodeIndex = state->currentFrame - (screen->frameCount - favoritedNodes.size());
|
||
if (nodeIndex < 0 || nodeIndex >= (int)favoritedNodes.size())
|
||
return;
|
||
|
||
meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
|
||
if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite)
|
||
return;
|
||
display->clear();
|
||
#if defined(M5STACK_UNITC6L)
|
||
uint32_t now = millis();
|
||
if (now - lastSwitchTime >= 10000) // 10000 ms = 10 秒
|
||
{
|
||
display->display();
|
||
lastSwitchTime = now;
|
||
}
|
||
#endif
|
||
currentFavoriteNodeNum = node->num;
|
||
// === Create the shortName and title string ===
|
||
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
|
||
char titlestr[32] = {0};
|
||
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
|
||
|
||
// === Draw battery/time/mail header (common across screens) ===
|
||
graphics::drawCommonHeader(display, x, y, titlestr);
|
||
|
||
// ===== 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.
|
||
// 3. Each row "moves up" if previous are empty, so there are never any blank rows.
|
||
// 4. The first line is ALWAYS at your macro position; subsequent lines use the next available macro slot.
|
||
|
||
// List of available macro Y positions in order, from top to bottom.
|
||
int line = 1; // which slot to use next
|
||
std::string usernameStr;
|
||
// === 1. Long Name (always try to show first) ===
|
||
const char *username;
|
||
if (currentResolution == ScreenResolution::UltraLow) {
|
||
username = (node->has_user && node->user.long_name[0]) ? node->user.short_name : nullptr;
|
||
} else {
|
||
username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
|
||
}
|
||
|
||
if (username) {
|
||
usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case
|
||
// Print node's long name (e.g. "Backpack Node")
|
||
display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str());
|
||
}
|
||
|
||
// === 2. Signal and Hops (combined on one line, if available) ===
|
||
char signalHopsStr[32] = "";
|
||
bool haveSignal = false;
|
||
int bars = 0;
|
||
|
||
// Helper to get SNR limit based on modem preset
|
||
auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float {
|
||
switch (preset) {
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
|
||
return -6.0f;
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
|
||
return -5.5f;
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
|
||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
|
||
return -4.5f;
|
||
default:
|
||
return -6.0f;
|
||
}
|
||
};
|
||
|
||
// Calculate signal grade using modem preset and SNR only
|
||
float snrLimit = getSnrLimit(config.lora.modem_preset);
|
||
float snr = node->snr;
|
||
|
||
// Determine signal quality label and bars using SNR-only grading
|
||
const char *qualityLabel = nullptr;
|
||
|
||
if (snr > snrLimit + 10) {
|
||
qualityLabel = "Good";
|
||
bars = 4;
|
||
} else if (snr > snrLimit + 6) {
|
||
qualityLabel = "Good";
|
||
bars = 3;
|
||
} else if (snr > snrLimit + 2) {
|
||
qualityLabel = "Good";
|
||
bars = 2;
|
||
} else if (snr > snrLimit - 4) {
|
||
qualityLabel = "Fair";
|
||
bars = 1;
|
||
} else {
|
||
qualityLabel = "Bad";
|
||
bars = 1;
|
||
}
|
||
|
||
// Add extra spacing on the left if we have an API connection to account for the common footer icons
|
||
const char *leftSideSpacing =
|
||
graphics::isAPIConnected(service->api_state) ? (currentResolution == ScreenResolution::High ? " " : " ") : " ";
|
||
|
||
// --- Build the Signal/Hops line ---
|
||
// Only show signal if we have valid SNR
|
||
if (snr > -100 && snr != 0) {
|
||
snprintf(signalHopsStr, sizeof(signalHopsStr), "%sSig:%s", leftSideSpacing, qualityLabel);
|
||
haveSignal = true;
|
||
}
|
||
|
||
if (node->hops_away > 0) {
|
||
size_t len = strlen(signalHopsStr);
|
||
if (haveSignal) {
|
||
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [#]");
|
||
} else {
|
||
snprintf(signalHopsStr, sizeof(signalHopsStr), "[#]");
|
||
}
|
||
}
|
||
|
||
if (signalHopsStr[0]) {
|
||
int yPos = getTextPositions(display)[line++];
|
||
int curX = x;
|
||
|
||
// Split combined string into signal text and hop suffix
|
||
char sigPart[20] = "";
|
||
const char *hopPart = nullptr;
|
||
|
||
char *bracket = strchr(signalHopsStr, '[');
|
||
if (bracket) {
|
||
size_t n = (size_t)(bracket - signalHopsStr);
|
||
if (n >= sizeof(sigPart))
|
||
n = sizeof(sigPart) - 1;
|
||
memcpy(sigPart, signalHopsStr, n);
|
||
sigPart[n] = '\0';
|
||
|
||
// Trim trailing spaces
|
||
while (strlen(sigPart) && sigPart[strlen(sigPart) - 1] == ' ') {
|
||
sigPart[strlen(sigPart) - 1] = '\0';
|
||
}
|
||
|
||
hopPart = bracket; // "[n Hop(s)]"
|
||
} else {
|
||
strncpy(sigPart, signalHopsStr, sizeof(sigPart) - 1);
|
||
sigPart[sizeof(sigPart) - 1] = '\0';
|
||
}
|
||
|
||
// Draw signal quality text
|
||
display->drawString(curX, yPos, sigPart);
|
||
curX += display->getStringWidth(sigPart) + 4;
|
||
|
||
// Draw signal bars (skip on UltraLow, text only)
|
||
if (currentResolution != ScreenResolution::UltraLow && haveSignal && bars > 0) {
|
||
const int kMaxBars = 4;
|
||
if (bars < 1)
|
||
bars = 1;
|
||
if (bars > kMaxBars)
|
||
bars = kMaxBars;
|
||
|
||
int barX = curX;
|
||
|
||
const bool hi = (currentResolution == ScreenResolution::High);
|
||
int barWidth = hi ? 2 : 1;
|
||
int barGap = hi ? 2 : 1;
|
||
int maxBarHeight = FONT_HEIGHT_SMALL - 7;
|
||
if (!hi)
|
||
maxBarHeight -= 1;
|
||
int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2;
|
||
|
||
for (int bi = 0; bi < kMaxBars; bi++) {
|
||
int barHeight = maxBarHeight * (bi + 1) / kMaxBars;
|
||
if (barHeight < 2)
|
||
barHeight = 2;
|
||
|
||
int bx = barX + bi * (barWidth + barGap);
|
||
int by = barY + maxBarHeight - barHeight;
|
||
|
||
if (bi < bars) {
|
||
display->fillRect(bx, by, barWidth, barHeight);
|
||
} else {
|
||
int baseY = barY + maxBarHeight - 1;
|
||
display->drawHorizontalLine(bx, baseY, barWidth);
|
||
}
|
||
}
|
||
|
||
curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2;
|
||
}
|
||
|
||
// Draw hops AFTER the bars as: [ number + hop icon ]
|
||
if (hopPart && node->hops_away > 0) {
|
||
|
||
// open bracket
|
||
display->drawString(curX, yPos, "[");
|
||
curX += display->getStringWidth("[") + 1;
|
||
|
||
// hop count
|
||
char hopCount[6];
|
||
snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away);
|
||
display->drawString(curX, yPos, hopCount);
|
||
curX += display->getStringWidth(hopCount) + 2;
|
||
|
||
// hop icon
|
||
const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2;
|
||
display->drawXbm(curX, iconY, hop_width, hop_height, hop);
|
||
curX += hop_width + 1;
|
||
|
||
// closing bracket
|
||
display->drawString(curX, yPos, "]");
|
||
}
|
||
}
|
||
|
||
// === 3. Heard (last seen, skip if node never seen) ===
|
||
char seenStr[20] = "";
|
||
uint32_t seconds = sinceLastSeen(node);
|
||
if (seconds != 0 && seconds != UINT32_MAX) {
|
||
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
|
||
// Format as "Heard:Xm ago", "Heard:Xh ago", or "Heard:Xd ago"
|
||
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard:?" : "%sHeard:%d%c ago"), leftSideSpacing,
|
||
(days ? days
|
||
: hours ? hours
|
||
: minutes),
|
||
(days ? 'd'
|
||
: hours ? 'h'
|
||
: 'm'));
|
||
}
|
||
if (seenStr[0]) {
|
||
display->drawString(x, getTextPositions(display)[line++], seenStr);
|
||
}
|
||
#if !defined(M5STACK_UNITC6L)
|
||
// === 4. Uptime (only show if metric is present) ===
|
||
char uptimeStr[32] = "";
|
||
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
|
||
char upPrefix[12]; // enough for leftSideSpacing + "Up:"
|
||
snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing);
|
||
getUptimeStr(node->device_metrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr));
|
||
}
|
||
if (uptimeStr[0]) {
|
||
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
|
||
}
|
||
|
||
// === 5. Distance (only if both nodes have GPS position) ===
|
||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||
char distStr[24] = ""; // Make buffer big enough for any string
|
||
bool haveDistance = false;
|
||
|
||
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
||
double lat1 = ourNode->position.latitude_i * 1e-7;
|
||
double lon1 = ourNode->position.longitude_i * 1e-7;
|
||
double lat2 = node->position.latitude_i * 1e-7;
|
||
double lon2 = node->position.longitude_i * 1e-7;
|
||
double earthRadiusKm = 6371.0;
|
||
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
||
double dLon = (lon2 - lon1) * DEG_TO_RAD;
|
||
double a =
|
||
sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
|
||
double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||
double distanceKm = earthRadiusKm * c;
|
||
|
||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
||
double miles = distanceKm * 0.621371;
|
||
if (miles < 0.1) {
|
||
int feet = (int)(miles * 5280);
|
||
if (feet > 0 && feet < 1000) {
|
||
snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet);
|
||
haveDistance = true;
|
||
} else if (feet >= 1000) {
|
||
snprintf(distStr, sizeof(distStr), "%sDistance:¼mi", leftSideSpacing);
|
||
haveDistance = true;
|
||
}
|
||
} else {
|
||
int roundedMiles = (int)(miles + 0.5);
|
||
if (roundedMiles > 0 && roundedMiles < 1000) {
|
||
snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, roundedMiles);
|
||
haveDistance = true;
|
||
}
|
||
}
|
||
} else {
|
||
if (distanceKm < 1.0) {
|
||
int meters = (int)(distanceKm * 1000);
|
||
if (meters > 0 && meters < 1000) {
|
||
snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters);
|
||
haveDistance = true;
|
||
} else if (meters >= 1000) {
|
||
snprintf(distStr, sizeof(distStr), "%sDistance:1km", leftSideSpacing);
|
||
haveDistance = true;
|
||
}
|
||
} else {
|
||
int km = (int)(distanceKm + 0.5);
|
||
if (km > 0 && km < 1000) {
|
||
snprintf(distStr, sizeof(distStr), "%sDistance:%dkm", leftSideSpacing, km);
|
||
haveDistance = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (haveDistance && distStr[0]) {
|
||
display->drawString(x, getTextPositions(display)[line++], distStr);
|
||
}
|
||
|
||
// === 6. Battery after Distance line, otherwise next available line ===
|
||
char batLine[32] = "";
|
||
bool haveBatLine = false;
|
||
|
||
if (node->has_device_metrics) {
|
||
bool hasPct = node->device_metrics.has_battery_level;
|
||
bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f;
|
||
|
||
int pct = 0;
|
||
float volt = 0.0f;
|
||
|
||
if (hasPct) {
|
||
pct = (int)node->device_metrics.battery_level;
|
||
}
|
||
|
||
if (hasVolt) {
|
||
volt = node->device_metrics.voltage;
|
||
}
|
||
|
||
if (hasPct && pct > 0 && pct <= 100) {
|
||
// Normal battery percentage
|
||
if (hasVolt) {
|
||
snprintf(batLine, sizeof(batLine), "%sBat:%d%% (%.2fV)", leftSideSpacing, pct, volt);
|
||
} else {
|
||
snprintf(batLine, sizeof(batLine), "%sBat:%d%%", leftSideSpacing, pct);
|
||
}
|
||
haveBatLine = true;
|
||
} else if (hasPct && pct > 100) {
|
||
// Plugged in
|
||
if (hasVolt) {
|
||
snprintf(batLine, sizeof(batLine), "%sPlugged In (%.2fV)", leftSideSpacing, volt);
|
||
} else {
|
||
snprintf(batLine, sizeof(batLine), "%sPlugged In", leftSideSpacing);
|
||
}
|
||
haveBatLine = true;
|
||
} else if (!hasPct && hasVolt) {
|
||
// Voltage only
|
||
snprintf(batLine, sizeof(batLine), "%sBat:%.2fV", leftSideSpacing, volt);
|
||
haveBatLine = true;
|
||
}
|
||
}
|
||
|
||
const int maxTextLines = (currentResolution == ScreenResolution::High) ? 6 : 5;
|
||
|
||
// Only draw battery if it fits within the allowed lines
|
||
if (haveBatLine && line <= maxTextLines) {
|
||
display->drawString(x, getTextPositions(display)[line++], batLine);
|
||
}
|
||
|
||
// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
|
||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||
bool showCompass = false;
|
||
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
||
showCompass = true;
|
||
}
|
||
if (showCompass) {
|
||
const int16_t topY = getTextPositions(display)[1];
|
||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
|
||
const int16_t usableHeight = bottomY - topY - 5;
|
||
int16_t compassRadius = usableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
const int16_t compassDiam = compassRadius * 2;
|
||
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||
|
||
const auto &op = ourNode->position;
|
||
float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
||
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
||
|
||
const auto &p = node->position;
|
||
/* unused
|
||
float d =
|
||
GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||
*/
|
||
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) {
|
||
myHeading = 0;
|
||
} else {
|
||
bearing -= myHeading;
|
||
}
|
||
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
|
||
}
|
||
// else show nothing
|
||
} else {
|
||
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
||
bool showCompass = false;
|
||
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
||
showCompass = true;
|
||
}
|
||
if (showCompass) {
|
||
int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2)
|
||
: getTextPositions(display)[1];
|
||
const int margin = 4;
|
||
// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) -----------
|
||
#if defined(USE_EINK)
|
||
const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8;
|
||
const int navBarHeight = iconSize + 6;
|
||
#else
|
||
const int navBarHeight = 0;
|
||
#endif
|
||
int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin;
|
||
// --------- END PATCH FOR EINK NAV BAR -----------
|
||
|
||
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
||
return;
|
||
|
||
int compassRadius = availableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
if (compassRadius * 2 > SCREEN_WIDTH - 16)
|
||
compassRadius = (SCREEN_WIDTH - 16) / 2;
|
||
|
||
int compassX = x + SCREEN_WIDTH / 2;
|
||
int compassY = yBelowContent + availableHeight / 2;
|
||
|
||
const auto &op = ourNode->position;
|
||
float myHeading = 0;
|
||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) {
|
||
myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
||
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
||
}
|
||
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||
|
||
const auto &p = node->position;
|
||
/* unused
|
||
float d =
|
||
GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||
*/
|
||
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||
if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING)
|
||
bearing -= myHeading;
|
||
graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
||
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
}
|
||
// else show nothing
|
||
}
|
||
#endif
|
||
graphics::drawCommonFooter(display, x, y);
|
||
}
|
||
|
||
// ****************************
|
||
// * Device Focused Screen *
|
||
// ****************************
|
||
void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
display->clear();
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
display->setFont(FONT_SMALL);
|
||
int line = 1;
|
||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||
|
||
// === Header ===
|
||
if (currentResolution == ScreenResolution::UltraLow) {
|
||
graphics::drawCommonHeader(display, x, y, "Home");
|
||
} else {
|
||
graphics::drawCommonHeader(display, x, y, "");
|
||
}
|
||
|
||
// === Content below header ===
|
||
|
||
// Determine if we need to show 4 or 5 rows on the screen
|
||
int rows = 4;
|
||
if (!config.bluetooth.enabled) {
|
||
rows = 5;
|
||
}
|
||
|
||
// === First Row: Region / Channel Utilization and Uptime ===
|
||
bool origBold = config.display.heading_bold;
|
||
config.display.heading_bold = false;
|
||
|
||
// Display Region and Channel Utilization
|
||
if (currentResolution == ScreenResolution::UltraLow) {
|
||
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||
} else {
|
||
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||
}
|
||
char uptimeStr[32] = "";
|
||
if (currentResolution != ScreenResolution::UltraLow) {
|
||
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
|
||
}
|
||
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
|
||
|
||
// === Second Row: Satellites and Voltage ===
|
||
config.display.heading_bold = false;
|
||
|
||
#if HAS_GPS
|
||
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
||
const char *displayLine;
|
||
if (config.position.fixed_position) {
|
||
displayLine = "Fixed GPS";
|
||
} else {
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
||
}
|
||
drawSatelliteIcon(display, x, getTextPositions(display)[line]);
|
||
int xOffset = (currentResolution == ScreenResolution::High) ? 6 : 0;
|
||
display->drawString(x + 11 + xOffset, getTextPositions(display)[line], displayLine);
|
||
} else {
|
||
UIRenderer::drawGps(display, 0, getTextPositions(display)[line], gpsStatus);
|
||
}
|
||
#endif
|
||
|
||
#if defined(M5STACK_UNITC6L)
|
||
line += 1;
|
||
|
||
// === Node Identity ===
|
||
int textWidth = 0;
|
||
int nameX = 0;
|
||
char shortnameble[35];
|
||
snprintf(shortnameble, sizeof(shortnameble), "%s",
|
||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||
|
||
// === ShortName Centered ===
|
||
textWidth = display->getStringWidth(shortnameble);
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
|
||
#else
|
||
if (powerStatus->getHasBattery()) {
|
||
char batStr[20];
|
||
int batV = powerStatus->getBatteryVoltageMv() / 1000;
|
||
int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
|
||
snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv);
|
||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(batStr), getTextPositions(display)[line++], batStr);
|
||
} else {
|
||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth("USB"), getTextPositions(display)[line++], "USB");
|
||
}
|
||
|
||
config.display.heading_bold = origBold;
|
||
|
||
// === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) ===
|
||
const char *chUtil = "ChUtil:";
|
||
char chUtilPercentage[10];
|
||
snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent());
|
||
|
||
int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10
|
||
: display->getStringWidth(chUtil) + 5;
|
||
int chUtil_y = getTextPositions(display)[line] + 3;
|
||
|
||
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
||
if (!config.bluetooth.enabled) {
|
||
#if defined(USE_EINK)
|
||
chutil_bar_width = (currentResolution == ScreenResolution::High) ? 50 : 30;
|
||
#else
|
||
chutil_bar_width = (currentResolution == ScreenResolution::High) ? 80 : 40;
|
||
#endif
|
||
}
|
||
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
||
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
||
if (!config.bluetooth.enabled) {
|
||
extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 1;
|
||
}
|
||
int chutil_percent = airTime->channelUtilizationPercent();
|
||
|
||
int centerofscreen = SCREEN_WIDTH / 2;
|
||
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
||
int starting_position = centerofscreen - total_line_content_width;
|
||
if (!config.bluetooth.enabled) {
|
||
starting_position = 0;
|
||
}
|
||
|
||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||
|
||
// Force 56% or higher to show a full 100% bar, text would still show related percent.
|
||
if (chutil_percent >= 61) {
|
||
chutil_percent = 100;
|
||
}
|
||
|
||
// Weighting for nonlinear segments
|
||
float milestone1 = 25;
|
||
float milestone2 = 40;
|
||
float weight1 = 0.45; // Weight for 0–25%
|
||
float weight2 = 0.35; // Weight for 25–40%
|
||
float weight3 = 0.20; // Weight for 40–100%
|
||
float totalWeight = weight1 + weight2 + weight3;
|
||
|
||
int seg1 = chutil_bar_width * (weight1 / totalWeight);
|
||
int seg2 = chutil_bar_width * (weight2 / totalWeight);
|
||
int seg3 = chutil_bar_width * (weight3 / totalWeight);
|
||
|
||
int fillRight = 0;
|
||
|
||
if (chutil_percent <= milestone1) {
|
||
fillRight = (seg1 * (chutil_percent / milestone1));
|
||
} else if (chutil_percent <= milestone2) {
|
||
fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1)));
|
||
} else {
|
||
fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2)));
|
||
}
|
||
|
||
// Draw outline
|
||
display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height);
|
||
|
||
// Fill progress
|
||
if (fillRight > 0) {
|
||
display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
|
||
}
|
||
|
||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line],
|
||
chUtilPercentage);
|
||
|
||
if (!config.bluetooth.enabled) {
|
||
display->drawString(SCREEN_WIDTH - display->getStringWidth("BT off"), getTextPositions(display)[line], "BT off");
|
||
}
|
||
|
||
line += 1;
|
||
|
||
// === Fourth & Fifth Rows: Node Identity ===
|
||
int textWidth = 0;
|
||
int nameX = 0;
|
||
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
|
||
std::string longNameStr;
|
||
|
||
if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
|
||
longNameStr = sanitizeString(ourNode->user.long_name);
|
||
}
|
||
char shortnameble[35];
|
||
snprintf(shortnameble, sizeof(shortnameble), "%s",
|
||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||
|
||
char combinedName[50];
|
||
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble);
|
||
if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) {
|
||
size_t len = strlen(combinedName);
|
||
if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) {
|
||
combinedName[len - 3] = '\0'; // Remove the last three characters
|
||
}
|
||
textWidth = display->getStringWidth(combinedName);
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(
|
||
nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName);
|
||
} else {
|
||
// === LongName Centered ===
|
||
textWidth = display->getStringWidth(longNameStr.c_str());
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str());
|
||
|
||
// === ShortName Centered ===
|
||
textWidth = display->getStringWidth(shortnameble);
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
|
||
}
|
||
#endif
|
||
graphics::drawCommonFooter(display, x, y);
|
||
}
|
||
|
||
// Start Functions to write date/time to the screen
|
||
// Helper function to check if a year is a leap year
|
||
constexpr bool isLeapYear(int year)
|
||
{
|
||
return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
|
||
}
|
||
|
||
// Array of days in each month (non-leap year)
|
||
const int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||
|
||
// Fills the buffer with a formatted date/time string and returns pixel width
|
||
int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *display, bool includeTime)
|
||
{
|
||
int sec = rtc_sec % 60;
|
||
rtc_sec /= 60;
|
||
int min = rtc_sec % 60;
|
||
rtc_sec /= 60;
|
||
int hour = rtc_sec % 24;
|
||
rtc_sec /= 24;
|
||
|
||
int year = 1970;
|
||
while (true) {
|
||
int daysInYear = isLeapYear(year) ? 366 : 365;
|
||
if (rtc_sec >= (uint32_t)daysInYear) {
|
||
rtc_sec -= daysInYear;
|
||
year++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
int month = 0;
|
||
while (month < 12) {
|
||
int dim = daysInMonth[month];
|
||
if (month == 1 && isLeapYear(year))
|
||
dim++;
|
||
if (rtc_sec >= (uint32_t)dim) {
|
||
rtc_sec -= dim;
|
||
month++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
int day = rtc_sec + 1;
|
||
|
||
if (includeTime) {
|
||
snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d", year, month + 1, day, hour, min, sec);
|
||
} else {
|
||
snprintf(buf, bufSize, "%04d-%02d-%02d", year, month + 1, day);
|
||
}
|
||
|
||
return display->getStringWidth(buf);
|
||
}
|
||
|
||
// Check if the display can render a string (detect special chars; emoji)
|
||
bool UIRenderer::haveGlyphs(const char *str)
|
||
{
|
||
#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS)
|
||
// Don't want to make any assumptions about custom language support
|
||
return true;
|
||
#endif
|
||
|
||
// Check each character with the lookup function for the OLED library
|
||
// We're not really meant to use this directly..
|
||
bool have = true;
|
||
for (uint16_t i = 0; i < strlen(str); i++) {
|
||
uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]);
|
||
// If font doesn't support a character, it is substituted for ¿
|
||
if (result == 191 && (uint8_t)str[i] != 191) {
|
||
have = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// LOG_DEBUG("haveGlyphs=%d", have);
|
||
return have;
|
||
}
|
||
|
||
#ifdef USE_EINK
|
||
/// Used on eink displays while in deep sleep
|
||
void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
|
||
// Next frame should use full-refresh, and block while running, else device will sleep before async callback
|
||
EINK_ADD_FRAMEFLAG(display, COSMETIC);
|
||
EINK_ADD_FRAMEFLAG(display, BLOCKING);
|
||
|
||
LOG_DEBUG("Draw deep sleep screen");
|
||
|
||
// Display displayStr on the screen
|
||
graphics::UIRenderer::drawIconScreen("Sleeping", display, state, x, y);
|
||
}
|
||
|
||
/// Used on eink displays when screen updates are paused
|
||
void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||
{
|
||
LOG_DEBUG("Draw screensaver overlay");
|
||
|
||
EINK_ADD_FRAMEFLAG(display, COSMETIC); // Full refresh for screensaver
|
||
|
||
// Config
|
||
display->setFont(FONT_SMALL);
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
const char *pauseText = "Screen Paused";
|
||
const char *idText = owner.short_name;
|
||
const bool useId = haveGlyphs(idText);
|
||
constexpr uint8_t padding = 2;
|
||
constexpr uint8_t dividerGap = 1;
|
||
|
||
// Text widths
|
||
const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true);
|
||
const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
|
||
const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding;
|
||
const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2);
|
||
|
||
// Flush with bottom
|
||
const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2);
|
||
const int16_t boxTop = display->height() - boxHeight;
|
||
const int16_t boxBottom = display->height() - 1;
|
||
const int16_t idTextLeft = boxLeft + padding;
|
||
const int16_t idTextTop = boxTop + padding;
|
||
const int16_t pauseTextLeft = boxLeft + (useId ? idTextWidth + (padding * 2) : 0) + padding;
|
||
const int16_t pauseTextTop = boxTop + padding;
|
||
const int16_t dividerX = boxLeft + padding + idTextWidth + padding;
|
||
const int16_t dividerTop = boxTop + dividerGap;
|
||
const int16_t dividerBottom = boxBottom - dividerGap;
|
||
|
||
// Draw: box
|
||
display->setColor(EINK_WHITE);
|
||
display->fillRect(boxLeft, boxTop, boxWidth, boxHeight);
|
||
display->setColor(EINK_BLACK);
|
||
display->drawRect(boxLeft, boxTop, boxWidth, boxHeight);
|
||
|
||
// Draw: text
|
||
if (useId)
|
||
display->drawString(idTextLeft, idTextTop, idText);
|
||
display->drawString(pauseTextLeft, pauseTextTop, pauseText);
|
||
display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold
|
||
|
||
// Draw: divider
|
||
if (useId)
|
||
display->drawLine(dividerX, dividerTop, dividerX, dividerBottom);
|
||
}
|
||
#endif
|
||
|
||
/**
|
||
* Draw the icon with extra info printed around the corners
|
||
*/
|
||
void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
// draw an xbm image.
|
||
// Please note that everything that should be transitioned
|
||
// needs to be drawn relative to x and y
|
||
|
||
// draw centered icon left to right and centered above the one line of app text
|
||
#if defined(M5STACK_UNITC6L)
|
||
display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits);
|
||
display->setFont(FONT_MEDIUM);
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
display->setFont(FONT_SMALL);
|
||
// Draw region in upper left
|
||
if (upperMsg) {
|
||
int msgWidth = display->getStringWidth(upperMsg);
|
||
int msgX = x + (SCREEN_WIDTH - msgWidth) / 2;
|
||
int msgY = y;
|
||
display->drawString(msgX, msgY, upperMsg);
|
||
}
|
||
// Draw version and short name in bottom middle
|
||
char buf[25];
|
||
snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT),
|
||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||
|
||
display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf);
|
||
screen->forceDisplay();
|
||
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
||
#else
|
||
display->drawXbm(x + (SCREEN_WIDTH - icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2,
|
||
icon_width, icon_height, icon_bits);
|
||
|
||
display->setFont(FONT_MEDIUM);
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
const char *title = "meshtastic.org";
|
||
display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title);
|
||
display->setFont(FONT_SMALL);
|
||
// Draw region in upper left
|
||
if (upperMsg)
|
||
display->drawString(x + 0, y + 0, upperMsg);
|
||
|
||
// Draw version and short name in upper right
|
||
char buf[25];
|
||
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT),
|
||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||
|
||
display->setTextAlignment(TEXT_ALIGN_RIGHT);
|
||
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
|
||
screen->forceDisplay();
|
||
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
||
#endif
|
||
}
|
||
|
||
// ****************************
|
||
// * My Position Screen *
|
||
// ****************************
|
||
void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
display->clear();
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
display->setFont(FONT_SMALL);
|
||
int line = 1;
|
||
|
||
// === Set Title
|
||
const char *titleStr = "Position";
|
||
|
||
// === Header ===
|
||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||
|
||
// === First Row: My Location ===
|
||
#if HAS_GPS
|
||
bool origBold = config.display.heading_bold;
|
||
config.display.heading_bold = false;
|
||
|
||
const char *displayLine = ""; // Initialize to empty string by default
|
||
|
||
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
||
if (config.position.fixed_position) {
|
||
displayLine = "Fixed GPS";
|
||
} else {
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
||
}
|
||
drawSatelliteIcon(display, x, getTextPositions(display)[line]);
|
||
int xOffset = (currentResolution == ScreenResolution::High) ? 6 : 0;
|
||
display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine);
|
||
} else {
|
||
// Onboard GPS
|
||
UIRenderer::drawGps(display, 0, getTextPositions(display)[line++], gpsStatus);
|
||
}
|
||
|
||
config.display.heading_bold = origBold;
|
||
|
||
// === Update GeoCoord ===
|
||
geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
|
||
int32_t(gpsStatus->getAltitude()));
|
||
|
||
// === Determine Compass Heading ===
|
||
float heading = 0;
|
||
bool validHeading = false;
|
||
if (uiconfig.compass_mode == meshtastic_CompassMode_FREEZE_HEADING) {
|
||
validHeading = true;
|
||
} else {
|
||
if (screen->hasHeading()) {
|
||
heading = radians(screen->getHeading());
|
||
validHeading = true;
|
||
} else {
|
||
heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7);
|
||
validHeading = !isnan(heading);
|
||
}
|
||
}
|
||
|
||
// If GPS is off, no need to display these parts
|
||
if (strcmp(displayLine, "GPS off") != 0 && strcmp(displayLine, "No GPS") != 0) {
|
||
// === Second Row: Last GPS Fix ===
|
||
if (gpsStatus->getLastFixMillis() > 0) {
|
||
uint32_t delta = millis() - gpsStatus->getLastFixMillis();
|
||
char uptimeStr[32];
|
||
#if defined(USE_EINK)
|
||
// E-Ink: skip seconds, show only days/hours/mins
|
||
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), false);
|
||
#else
|
||
// Non E-Ink: include seconds where useful
|
||
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true);
|
||
#endif
|
||
|
||
display->drawString(0, getTextPositions(display)[line++], uptimeStr);
|
||
} else {
|
||
display->drawString(0, getTextPositions(display)[line++], "Last: ?");
|
||
}
|
||
|
||
// === Third Row: Line 1 GPS Info ===
|
||
UIRenderer::drawGpsCoordinates(display, x, getTextPositions(display)[line++], gpsStatus, "line1");
|
||
|
||
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
|
||
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
|
||
// === Fourth Row: Line 2 GPS Info ===
|
||
UIRenderer::drawGpsCoordinates(display, x, getTextPositions(display)[line++], gpsStatus, "line2");
|
||
}
|
||
|
||
// === Final Row: Altitude ===
|
||
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, getTextPositions(display)[line++], altitudeLine);
|
||
}
|
||
#if !defined(M5STACK_UNITC6L)
|
||
// === Draw Compass if heading is valid ===
|
||
if (validHeading) {
|
||
// --- Compass Rendering: landscape (wide) screens use original side-aligned logic ---
|
||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||
const int16_t topY = getTextPositions(display)[1];
|
||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height
|
||
const int16_t usableHeight = bottomY - topY - 5;
|
||
|
||
int16_t compassRadius = usableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
const int16_t compassDiam = compassRadius * 2;
|
||
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
||
|
||
// Center vertically and nudge down slightly to keep "N" clear of header
|
||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||
|
||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading);
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
|
||
// "N" label
|
||
float northAngle = 0;
|
||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||
northAngle = -heading;
|
||
float radius = compassRadius;
|
||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||
|
||
display->setColor(BLACK);
|
||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||
display->setColor(WHITE);
|
||
display->setFont(FONT_SMALL);
|
||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||
} else {
|
||
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
||
// For E-Ink screens, account for navigation bar at the bottom!
|
||
int yBelowContent = getTextPositions(display)[5] + FONT_HEIGHT_SMALL + 2;
|
||
const int margin = 4;
|
||
int availableHeight =
|
||
#if defined(USE_EINK)
|
||
SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink
|
||
#else
|
||
SCREEN_HEIGHT - yBelowContent - margin;
|
||
#endif
|
||
|
||
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
||
return;
|
||
|
||
int compassRadius = availableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
if (compassRadius * 2 > SCREEN_WIDTH - 16)
|
||
compassRadius = (SCREEN_WIDTH - 16) / 2;
|
||
|
||
int compassX = x + SCREEN_WIDTH / 2;
|
||
int compassY = yBelowContent + availableHeight / 2;
|
||
|
||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading);
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
|
||
// "N" label
|
||
float northAngle = 0;
|
||
if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
|
||
northAngle = -heading;
|
||
float radius = compassRadius;
|
||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||
|
||
display->setColor(BLACK);
|
||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||
display->setColor(WHITE);
|
||
display->setFont(FONT_SMALL);
|
||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||
}
|
||
}
|
||
#endif
|
||
#endif // HAS_GPS
|
||
graphics::drawCommonFooter(display, x, y);
|
||
}
|
||
|
||
#ifdef USERPREFS_OEM_TEXT
|
||
|
||
void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA;
|
||
if (currentResolution == ScreenResolution::High) {
|
||
display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2,
|
||
y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH,
|
||
USERPREFS_OEM_IMAGE_HEIGHT, xbm);
|
||
} else {
|
||
|
||
display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2,
|
||
y + (SCREEN_HEIGHT - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH,
|
||
USERPREFS_OEM_IMAGE_HEIGHT, xbm);
|
||
}
|
||
|
||
switch (USERPREFS_OEM_FONT_SIZE) {
|
||
case 0:
|
||
display->setFont(FONT_SMALL);
|
||
break;
|
||
case 2:
|
||
display->setFont(FONT_LARGE);
|
||
break;
|
||
default:
|
||
display->setFont(FONT_MEDIUM);
|
||
break;
|
||
}
|
||
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
const char *title = USERPREFS_OEM_TEXT;
|
||
if (currentResolution == ScreenResolution::High) {
|
||
display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title);
|
||
}
|
||
display->setFont(FONT_SMALL);
|
||
|
||
// Draw region in upper left
|
||
if (upperMsg)
|
||
display->drawString(x + 0, y + 0, upperMsg);
|
||
|
||
// Draw version and shortname in upper right
|
||
char buf[25];
|
||
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||
|
||
display->setTextAlignment(TEXT_ALIGN_RIGHT);
|
||
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
|
||
screen->forceDisplay();
|
||
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
||
}
|
||
|
||
void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
// Draw region in upper left
|
||
const char *region = myRegion ? myRegion->name : NULL;
|
||
drawOEMIconScreen(region, display, state, x, y);
|
||
}
|
||
|
||
#endif
|
||
|
||
// Navigation bar overlay implementation
|
||
static int8_t lastFrameIndex = -1;
|
||
static uint32_t lastFrameChangeTime = 0;
|
||
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
|
||
|
||
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
|
||
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||
{
|
||
int currentFrame = state->currentFrame;
|
||
|
||
// Detect frame change and record time
|
||
if (currentFrame != lastFrameIndex) {
|
||
lastFrameIndex = currentFrame;
|
||
lastFrameChangeTime = millis();
|
||
}
|
||
|
||
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 size_t totalIcons = screen->indicatorIcons.size();
|
||
if (totalIcons == 0)
|
||
return;
|
||
|
||
const int navPadding = (currentResolution == ScreenResolution::High) ? 24 : 12; // padding per side
|
||
|
||
int usableWidth = SCREEN_WIDTH - (navPadding * 2);
|
||
if (usableWidth < iconSize)
|
||
usableWidth = iconSize;
|
||
|
||
const size_t iconsPerPage = usableWidth / (iconSize + spacing);
|
||
const size_t currentPage = currentFrame / iconsPerPage;
|
||
const size_t pageStart = currentPage * iconsPerPage;
|
||
const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons);
|
||
|
||
const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing;
|
||
const int xStart = (SCREEN_WIDTH - totalWidth) / 2;
|
||
|
||
bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS;
|
||
int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT;
|
||
|
||
#if defined(USE_EINK)
|
||
// Only show bar briefly after switching frames
|
||
static uint32_t navBarLastShown = 0;
|
||
static bool cosmeticRefreshDone = false;
|
||
static bool navBarPrevVisible = false;
|
||
|
||
if (navBarVisible && !navBarPrevVisible) {
|
||
EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when showing nav bar
|
||
cosmeticRefreshDone = false;
|
||
navBarLastShown = millis();
|
||
}
|
||
|
||
if (!navBarVisible && navBarPrevVisible) {
|
||
EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar
|
||
navBarLastShown = millis(); // Mark when it disappeared
|
||
}
|
||
|
||
if (!navBarVisible && navBarLastShown != 0 && !cosmeticRefreshDone) {
|
||
if (millis() - navBarLastShown > 10000) { // 10s after hidden
|
||
EINK_ADD_FRAMEFLAG(display, COSMETIC); // One-time ghost cleanup
|
||
cosmeticRefreshDone = true;
|
||
}
|
||
}
|
||
|
||
navBarPrevVisible = navBarVisible;
|
||
#endif
|
||
|
||
// Pre-calculate bounding rect
|
||
const int rectX = xStart - 2 - bigOffset;
|
||
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
|
||
const int rectHeight = iconSize + 6;
|
||
|
||
// Clear background and draw border
|
||
display->setColor(BLACK);
|
||
display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2);
|
||
display->setColor(WHITE);
|
||
display->drawRect(rectX, y - 2, rectWidth, rectHeight);
|
||
|
||
// Icon drawing loop for the current page
|
||
for (size_t i = pageStart; i < pageEnd; ++i) {
|
||
const uint8_t *icon = screen->indicatorIcons[i];
|
||
const int x = xStart + (i - pageStart) * (iconSize + spacing);
|
||
const bool isActive = (i == static_cast<size_t>(currentFrame));
|
||
|
||
if (isActive) {
|
||
display->setColor(WHITE);
|
||
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
|
||
display->setColor(BLACK);
|
||
}
|
||
|
||
if (currentResolution == ScreenResolution::High) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display);
|
||
} else {
|
||
display->drawXbm(x, y, iconSize, iconSize, icon);
|
||
}
|
||
|
||
if (isActive) {
|
||
display->setColor(WHITE);
|
||
}
|
||
}
|
||
|
||
// Compact arrow drawer
|
||
auto drawArrow = [&](bool rightSide) {
|
||
display->setColor(WHITE);
|
||
|
||
const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1;
|
||
const int halfH = rectHeight / 2;
|
||
|
||
const int top = (y - 2) + (rectHeight - halfH) / 2;
|
||
const int bottom = top + halfH - 1;
|
||
const int midY = top + (halfH / 2);
|
||
|
||
const int maxW = 4;
|
||
|
||
// Determine left X coordinate
|
||
int baseX = rightSide ? (rectX + rectWidth + offset) : // right arrow
|
||
(rectX - offset - 1); // left arrow
|
||
|
||
for (int yy = top; yy <= bottom; yy++) {
|
||
int dist = abs(yy - midY);
|
||
int lineW = maxW - (dist * maxW / (halfH / 2));
|
||
if (lineW < 1)
|
||
lineW = 1;
|
||
|
||
if (rightSide) {
|
||
display->drawHorizontalLine(baseX, yy, lineW);
|
||
} else {
|
||
display->drawHorizontalLine(baseX - lineW + 1, yy, lineW);
|
||
}
|
||
}
|
||
};
|
||
// Right arrow
|
||
if (pageEnd < totalIcons) {
|
||
drawArrow(true);
|
||
}
|
||
|
||
// Left arrow
|
||
if (pageStart > 0) {
|
||
drawArrow(false);
|
||
}
|
||
|
||
// Knock the corners off the square
|
||
display->setColor(BLACK);
|
||
display->drawRect(rectX, y - 2, 1, 1);
|
||
display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1);
|
||
display->setColor(WHITE);
|
||
}
|
||
|
||
void UIRenderer::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message)
|
||
{
|
||
uint16_t x_offset = display->width() / 2;
|
||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||
display->setFont(FONT_MEDIUM);
|
||
display->drawString(x_offset + x, 26 + y, message);
|
||
}
|
||
|
||
std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds)
|
||
{
|
||
std::string uptime;
|
||
|
||
if (days > (HOURS_IN_MONTH * 6))
|
||
uptime = "?";
|
||
else if (days >= 2)
|
||
uptime = std::to_string(days) + "d";
|
||
else if (hours >= 2)
|
||
uptime = std::to_string(hours) + "h";
|
||
else if (minutes >= 1)
|
||
uptime = std::to_string(minutes) + "m";
|
||
else
|
||
uptime = std::to_string(seconds) + "s";
|
||
return uptime;
|
||
}
|
||
|
||
} // namespace graphics
|
||
|
||
#endif // HAS_SCREEN
|