Files
firmware/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
T
+19 4c91beeda9 Develop to master (#9618)
* 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>
2026-02-19 07:16:33 -06:00

2016 lines
73 KiB
C++

#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./MenuApplet.h"
#include "DisplayFormatters.h"
#include "GPS.h"
#include "MeshService.h"
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
#include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "power.h"
#include <RadioLibInterface.h>
#include <target_specific.h>
#if defined(ARCH_ESP32) && HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
#include <WiFi.h>
#include <esp_wifi.h>
#endif
using namespace NicheGraphics;
static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu auto-closes
// Options for the "Recents" menu
// These are offered to users as possible values for settings.recentlyActiveSeconds
static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120};
struct PositionPrecisionOption {
uint8_t value; // proto value
const char *metric;
const char *imperial;
};
static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = {
{32, "Precise", "Precise"}, {19, "50 m", "150 ft"}, {18, "90 m", "300 ft"}, {17, "200 m", "600 ft"},
{16, "350 m", "0.2 mi"}, {15, "700 m", "0.5 mi"}, {14, "1.5 km", "0.9 mi"}, {13, "2.9 km", "1.8 mi"},
{12, "5.8 km", "3.6 mi"}, {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"},
};
InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
{
// No timer tasks at boot
OSThread::disable();
// Note: don't get instance if we're not actually using the backlight,
// or else you will unintentionally instantiate it
if (settings->optionalMenuItems.backlight) {
backlight = Drivers::LatchingBacklight::getInstance();
}
// Initialize the Canned Message store
// This is a shared nicheGraphics component
// - handles loading & parsing the canned messages
// - handles setting / getting of canned messages via apps (Client API Admin Messages)
cm.store = CannedMessageStore::getInstance();
}
void InkHUD::MenuApplet::onForeground()
{
// We do need this before we render, but we can optimize by just calculating it once now
systemInfoPanelHeight = getSystemInfoPanelHeight();
// Force Region page ONLY when explicitly requested (one-shot)
if (inkhud->forceRegionMenu) {
inkhud->forceRegionMenu = false; // consume one-shot flag
showPage(MenuPage::REGION);
} else {
showPage(MenuPage::ROOT);
}
// If device has a backlight which isn't controlled by aux button:
// backlight on always when menu opens.
// Courtesy to T-Echo users who removed the capacitive touch button
if (settings->optionalMenuItems.backlight) {
assert(backlight);
if (!backlight->isOn())
backlight->peek();
}
// Prevent user applets requesting update while menu is open
// Handle button input with this applet
SystemApplet::lockRequests = true;
SystemApplet::handleInput = true;
// Begin the auto-close timeout
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
OSThread::enabled = true;
freeTextMode = false;
// Upgrade the refresh to FAST, for guaranteed responsiveness
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
void InkHUD::MenuApplet::onBackground()
{
// Discard any data we generated while selecting a canned message
// Frees heap mem
freeCannedMessageResources();
// If device has a backlight which isn't controlled by aux button:
// Item in options submenu allows keeping backlight on after menu is closed
// If this item is deselected we will turn backlight off again, now that menu is closing
if (settings->optionalMenuItems.backlight) {
assert(backlight);
if (!backlight->isLatched())
backlight->off();
}
// Stop the auto-timeout
OSThread::disable();
// Resume normal rendering and button behavior of user applets
SystemApplet::lockRequests = false;
SystemApplet::handleInput = false;
handleFreeText = false;
// Restore the user applet whose tile we borrowed
if (borrowedTileOwner)
borrowedTileOwner->bringToForeground();
Tile *t = getTile();
t->assignApplet(borrowedTileOwner); // Break our link with the tile, (and relink it with real owner, if it had one)
borrowedTileOwner = nullptr;
// Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
// We're only updating here to upgrade from UNSPECIFIED to FAST, to ensure responsiveness when exiting menu
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
// Open the menu
// Parameter specifies which user-tile the menu will use
// The user applet originally on this tile will be restored when the menu closes
void InkHUD::MenuApplet::show(Tile *t)
{
// Remember who *really* owns this tile
borrowedTileOwner = t->getAssignedApplet();
// Hide the owner, if it is a valid applet
if (borrowedTileOwner)
borrowedTileOwner->sendToBackground();
// Break the owner's link with tile
// Relink it to menu applet
t->assignApplet(this);
// Show menu
bringToForeground();
}
// Auto-exit the menu applet after a period of inactivity
// The values shown on the root menu are only a snapshot: they are not re-rendered while the menu remains open.
// By exiting the menu, we prevent users mistakenly believing that the data will update.
int32_t InkHUD::MenuApplet::runOnce()
{
// runOnce's interval is pushed back when a button is pressed
// If we do actually run, it means no button input occurred within MENU_TIMEOUT_SEC,
// so we close the menu.
showPage(EXIT);
// Timer should disable after firing
// This is redundant, as onBackground() will also disable
return OSThread::disable();
}
static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
{
if (config.lora.region == region)
return;
config.lora.region = region;
auto changes = SEGMENT_CONFIG;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (!owner.is_licensed) {
bool keygenSuccess = false;
if (config.security.private_key.size == 32) {
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
keygenSuccess = true;
}
} else {
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
keygenSuccess = true;
}
if (keygenSuccess) {
config.security.public_key.size = 32;
config.security.private_key.size = 32;
owner.public_key.size = 32;
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
}
}
#endif
config.lora.tx_enabled = true;
initRegion();
if (myRegion && myRegion->dutyCycle < 100) {
config.lora.ignore_mqtt = true;
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG;
}
// Notify UI that changes are being applied
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
service->reloadConfig(changes);
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}
static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role)
{
if (config.device.role == role)
return;
config.device.role = role;
nodeDB->saveToDisk(SEGMENT_CONFIG);
service->reloadConfig(SEGMENT_CONFIG);
// Notify UI that changes are being applied
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}
static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
{
if (config.lora.modem_preset == preset)
return;
config.lora.use_preset = true;
config.lora.modem_preset = preset;
nodeDB->saveToDisk(SEGMENT_CONFIG);
service->reloadConfig(SEGMENT_CONFIG);
// Notify UI that changes are being applied
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
}
static const char *getTimezoneLabelFromValue(const char *tzdef)
{
if (!tzdef || !*tzdef)
return "Unset";
// Must match TIMEZONE menu entries
if (strcmp(tzdef, "HST10") == 0)
return "US/Hawaii";
if (strcmp(tzdef, "AKST9AKDT,M3.2.0,M11.1.0") == 0)
return "US/Alaska";
if (strcmp(tzdef, "PST8PDT,M3.2.0,M11.1.0") == 0)
return "US/Pacific";
if (strcmp(tzdef, "MST7") == 0)
return "US/Arizona";
if (strcmp(tzdef, "MST7MDT,M3.2.0,M11.1.0") == 0)
return "US/Mountain";
if (strcmp(tzdef, "CST6CDT,M3.2.0,M11.1.0") == 0)
return "US/Central";
if (strcmp(tzdef, "EST5EDT,M3.2.0,M11.1.0") == 0)
return "US/Eastern";
if (strcmp(tzdef, "BRT3") == 0)
return "BR/Brasilia";
if (strcmp(tzdef, "UTC0") == 0)
return "UTC";
if (strcmp(tzdef, "GMT0BST,M3.5.0/1,M10.5.0") == 0)
return "EU/Western";
if (strcmp(tzdef, "CET-1CEST,M3.5.0,M10.5.0/3") == 0)
return "EU/Central";
if (strcmp(tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4") == 0)
return "EU/Eastern";
if (strcmp(tzdef, "IST-5:30") == 0)
return "Asia/Kolkata";
if (strcmp(tzdef, "HKT-8") == 0)
return "Asia/Hong Kong";
if (strcmp(tzdef, "AWST-8") == 0)
return "AU/AWST";
if (strcmp(tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3") == 0)
return "AU/ACST";
if (strcmp(tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3") == 0)
return "AU/AEST";
if (strcmp(tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3") == 0)
return "Pacific/NZ";
return tzdef; // fallback for unknown/custom values
}
static void applyTimezone(const char *tz)
{
if (!tz || strcmp(config.device.tzdef, tz) == 0)
return;
strncpy(config.device.tzdef, tz, sizeof(config.device.tzdef));
config.device.tzdef[sizeof(config.device.tzdef) - 1] = '\0';
setenv("TZ", config.device.tzdef, 1);
nodeDB->saveToDisk(SEGMENT_CONFIG);
service->reloadConfig(SEGMENT_CONFIG);
}
// Perform action for a menu item, then change page
// Behaviors for MenuActions are defined here
void InkHUD::MenuApplet::execute(MenuItem item)
{
// Perform an action
// ------------------
switch (item.action) {
// Open a submenu without performing any action
// Also handles exit
case NO_ACTION:
if (currentPage == MenuPage::NODE_CONFIG_CHANNELS && item.nextPage == MenuPage::NODE_CONFIG_CHANNEL_DETAIL) {
// cursor - 1 because index 0 is "Back"
selectedChannelIndex = cursor - 1;
}
break;
case NEXT_TILE:
inkhud->nextTile();
// Unselect menu item after tile change
cursorShown = false;
cursor = 0;
break;
case SEND_PING:
service->refreshLocalMeshNode();
service->trySendPosition(NODENUM_BROADCAST, true);
// Force the next refresh to use FULL, to protect the display, as some users will probably spam this button
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL);
break;
case FREE_TEXT:
OSThread::enabled = false;
handleFreeText = true;
cm.freeTextItem.rawText.erase(); // clear the previous freetext message
freeTextMode = true; // render input field instead of normal menu
// Open the on-screen keyboard if the joystick is enabled
if (settings->joystick.enabled)
inkhud->openKeyboard();
break;
case STORE_CANNEDMESSAGE_SELECTION:
if (!settings->joystick.enabled)
cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry
else
cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry
break;
case SEND_CANNEDMESSAGE:
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
// send selected message
sendText(cm.selectedRecipientItem->dest, cm.selectedRecipientItem->channelIndex, cm.selectedMessageItem->rawText.c_str());
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL); // Next refresh should be FULL. Lots of button pressing to get here
break;
case ROTATE:
inkhud->rotate();
break;
case ALIGN_JOYSTICK:
inkhud->openAlignStick();
break;
case LAYOUT:
// Todo: smarter incrementing of tile count
settings->userTiles.count++;
if (settings->userTiles.count == 3) // Skip 3 tiles: not done yet
settings->userTiles.count++;
if (settings->userTiles.count > settings->userTiles.maxCount) // Loop around if tile count now too high
settings->userTiles.count = 1;
inkhud->updateLayout();
break;
case TOGGLE_APPLET:
if (item.checkState) {
*item.checkState = !(*item.checkState);
inkhud->updateAppletSelection();
}
break;
case TOGGLE_AUTOSHOW_APPLET:
// Toggle settings.userApplets.autoshow[] value, via MenuItem::checkState pointer set in populateAutoshowPage()
if (item.checkState) {
*item.checkState = !(*item.checkState);
}
break;
case TOGGLE_NOTIFICATIONS:
if (item.checkState) {
*item.checkState = !(*item.checkState);
}
break;
case TOGGLE_INVERT_COLOR:
if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
else
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_INVERTED;
nodeDB->saveToDisk(SEGMENT_CONFIG);
break;
case SET_RECENTS: {
// cursor - 1 because index 0 is "Back"
const uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]);
assert(index < optionCount);
settings->recentlyActiveSeconds = RECENTS_OPTIONS_MINUTES[index] * 60;
break;
}
case SHUTDOWN:
LOG_INFO("Shutting down from menu");
shutdownAtMsec = millis();
// Menu is then sent to background via onShutdown
break;
case TOGGLE_BATTERY_ICON:
inkhud->toggleBatteryIcon();
break;
case TOGGLE_BACKLIGHT:
// Note: backlight is already on in this situation
// We're marking that it should *remain* on once menu closes
assert(backlight);
if (backlight->isLatched())
backlight->off();
else
backlight->latch();
break;
case TOGGLE_12H_CLOCK:
config.display.use_12h_clock = !config.display.use_12h_clock;
nodeDB->saveToDisk(SEGMENT_CONFIG);
break;
case TOGGLE_GPS:
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED) {
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
} else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED;
} else {
// NOT_PRESENT do nothing
break;
}
nodeDB->saveToDisk(SEGMENT_CONFIG);
service->reloadConfig(SEGMENT_CONFIG);
#endif
break;
case ENABLE_BLUETOOTH:
// This helps users recover from a bad wifi config
LOG_INFO("Enabling Bluetooth");
config.network.wifi_enabled = false;
config.bluetooth.enabled = true;
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + 2000;
break;
// Power / Network (ESP32-only)
#if defined(ARCH_ESP32)
case TOGGLE_POWER_SAVE:
config.power.is_power_saving = !config.power.is_power_saving;
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
case TOGGLE_WIFI:
config.network.wifi_enabled = !config.network.wifi_enabled;
if (config.network.wifi_enabled) {
// Switch behavior: WiFi ON forces Bluetooth OFF
config.bluetooth.enabled = false;
}
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
#endif
// ADC Calibration
case CALIBRATE_ADC: {
// Read current measured voltage
float measuredV = powerStatus->getBatteryVoltageMv() / 1000.0f;
// Sanity check
if (measuredV < 3.0f || measuredV > 4.5f) {
LOG_WARN("ADC calibration aborted, unreasonable voltage: %.2fV", measuredV);
break;
}
// Determine the base multiplier currently in effect
float baseMult = 0.0f;
if (config.power.adc_multiplier_override > 0.0f) {
baseMult = config.power.adc_multiplier_override;
}
#ifdef ADC_MULTIPLIER
else {
baseMult = ADC_MULTIPLIER;
}
#endif
if (baseMult <= 0.0f) {
LOG_WARN("ADC calibration failed: no base multiplier");
break;
}
// Target voltage considered 100% by UI
constexpr float TARGET_VOLTAGE = 4.19f;
// Calculate new multiplier
float newMult = baseMult * (TARGET_VOLTAGE / measuredV);
config.power.adc_multiplier_override = newMult;
nodeDB->saveToDisk(SEGMENT_CONFIG);
LOG_INFO("ADC calibrated: measured=%.3fV base=%.4f new=%.4f", measuredV, baseMult, newMult);
break;
}
// Display
case TOGGLE_DISPLAY_UNITS:
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
else
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL;
nodeDB->saveToDisk(SEGMENT_CONFIG);
break;
// Bluetooth
case TOGGLE_BLUETOOTH:
config.bluetooth.enabled = !config.bluetooth.enabled;
if (config.bluetooth.enabled) {
// Switch behavior: Bluetooth ON forces WiFi OFF
config.network.wifi_enabled = false;
}
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
case TOGGLE_BLUETOOTH_PAIR_MODE:
config.bluetooth.fixed_pin = !config.bluetooth.fixed_pin;
nodeDB->saveToDisk(SEGMENT_CONFIG);
break;
// Regions
case SET_REGION_US:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
break;
case SET_REGION_EU_868:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
break;
case SET_REGION_EU_433:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_433);
break;
case SET_REGION_CN:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_CN);
break;
case SET_REGION_JP:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_JP);
break;
case SET_REGION_ANZ:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ANZ);
break;
case SET_REGION_KR:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KR);
break;
case SET_REGION_TW:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_TW);
break;
case SET_REGION_RU:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_RU);
break;
case SET_REGION_IN:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_IN);
break;
case SET_REGION_NZ_865:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NZ_865);
break;
case SET_REGION_TH:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_TH);
break;
case SET_REGION_LORA_24:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
break;
case SET_REGION_UA_433:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_433);
break;
case SET_REGION_UA_868:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_868);
break;
case SET_REGION_MY_433:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_433);
break;
case SET_REGION_MY_919:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_919);
break;
case SET_REGION_SG_923:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_SG_923);
break;
case SET_REGION_PH_433:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_433);
break;
case SET_REGION_PH_868:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_868);
break;
case SET_REGION_PH_915:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_PH_915);
break;
case SET_REGION_ANZ_433:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_ANZ_433);
break;
case SET_REGION_KZ_433:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KZ_433);
break;
case SET_REGION_KZ_863:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_KZ_863);
break;
case SET_REGION_NP_865:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NP_865);
break;
case SET_REGION_BR_902:
applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902);
break;
// Roles
case SET_ROLE_CLIENT:
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT);
break;
case SET_ROLE_CLIENT_MUTE:
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE);
break;
case SET_ROLE_ROUTER:
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_ROUTER);
break;
case SET_ROLE_REPEATER:
applyDeviceRole(meshtastic_Config_DeviceConfig_Role_REPEATER);
break;
// Presets
case SET_PRESET_LONG_SLOW:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW);
break;
case SET_PRESET_LONG_MODERATE:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE);
break;
case SET_PRESET_LONG_FAST:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
break;
case SET_PRESET_MEDIUM_SLOW:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW);
break;
case SET_PRESET_MEDIUM_FAST:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
break;
case SET_PRESET_SHORT_SLOW:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW);
break;
case SET_PRESET_SHORT_FAST:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
break;
case SET_PRESET_SHORT_TURBO:
applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
break;
// Timezones
case SET_TZ_US_HAWAII:
applyTimezone("HST10");
break;
case SET_TZ_US_ALASKA:
applyTimezone("AKST9AKDT,M3.2.0,M11.1.0");
break;
case SET_TZ_US_PACIFIC:
applyTimezone("PST8PDT,M3.2.0,M11.1.0");
break;
case SET_TZ_US_ARIZONA:
applyTimezone("MST7");
break;
case SET_TZ_US_MOUNTAIN:
applyTimezone("MST7MDT,M3.2.0,M11.1.0");
break;
case SET_TZ_US_CENTRAL:
applyTimezone("CST6CDT,M3.2.0,M11.1.0");
break;
case SET_TZ_US_EASTERN:
applyTimezone("EST5EDT,M3.2.0,M11.1.0");
break;
case SET_TZ_BR_BRAZILIA:
applyTimezone("BRT3");
break;
case SET_TZ_UTC:
applyTimezone("UTC0");
break;
case SET_TZ_EU_WESTERN:
applyTimezone("GMT0BST,M3.5.0/1,M10.5.0");
break;
case SET_TZ_EU_CENTRAL:
applyTimezone("CET-1CEST,M3.5.0,M10.5.0/3");
break;
case SET_TZ_EU_EASTERN:
applyTimezone("EET-2EEST,M3.5.0/3,M10.5.0/4");
break;
case SET_TZ_ASIA_KOLKATA:
applyTimezone("IST-5:30");
break;
case SET_TZ_ASIA_HONG_KONG:
applyTimezone("HKT-8");
break;
case SET_TZ_AU_AWST:
applyTimezone("AWST-8");
break;
case SET_TZ_AU_ACST:
applyTimezone("ACST-9:30ACDT,M10.1.0,M4.1.0/3");
break;
case SET_TZ_AU_AEST:
applyTimezone("AEST-10AEDT,M10.1.0,M4.1.0/3");
break;
case SET_TZ_PACIFIC_NZ:
applyTimezone("NZST-12NZDT,M9.5.0,M4.1.0/3");
break;
// Channels
case TOGGLE_CHANNEL_UPLINK: {
auto &ch = channels.getByIndex(selectedChannelIndex);
ch.settings.uplink_enabled = !ch.settings.uplink_enabled;
nodeDB->saveToDisk(SEGMENT_CHANNELS);
service->reloadConfig(SEGMENT_CHANNELS);
break;
}
case TOGGLE_CHANNEL_DOWNLINK: {
auto &ch = channels.getByIndex(selectedChannelIndex);
ch.settings.downlink_enabled = !ch.settings.downlink_enabled;
nodeDB->saveToDisk(SEGMENT_CHANNELS);
service->reloadConfig(SEGMENT_CHANNELS);
break;
}
case TOGGLE_CHANNEL_POSITION: {
auto &ch = channels.getByIndex(selectedChannelIndex);
if (!ch.settings.has_module_settings)
ch.settings.has_module_settings = true;
if (ch.settings.module_settings.position_precision > 0)
ch.settings.module_settings.position_precision = 0;
else
ch.settings.module_settings.position_precision = 13; // default
nodeDB->saveToDisk(SEGMENT_CHANNELS);
service->reloadConfig(SEGMENT_CHANNELS);
break;
}
case SET_CHANNEL_PRECISION: {
auto &ch = channels.getByIndex(selectedChannelIndex);
if (!ch.settings.has_module_settings)
ch.settings.has_module_settings = true;
// Cursor - 1 because of "Back"
uint8_t index = cursor - 1;
constexpr uint8_t optionCount = sizeof(POSITION_PRECISION_OPTIONS) / sizeof(POSITION_PRECISION_OPTIONS[0]);
if (index < optionCount) {
ch.settings.module_settings.position_precision = POSITION_PRECISION_OPTIONS[index].value;
}
nodeDB->saveToDisk(SEGMENT_CHANNELS);
service->reloadConfig(SEGMENT_CHANNELS);
break;
}
case RESET_NODEDB_ALL:
InkHUD::getInstance()->notifyApplyingChanges();
nodeDB->resetNodes();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
case RESET_NODEDB_KEEP_FAVORITES:
InkHUD::getInstance()->notifyApplyingChanges();
nodeDB->resetNodes(1);
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
default:
LOG_WARN("Action not implemented");
}
// Move to next page, as defined for the MenuItem
showPage(item.nextPage);
}
// Display a new page of MenuItems
// May reload same page, or exit menu applet entirely
// Fills the MenuApplet::items vector
void InkHUD::MenuApplet::showPage(MenuPage page)
{
items.clear();
items.shrink_to_fit();
nodeConfigLabels.clear();
switch (page) {
case ROOT:
previousPage = MenuPage::EXIT;
// Optional: next applet
if (settings->optionalMenuItems.nextTile && settings->userTiles.count > 1)
items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown
items.push_back(MenuItem("Send", MenuPage::SEND));
items.push_back(MenuItem("Options", MenuPage::OPTIONS));
// items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO
items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
items.push_back(MenuItem("Save & Shut Down", MenuAction::SHUTDOWN));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case SEND:
populateSendPage();
previousPage = MenuPage::ROOT;
break;
case CANNEDMESSAGE_RECIPIENT:
populateRecipientPage();
previousPage = MenuPage::SEND;
break;
case OPTIONS:
previousPage = MenuPage::ROOT;
items.push_back(MenuItem("Back", previousPage));
// Optional: backlight
if (settings->optionalMenuItems.backlight)
items.push_back(MenuItem(backlight->isLatched() ? "Backlight Off" : "Keep Backlight On", // Label
MenuAction::TOGGLE_BACKLIGHT, // Action
MenuPage::EXIT // Exit once complete
));
// Options Toggles
items.push_back(MenuItem("Applets", MenuPage::APPLETS));
items.push_back(MenuItem("Auto-show", MenuPage::AUTOSHOW));
items.push_back(MenuItem("Recents Duration", MenuPage::RECENTS));
if (settings->userTiles.maxCount > 1)
items.push_back(MenuItem("Layout", MenuAction::LAYOUT, MenuPage::OPTIONS));
items.push_back(MenuItem("Rotate", MenuAction::ROTATE, MenuPage::OPTIONS));
if (settings->joystick.enabled)
items.push_back(MenuItem("Align Joystick", MenuAction::ALIGN_JOYSTICK, MenuPage::EXIT));
items.push_back(MenuItem("Notifications", MenuAction::TOGGLE_NOTIFICATIONS, MenuPage::OPTIONS,
&settings->optionalFeatures.notifications));
items.push_back(MenuItem("Battery Icon", MenuAction::TOGGLE_BATTERY_ICON, MenuPage::OPTIONS,
&settings->optionalFeatures.batteryIcon));
invertedColors = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
items.push_back(MenuItem("Invert Color", MenuAction::TOGGLE_INVERT_COLOR, MenuPage::OPTIONS, &invertedColors));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case APPLETS:
previousPage = MenuPage::OPTIONS;
populateAppletPage(); // must be first
items.insert(items.begin(), MenuItem("Back", previousPage));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case AUTOSHOW:
previousPage = MenuPage::OPTIONS;
populateAutoshowPage(); // must be first
items.insert(items.begin(), MenuItem("Back", previousPage));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case RECENTS:
previousPage = MenuPage::OPTIONS;
populateRecentsPage(); // builds only the options
items.insert(items.begin(), MenuItem("Back", previousPage));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG:
previousPage = MenuPage::ROOT;
items.push_back(MenuItem("Back", previousPage));
// Radio Config Section
items.push_back(MenuItem::Header("Radio Config"));
items.push_back(MenuItem("LoRa", MenuPage::NODE_CONFIG_LORA));
items.push_back(MenuItem("Channel", MenuPage::NODE_CONFIG_CHANNELS));
// Device Config Section
items.push_back(MenuItem::Header("Device Config"));
items.push_back(MenuItem("Device", MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("Position", MenuPage::NODE_CONFIG_POSITION));
items.push_back(MenuItem("Power", MenuPage::NODE_CONFIG_POWER));
#if defined(ARCH_ESP32)
items.push_back(MenuItem("Network", MenuPage::NODE_CONFIG_NETWORK));
#endif
items.push_back(MenuItem("Display", MenuPage::NODE_CONFIG_DISPLAY));
items.push_back(MenuItem("Bluetooth", MenuPage::NODE_CONFIG_BLUETOOTH));
// Administration Section
items.push_back(MenuItem::Header("Administration"));
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
// Exit
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_DEVICE: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
const char *role = DisplayFormatters::getDeviceRole(config.device.role);
nodeConfigLabels.emplace_back("Role: " + std::string(role));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DEVICE_ROLE));
const char *tzLabel = getTimezoneLabelFromValue(config.device.tzdef);
nodeConfigLabels.emplace_back("Timezone: " + std::string(tzLabel));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::TIMEZONE));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_POSITION: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
const auto mode = config.position.gps_mode;
if (mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
items.push_back(MenuItem("GPS None", MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POSITION));
} else {
gpsEnabled = (mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED);
items.push_back(MenuItem("GPS", MenuAction::TOGGLE_GPS, MenuPage::NODE_CONFIG_POSITION, &gpsEnabled));
}
#endif
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_POWER: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
#if defined(ARCH_ESP32)
items.push_back(MenuItem("Powersave", MenuAction::TOGGLE_POWER_SAVE, MenuPage::EXIT, &config.power.is_power_saving));
#endif
// ADC Multiplier
float effectiveMult = 0.0f;
// User override always shows if it exists
if (config.power.adc_multiplier_override > 0.0f) {
effectiveMult = config.power.adc_multiplier_override;
}
#ifdef ADC_MULTIPLIER
else {
// Fallback to variant defined
effectiveMult = ADC_MULTIPLIER;
}
#endif
// Only show if we actually have a value
if (effectiveMult > 0.0f) {
char buf[32];
snprintf(buf, sizeof(buf), "ADC Mult: %.3f", effectiveMult);
nodeConfigLabels.emplace_back(buf);
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_POWER_ADC_CAL));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_POWER_ADC_CAL: {
previousPage = MenuPage::NODE_CONFIG_POWER;
items.push_back(MenuItem("Back", previousPage));
// Instruction text (header-style, non-selectable)
items.push_back(MenuItem::Header("Run on full charge Only"));
// Action
items.push_back(MenuItem("Calibrate ADC", MenuAction::CALIBRATE_ADC, MenuPage::NODE_CONFIG_POWER));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_NETWORK: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
const char *wifiLabel = config.network.wifi_enabled ? "WiFi: On" : "WiFi: Off";
items.push_back(MenuItem(wifiLabel, MenuAction::TOGGLE_WIFI, MenuPage::EXIT));
#if HAS_WIFI && defined(ARCH_ESP32)
if (config.network.wifi_enabled) {
// Status
if (WiFi.status() == WL_CONNECTED) {
nodeConfigLabels.emplace_back("Status: Connected");
} else {
nodeConfigLabels.emplace_back("Status: Not Connected");
}
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
// Signal
if (WiFi.status() == WL_CONNECTED) {
int rssi = WiFi.RSSI();
int quality = constrain(2 * (rssi + 100), 0, 100);
char sigBuf[32];
snprintf(sigBuf, sizeof(sigBuf), "Signal: %d%%", quality);
nodeConfigLabels.emplace_back(sigBuf);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
char ipBuf[64];
snprintf(ipBuf, sizeof(ipBuf), "IP: %s", WiFi.localIP().toString().c_str());
nodeConfigLabels.emplace_back(ipBuf);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
}
// SSID
if (config.network.wifi_ssid && strlen(config.network.wifi_ssid) > 0) {
std::string ssidLabel = "SSID: ";
ssidLabel += config.network.wifi_ssid;
nodeConfigLabels.emplace_back(ssidLabel);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
}
// Hostname
const char *host = WiFi.getHostname();
if (host && strlen(host) > 0) {
std::string hostLabel = "Host: ";
hostLabel += host;
nodeConfigLabels.emplace_back(hostLabel);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_NETWORK));
}
}
#endif
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_DISPLAY: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY,
&config.display.use_12h_clock));
const char *unitsLabel =
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric";
items.push_back(MenuItem(unitsLabel, MenuAction::TOGGLE_DISPLAY_UNITS, MenuPage::NODE_CONFIG_DISPLAY));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_BLUETOOTH: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
const char *btLabel = config.bluetooth.enabled ? "Bluetooth: On" : "Bluetooth: Off";
items.push_back(MenuItem(btLabel, MenuAction::TOGGLE_BLUETOOTH, MenuPage::EXIT));
const char *pairLabel = config.bluetooth.fixed_pin ? "Pair Mode: Fixed" : "Pair Mode: Random";
items.push_back(MenuItem(pairLabel, MenuAction::TOGGLE_BLUETOOTH_PAIR_MODE, MenuPage::NODE_CONFIG_BLUETOOTH));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_LORA: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
const char *region = myRegion ? myRegion->name : "Unset";
nodeConfigLabels.emplace_back("Region: " + std::string(region));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::REGION));
const char *preset =
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
nodeConfigLabels.emplace_back("Preset: " + std::string(preset));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_PRESET));
char freqBuf[32];
float freq = RadioLibInterface::instance->getFreq();
snprintf(freqBuf, sizeof(freqBuf), "Freq: %.3f MHz", freq);
nodeConfigLabels.emplace_back(freqBuf);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_LORA));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_CHANNELS: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
const meshtastic_Channel &ch = channels.getByIndex(i);
if (!ch.has_settings)
continue;
if (ch.role == meshtastic_Channel_Role_DISABLED)
continue;
std::string label = "#";
if (ch.role == meshtastic_Channel_Role_PRIMARY) {
label += "Primary";
} else if (strlen(ch.settings.name) > 0) {
label += parse(ch.settings.name);
} else {
label += "Channel" + to_string(i + 1);
}
nodeConfigLabels.push_back(label);
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_CHANNEL_DETAIL: {
previousPage = MenuPage::NODE_CONFIG_CHANNELS;
items.push_back(MenuItem("Back", previousPage));
meshtastic_Channel &ch = channels.getByIndex(selectedChannelIndex);
// Name (read-only)
const char *name = strlen(ch.settings.name) > 0 ? ch.settings.name : "Unnamed";
nodeConfigLabels.emplace_back("Ch: " + parse(name));
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
// Uplink
items.push_back(MenuItem("Uplink", MenuAction::TOGGLE_CHANNEL_UPLINK, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
&ch.settings.uplink_enabled));
items.push_back(MenuItem("Downlink", MenuAction::TOGGLE_CHANNEL_DOWNLINK, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
&ch.settings.downlink_enabled));
// Position
channelPositionEnabled = ch.settings.has_module_settings && ch.settings.module_settings.position_precision > 0;
items.push_back(MenuItem("Position", MenuAction::TOGGLE_CHANNEL_POSITION, MenuPage::NODE_CONFIG_CHANNEL_DETAIL,
&channelPositionEnabled));
// Precision
if (channelPositionEnabled) {
std::string precisionLabel = "Unknown";
for (const auto &opt : POSITION_PRECISION_OPTIONS) {
if (opt.value == ch.settings.module_settings.position_precision) {
precisionLabel = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
? opt.imperial
: opt.metric;
break;
}
}
nodeConfigLabels.emplace_back("Precision: " + precisionLabel);
items.push_back(
MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_CHANNEL_PRECISION));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_CHANNEL_PRECISION: {
previousPage = MenuPage::NODE_CONFIG_CHANNEL_DETAIL;
items.push_back(MenuItem("Back", previousPage));
const meshtastic_Channel &ch = channels.getByIndex(selectedChannelIndex);
if (!ch.settings.has_module_settings || ch.settings.module_settings.position_precision == 0) {
items.push_back(MenuItem("Position is Off", MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
break;
}
constexpr uint8_t optionCount = sizeof(POSITION_PRECISION_OPTIONS) / sizeof(POSITION_PRECISION_OPTIONS[0]);
for (uint8_t i = 0; i < optionCount; i++) {
const auto &opt = POSITION_PRECISION_OPTIONS[i];
const char *label =
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? opt.imperial : opt.metric;
nodeConfigLabels.emplace_back(label);
items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_CHANNEL_PRECISION,
MenuPage::NODE_CONFIG_CHANNEL_DETAIL));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case NODE_CONFIG_DEVICE_ROLE: {
previousPage = MenuPage::NODE_CONFIG_DEVICE;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("Client", MenuAction::SET_ROLE_CLIENT, MenuPage::EXIT));
items.push_back(MenuItem("Client Mute", MenuAction::SET_ROLE_CLIENT_MUTE, MenuPage::EXIT));
items.push_back(MenuItem("Router", MenuAction::SET_ROLE_ROUTER, MenuPage::EXIT));
items.push_back(MenuItem("Repeater", MenuAction::SET_ROLE_REPEATER, MenuPage::EXIT));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
case TIMEZONE:
previousPage = MenuPage::NODE_CONFIG_DEVICE;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("US/Hawaii", SET_TZ_US_HAWAII, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("US/Alaska", SET_TZ_US_ALASKA, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("US/Pacific", SET_TZ_US_PACIFIC, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("US/Arizona", SET_TZ_US_ARIZONA, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("US/Mountain", SET_TZ_US_MOUNTAIN, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("US/Central", SET_TZ_US_CENTRAL, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("US/Eastern", SET_TZ_US_EASTERN, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("BR/Brasilia", SET_TZ_BR_BRAZILIA, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("UTC", SET_TZ_UTC, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("EU/Western", SET_TZ_EU_WESTERN, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("EU/Central", SET_TZ_EU_CENTRAL, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("EU/Eastern", SET_TZ_EU_EASTERN, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("Asia/Kolkata", SET_TZ_ASIA_KOLKATA, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("Asia/Hong Kong", SET_TZ_ASIA_HONG_KONG, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("AU/AWST", SET_TZ_AU_AWST, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("AU/ACST", SET_TZ_AU_ACST, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("AU/AEST", SET_TZ_AU_AEST, MenuPage::NODE_CONFIG_DEVICE));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case REGION:
previousPage = MenuPage::NODE_CONFIG_LORA;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT));
items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT));
items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT));
items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT));
items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT));
items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT));
items.push_back(MenuItem("KR", MenuAction::SET_REGION_KR, MenuPage::EXIT));
items.push_back(MenuItem("TW", MenuAction::SET_REGION_TW, MenuPage::EXIT));
items.push_back(MenuItem("RU", MenuAction::SET_REGION_RU, MenuPage::EXIT));
items.push_back(MenuItem("IN", MenuAction::SET_REGION_IN, MenuPage::EXIT));
items.push_back(MenuItem("NZ 865", MenuAction::SET_REGION_NZ_865, MenuPage::EXIT));
items.push_back(MenuItem("TH", MenuAction::SET_REGION_TH, MenuPage::EXIT));
items.push_back(MenuItem("LoRa 2.4", MenuAction::SET_REGION_LORA_24, MenuPage::EXIT));
items.push_back(MenuItem("UA 433", MenuAction::SET_REGION_UA_433, MenuPage::EXIT));
items.push_back(MenuItem("UA 868", MenuAction::SET_REGION_UA_868, MenuPage::EXIT));
items.push_back(MenuItem("MY 433", MenuAction::SET_REGION_MY_433, MenuPage::EXIT));
items.push_back(MenuItem("MY 919", MenuAction::SET_REGION_MY_919, MenuPage::EXIT));
items.push_back(MenuItem("SG 923", MenuAction::SET_REGION_SG_923, MenuPage::EXIT));
items.push_back(MenuItem("PH 433", MenuAction::SET_REGION_PH_433, MenuPage::EXIT));
items.push_back(MenuItem("PH 868", MenuAction::SET_REGION_PH_868, MenuPage::EXIT));
items.push_back(MenuItem("PH 915", MenuAction::SET_REGION_PH_915, MenuPage::EXIT));
items.push_back(MenuItem("ANZ 433", MenuAction::SET_REGION_ANZ_433, MenuPage::EXIT));
items.push_back(MenuItem("KZ 433", MenuAction::SET_REGION_KZ_433, MenuPage::EXIT));
items.push_back(MenuItem("KZ 863", MenuAction::SET_REGION_KZ_863, MenuPage::EXIT));
items.push_back(MenuItem("NP 865", MenuAction::SET_REGION_NP_865, MenuPage::EXIT));
items.push_back(MenuItem("BR 902", MenuAction::SET_REGION_BR_902, MenuPage::EXIT));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_PRESET: {
previousPage = MenuPage::NODE_CONFIG_LORA;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("Long Moderate", MenuAction::SET_PRESET_LONG_MODERATE, MenuPage::EXIT));
items.push_back(MenuItem("Long Fast", MenuAction::SET_PRESET_LONG_FAST, MenuPage::EXIT));
items.push_back(MenuItem("Medium Slow", MenuAction::SET_PRESET_MEDIUM_SLOW, MenuPage::EXIT));
items.push_back(MenuItem("Medium Fast", MenuAction::SET_PRESET_MEDIUM_FAST, MenuPage::EXIT));
items.push_back(MenuItem("Short Slow", MenuAction::SET_PRESET_SHORT_SLOW, MenuPage::EXIT));
items.push_back(MenuItem("Short Fast", MenuAction::SET_PRESET_SHORT_FAST, MenuPage::EXIT));
items.push_back(MenuItem("Short Turbo", MenuAction::SET_PRESET_SHORT_TURBO, MenuPage::EXIT));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
}
// Administration Section
case NODE_CONFIG_ADMIN_RESET:
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("Reset All", MenuAction::RESET_NODEDB_ALL, MenuPage::EXIT));
items.push_back(MenuItem("Keep Favorites Only", MenuAction::RESET_NODEDB_KEEP_FAVORITES, MenuPage::EXIT));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
// Exit
case EXIT:
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
break;
default:
LOG_WARN("Page not implemented");
}
// Reset the cursor, unless reloading same page
// (or now out-of-bounds)
if (page != currentPage || cursor >= items.size()) {
cursor = 0;
// ROOT menu has special handling: unselected at first, to emphasise the system info panel
if (page == ROOT)
cursorShown = false;
}
// Ensure cursor never rests on a header
if (cursorShown) {
while (cursor < items.size() && items.at(cursor).isHeader) {
cursor++;
}
if (cursor >= items.size())
cursor = 0;
}
// Remember which page we are on now
currentPage = page;
}
void InkHUD::MenuApplet::onRender(bool full)
{
// Free text mode draws a text input field and skips the normal rendering
if (freeTextMode) {
drawInputField(0, fontSmall.lineHeight(), X(1.0), Y(1.0) - fontSmall.lineHeight() - 1, cm.freeTextItem.rawText);
return;
}
if (items.size() == 0)
LOG_ERROR("Empty Menu");
// Dimensions for the slots where we will draw menuItems
const float padding = 0.05;
const uint16_t itemH = fontSmall.lineHeight() * 1.6;
const int16_t selectInsetY = 2;
const int16_t itemW = width() - X(padding) - X(padding);
const int16_t itemL = X(padding);
const int16_t itemR = X(1 - padding);
int16_t itemT = 0; // Top (y px of current slot). Incremented as we draw. Adjusted to fit system info panel on ROOT menu.
// How many full menuItems will fit on screen
uint8_t slotCount = (height() - itemT) / itemH;
// System info panel at the top of the menu
// =========================================
uint16_t &siH = systemInfoPanelHeight; // System info - height. Calculated at onForeground
const uint8_t slotsObscured = ceilf(siH / (float)itemH); // How many slots are obscured by system info panel
// System info - top
// Remain at 0px, until cursor reaches bottom of screen, then begin to scroll off screen.
// This is the same behavior we expect from the non-root menus.
// Implementing this with the systemp panel is slightly annoying though,
// and required adding the MenuApplet::getSystemInfoPanelHeight method
int16_t siT;
if (cursor < slotCount - slotsObscured - 1) // (Minus 1: comparing zero based index with a count)
siT = 0;
else
siT = 0 - ((cursor - (slotCount - slotsObscured - 1)) * itemH);
// If showing ROOT menu,
// and the panel isn't yet scrolled off screen top
if (currentPage == ROOT) {
drawSystemInfoPanel(0, siT, width()); // Draw the panel.
itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel
}
// Draw menu items
// ===================
// Which item will be drawn to the top-most slot?
// Initially, this is the item 0, but may increase once we begin scrolling
uint8_t firstItem;
if (cursor < slotCount)
firstItem = 0;
else
firstItem = cursor - (slotCount - 1);
// Which item will be drawn to the bottom-most slot?
// This may be beyond the slot-count, to draw a partially off-screen item below the bottom-most slow
// This may be less than the slot-count, if we are reaching the end of the menuItems
uint8_t lastItem = min((uint8_t)firstItem + slotCount, (uint8_t)items.size() - 1);
// -- Loop: draw each (visible) menu item --
for (uint8_t i = firstItem; i <= lastItem; i++) {
// Grab the menu item
MenuItem &item = items.at(i);
// Vertical center of this slot
int16_t center = itemT + (itemH / 2);
// Header (non-selectable section label)
if (item.isHeader) {
setFont(fontSmall);
// Header text (flush left)
printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE);
// Subtle underline
int16_t underlineY = itemT + itemH - 2;
drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK);
} else {
// Box, if currently selected
if (cursorShown && i == cursor)
drawRect(itemL, itemT + selectInsetY, itemW, itemH - (selectInsetY * 2), BLACK);
// Indented normal item text
printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
}
// Checkbox, if relevant
if (item.checkState) {
const uint16_t cbWH = fontSmall.lineHeight(); // Checkbox: width / height
const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
const int16_t cbT = center - (cbWH / 2); // Checkbox : top
// Checkbox ticked
if (*(item.checkState)) {
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
// First point of tick: pen down
const int16_t t1Y = center;
const int16_t t1X = cbL + 3;
// Second point of tick: base
const int16_t t2Y = center + (cbWH / 2) - 2;
const int16_t t2X = cbL + (cbWH / 2);
// Third point of tick: end of tail
const int16_t t3Y = center - (cbWH / 2) - 2;
const int16_t t3X = cbL + cbWH + 2;
// Draw twice: faux bold
drawLine(t1X, t1Y, t2X, t2Y, BLACK);
drawLine(t2X, t2Y, t3X, t3Y, BLACK);
drawLine(t1X + 1, t1Y, t2X + 1, t2Y, BLACK);
drawLine(t2X + 1, t2Y, t3X + 1, t3Y, BLACK);
}
// Checkbox ticked
else
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
}
// Increment the y value (top) as we go
itemT += itemH;
}
}
void InkHUD::MenuApplet::onButtonShortPress()
{
if (!freeTextMode) {
// Push the auto-close timer back
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
if (!settings->joystick.enabled) {
if (!cursorShown) {
cursorShown = true;
cursor = 0;
} else {
do {
cursor = (cursor + 1) % items.size();
} while (items.at(cursor).isHeader);
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
} else {
if (cursorShown)
execute(items.at(cursor));
else
showPage(MenuPage::EXIT);
if (!wantsToRender())
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
}
}
void InkHUD::MenuApplet::onButtonLongPress()
{
if (!freeTextMode) {
// Push the auto-close timer back
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
if (cursorShown)
execute(items.at(cursor));
else
showPage(MenuPage::EXIT); // Special case: Peek at root-menu; longpress again to close
// If we didn't already request a specialized update, when handling a menu action,
// then perform the usual fast update.
// FAST keeps things responsive: important because we're dealing with user input
if (!wantsToRender())
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
}
void InkHUD::MenuApplet::onExitShort()
{
// Exit the menu
showPage(MenuPage::EXIT);
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::MenuApplet::onNavUp()
{
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
if (!cursorShown) {
cursorShown = true;
cursor = 0;
} else {
do {
if (cursor == 0)
cursor = items.size() - 1;
else
cursor--;
} while (items.at(cursor).isHeader);
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
}
void InkHUD::MenuApplet::onNavDown()
{
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
if (!cursorShown) {
cursorShown = true;
cursor = 0;
} else {
do {
cursor = (cursor + 1) % items.size();
} while (items.at(cursor).isHeader);
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
}
void InkHUD::MenuApplet::onNavLeft()
{
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
// Go to the previous menu page
showPage(previousPage);
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
}
void InkHUD::MenuApplet::onNavRight()
{
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
if (cursorShown)
execute(items.at(cursor));
if (!wantsToRender())
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
}
void InkHUD::MenuApplet::onFreeText(char c)
{
if (cm.freeTextItem.rawText.length() >= menuTextLimit && c != '\b')
return;
if (c == '\b') {
if (!cm.freeTextItem.rawText.empty())
cm.freeTextItem.rawText.pop_back();
} else {
cm.freeTextItem.rawText += c;
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::MenuApplet::onFreeTextDone()
{
// Restart the auto-close timeout
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
OSThread::enabled = true;
handleFreeText = false;
freeTextMode = false;
if (!cm.freeTextItem.rawText.empty()) {
cm.selectedMessageItem = &cm.freeTextItem;
showPage(MenuPage::CANNEDMESSAGE_RECIPIENT);
}
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::MenuApplet::onFreeTextCancel()
{
// Restart the auto-close timeout
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
OSThread::enabled = true;
handleFreeText = false;
freeTextMode = false;
// Clear the free text message
cm.freeTextItem.rawText.erase();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
// Dynamically create MenuItem entries for activating / deactivating Applets, for the "Applet Selection" submenu
void InkHUD::MenuApplet::populateAppletPage()
{
assert(items.size() == 0);
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
const char *name = inkhud->userApplets.at(i)->name;
bool *isActive = &(settings->userApplets.active[i]);
items.push_back(MenuItem(name, MenuAction::TOGGLE_APPLET, MenuPage::APPLETS, isActive));
}
}
// Dynamically create MenuItem entries for selecting which applets will automatically come to foreground when they have new data
// We only populate this menu page with applets which are actually active
// We use the MenuItem::checkState pointer to toggle the setting in MenuApplet::execute. Bit of a hack, but convenient.
void InkHUD::MenuApplet::populateAutoshowPage()
{
assert(items.size() == 0);
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
// Only add a menu item if applet is active
if (settings->userApplets.active[i]) {
const char *name = inkhud->userApplets.at(i)->name;
bool *isActive = &(settings->userApplets.autoshow[i]);
items.push_back(MenuItem(name, MenuAction::TOGGLE_AUTOSHOW_APPLET, MenuPage::AUTOSHOW, isActive));
}
}
}
// Create MenuItem entries to select our definition of "Recent"
// Controls how long data will remain in any "Recents" flavored applets
void InkHUD::MenuApplet::populateRecentsPage()
{
// How many values are shown for use to choose from
constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]);
// Create an entry for each item in RECENTS_OPTIONS_MINUTES array
// (Defined at top of this file)
for (uint8_t i = 0; i < optionCount; i++) {
std::string label = to_string(RECENTS_OPTIONS_MINUTES[i]) + " mins";
recentsSelected[i] = (settings->recentlyActiveSeconds == RECENTS_OPTIONS_MINUTES[i] * 60);
items.push_back(MenuItem(label.c_str(), MenuAction::SET_RECENTS, MenuPage::OPTIONS, &recentsSelected[i]));
}
}
// MenuItem entries for the "send" page
// Dynamically creates menu items based on available canned messages
void InkHUD::MenuApplet::populateSendPage()
{
// Position / NodeInfo packet
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
// If joystick is available, include the Free Text option
if (settings->joystick.enabled)
items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));
// One menu item for each canned message
uint8_t count = cm.store->size();
for (uint8_t i = 0; i < count; i++) {
// Gather the information for this item
CannedMessages::MessageItem messageItem;
messageItem.rawText = cm.store->at(i);
messageItem.label = parse(messageItem.rawText);
// Store the item (until the menu closes)
cm.messageItems.push_back(messageItem);
// Create a menu item
const char *itemText = cm.messageItems.back().label.c_str();
items.push_back(MenuItem(itemText, MenuAction::STORE_CANNEDMESSAGE_SELECTION, MenuPage::CANNEDMESSAGE_RECIPIENT));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
}
// Dynamically create MenuItem entries for possible canned message destinations
// All available channels are shown
// Favorite nodes are shown, provided we don't have an *excessive* amount
void InkHUD::MenuApplet::populateRecipientPage()
{
// Create recipient data (and menu items) for any channels
// --------------------------------------------------------
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
// Get the channel, and check if it's enabled
const meshtastic_Channel &channel = channels.getByIndex(i);
if (!channel.has_settings || channel.role == meshtastic_Channel_Role_DISABLED)
continue;
CannedMessages::RecipientItem r;
// Set index
r.channelIndex = channel.index;
// Set a label for the menu item
r.label = "Ch " + to_string(i) + ": ";
if (channel.role == meshtastic_Channel_Role_PRIMARY)
r.label += "Primary";
else
r.label += parse(channel.settings.name);
// Add to the list of recipients
cm.recipientItems.push_back(r);
// Add a menu item for this recipient
const char *itemText = cm.recipientItems.back().label.c_str();
items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
}
// Create recipient data (and menu items) for favorite nodes
// ---------------------------------------------------------
uint32_t nodeCount = nodeDB->getNumMeshNodes();
uint32_t favoriteCount = 0;
// Count favorites
for (uint32_t i = 0; i < nodeCount; i++) {
if (nodeDB->getMeshNodeByIndex(i)->is_favorite)
favoriteCount++;
}
// Only add favorites if the number is reasonable
// Don't want some monstrous list that takes 100 clicks to reach exit
if (favoriteCount < 20) {
for (uint32_t i = 0; i < nodeCount; i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Skip node if not a favorite
if (!node->is_favorite)
continue;
CannedMessages::RecipientItem r;
r.dest = node->num;
r.channelIndex = nodeDB->getMeshNodeChannel(node->num); // Channel index only relevant if encrypted DM not possible(?)
// Set a label for the menu item
r.label = "DM: ";
if (node->has_user)
r.label += parse(node->user.long_name);
else
r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?
// Add to the list of recipients
cm.recipientItems.push_back(r);
// Add a menu item for this recipient
const char *itemText = cm.recipientItems.back().label.c_str();
items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
}
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
}
void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text)
{
setFont(fontSmall);
uint16_t wrapMaxH = 0;
// Draw the text, input box, and cursor
// Adjusting the box for screen height
while (wrapMaxH < height - fontSmall.lineHeight()) {
wrapMaxH += fontSmall.lineHeight();
}
// If the text is so long that it goes outside of the input box, the text is actually rendered off screen.
uint32_t textHeight = getWrappedTextHeight(0, width - 5, text);
if (!text.empty()) {
uint16_t textPadding = X(1.0) > Y(1.0) ? wrapMaxH - textHeight : wrapMaxH - textHeight + 1;
if (textHeight > wrapMaxH)
printWrapped(2, textPadding, width - 5, text);
else
printWrapped(2, top + 2, width - 5, text);
}
uint16_t textCursorX = text.empty() ? 1 : getCursorX();
uint16_t textCursorY = text.empty() ? fontSmall.lineHeight() + 2 : getCursorY() - fontSmall.lineHeight() + 3;
if (textCursorX + 1 > width - 5) {
textCursorX = getCursorX() - width + 5;
textCursorY += fontSmall.lineHeight();
}
fillRect(textCursorX + 1, textCursorY, 1, fontSmall.lineHeight(), BLACK);
// A white rectangle clears the top part of the screen for any text that's printed beyond the input box
fillRect(0, 0, X(1.0), top, WHITE);
// Draw character limit
std::string ftlen = std::to_string(text.length()) + "/" + to_string(menuTextLimit);
uint16_t textLen = getTextWidth(ftlen);
printAt(X(1.0) - textLen - 2, 0, ftlen);
// Draw the border
drawRect(0, top, width, wrapMaxH + 5, BLACK);
}
// Renders the panel shown at the top of the root menu.
// Displays the clock, and several other pieces of instantaneous system info,
// which we'd prefer not to have displayed in a normal applet, as they update too frequently.
void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width, uint16_t *renderedHeight)
{
// Reset the height
// We'll add to this as we add elements
uint16_t height = 0;
// Clock (potentially)
// ====================
std::string clockString = getTimeString();
if (clockString.length() > 0) {
setFont(fontMedium);
printAt(width / 2, top, clockString, CENTER, TOP);
height += fontMedium.lineHeight();
height += fontMedium.lineHeight() * 0.1; // Padding below clock
}
// Stats
// ===================
setFont(fontSmall);
// Position of the label row for the system info
const int16_t labelT = top + height;
height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing
// Position of the data row for the system info
const int16_t valT = top + height;
height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing (between bottom line and divider)
// Position of divider between the info panel and the menu entries
const int16_t divY = top + height;
height += fontSmall.lineHeight() * 0.2; // Padding *below* the divider. (Above first menu item)
// Create a variable number of columns
// Either 3 or 4, depending on whether we have GPS
// Todo
constexpr uint8_t N_COL = 3;
int16_t colL[N_COL];
int16_t colC[N_COL];
int16_t colR[N_COL];
for (uint8_t i = 0; i < N_COL; i++) {
colL[i] = left + ((width / N_COL) * i);
colC[i] = colL[i] + ((width / N_COL) / 2);
colR[i] = colL[i] + (width / N_COL);
}
// Info blocks, left to right
// Voltage
float voltage = powerStatus->getBatteryVoltageMv() / 1000.0;
char voltageStr[6]; // "XX.XV"
sprintf(voltageStr, "%.2fV", voltage);
printAt(colC[0], labelT, "Bat", CENTER, TOP);
printAt(colC[0], valT, voltageStr, CENTER, TOP);
// Divider
for (int16_t y = valT; y <= divY; y += 3)
drawPixel(colR[0], y, BLACK);
// Channel Util
char chUtilStr[4]; // "XX%"
sprintf(chUtilStr, "%2.f%%", airTime->channelUtilizationPercent());
printAt(colC[1], labelT, "Ch", CENTER, TOP);
printAt(colC[1], valT, chUtilStr, CENTER, TOP);
// Divider
for (int16_t y = valT; y <= divY; y += 3)
drawPixel(colR[1], y, BLACK);
// Duty Cycle (AirTimeTx)
char dutyUtilStr[4]; // "XX%"
sprintf(dutyUtilStr, "%2.f%%", airTime->utilizationTXPercent());
printAt(colC[2], labelT, "Duty", CENTER, TOP);
printAt(colC[2], valT, dutyUtilStr, CENTER, TOP);
/*
// Divider
for (int16_t y = valT; y <= divY; y += 3)
drawPixel(colR[2], y, BLACK);
// GPS satellites - todo
printAt(colC[3], labelT, "Sats", CENTER, TOP);
printAt(colC[3], valT, "ToDo", CENTER, TOP);
*/
// Horizontal divider, at bottom of system info panel
for (int16_t x = 0; x < width; x += 2) // Divider, centered in the padding between first system panel and first item
drawPixel(x, divY, BLACK);
if (renderedHeight != nullptr)
*renderedHeight = height;
}
// Get the height of the the panel drawn at the top of the menu
// This is inefficient, as we do actually have to render the panel to determine the height
// It solves a catch-22 situation, where slotCount needs to know panel height, and panel height needs to know slotCount
uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
{
// Render *far* off screen
uint16_t height = 0;
drawSystemInfoPanel(INT16_MIN, INT16_MIN, 1, &height);
return height;
}
// Send a text message to the mesh
// Used to send our canned messages
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
{
meshtastic_MeshPacket *p = router->allocForSending();
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p->to = dest;
p->channel = channel;
p->want_ack = true;
p->decoded.payload.size = strlen(message);
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
// Tack on a bell character if requested
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
p->decoded.payload.size++;
}
LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes);
service->sendToMesh(p, RX_SRC_LOCAL, true); // Send to mesh, cc to phone
}
// Free up any heap mmemory we'd used while selecting / sending canned messages
void InkHUD::MenuApplet::freeCannedMessageResources()
{
cm.selectedMessageItem = nullptr;
cm.selectedRecipientItem = nullptr;
cm.messageItems.clear();
cm.recipientItems.clear();
}
#endif // MESHTASTIC_INCLUDE_INKHUD