diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..8529eb7fc
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+open_collective: meshtastic
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index a15b34aae..0142c57a2 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,6 +1,7 @@
## 🙏 Thank you for sending in a pull request, here's some tips to get started!
### ❌ (Please delete all these tips and replace them with your text) ❌
+
- Before starting on some new big chunk of code, it it is optional but highly recommended to open an issue first
to say "Hey, I think this idea X should be implemented and I'm starting work on it. My general plan is Y, any feedback
is appreciated." This will allow other devs to potentially save you time by not accidentially duplicating work etc...
@@ -15,12 +16,12 @@
- If you do not have the affected hardware to test your code changes adequately against regressions, please indicate this, so that contributors and commnunity members can help test your changes.
- If your PR gets accepted you can request a "Contributor" role in the Meshtastic Discord
-
## 🤝 Attestations
+
- [ ] I have tested that my proposed changes behave as described.
- [ ] I have tested that my proposed changes do not cause any obvious regressions on the following devices:
- [ ] Heltec (Lora32) V3
- - [ ] LilyGo T-Deck
+ - [ ] LilyGo T-Deck
- [ ] LilyGo T-Beam
- [ ] RAK WisBlock 4631
- [ ] Seeed Studio T-1000E tracker card
diff --git a/.github/workflows/daily_packaging.yml b/.github/workflows/daily_packaging.yml
index 11fe2043a..18939d567 100644
--- a/.github/workflows/daily_packaging.yml
+++ b/.github/workflows/daily_packaging.yml
@@ -1,7 +1,7 @@
name: Daily Packaging
on:
schedule:
- - cron: 0 9 * * *
+ - cron: 0 2 * * *
workflow_dispatch:
push:
branches:
diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml
index 6f216b411..aac57fcbf 100644
--- a/.github/workflows/release_channels.yml
+++ b/.github/workflows/release_channels.yml
@@ -98,6 +98,7 @@ jobs:
uses: peter-evans/create-pull-request@v7
with:
base: ${{ github.event.repository.default_branch }}
+ branch: create-pull-request/bump-version
title: Bump release version
commit-message: automated bumps
add-paths: |
diff --git a/.github/workflows/update_protobufs.yml b/.github/workflows/update_protobufs.yml
index 5aa295b89..ccdcc19ae 100644
--- a/.github/workflows/update_protobufs.yml
+++ b/.github/workflows/update_protobufs.yml
@@ -33,6 +33,7 @@ jobs:
- name: Create pull request
uses: peter-evans/create-pull-request@v7
with:
+ branch: create-pull-request/update-protobufs
title: Update protobufs and classes
add-paths: |
protobufs
diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml
index 79bdf4778..b40f9458b 100644
--- a/.trunk/trunk.yaml
+++ b/.trunk/trunk.yaml
@@ -1,23 +1,24 @@
version: 0.1
cli:
- version: 1.22.15
+ version: 1.24.0
plugins:
sources:
- id: trunk
- ref: v1.6.8
+ ref: v1.7.0
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- - renovate@40.0.6
+ - checkov@3.2.442
+ - renovate@40.60.3
- prettier@3.5.3
- - trufflehog@3.88.29
+ - trufflehog@3.89.2
- yamllint@1.37.1
- - bandit@1.8.3
- - trivy@0.62.1
+ - bandit@1.8.5
+ - trivy@0.63.0
- taplo@0.9.3
- - ruff@0.11.9
+ - ruff@0.12.0
- isort@6.0.1
- - markdownlint@0.44.0
+ - markdownlint@0.45.0
- oxipng@9.1.5
- svgo@3.3.2
- actionlint@1.7.7
@@ -27,7 +28,7 @@ lint:
- shellcheck@0.10.0
- black@25.1.0
- git-diff-check
- - gitleaks@8.26.0
+ - gitleaks@8.27.2
- clang-format@16.0.3
ignore:
- linters: [ALL]
@@ -37,7 +38,7 @@ runtimes:
enabled:
- python@3.10.8
- go@1.21.0
- - node@18.20.5
+ - node@22.16.0
actions:
disabled:
- trunk-announce
diff --git a/README.md b/README.md
index f34bf1839..a53fe9646 100644
--- a/README.md
+++ b/README.md
@@ -37,3 +37,4 @@ Join our community and help improve Meshtastic! 🚀
## Stats

+
diff --git a/alpine.Dockerfile b/alpine.Dockerfile
index bf7cad6d4..670736241 100644
--- a/alpine.Dockerfile
+++ b/alpine.Dockerfile
@@ -3,7 +3,7 @@
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
-FROM python:3.13-alpine3.21 AS builder
+FROM python:3.13-alpine3.22 AS builder
ARG PIO_ENV=native
ENV PIP_ROOT_USER_ACTION=ignore
@@ -27,7 +27,7 @@ RUN bash ./bin/build-native.sh "$PIO_ENV" && \
# ##### PRODUCTION BUILD #############
-FROM alpine:3.21
+FROM alpine:3.22
LABEL org.opencontainers.image.title="Meshtastic" \
org.opencontainers.image.description="Alpine Meshtastic daemon" \
org.opencontainers.image.url="https://meshtastic.org" \
diff --git a/arch/esp32/esp32.ini b/arch/esp32/esp32.ini
index a6eff7bf9..cba84181b 100644
--- a/arch/esp32/esp32.ini
+++ b/arch/esp32/esp32.ini
@@ -4,7 +4,7 @@ extends = arduino_base
custom_esp32_kind = esp32
platform =
# renovate: datasource=custom.pio depName=platformio/espressif32 packageName=platformio/platform/espressif32
- platformio/espressif32@6.10.0
+ platformio/espressif32@6.11.0
build_src_filter =
${arduino_base.build_src_filter} - - - - -
diff --git a/arch/portduino/portduino.ini b/arch/portduino/portduino.ini
index a19c50319..429e010f5 100644
--- a/arch/portduino/portduino.ini
+++ b/arch/portduino/portduino.ini
@@ -2,7 +2,7 @@
[portduino_base]
platform =
# renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop
- https://github.com/meshtastic/platform-native/archive/622341c6de8a239704318b10c3dbb00c21a3eab3.zip
+ https://github.com/meshtastic/platform-native/archive/681ee029207e9fd040afa223df6e54074cbbe084.zip
framework = arduino
build_src_filter =
diff --git a/arch/stm32/stm32.ini b/arch/stm32/stm32.ini
index dd190c9d4..e7a340f92 100644
--- a/arch/stm32/stm32.ini
+++ b/arch/stm32/stm32.ini
@@ -2,7 +2,7 @@
extends = arduino_base
platform =
# renovate: datasource=custom.pio depName=platformio/ststm32 packageName=platformio/platform/ststm32
- platformio/ststm32@19.1.0
+ platformio/ststm32@19.2.0
platform_packages =
# TODO renovate
platformio/framework-arduinoststm32@https://github.com/stm32duino/Arduino_Core_STM32/archive/2.10.1.zip
diff --git a/bin/99-meshtasticd-udev.rules b/bin/99-meshtasticd-udev.rules
index 69a468d7a..1151efafd 100644
--- a/bin/99-meshtasticd-udev.rules
+++ b/bin/99-meshtasticd-udev.rules
@@ -1,4 +1,7 @@
-# Set spidev ownership to 'spi' group.
+# Set spidev ownership to 'spi' group
SUBSYSTEM=="spidev", KERNEL=="spidev*", GROUP="spi", MODE="0660"
# Allow access to USB CH341 devices
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="5512", MODE="0666"
+# Set gpio ownership to 'gpio' group
+SUBSYSTEM=="*gpiomem*", GROUP="gpio", MODE="0660"
+SUBSYSTEM=="gpio", GROUP="gpio", MODE="0660"
diff --git a/bin/check-all.sh b/bin/check-all.sh
index d1b50a8aa..29d6b5532 100755
--- a/bin/check-all.sh
+++ b/bin/check-all.sh
@@ -23,4 +23,4 @@ for BOARD in $BOARDS; do
CHECK="${CHECK} -e ${BOARD}"
done
-pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt" $CHECK --skip-packages --pattern="src/" --fail-on-defect=medium --fail-on-defect=high
+pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" $CHECK --skip-packages --pattern="src/" --fail-on-defect=medium --fail-on-defect=high
diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml
index 55e8648d9..b40fb85a5 100644
--- a/bin/config-dist.yaml
+++ b/bin/config-dist.yaml
@@ -96,9 +96,9 @@ Lora:
### Some devices, like the pinedio, may require spidev0.1 as a workaround.
# spidev: spidev0.0
-### Define GPIO buttons here:
+### Deprecated location for User Button:
-GPIO:
+#GPIO:
# User: 6
### Define GPS
@@ -115,17 +115,6 @@ I2C:
Display:
-### Waveshare 1.44inch LCD HAT
-# Panel: ST7735S
-# CS: 8 #Chip Select
-# DC: 25 # Data/Command pin
-# Backlight: 24
-# Width: 128
-# Height: 128
-# Reset: 27
-# OffsetX: 0
-# OffsetY: 0
-
### Adafruit PiTFT 2.8 TFT+Touchscreen
# Panel: ILI9341
# CS: 8
@@ -180,6 +169,16 @@ Input:
# KeyboardDevice: /dev/input/by-id/usb-_Raspberry_Pi_Internal_Keyboard-event-kbd
+### Standard User Button Config
+# UserButton: 6
+
+### Trackball/Joystick input
+# TrackballUp: 6
+# TrackballDown: 19
+# TrackballLeft: 5
+# TrackballRight: 26
+# TrackballPress: 13
+
###
Logging:
diff --git a/bin/config.d/display-waveshare-1-44.yaml b/bin/config.d/display-waveshare-1-44.yaml
new file mode 100644
index 000000000..1d85a4a3b
--- /dev/null
+++ b/bin/config.d/display-waveshare-1-44.yaml
@@ -0,0 +1,26 @@
+### Waveshare 1.44inch LCD HAT
+Display:
+ Panel: ST7735S
+ spidev: spidev0.0 # Specify either the spidev here, or the CS below
+# CS: 8 #Chip Select # Optional, as this is the default pin for spidev0.0
+ DC: 25 # Data/Command pin
+ Backlight: 24
+ Width: 128
+ Height: 128
+ Reset: 27
+ OffsetX: 2
+ OffsetY: 1
+
+
+# OffsetY: 31 # These two options are used to properly flip the screen 180 degrees
+# OffsetRotate: 3
+
+
+Input:
+ TrackballUp: 6
+ TrackballDown: 19
+ TrackballLeft: 5
+ TrackballRight: 26
+ TrackballPress: 13
+
+# User: 21
diff --git a/bin/config.d/lora-RAK6421.yaml b/bin/config.d/lora-RAK6421.yaml
new file mode 100644
index 000000000..bbf38a474
--- /dev/null
+++ b/bin/config.d/lora-RAK6421.yaml
@@ -0,0 +1,21 @@
+Lora:
+
+ ### RAK13300in Slot 1
+ Module: sx1262
+ IRQ: 22 #IO6
+ Reset: 16 # IO4
+ Busy: 24 # IO5
+ # Ant_sw: 13 # IO3
+ DIO3_TCXO_VOLTAGE: true
+ DIO2_AS_RF_SWITCH: true
+ spidev: spidev0.0
+ # CS: 8
+
+
+ ### RAK13300in Slot 2 pins
+# IRQ: 18 #IO6
+# Reset: 24 # IO4
+# Busy: 19 # IO5
+# # Ant_sw: 23 # IO3
+# spidev: spidev0.1
+# # CS: 7
\ No newline at end of file
diff --git a/bin/config.d/lora-lyra-picocalc-wio-sx1262.yaml b/bin/config.d/lora-lyra-picocalc-wio-sx1262.yaml
new file mode 100644
index 000000000..2fd128ce8
--- /dev/null
+++ b/bin/config.d/lora-lyra-picocalc-wio-sx1262.yaml
@@ -0,0 +1,18 @@
+Lora:
+ Module: sx1262
+ DIO2_AS_RF_SWITCH: true
+ DIO3_TCXO_VOLTAGE: true
+ gpiochip: 0
+ MOSI: 12
+ MISO: 13
+ IRQ: 1
+ Busy: 23
+ Reset: 22
+ RXen: 0
+ gpiochip: 1
+ CS: 9
+ SCK: 11
+# TXen: bridge to DIO2 on E22 module
+ SX126X_MAX_POWER: 22
+ spidev: spidev1.0
+ spiSpeed: 2000000
diff --git a/bin/device-install.bat b/bin/device-install.bat
index 3ffca0b63..816d2fbba 100755
--- a/bin/device-install.bat
+++ b/bin/device-install.bat
@@ -12,6 +12,7 @@ SET "BIGDB16=0"
SET "ESPTOOL_BAUD=115200"
SET "ESPTOOL_CMD="
SET "LOGCOUNTER=0"
+SET "BPS_RESET=0"
@REM FIXME: Determine mcu from PlatformIO variant, this is unmaintainable.
SET "S3=s3 v3 t-deck wireless-paper wireless-tracker station-g2 unphone"
@@ -24,7 +25,7 @@ GOTO getopts
:help
ECHO Flash image file to device, but first erasing and writing system information.
ECHO.
-ECHO Usage: %SCRIPT_NAME% -f filename [-p PORT] [-P python] (--web)
+ECHO Usage: %SCRIPT_NAME% -f filename [-p PORT] [-P python] (--web) [--1200bps-reset]
ECHO.
ECHO Options:
ECHO -f filename The firmware .bin file to flash. Custom to your device type and region. (required)
@@ -35,13 +36,16 @@ ECHO -P python Specify alternate python interpreter to use to invoke
ECHO If supplied the script will use python.
ECHO If not supplied the script will try to find esptool in Path.
ECHO --web Enable WebUI. (default: false)
+ECHO --1200bps-reset Attempt to place the device in correct mode. (1200bps Reset)
+ECHO Some hardware requires this twice.
ECHO.
+ECHO Example: %SCRIPT_NAME% -p COM17 --1200bps-reset
ECHO Example: %SCRIPT_NAME% -f firmware-t-deck-tft-2.6.0.0b106d4.bin -p COM11
ECHO Example: %SCRIPT_NAME% -f firmware-unphone-2.6.0.0b106d4.bin -p COM11 --web
GOTO eof
:version
-ECHO %SCRIPT_NAME% [Version 2.6.1]
+ECHO %SCRIPT_NAME% [Version 2.6.2]
ECHO Meshtastic
GOTO eof
@@ -58,10 +62,13 @@ IF "%~1"=="-p" SET "ESPTOOL_PORT=%~2" & SHIFT
IF /I "%~1"=="--port" SET "ESPTOOL_PORT=%~2" & SHIFT
IF "%~1"=="-P" SET "PYTHON=%~2" & SHIFT
IF /I "%~1"=="--web" SET "WEB_APP=1"
+IF /I "%~1"=="--1200bps-reset" SET "BPS_RESET=1"
SHIFT
GOTO getopts
:endopts
+IF %BPS_RESET% EQU 1 GOTO skip-filename
+
CALL :LOG_MESSAGE DEBUG "Checking FILENAME parameter..."
IF "__!FILENAME!__"=="____" (
CALL :LOG_MESSAGE DEBUG "Missing -f filename input."
@@ -95,6 +102,9 @@ IF NOT "!FILENAME:update=!"=="!FILENAME!" (
CALL :LOG_MESSAGE DEBUG "We are NOT working with a *update* file. !FILENAME!"
)
+:skip-filename
+SET "ESPTOOL_BAUD=1200"
+
CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..."
IF NOT "__%PYTHON%__"=="____" (
SET "ESPTOOL_CMD=!PYTHON! -m esptool"
@@ -133,6 +143,12 @@ IF "__!ESPTOOL_PORT!__" == "____" (
)
CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!."
+IF %BPS_RESET% EQU 1 (
+ @REM Attempt to change mode via 1200bps Reset.
+ CALL :RUN_ESPTOOL !ESPTOOL_BAUD! --after no_reset read_flash_status
+ GOTO eof
+)
+
@REM Check if FILENAME contains "-tft-" and set target partitionScheme accordingly.
@REM https://github.com/meshtastic/web-flasher/blob/main/types/resources.ts#L3
IF NOT "!FILENAME:-tft-=!"=="!FILENAME!" (
@@ -254,6 +270,7 @@ EXIT /B %ERRORLEVEL%
IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
CALL :RESET_ERROR
!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4
+IF %BPS_RESET% EQU 1 GOTO :eof
IF %ERRORLEVEL% NEQ 0 (
CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
EXIT /B %ERRORLEVEL%
diff --git a/bin/device-install.sh b/bin/device-install.sh
index 7fa5ffdbb..613696d2f 100755
--- a/bin/device-install.sh
+++ b/bin/device-install.sh
@@ -2,6 +2,7 @@
PYTHON=${PYTHON:-$(which python3 python | head -n 1)}
WEB_APP=false
+BPS_RESET=false
TFT_BUILD=false
MCU=""
@@ -32,47 +33,50 @@ BIGDB_16MB=(
"ESP32-S3-Pico"
"m5stack-cores3"
"station-g2"
- "t-eth-elite"
- "t-watch-s3"
+ "t-eth-elite"
+ "t-watch-s3"
+ "elecrow-adv-35-tft"
+ "elecrow-adv-24-28-tft"
+ "elecrow-adv1-43-50-70-tft"
)
S3_VARIANTS=(
- "s3"
- "-v3"
- "t-deck"
- "wireless-paper"
- "wireless-tracker"
- "station-g2"
- "unphone"
- "t-eth-elite"
- "mesh-tab"
- "dreamcatcher"
- "ESP32-S3-Pico"
- "seeed-sensecap-indicator"
- "heltec_capsule_sensor_v3"
- "vision-master"
- "icarus"
- "tracksenger"
- "elecrow-adv"
+ "s3"
+ "-v3"
+ "t-deck"
+ "wireless-paper"
+ "wireless-tracker"
+ "station-g2"
+ "unphone"
+ "t-eth-elite"
+ "mesh-tab"
+ "dreamcatcher"
+ "ESP32-S3-Pico"
+ "seeed-sensecap-indicator"
+ "heltec_capsule_sensor_v3"
+ "vision-master"
+ "icarus"
+ "tracksenger"
+ "elecrow-adv"
)
# Determine the correct esptool command to use
if "$PYTHON" -m esptool version >/dev/null 2>&1; then
- ESPTOOL_CMD="$PYTHON -m esptool"
+ ESPTOOL_CMD="$PYTHON -m esptool"
elif command -v esptool >/dev/null 2>&1; then
- ESPTOOL_CMD="esptool"
+ ESPTOOL_CMD="esptool"
elif command -v esptool.py >/dev/null 2>&1; then
- ESPTOOL_CMD="esptool.py"
+ ESPTOOL_CMD="esptool.py"
else
- echo "Error: esptool not found"
- exit 1
+ echo "Error: esptool not found"
+ exit 1
fi
set -e
# Usage info
show_help() {
- cat <&2
- exit 1
- ;;
- esac
- shift # Move to the next argument
+ case "$1" in
+ -h | --help)
+ show_help
+ exit 0
+ ;;
+ -p)
+ ESPTOOL_CMD="$ESPTOOL_CMD --port $2"
+ shift
+ ;;
+ -P)
+ PYTHON="$2"
+ shift
+ ;;
+ -f)
+ FILENAME="$2"
+ shift
+ ;;
+ --web)
+ WEB_APP=true
+ ;;
+ --1200bps-reset)
+ BPS_RESET=true
+ ;;
+ --) # Stop parsing options
+ shift
+ break
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ exit 1
+ ;;
+ esac
+ shift # Move to the next argument
done
-[ -z "$FILENAME" -a -n "$1" ] && {
- FILENAME=$1
- shift
+if [[ $BPS_RESET == true ]]; then
+ $ESPTOOL_CMD --baud 1200 --after no_reset read_flash_status
+ exit 0
+fi
+
+[ -z "$FILENAME" ] && [ -n "$1" ] && {
+ FILENAME="$1"
+ shift
}
-if [[ $FILENAME != firmware-* ]]; then
+if [[ "$FILENAME" != firmware-* ]]; then
echo "Filename must be a firmware-* file."
exit 1
fi
# Check if FILENAME contains "-tft-" and prevent web/mui comingling.
-if [[ ${FILENAME//-tft-/} != "$FILENAME" ]]; then
- TFT_BUILD=true
- if [[ $WEB_APP == true ]] && [[ $TFT_BUILD == true ]]; then
- echo "Cannot enable WebUI (--web) and MUI."
- exit 1
- fi
+if [[ "${FILENAME//-tft-/}" != "$FILENAME" ]]; then
+ TFT_BUILD=true
+ if [[ $WEB_APP == true ]] && [[ $TFT_BUILD == true ]]; then
+ echo "Cannot enable WebUI (--web) and MUI."
+ exit 1
+ fi
fi
# Extract BASENAME from %FILENAME% for later use.
BASENAME="${FILENAME/firmware-/}"
if [ -f "${FILENAME}" ] && [ -n "${FILENAME##*"update"*}" ]; then
- # Default littlefs* offset (--web).
- OFFSET=0x300000
+ # Default littlefs* offset (--web).
+ OFFSET=0x300000
- # Default OTA Offset
- OTA_OFFSET=0x260000
+ # Default OTA Offset
+ OTA_OFFSET=0x260000
- # littlefs* offset for BigDB 8mb and OTA OFFSET.
- for variant in "${BIGDB_8MB[@]}"; do
- if [ -z "${FILENAME##*"$variant"*}" ]; then
- OFFSET=0x670000
- OTA_OFFSET=0x340000
- fi
- done
+ # littlefs* offset for BigDB 8mb and OTA OFFSET.
+ for variant in "${BIGDB_8MB[@]}"; do
+ if [ -z "${FILENAME##*"$variant"*}" ]; then
+ OFFSET=0x670000
+ OTA_OFFSET=0x340000
+ fi
+ done
- # littlefs* offset for BigDB 16mb and OTA OFFSET.
- for variant in "${BIGDB_16MB[@]}"; do
- if [ -z "${FILENAME##*"$variant"*}" ]; then
- OFFSET=0xc90000
- OTA_OFFSET=0x650000
- fi
- done
+ # littlefs* offset for BigDB 16mb and OTA OFFSET.
+ for variant in "${BIGDB_16MB[@]}"; do
+ if [ -z "${FILENAME##*"$variant"*}" ]; then
+ OFFSET=0xc90000
+ OTA_OFFSET=0x650000
+ fi
+ done
- # Account for S3 board's different OTA partition
- # FIXME: Use PlatformIO info to determine MCU type, this is unmaintainable
- for variant in "${S3_VARIANTS[@]}"; do
- if [ -z "${FILENAME##*"$variant"*}" ]; then
- MCU="esp32s3"
- fi
- done
+ # Account for S3 board's different OTA partition
+ # FIXME: Use PlatformIO info to determine MCU type, this is unmaintainable
+ for variant in "${S3_VARIANTS[@]}"; do
+ if [ -z "${FILENAME##*"$variant"*}" ]; then
+ MCU="esp32s3"
+ fi
+ done
- if [ "$MCU" != "esp32s3" ]; then
- if [ -n "${FILENAME##*"esp32c3"*}" ]; then
- OTAFILE=bleota.bin
- else
- OTAFILE=bleota-c3.bin
- fi
- else
- OTAFILE=bleota-s3.bin
- fi
+ if [ "$MCU" != "esp32s3" ]; then
+ if [ -n "${FILENAME##*"esp32c3"*}" ]; then
+ OTAFILE=bleota.bin
+ else
+ OTAFILE=bleota-c3.bin
+ fi
+ else
+ OTAFILE=bleota-s3.bin
+ fi
- # Check if WEB_APP (--web) is enabled and add "littlefswebui-" to BASENAME else "littlefs-".
- if [ "$WEB_APP" = true ]; then
- SPIFFSFILE=littlefswebui-${BASENAME}
- else
- SPIFFSFILE=littlefs-${BASENAME}
- fi
+ # Check if WEB_APP (--web) is enabled and add "littlefswebui-" to BASENAME else "littlefs-".
+ if [ "$WEB_APP" = true ]; then
+ SPIFFSFILE=littlefswebui-${BASENAME}
+ else
+ SPIFFSFILE=littlefs-${BASENAME}
+ fi
- if [[ ! -f $FILENAME ]]; then
- echo "Error: file ${FILENAME} wasn't found. Terminating."
- exit 1
- fi
- if [[ ! -f $OTAFILE ]]; then
- echo "Error: file ${OTAFILE} wasn't found. Terminating."
- exit 1
- fi
- if [[ ! -f $SPIFFSFILE ]]; then
- echo "Error: file ${SPIFFSFILE} wasn't found. Terminating."
- exit 1
- fi
+ if [[ ! -f "$FILENAME" ]]; then
+ echo "Error: file ${FILENAME} wasn't found. Terminating."
+ exit 1
+ fi
+ if [[ ! -f "$OTAFILE" ]]; then
+ echo "Error: file ${OTAFILE} wasn't found. Terminating."
+ exit 1
+ fi
+ if [[ ! -f "$SPIFFSFILE" ]]; then
+ echo "Error: file ${SPIFFSFILE} wasn't found. Terminating."
+ exit 1
+ fi
- echo "Trying to flash ${FILENAME}, but first erasing and writing system information"
- $ESPTOOL_CMD erase_flash
- $ESPTOOL_CMD write_flash 0x00 "${FILENAME}"
- echo "Trying to flash ${OTAFILE} at offset ${OTA_OFFSET}"
- $ESPTOOL_CMD write_flash $OTA_OFFSET "${OTAFILE}"
- echo "Trying to flash ${SPIFFSFILE}, at offset ${OFFSET}"
- $ESPTOOL_CMD write_flash $OFFSET "${SPIFFSFILE}"
+ echo "Trying to flash ${FILENAME}, but first erasing and writing system information"
+ $ESPTOOL_CMD erase_flash
+ $ESPTOOL_CMD write_flash 0x00 "${FILENAME}"
+ echo "Trying to flash ${OTAFILE} at offset ${OTA_OFFSET}"
+ $ESPTOOL_CMD write_flash $OTA_OFFSET "${OTAFILE}"
+ echo "Trying to flash ${SPIFFSFILE}, at offset ${OFFSET}"
+ $ESPTOOL_CMD write_flash $OFFSET "${SPIFFSFILE}"
else
- show_help
- echo "Invalid file: ${FILENAME}"
+ show_help
+ echo "Invalid file: ${FILENAME}"
fi
exit 0
diff --git a/bin/device-update.bat b/bin/device-update.bat
index d9a4bd19a..6d55294a7 100755
--- a/bin/device-update.bat
+++ b/bin/device-update.bat
@@ -8,12 +8,13 @@ SET "PYTHON="
SET "ESPTOOL_BAUD=115200"
SET "ESPTOOL_CMD="
SET "LOGCOUNTER=0"
+SET "CHANGE_MODE=0"
GOTO getopts
:help
ECHO Flash image file to device, but leave existing system intact.
ECHO.
-ECHO Usage: %SCRIPT_NAME% -f filename [-p PORT] [-P python]
+ECHO Usage: %SCRIPT_NAME% -f filename [-p PORT] [-P python] [--change-mode]
ECHO.
ECHO Options:
ECHO -f filename The update .bin file to flash. Custom to your device type and region. (required)
@@ -23,12 +24,15 @@ ECHO If not set, ESPTOOL iterates all ports (Dangerous).
ECHO -P python Specify alternate python interpreter to use to invoke esptool. (default: python)
ECHO If supplied the script will use python.
ECHO If not supplied the script will try to find esptool in Path.
+ECHO --change-mode Attempt to place the device in correct mode. (1200bps Reset)
+ECHO Some hardware requires this twice.
ECHO.
+ECHO Example: %SCRIPT_NAME% -p COM17 --change-mode
ECHO Example: %SCRIPT_NAME% -f firmware-t-deck-tft-2.6.0.0b106d4-update.bin -p COM11
GOTO eof
:version
-ECHO %SCRIPT_NAME% [Version 2.6.1]
+ECHO %SCRIPT_NAME% [Version 2.6.2]
ECHO Meshtastic
GOTO eof
@@ -44,10 +48,13 @@ IF /I "%~1"=="-f" SET "FILENAME=%~2" & SHIFT
IF "%~1"=="-p" SET "ESPTOOL_PORT=%~2" & SHIFT
IF /I "%~1"=="--port" SET "ESPTOOL_PORT=%~2" & SHIFT
IF "%~1"=="-P" SET "PYTHON=%~2" & SHIFT
+IF /I "%~1"=="--change-mode" SET "CHANGE_MODE=1"
SHIFT
GOTO getopts
:endopts
+IF %CHANGE_MODE% EQU 1 GOTO skip-filename
+
CALL :LOG_MESSAGE DEBUG "Checking FILENAME parameter..."
IF "__!FILENAME!__"=="____" (
CALL :LOG_MESSAGE DEBUG "Missing -f filename input."
@@ -77,6 +84,9 @@ IF "!FILENAME:update=!"=="!FILENAME!" (
CALL :LOG_MESSAGE DEBUG "We are working with a *update* file. !FILENAME!"
)
+:skip-filename
+SET "ESPTOOL_BAUD=1200"
+
CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..."
IF NOT "__%PYTHON%__"=="____" (
SET "ESPTOOL_CMD=!PYTHON! -m esptool"
@@ -115,6 +125,12 @@ IF "__!ESPTOOL_PORT!__" == "____" (
)
CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!."
+IF %CHANGE_MODE% EQU 1 (
+ @REM Attempt to change mode via 1200bps Reset.
+ CALL :RUN_ESPTOOL !ESPTOOL_BAUD! --after no_reset read_flash_status
+ GOTO eof
+)
+
@REM Flashing operations.
CALL :LOG_MESSAGE INFO "Trying to flash update "!FILENAME!" at OFFSET 0x10000..."
CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash 0x10000 "!FILENAME!" || GOTO eof
@@ -135,6 +151,7 @@ EXIT /B %ERRORLEVEL%
IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
CALL :RESET_ERROR
!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4
+IF %CHANGE_MODE% EQU 1 GOTO :eof
IF %ERRORLEVEL% NEQ 0 (
CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4"
EXIT /B %ERRORLEVEL%
diff --git a/bin/device-update.sh b/bin/device-update.sh
index ae7b52ea2..6adfe4e0e 100755
--- a/bin/device-update.sh
+++ b/bin/device-update.sh
@@ -1,6 +1,7 @@
#!/bin/sh
PYTHON=${PYTHON:-$(which python3 python|head -n 1)}
+CHANGE_MODE=false
# Determine the correct esptool command to use
if "$PYTHON" -m esptool version >/dev/null 2>&1; then
@@ -17,14 +18,15 @@ fi
# Usage info
show_help() {
cat << EOF
-Usage: $(basename $0) [-h] [-p ESPTOOL_PORT] [-P PYTHON] [-f FILENAME|FILENAME]
+Usage: $(basename "$0") [-h] [-p ESPTOOL_PORT] [-P PYTHON] [-f FILENAME|FILENAME] [--change-mode]
Flash image file to device, leave existing system intact."
-h Display this help and exit
-p ESPTOOL_PORT Set the environment variable for ESPTOOL_PORT. If not set, ESPTOOL iterates all ports (Dangerous).
-P PYTHON Specify alternate python interpreter to use to invoke esptool. (Default: "$PYTHON")
-f FILENAME The *update.bin file to flash. Custom to your device type.
-
+ --change-mode Attempt to place the device in correct mode. Some hardware requires this twice. (1200bps Reset)
+
EOF
}
@@ -36,13 +38,16 @@ while getopts ":hp:P:f:" opt; do
exit 0
;;
p) ESPTOOL_CMD="$ESPTOOL_CMD --port ${OPTARG}"
- ;;
+ ;;
P) PYTHON=${OPTARG}
;;
f) FILENAME=${OPTARG}
;;
+ --change-mode)
+ CHANGE_MODE=true
+ ;;
*)
- echo "Invalid flag."
+ echo "Invalid flag."
show_help >&2
exit 1
;;
@@ -50,17 +55,22 @@ while getopts ":hp:P:f:" opt; do
done
shift "$((OPTIND-1))"
-[ -z "$FILENAME" -a -n "$1" ] && {
- FILENAME=$1
+if [[ $CHANGE_MODE == true ]]; then
+ $ESPTOOL_CMD --baud 1200 --after no_reset read_flash_status
+ exit 0
+fi
+
+[ -z "$FILENAME" ] && [ -n "$1" ] && {
+ FILENAME="$1"
shift
}
if [ -f "${FILENAME}" ] && [ -z "${FILENAME##*"update"*}" ]; then
- printf "Trying to flash update ${FILENAME}"
- $ESPTOOL_CMD --baud 115200 write_flash 0x10000 ${FILENAME}
+ echo "Trying to flash update ${FILENAME}"
+ $ESPTOOL_CMD --baud 115200 write_flash 0x10000 "${FILENAME}"
else
- show_help
- echo "Invalid file: ${FILENAME}"
+ show_help
+ echo "Invalid file: ${FILENAME}"
fi
exit 0
diff --git a/bin/generate_ci_matrix.py b/bin/generate_ci_matrix.py
index 7513ccff5..0ce6b0f6b 100755
--- a/bin/generate_ci_matrix.py
+++ b/bin/generate_ci_matrix.py
@@ -27,7 +27,7 @@ for subdir, dirs, files in os.walk(rootdir):
if c.startswith("env:"):
section = config[c].name[4:]
if "extends" in config[config[c].name]:
- if config[config[c].name]["extends"] == options[0] + "_base":
+ if options[0] + "_base" in config[config[c].name]["extends"]:
if "board_level" in config[config[c].name]:
if (
config[config[c].name]["board_level"] == "extra"
diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml
index 1a7ad284d..f9f647dae 100644
--- a/bin/org.meshtastic.meshtasticd.metainfo.xml
+++ b/bin/org.meshtastic.meshtasticd.metainfo.xml
@@ -87,6 +87,24 @@
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.1
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.0
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.6.13
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.6.12
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.6.11
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.6.10
+
https://github.com/meshtastic/firmware/releases?q=tag%3Av2.6.9
diff --git a/boards/ThinkNode-M1.json b/boards/ThinkNode-M1.json
index e55da3ec7..2d6dbc352 100644
--- a/boards/ThinkNode-M1.json
+++ b/boards/ThinkNode-M1.json
@@ -48,6 +48,6 @@
"require_upload_port": true,
"wait_for_upload_port": true
},
- "url": "FIXME",
+ "url": "https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html",
"vendor": "ELECROW"
}
diff --git a/boards/gat562_mesh_trial_tracker.json b/boards/gat562_mesh_trial_tracker.json
new file mode 100644
index 000000000..a3fb8a264
--- /dev/null
+++ b/boards/gat562_mesh_trial_tracker.json
@@ -0,0 +1,52 @@
+{
+ "build": {
+ "arduino": {
+ "ldscript": "nrf52840_s140_v6.ld"
+ },
+ "core": "nRF5",
+ "cpu": "cortex-m4",
+ "extra_flags": "-DARDUINO_NRF52840_FEATHER -DNRF52840_XXAA",
+ "f_cpu": "64000000L",
+ "hwids": [
+ ["0x239A", "0x8029"],
+ ["0x239A", "0x0029"],
+ ["0x239A", "0x002A"],
+ ["0x239A", "0x802A"]
+ ],
+ "usb_product": "GAT562 Mesh Trial Tracker",
+ "mcu": "nrf52840",
+ "variant": "gat562_mesh_trial_tracker",
+ "bsp": {
+ "name": "adafruit"
+ },
+ "softdevice": {
+ "sd_flags": "-DS140",
+ "sd_name": "s140",
+ "sd_version": "6.1.1",
+ "sd_fwid": "0x00B6"
+ },
+ "bootloader": {
+ "settings_addr": "0xFF000"
+ }
+ },
+ "connectivity": ["bluetooth"],
+ "debug": {
+ "jlink_device": "nRF52840_xxAA",
+ "svd_path": "nrf52840.svd",
+ "openocd_target": "nrf52840-mdk-rs"
+ },
+ "frameworks": ["arduino", "freertos"],
+ "name": "GAT562 Mesh Trial Tracker",
+ "upload": {
+ "maximum_ram_size": 248832,
+ "maximum_size": 815104,
+ "speed": 115200,
+ "protocol": "nrfutil",
+ "protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
+ "use_1200bps_touch": true,
+ "require_upload_port": true,
+ "wait_for_upload_port": true
+ },
+ "url": "http://www.gat-iot.com/",
+ "vendor": "GAT-IOT"
+}
diff --git a/boards/heltec_mesh_node_t114.json b/boards/heltec_mesh_node_t114.json
index 2bd306eb9..d516c9701 100644
--- a/boards/heltec_mesh_node_t114.json
+++ b/boards/heltec_mesh_node_t114.json
@@ -48,6 +48,6 @@
"require_upload_port": true,
"wait_for_upload_port": true
},
- "url": "FIXME",
+ "url": "https://heltec.org/project/mesh-node-t114/",
"vendor": "Heltec"
}
diff --git a/boards/seeed_wio_tracker_L1.json b/boards/seeed_wio_tracker_L1.json
new file mode 100644
index 000000000..7c7bc62fa
--- /dev/null
+++ b/boards/seeed_wio_tracker_L1.json
@@ -0,0 +1,54 @@
+{
+ "build": {
+ "arduino": {
+ "ldscript": "nrf52840_s140_v7.ld"
+ },
+ "core": "nRF5",
+ "cpu": "cortex-m4",
+ "extra_flags": "-DARDUINO_MDBT50Q_RX -DNRF52840_XXAA",
+ "f_cpu": "64000000L",
+ "hwids": [["0x2886", "0x1668"]],
+ "usb_product": "TRACKER L1",
+ "mcu": "nrf52840",
+ "variant": "seeed_wio_tracker_L1",
+ "bsp": {
+ "name": "adafruit"
+ },
+ "softdevice": {
+ "sd_flags": "-DS140",
+ "sd_name": "s140",
+ "sd_version": "7.3.0",
+ "sd_fwid": "0x0123"
+ },
+ "bootloader": {
+ "settings_addr": "0xFF000"
+ }
+ },
+ "connectivity": ["bluetooth"],
+ "debug": {
+ "jlink_device": "nRF52840_xxAA",
+ "svd_path": "nrf52840.svd",
+ "openocd_target": "nrf52840-mdk-rs"
+ },
+ "frameworks": ["arduino"],
+ "name": "seeed_wio_tracker_L1",
+ "upload": {
+ "maximum_ram_size": 248832,
+ "maximum_size": 815104,
+ "speed": 115200,
+ "protocol": "nrfutil",
+ "protocols": [
+ "jlink",
+ "nrfjprog",
+ "nrfutil",
+ "stlink",
+ "cmsis-dap",
+ "blackmagic"
+ ],
+ "use_1200bps_touch": true,
+ "require_upload_port": true,
+ "wait_for_upload_port": true
+ },
+ "url": "https://www.seeedstudio.com/Wio-Tracker-L1-p-6477.html",
+ "vendor": "Seeed Studio"
+}
diff --git a/boards/seeed_xiao_nrf52840_kit.json b/boards/seeed_xiao_nrf52840_kit.json
index 4c5fdbeda..676733874 100644
--- a/boards/seeed_xiao_nrf52840_kit.json
+++ b/boards/seeed_xiao_nrf52840_kit.json
@@ -7,9 +7,7 @@
"cpu": "cortex-m4",
"extra_flags": "-DARDUINO_MDBT50Q_RX -DNRF52840_XXAA",
"f_cpu": "64000000L",
- "hwids": [
- ["0x2886", "0x0166"]
- ],
+ "hwids": [["0x2886", "0x0166"]],
"usb_product": "XIAO-BOOT",
"mcu": "nrf52840",
"variant": "seeed_xiao_nrf52840_kit",
diff --git a/debian/changelog b/debian/changelog
index ae27bc3e9..4629e8c3a 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,4 +1,4 @@
-meshtasticd (2.6.9.0) UNRELEASED; urgency=medium
+meshtasticd (2.7.1.0) UNRELEASED; urgency=medium
[ Austin Lane ]
* Initial packaging
@@ -13,4 +13,16 @@ meshtasticd (2.6.9.0) UNRELEASED; urgency=medium
[ ]
* GitHub Actions Automatic version bump
- -- Thu, 15 May 2025 11:13:30 +0000
+ [ ]
+ * GitHub Actions Automatic version bump
+
+ [ ]
+ * GitHub Actions Automatic version bump
+
+ [ ]
+ * GitHub Actions Automatic version bump
+
+ [ ]
+ * GitHub Actions Automatic version bump
+
+ -- Sat, 21 Jun 2025 15:51:49 +0000
diff --git a/debian/meshtasticd.postinst b/debian/meshtasticd.postinst
index 324865718..fe0dbc332 100755
--- a/debian/meshtasticd.postinst
+++ b/debian/meshtasticd.postinst
@@ -20,16 +20,17 @@ set -e
case "$1" in
configure|reconfigure)
- # create spi group (for udev rules)
- # this group already exists on Raspberry Pi OS
+ # create spi, gpio groups (for udev rules)
+ # these groups already exist on Raspberry Pi OS
getent group spi >/dev/null 2>/dev/null || addgroup --system spi
+ getent group gpio >/dev/null 2>/dev/null || addgroup --system gpio
# create a meshtasticd group and user
getent passwd meshtasticd >/dev/null 2>/dev/null || adduser --system --home /var/lib/meshtasticd --no-create-home meshtasticd
getent group meshtasticd >/dev/null 2>/dev/null || addgroup --system meshtasticd
adduser meshtasticd meshtasticd >/dev/null 2>/dev/null
adduser meshtasticd spi >/dev/null 2>/dev/null
+ adduser meshtasticd gpio >/dev/null 2>/dev/null
# add meshtasticd user to appropriate groups (if they exist)
- getent group gpio >/dev/null 2>/dev/null && adduser meshtasticd gpio >/dev/null 2>/dev/null
getent group plugdev >/dev/null 2>/dev/null && adduser meshtasticd plugdev >/dev/null 2>/dev/null
getent group dialout >/dev/null 2>/dev/null && adduser meshtasticd dialout >/dev/null 2>/dev/null
getent group i2c >/dev/null 2>/dev/null && adduser meshtasticd i2c >/dev/null 2>/dev/null
diff --git a/debian/meshtasticd.udev b/debian/meshtasticd.udev
index 69a468d7a..1151efafd 100644
--- a/debian/meshtasticd.udev
+++ b/debian/meshtasticd.udev
@@ -1,4 +1,7 @@
-# Set spidev ownership to 'spi' group.
+# Set spidev ownership to 'spi' group
SUBSYSTEM=="spidev", KERNEL=="spidev*", GROUP="spi", MODE="0660"
# Allow access to USB CH341 devices
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="5512", MODE="0666"
+# Set gpio ownership to 'gpio' group
+SUBSYSTEM=="*gpiomem*", GROUP="gpio", MODE="0660"
+SUBSYSTEM=="gpio", GROUP="gpio", MODE="0660"
diff --git a/platformio.ini b/platformio.ini
index 836b723af..693fdc9c3 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -51,6 +51,7 @@ build_flags = -Wno-missing-field-initializers
-DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
+ -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
#-DBUILD_EPOCH=$UNIX_TIME
#-D OLED_PL=1
@@ -103,12 +104,12 @@ lib_deps =
[radiolib_base]
lib_deps =
# renovate: datasource=custom.pio depName=RadioLib packageName=jgromes/library/RadioLib
- jgromes/RadioLib@7.1.2
+ jgromes/RadioLib@7.2.0
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
- https://github.com/meshtastic/device-ui/archive/0e9bb792bb4b015b487397427781eda2767c87e6.zip
+ https://github.com/meshtastic/device-ui/archive/cdc6e5bdeedb8293d10e4a02be6ca64e95a7c515.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]
@@ -161,6 +162,12 @@ lib_deps =
sparkfun/SparkFun MAX3010x Pulse and Proximity Sensor Library@1.1.2
# renovate: datasource=custom.pio depName=SparkFun 9DoF IMU Breakout ICM 20948 packageName=sparkfun/library/SparkFun 9DoF IMU Breakout - ICM 20948 - Arduino Library
sparkfun/SparkFun 9DoF IMU Breakout - ICM 20948 - Arduino Library@1.3.2
+ # renovate: datasource=custom.pio depName=Adafruit LTR390 Library packageName=adafruit/library/Adafruit LTR390 Library
+ adafruit/Adafruit LTR390 Library@1.1.2
+ # renovate: datasource=custom.pio depName=Adafruit PCT2075 packageName=adafruit/library/Adafruit PCT2075
+ adafruit/Adafruit PCT2075@1.0.5
+ # renovate: datasource=custom.pio depName=DFRobot_BMM150 packageName=dfrobot/library/DFRobot_BMM150
+ dfrobot/DFRobot_BMM150@1.0.0
; (not included in native / portduino)
[environmental_extra]
@@ -188,4 +195,4 @@ lib_deps =
# renovate: datasource=custom.pio depName=Bosch BME68x packageName=boschsensortec/library/BME68x Sensor Library
boschsensortec/BME68x Sensor Library@1.3.40408
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
- https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
+ https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
\ No newline at end of file
diff --git a/protobufs b/protobufs
index 0b32ce24f..6791138f0 160000
--- a/protobufs
+++ b/protobufs
@@ -1 +1 @@
-Subproject commit 0b32ce24f029f69635026aec9428b5c8176e2ce1
+Subproject commit 6791138f0ba2b7c471072bd4bba6cbb8bacffe2d
diff --git a/src/AmbientLightingThread.h b/src/AmbientLightingThread.h
index bff8846d6..e4ef3b443 100644
--- a/src/AmbientLightingThread.h
+++ b/src/AmbientLightingThread.h
@@ -59,82 +59,82 @@ class AmbientLightingThread : public concurrency::OSThread
return;
}
LOG_DEBUG("AmbientLighting init");
-#if defined(HAS_NCP5623) || defined(HAS_LP5562)
+#ifdef HAS_NCP5623
if (_type == ScanI2C::NCP5623) {
rgb.begin();
#endif
#ifdef HAS_LP5562
- } else if (_type == ScanI2C::LP5562) {
- rgbw.begin();
+ if (_type == ScanI2C::LP5562) {
+ rgbw.begin();
#endif
#ifdef RGBLED_RED
- pinMode(RGBLED_RED, OUTPUT);
- pinMode(RGBLED_GREEN, OUTPUT);
- pinMode(RGBLED_BLUE, OUTPUT);
+ pinMode(RGBLED_RED, OUTPUT);
+ pinMode(RGBLED_GREEN, OUTPUT);
+ pinMode(RGBLED_BLUE, OUTPUT);
#endif
#ifdef HAS_NEOPIXEL
- pixels.begin(); // Initialise the pixel(s)
- pixels.clear(); // Set all pixel colors to 'off'
- pixels.setBrightness(moduleConfig.ambient_lighting.current);
+ pixels.begin(); // Initialise the pixel(s)
+ pixels.clear(); // Set all pixel colors to 'off'
+ pixels.setBrightness(moduleConfig.ambient_lighting.current);
#endif
- setLighting();
+ setLighting();
#endif
#if defined(HAS_NCP5623) || defined(HAS_LP5562)
- }
+ }
#endif
- }
+ }
- protected:
- int32_t runOnce() override
- {
+ protected:
+ int32_t runOnce() override
+ {
#ifdef HAS_RGB_LED
#if defined(HAS_NCP5623) || defined(HAS_LP5562)
- if ((_type == ScanI2C::NCP5623 || _type == ScanI2C::LP5562) && moduleConfig.ambient_lighting.led_state) {
+ if ((_type == ScanI2C::NCP5623 || _type == ScanI2C::LP5562) && moduleConfig.ambient_lighting.led_state) {
#endif
- setLighting();
- return 30000; // 30 seconds to reset from any animations that may have been running from Ext. Notification
+ setLighting();
+ return 30000; // 30 seconds to reset from any animations that may have been running from Ext. Notification
#if defined(HAS_NCP5623) || defined(HAS_LP5562)
+ }
+#endif
+#endif
+ return disable();
}
-#endif
-#endif
- return disable();
- }
- // When shutdown() is issued, setLightingOff will be called.
- CallbackObserver notifyDeepSleepObserver =
- CallbackObserver(this, &AmbientLightingThread::setLightingOff);
+ // When shutdown() is issued, setLightingOff will be called.
+ CallbackObserver notifyDeepSleepObserver =
+ CallbackObserver(this, &AmbientLightingThread::setLightingOff);
- private:
- ScanI2C::DeviceType _type = ScanI2C::DeviceType::NONE;
+ private:
+ ScanI2C::DeviceType _type = ScanI2C::DeviceType::NONE;
- // Turn RGB lighting off, is used in junction to shutdown()
- int setLightingOff(void *unused)
- {
+ // Turn RGB lighting off, is used in junction to shutdown()
+ int setLightingOff(void *unused)
+ {
#ifdef HAS_NCP5623
- rgb.setCurrent(0);
- rgb.setRed(0);
- rgb.setGreen(0);
- rgb.setBlue(0);
- LOG_INFO("OFF: NCP5623 Ambient lighting");
+ rgb.setCurrent(0);
+ rgb.setRed(0);
+ rgb.setGreen(0);
+ rgb.setBlue(0);
+ LOG_INFO("OFF: NCP5623 Ambient lighting");
#endif
#ifdef HAS_LP5562
- rgbw.setCurrent(0);
- rgbw.setRed(0);
- rgbw.setGreen(0);
- rgbw.setBlue(0);
- rgbw.setWhite(0);
- LOG_INFO("OFF: LP5562 Ambient lighting");
+ rgbw.setCurrent(0);
+ rgbw.setRed(0);
+ rgbw.setGreen(0);
+ rgbw.setBlue(0);
+ rgbw.setWhite(0);
+ LOG_INFO("OFF: LP5562 Ambient lighting");
#endif
#ifdef HAS_NEOPIXEL
- pixels.clear();
- pixels.show();
- LOG_INFO("OFF: NeoPixel Ambient lighting");
+ pixels.clear();
+ pixels.show();
+ LOG_INFO("OFF: NeoPixel Ambient lighting");
#endif
#ifdef RGBLED_CA
- analogWrite(RGBLED_RED, 255 - 0);
- analogWrite(RGBLED_GREEN, 255 - 0);
- analogWrite(RGBLED_BLUE, 255 - 0);
- LOG_INFO("OFF: Ambient light RGB Common Anode");
+ analogWrite(RGBLED_RED, 255 - 0);
+ analogWrite(RGBLED_GREEN, 255 - 0);
+ analogWrite(RGBLED_BLUE, 255 - 0);
+ LOG_INFO("OFF: Ambient light RGB Common Anode");
#elif defined(RGBLED_RED)
analogWrite(RGBLED_RED, 0);
analogWrite(RGBLED_GREEN, 0);
@@ -142,56 +142,57 @@ class AmbientLightingThread : public concurrency::OSThread
LOG_INFO("OFF: Ambient light RGB Common Cathode");
#endif
#ifdef UNPHONE
- unphone.rgb(0, 0, 0);
- LOG_INFO("OFF: unPhone Ambient lighting");
+ unphone.rgb(0, 0, 0);
+ LOG_INFO("OFF: unPhone Ambient lighting");
#endif
- return 0;
- }
+ return 0;
+ }
- void setLighting()
- {
+ void setLighting()
+ {
#ifdef HAS_NCP5623
- rgb.setCurrent(moduleConfig.ambient_lighting.current);
- rgb.setRed(moduleConfig.ambient_lighting.red);
- rgb.setGreen(moduleConfig.ambient_lighting.green);
- rgb.setBlue(moduleConfig.ambient_lighting.blue);
- LOG_DEBUG("Init NCP5623 Ambient light w/ current=%d, red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.current,
- moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
+ rgb.setCurrent(moduleConfig.ambient_lighting.current);
+ rgb.setRed(moduleConfig.ambient_lighting.red);
+ rgb.setGreen(moduleConfig.ambient_lighting.green);
+ rgb.setBlue(moduleConfig.ambient_lighting.blue);
+ LOG_DEBUG("Init NCP5623 Ambient light w/ current=%d, red=%d, green=%d, blue=%d",
+ moduleConfig.ambient_lighting.current, moduleConfig.ambient_lighting.red,
+ moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
#endif
#ifdef HAS_LP5562
- rgbw.setCurrent(moduleConfig.ambient_lighting.current);
- rgbw.setRed(moduleConfig.ambient_lighting.red);
- rgbw.setGreen(moduleConfig.ambient_lighting.green);
- rgbw.setBlue(moduleConfig.ambient_lighting.blue);
- LOG_DEBUG("Init LP5562 Ambient light w/ current=%d, red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.current,
- moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
+ rgbw.setCurrent(moduleConfig.ambient_lighting.current);
+ rgbw.setRed(moduleConfig.ambient_lighting.red);
+ rgbw.setGreen(moduleConfig.ambient_lighting.green);
+ rgbw.setBlue(moduleConfig.ambient_lighting.blue);
+ LOG_DEBUG("Init LP5562 Ambient light w/ current=%d, red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.current,
+ moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
#endif
#ifdef HAS_NEOPIXEL
- pixels.fill(pixels.Color(moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green,
- moduleConfig.ambient_lighting.blue),
- 0, NEOPIXEL_COUNT);
+ pixels.fill(pixels.Color(moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green,
+ moduleConfig.ambient_lighting.blue),
+ 0, NEOPIXEL_COUNT);
// RadioMaster Bandit has addressable LED at the two buttons
// this allow us to set different lighting for them in variant.h file.
#ifdef RADIOMASTER_900_BANDIT
#if defined(BUTTON1_COLOR) && defined(BUTTON1_COLOR_INDEX)
- pixels.fill(BUTTON1_COLOR, BUTTON1_COLOR_INDEX, 1);
+ pixels.fill(BUTTON1_COLOR, BUTTON1_COLOR_INDEX, 1);
#endif
#if defined(BUTTON2_COLOR) && defined(BUTTON2_COLOR_INDEX)
- pixels.fill(BUTTON2_COLOR, BUTTON2_COLOR_INDEX, 1);
+ pixels.fill(BUTTON2_COLOR, BUTTON2_COLOR_INDEX, 1);
#endif
#endif
- pixels.show();
- LOG_DEBUG("Init NeoPixel Ambient light w/ brightness(current)=%d, red=%d, green=%d, blue=%d",
- moduleConfig.ambient_lighting.current, moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green,
- moduleConfig.ambient_lighting.blue);
+ pixels.show();
+ LOG_DEBUG("Init NeoPixel Ambient light w/ brightness(current)=%d, red=%d, green=%d, blue=%d",
+ moduleConfig.ambient_lighting.current, moduleConfig.ambient_lighting.red,
+ moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
#endif
#ifdef RGBLED_CA
- analogWrite(RGBLED_RED, 255 - moduleConfig.ambient_lighting.red);
- analogWrite(RGBLED_GREEN, 255 - moduleConfig.ambient_lighting.green);
- analogWrite(RGBLED_BLUE, 255 - moduleConfig.ambient_lighting.blue);
- LOG_DEBUG("Init Ambient light RGB Common Anode w/ red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.red,
- moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
+ analogWrite(RGBLED_RED, 255 - moduleConfig.ambient_lighting.red);
+ analogWrite(RGBLED_GREEN, 255 - moduleConfig.ambient_lighting.green);
+ analogWrite(RGBLED_BLUE, 255 - moduleConfig.ambient_lighting.blue);
+ LOG_DEBUG("Init Ambient light RGB Common Anode w/ red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.red,
+ moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
#elif defined(RGBLED_RED)
analogWrite(RGBLED_RED, moduleConfig.ambient_lighting.red);
analogWrite(RGBLED_GREEN, moduleConfig.ambient_lighting.green);
@@ -200,11 +201,12 @@ class AmbientLightingThread : public concurrency::OSThread
moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
#endif
#ifdef UNPHONE
- unphone.rgb(moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
- LOG_DEBUG("Init unPhone Ambient light w/ red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.red,
- moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
+ unphone.rgb(moduleConfig.ambient_lighting.red, moduleConfig.ambient_lighting.green,
+ moduleConfig.ambient_lighting.blue);
+ LOG_DEBUG("Init unPhone Ambient light w/ red=%d, green=%d, blue=%d", moduleConfig.ambient_lighting.red,
+ moduleConfig.ambient_lighting.green, moduleConfig.ambient_lighting.blue);
#endif
- }
-};
+ }
+ };
} // namespace concurrency
diff --git a/src/AudioThread.h b/src/AudioThread.h
index 04ff64a6e..286729909 100644
--- a/src/AudioThread.h
+++ b/src/AudioThread.h
@@ -47,6 +47,20 @@ class AudioThread : public concurrency::OSThread
setCPUFast(false);
}
+ void readAloud(const char *text)
+ {
+ if (i2sRtttl != nullptr) {
+ i2sRtttl->stop();
+ delete i2sRtttl;
+ i2sRtttl = nullptr;
+ }
+
+ ESP8266SAM *sam = new ESP8266SAM;
+ sam->Say(audioOut, text);
+ delete sam;
+ setCPUFast(false);
+ }
+
protected:
int32_t runOnce() override
{
diff --git a/src/BluetoothStatus.h b/src/BluetoothStatus.h
index 526b6f243..f6bb43cc2 100644
--- a/src/BluetoothStatus.h
+++ b/src/BluetoothStatus.h
@@ -88,10 +88,16 @@ class BluetoothStatus : public Status
break;
case ConnectionState::CONNECTED:
LOG_DEBUG("BluetoothStatus CONNECTED");
+#ifdef BLE_LED
+ digitalWrite(BLE_LED, HIGH);
+#endif
break;
case ConnectionState::DISCONNECTED:
LOG_DEBUG("BluetoothStatus DISCONNECTED");
+#ifdef BLE_LED
+ digitalWrite(BLE_LED, LOW);
+#endif
break;
}
}
@@ -102,4 +108,4 @@ class BluetoothStatus : public Status
} // namespace meshtastic
-extern meshtastic::BluetoothStatus *bluetoothStatus;
\ No newline at end of file
+extern meshtastic::BluetoothStatus *bluetoothStatus;
diff --git a/src/ButtonThread.cpp b/src/ButtonThread.cpp
deleted file mode 100644
index 8db52c074..000000000
--- a/src/ButtonThread.cpp
+++ /dev/null
@@ -1,467 +0,0 @@
-#include "ButtonThread.h"
-
-#include "configuration.h"
-#if !MESHTASTIC_EXCLUDE_GPS
-#include "GPS.h"
-#endif
-#include "MeshService.h"
-#include "PowerFSM.h"
-#include "RadioLibInterface.h"
-#include "buzz.h"
-#include "main.h"
-#include "modules/ExternalNotificationModule.h"
-#include "power.h"
-#include "sleep.h"
-#ifdef ARCH_PORTDUINO
-#include "platform/portduino/PortduinoGlue.h"
-#endif
-
-#define DEBUG_BUTTONS 0
-#if DEBUG_BUTTONS
-#define LOG_BUTTON(...) LOG_DEBUG(__VA_ARGS__)
-#else
-#define LOG_BUTTON(...)
-#endif
-
-using namespace concurrency;
-
-ButtonThread *buttonThread; // Declared extern in header
-volatile ButtonThread::ButtonEventType ButtonThread::btnEvent = ButtonThread::BUTTON_EVENT_NONE;
-
-#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN)
-OneButton ButtonThread::userButton; // Get reference to static member
-#endif
-ButtonThread::ButtonThread() : OSThread("Button")
-{
-#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN)
-
-#if defined(ARCH_PORTDUINO)
- if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) {
- this->userButton = OneButton(settingsMap[user], true, true);
- LOG_DEBUG("Use GPIO%02d for button", settingsMap[user]);
- }
-#elif defined(BUTTON_PIN)
-#if !defined(USERPREFS_BUTTON_PIN)
- int pin = config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN; // Resolved button pin
-#endif
-#ifdef USERPREFS_BUTTON_PIN
- int pin = config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN; // Resolved button pin
-#endif
-#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
- this->userButton = OneButton(pin, false, false);
-#elif defined(BUTTON_ACTIVE_LOW)
- this->userButton = OneButton(pin, BUTTON_ACTIVE_LOW, BUTTON_ACTIVE_PULLUP);
-#else
- this->userButton = OneButton(pin, true, true);
-#endif
- LOG_DEBUG("Use GPIO%02d for button", pin);
-#endif
-
-#ifdef INPUT_PULLUP_SENSE
- // Some platforms (nrf52) have a SENSE variant which allows wake from sleep - override what OneButton did
-#ifdef BUTTON_SENSE_TYPE
- pinMode(pin, BUTTON_SENSE_TYPE);
-#else
- pinMode(pin, INPUT_PULLUP_SENSE);
-#endif
-#endif
-
-#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN)
- userButton.attachClick(userButtonPressed);
- userButton.setClickMs(BUTTON_CLICK_MS);
- userButton.setPressMs(BUTTON_LONGPRESS_MS);
- userButton.setDebounceMs(1);
- userButton.attachDoubleClick(userButtonDoublePressed);
- userButton.attachMultiClick(userButtonMultiPressed, this); // Reference to instance: get click count from non-static OneButton
-#if !defined(T_DECK) && \
- !defined( \
- ELECROW_ThinkNode_M2) // T-Deck immediately wakes up after shutdown, Thinknode M2 has this on the smaller ALT button
- userButton.attachLongPressStart(userButtonPressedLongStart);
- userButton.attachLongPressStop(userButtonPressedLongStop);
-#endif
-#endif
-
-#ifdef BUTTON_PIN_ALT
-#if defined(ELECROW_ThinkNode_M2)
- this->userButtonAlt = OneButton(BUTTON_PIN_ALT, false, false);
-#else
- this->userButtonAlt = OneButton(BUTTON_PIN_ALT, true, true);
-#endif
-#ifdef INPUT_PULLUP_SENSE
- // Some platforms (nrf52) have a SENSE variant which allows wake from sleep - override what OneButton did
- pinMode(BUTTON_PIN_ALT, INPUT_PULLUP_SENSE);
-#endif
- userButtonAlt.attachClick(userButtonPressedScreen);
- userButtonAlt.setClickMs(BUTTON_CLICK_MS);
- userButtonAlt.setPressMs(BUTTON_LONGPRESS_MS);
- userButtonAlt.setDebounceMs(1);
- userButtonAlt.attachLongPressStart(userButtonPressedLongStart);
- userButtonAlt.attachLongPressStop(userButtonPressedLongStop);
-#endif
-
-#ifdef BUTTON_PIN_TOUCH
- userButtonTouch = OneButton(BUTTON_PIN_TOUCH, true, true);
- userButtonTouch.setPressMs(BUTTON_TOUCH_MS);
- userButtonTouch.attachLongPressStart(touchPressedLongStart); // Better handling with longpress than click?
-#endif
-
-#ifdef ARCH_ESP32
- // Register callbacks for before and after lightsleep
- // Used to detach and reattach interrupts
- lsObserver.observe(¬ifyLightSleep);
- lsEndObserver.observe(¬ifyLightSleepEnd);
-#endif
-
- attachButtonInterrupts();
-#endif
-}
-
-void ButtonThread::switchPage()
-{
-#ifdef BUTTON_PIN
-#if !defined(USERPREFS_BUTTON_PIN)
- if (((config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN) !=
- moduleConfig.canned_message.inputbroker_pin_press) ||
- !(moduleConfig.canned_message.updown1_enabled || moduleConfig.canned_message.rotary1_enabled) ||
- !moduleConfig.canned_message.enabled) {
- powerFSM.trigger(EVENT_PRESS);
- }
-#endif
-#if defined(USERPREFS_BUTTON_PIN)
- if (((config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN) !=
- moduleConfig.canned_message.inputbroker_pin_press) ||
- !(moduleConfig.canned_message.updown1_enabled || moduleConfig.canned_message.rotary1_enabled) ||
- !moduleConfig.canned_message.enabled) {
- powerFSM.trigger(EVENT_PRESS);
- }
-#endif
-
-#endif
-#if defined(ARCH_PORTDUINO)
- if ((settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) &&
- (settingsMap[user] != moduleConfig.canned_message.inputbroker_pin_press) ||
- !moduleConfig.canned_message.enabled) {
- powerFSM.trigger(EVENT_PRESS);
- }
-#endif
-}
-
-void ButtonThread::sendAdHocPosition()
-{
- service->refreshLocalMeshNode();
- auto sentPosition = service->trySendPosition(NODENUM_BROADCAST, true);
- if (screen) {
- if (sentPosition)
- screen->print("Sent ad-hoc position\n");
- else
- screen->print("Sent ad-hoc nodeinfo\n");
- screen->forceDisplay(true); // Force a new UI frame, then force an EInk update
- }
-}
-
-int32_t ButtonThread::runOnce()
-{
- // If the button is pressed we suppress CPU sleep until release
- canSleep = true; // Assume we should not keep the board awake
-
-#if defined(BUTTON_PIN) || defined(USERPREFS_BUTTON_PIN)
- userButton.tick();
- canSleep &= userButton.isIdle();
-#elif defined(ARCH_PORTDUINO)
- if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) {
- userButton.tick();
- canSleep &= userButton.isIdle();
- }
-#endif
-#ifdef BUTTON_PIN_ALT
- userButtonAlt.tick();
- canSleep &= userButtonAlt.isIdle();
-#endif
-#ifdef BUTTON_PIN_TOUCH
- userButtonTouch.tick();
- canSleep &= userButtonTouch.isIdle();
-#endif
-
- if (btnEvent != BUTTON_EVENT_NONE) {
- switch (btnEvent) {
- case BUTTON_EVENT_PRESSED: {
- LOG_BUTTON("press!");
- // If a nag notification is running, stop it and prevent other actions
- if (moduleConfig.external_notification.enabled && (externalNotificationModule->nagCycleCutoff != UINT32_MAX)) {
- externalNotificationModule->stopNow();
- break;
- }
-#ifdef ELECROW_ThinkNode_M1
- sendAdHocPosition();
- break;
-#endif
- switchPage();
- break;
- }
-
- case BUTTON_EVENT_PRESSED_SCREEN: {
- LOG_BUTTON("AltPress!");
-#ifdef ELECROW_ThinkNode_M1
- // If a nag notification is running, stop it and prevent other actions
- if (moduleConfig.external_notification.enabled && (externalNotificationModule->nagCycleCutoff != UINT32_MAX)) {
- externalNotificationModule->stopNow();
- break;
- }
- switchPage();
- break;
-#endif
- // turn screen on or off
- screen_flag = !screen_flag;
- if (screen)
- screen->setOn(screen_flag);
- break;
- }
-
- case BUTTON_EVENT_DOUBLE_PRESSED: {
- LOG_BUTTON("Double press!");
-#ifdef ELECROW_ThinkNode_M1
- digitalWrite(PIN_EINK_EN, digitalRead(PIN_EINK_EN) == LOW);
- break;
-#endif
- sendAdHocPosition();
- break;
- }
-
- case BUTTON_EVENT_MULTI_PRESSED: {
- LOG_BUTTON("Mulitipress! %hux", multipressClickCount);
- switch (multipressClickCount) {
-#if HAS_GPS && !defined(ELECROW_ThinkNode_M1)
- // 3 clicks: toggle GPS
- case 3:
- if (!config.device.disable_triple_click && (gps != nullptr)) {
- gps->toggleGpsMode();
- if (screen)
- screen->forceDisplay(true); // Force a new UI frame, then force an EInk update
- }
- break;
-#elif defined(ELECROW_ThinkNode_M1) || defined(ELECROW_ThinkNode_M2)
- case 3:
- LOG_INFO("3 clicks: toggle buzzer");
- buzzer_flag = !buzzer_flag;
- if (!buzzer_flag)
- noTone(PIN_BUZZER);
- break;
-
-#endif
-
-#if defined(USE_EINK) && defined(PIN_EINK_EN) && !defined(ELECROW_ThinkNode_M1) // i.e. T-Echo
- // 4 clicks: toggle backlight
- case 4:
- digitalWrite(PIN_EINK_EN, digitalRead(PIN_EINK_EN) == LOW);
- break;
-#endif
-#if !MESHTASTIC_EXCLUDE_SCREEN && HAS_SCREEN
- // 5 clicks: start accelerometer/magenetometer calibration for 30 seconds
- case 5:
- if (accelerometerThread) {
- accelerometerThread->calibrate(30);
- }
- break;
- // 6 clicks: start accelerometer/magenetometer calibration for 60 seconds
- case 6:
- if (accelerometerThread) {
- accelerometerThread->calibrate(60);
- }
- break;
-#endif
- // No valid multipress action
- default:
- break;
- } // end switch: click count
-
- break;
- } // end multipress event
-
- case BUTTON_EVENT_LONG_PRESSED: {
- LOG_BUTTON("Long press!");
- powerFSM.trigger(EVENT_PRESS);
- if (screen) {
- screen->startAlert("Shutting down...");
- }
- playBeep();
- break;
- }
-
- // Do actual shutdown when button released, otherwise the button release
- // may wake the board immediatedly.
- case BUTTON_EVENT_LONG_RELEASED: {
- LOG_INFO("Shutdown from long press");
- playShutdownMelody();
- delay(3000);
- power->shutdown();
- break;
- }
-
-#ifdef BUTTON_PIN_TOUCH
- case BUTTON_EVENT_TOUCH_LONG_PRESSED: {
- LOG_BUTTON("Touch press!");
- // Ignore if: no screen
- if (!screen)
- break;
-
-#ifdef TTGO_T_ECHO
- // Ignore if: TX in progress
- // Uncommon T-Echo hardware bug, LoRa TX triggers touch button
- if (!RadioLibInterface::instance || RadioLibInterface::instance->isSending())
- break;
-#endif
-
- // Wake if asleep
- if (powerFSM.getState() == &stateDARK)
- powerFSM.trigger(EVENT_PRESS);
-
- // Update display (legacy behaviour)
- screen->forceDisplay();
- break;
- }
-#endif // BUTTON_PIN_TOUCH
-
- default:
- break;
- }
- btnEvent = BUTTON_EVENT_NONE;
- }
-
- return 50;
-}
-
-/*
- * Attach (or re-attach) hardware interrupts for buttons
- * Public method. Used outside class when waking from MCU sleep
- */
-void ButtonThread::attachButtonInterrupts()
-{
-#if defined(ARCH_PORTDUINO)
- if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC)
- wakeOnIrq(settingsMap[user], FALLING);
-#elif defined(BUTTON_PIN)
- // Interrupt for user button, during normal use. Improves responsiveness.
- attachInterrupt(
-#if !defined(USERPREFS_BUTTON_PIN)
- config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN,
-#endif
-#if defined(USERPREFS_BUTTON_PIN)
- config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN,
-#endif
- []() {
- ButtonThread::userButton.tick();
- runASAP = true;
- BaseType_t higherWake = 0;
- mainDelay.interruptFromISR(&higherWake);
- },
- CHANGE);
-#endif
-
-#ifdef BUTTON_PIN_ALT
-#ifdef ELECROW_ThinkNode_M2
- wakeOnIrq(BUTTON_PIN_ALT, RISING);
-#else
- wakeOnIrq(BUTTON_PIN_ALT, FALLING);
-#endif
-#endif
-
-#ifdef BUTTON_PIN_TOUCH
- wakeOnIrq(BUTTON_PIN_TOUCH, FALLING);
-#endif
-}
-
-/*
- * Detach the "normal" button interrupts.
- * Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep
- */
-void ButtonThread::detachButtonInterrupts()
-{
-#if defined(ARCH_PORTDUINO)
- if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC)
- detachInterrupt(settingsMap[user]);
-#elif defined(BUTTON_PIN)
-#if !defined(USERPREFS_BUTTON_PIN)
- detachInterrupt(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
-#endif
-#if defined(USERPREFS_BUTTON_PIN)
- detachInterrupt(config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN);
-#endif
-#endif
-
-#ifdef BUTTON_PIN_ALT
- detachInterrupt(BUTTON_PIN_ALT);
-#endif
-
-#ifdef BUTTON_PIN_TOUCH
- detachInterrupt(BUTTON_PIN_TOUCH);
-#endif
-}
-
-#ifdef ARCH_ESP32
-
-// Detach our class' interrupts before lightsleep
-// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press
-int ButtonThread::beforeLightSleep(void *unused)
-{
- detachButtonInterrupts();
- return 0; // Indicates success
-}
-
-// Reconfigure our interrupts
-// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep
-int ButtonThread::afterLightSleep(esp_sleep_wakeup_cause_t cause)
-{
- attachButtonInterrupts();
- return 0; // Indicates success
-}
-
-#endif
-
-/**
- * Watch a GPIO and if we get an IRQ, wake the main thread.
- * Use to add wake on button press
- */
-void ButtonThread::wakeOnIrq(int irq, int mode)
-{
- attachInterrupt(
- irq,
- [] {
- BaseType_t higherWake = 0;
- mainDelay.interruptFromISR(&higherWake);
- runASAP = true;
- },
- FALLING);
-}
-
-// Static callback
-void ButtonThread::userButtonMultiPressed(void *callerThread)
-{
- // Grab click count from non-static button, while the info is still valid
- ButtonThread *thread = (ButtonThread *)callerThread;
- thread->storeClickCount();
-
- // Then handle later, in the usual way
- btnEvent = BUTTON_EVENT_MULTI_PRESSED;
-}
-
-// Non-static method, runs during callback. Grabs info while still valid
-void ButtonThread::storeClickCount()
-{
-#if defined(BUTTON_PIN) || defined(USERPREFS_BUTTON_PIN)
- multipressClickCount = userButton.getNumberClicks();
-#endif
-}
-
-void ButtonThread::userButtonPressedLongStart()
-{
- if (millis() > c_holdOffTime) {
- btnEvent = BUTTON_EVENT_LONG_PRESSED;
- }
-}
-
-void ButtonThread::userButtonPressedLongStop()
-{
- if (millis() > c_holdOffTime) {
- btnEvent = BUTTON_EVENT_LONG_RELEASED;
- }
-}
\ No newline at end of file
diff --git a/src/ButtonThread.h b/src/ButtonThread.h
deleted file mode 100644
index 3af700dd0..000000000
--- a/src/ButtonThread.h
+++ /dev/null
@@ -1,91 +0,0 @@
-#pragma once
-
-#include "OneButton.h"
-#include "concurrency/OSThread.h"
-#include "configuration.h"
-
-#ifndef BUTTON_CLICK_MS
-#define BUTTON_CLICK_MS 250
-#endif
-
-#ifndef BUTTON_LONGPRESS_MS
-#define BUTTON_LONGPRESS_MS 5000
-#endif
-
-#ifndef BUTTON_TOUCH_MS
-#define BUTTON_TOUCH_MS 400
-#endif
-
-class ButtonThread : public concurrency::OSThread
-{
- public:
- static const uint32_t c_holdOffTime = 30000; // hold off 30s after boot
-
- enum ButtonEventType {
- BUTTON_EVENT_NONE,
- BUTTON_EVENT_PRESSED,
- BUTTON_EVENT_PRESSED_SCREEN,
- BUTTON_EVENT_DOUBLE_PRESSED,
- BUTTON_EVENT_MULTI_PRESSED,
- BUTTON_EVENT_LONG_PRESSED,
- BUTTON_EVENT_LONG_RELEASED,
- BUTTON_EVENT_TOUCH_LONG_PRESSED,
- };
-
- ButtonThread();
- int32_t runOnce() override;
- void attachButtonInterrupts();
- void detachButtonInterrupts();
- void storeClickCount();
- bool isBuzzing() { return buzzer_flag; }
- void setScreenFlag(bool flag) { screen_flag = flag; }
- bool getScreenFlag() { return screen_flag; }
-
- // Disconnect and reconnect interrupts for light sleep
-#ifdef ARCH_ESP32
- int beforeLightSleep(void *unused);
- int afterLightSleep(esp_sleep_wakeup_cause_t cause);
-#endif
- private:
-#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN)
- static OneButton userButton; // Static - accessed from an interrupt
-#endif
-#ifdef BUTTON_PIN_ALT
- OneButton userButtonAlt;
-#endif
-#ifdef BUTTON_PIN_TOUCH
- OneButton userButtonTouch;
-#endif
-
-#ifdef ARCH_ESP32
- // Get notified when lightsleep begins and ends
- CallbackObserver lsObserver =
- CallbackObserver(this, &ButtonThread::beforeLightSleep);
- CallbackObserver lsEndObserver =
- CallbackObserver(this, &ButtonThread::afterLightSleep);
-#endif
-
- // set during IRQ
- static volatile ButtonEventType btnEvent;
- bool buzzer_flag = false;
- bool screen_flag = true;
-
- // Store click count during callback, for later use
- volatile int multipressClickCount = 0;
-
- static void wakeOnIrq(int irq, int mode);
-
- static void sendAdHocPosition();
- static void switchPage();
-
- // IRQ callbacks
- static void userButtonPressed() { btnEvent = BUTTON_EVENT_PRESSED; }
- static void userButtonPressedScreen() { btnEvent = BUTTON_EVENT_PRESSED_SCREEN; }
- static void userButtonDoublePressed() { btnEvent = BUTTON_EVENT_DOUBLE_PRESSED; }
- static void userButtonMultiPressed(void *callerThread); // Retrieve click count from non-static Onebutton while still valid
- static void userButtonPressedLongStart();
- static void userButtonPressedLongStop();
- static void touchPressedLongStart() { btnEvent = BUTTON_EVENT_TOUCH_LONG_PRESSED; }
-};
-
-extern ButtonThread *buttonThread;
diff --git a/src/Power.cpp b/src/Power.cpp
index a9ed6360e..400b6c6eb 100644
--- a/src/Power.cpp
+++ b/src/Power.cpp
@@ -661,12 +661,14 @@ bool Power::analogInit()
*/
bool Power::setup()
{
- // initialise one power sensor (only)
- bool found = axpChipInit();
- if (!found)
- found = lipoInit();
- if (!found)
- found = analogInit();
+ bool found = false;
+ if (axpChipInit()) {
+ found = true;
+ } else if (lipoInit()) {
+ found = true;
+ } else if (analogInit()) {
+ found = true;
+ }
#ifdef NRF_APM
found = true;
@@ -853,7 +855,8 @@ int32_t Power::runOnce()
#ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3?
if (PMU->isPekeyLongPressIrq()) {
LOG_DEBUG("PEK long button press");
- screen->setOn(false);
+ if (screen)
+ screen->setOn(false);
}
#endif
diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp
index dbe4796cf..3b3f8080d 100644
--- a/src/PowerFSM.cpp
+++ b/src/PowerFSM.cpp
@@ -26,7 +26,7 @@
#ifndef SLEEP_TIME
#define SLEEP_TIME 30
#endif
-#if EXCLUDE_POWER_FSM
+#if MESHTASTIC_EXCLUDE_POWER_FSM
FakeFsm powerFSM;
void PowerFSM_setup(){};
#else
@@ -82,7 +82,8 @@ static uint32_t secsSlept;
static void lsEnter()
{
LOG_INFO("lsEnter begin, ls_secs=%u", config.power.ls_secs);
- screen->setOn(false);
+ if (screen)
+ screen->setOn(false);
secsSlept = 0; // How long have we been sleeping this time
// LOG_INFO("lsEnter end");
@@ -160,7 +161,8 @@ static void lsExit()
static void nbEnter()
{
LOG_DEBUG("State: NB");
- screen->setOn(false);
+ if (screen)
+ screen->setOn(false);
#ifdef ARCH_ESP32
// Only ESP32 should turn off bluetooth
setBluetoothEnable(false);
@@ -172,22 +174,23 @@ static void nbEnter()
static void darkEnter()
{
setBluetoothEnable(true);
- screen->setOn(false);
+ if (screen)
+ screen->setOn(false);
}
static void serialEnter()
{
LOG_DEBUG("State: SERIAL");
setBluetoothEnable(false);
- screen->setOn(true);
- screen->print("Serial connected\n");
+ if (screen) {
+ screen->setOn(true);
+ }
}
static void serialExit()
{
// Turn bluetooth back on when we leave serial stream API
setBluetoothEnable(true);
- screen->print("Serial disconnected\n");
}
static void powerEnter()
@@ -198,15 +201,10 @@ static void powerEnter()
LOG_INFO("Loss of power in Powered");
powerFSM.trigger(EVENT_POWER_DISCONNECTED);
} else {
- screen->setOn(true);
+ if (screen)
+ screen->setOn(true);
setBluetoothEnable(true);
// within enter() the function getState() returns the state we came from
-
- // Mothballed: print change of power-state to device screen
- /* if (strcmp(powerFSM.getState()->name, "BOOT") != 0 && strcmp(powerFSM.getState()->name, "POWER") != 0 &&
- strcmp(powerFSM.getState()->name, "DARK") != 0) {
- screen->print("Powered...\n");
- }*/
}
}
@@ -221,18 +219,16 @@ static void powerIdle()
static void powerExit()
{
- screen->setOn(true);
+ if (screen)
+ screen->setOn(true);
setBluetoothEnable(true);
-
- // Mothballed: print change of power-state to device screen
- /*if (!isPowered())
- screen->print("Unpowered...\n");*/
}
static void onEnter()
{
LOG_DEBUG("State: ON");
- screen->setOn(true);
+ if (screen)
+ screen->setOn(true);
setBluetoothEnable(true);
}
@@ -244,11 +240,6 @@ static void onIdle()
}
}
-static void screenPress()
-{
- screen->onPress();
-}
-
static void bootEnter()
{
LOG_DEBUG("State: BOOT");
@@ -292,9 +283,9 @@ void PowerFSM_setup()
powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
- powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, screenPress, "Press");
- powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, screenPress, "Press"); // reenter On to restart our timers
- powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, screenPress,
+ powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
+ powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
+ powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
"Press"); // Allow button to work while in serial API
// Handle critically low power battery by forcing deep sleep
@@ -328,10 +319,10 @@ void PowerFSM_setup()
// if any packet destined for phone arrives, turn on bluetooth at least
powerFSM.add_transition(&stateNB, &stateDARK, EVENT_PACKET_FOR_PHONE, NULL, "Packet for phone");
- // show the latest node when we get a new node db update
- powerFSM.add_transition(&stateNB, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update");
- powerFSM.add_transition(&stateDARK, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update");
- powerFSM.add_transition(&stateON, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update");
+ // Removed 2.7: we don't show the nodes individually for every node on the screen anymore
+ // powerFSM.add_transition(&stateNB, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update");
+ // powerFSM.add_transition(&stateDARK, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update");
+ // powerFSM.add_transition(&stateON, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update");
// Show the received text message
powerFSM.add_transition(&stateLS, &stateON, EVENT_RECEIVED_MSG, NULL, "Received text");
diff --git a/src/PowerFSM.h b/src/PowerFSM.h
index 13dfdc4cc..6330a5fc6 100644
--- a/src/PowerFSM.h
+++ b/src/PowerFSM.h
@@ -11,7 +11,7 @@
#define EVENT_RECEIVED_MSG 5
// #define EVENT_BOOT 6 // now done with a timed transition
#define EVENT_BLUETOOTH_PAIR 7
-#define EVENT_NODEDB_UPDATED 8 // NodeDB has a big enough change that we think you should turn on the screen
+// #define EVENT_NODEDB_UPDATED 8 // Now defunct: NodeDB has a big enough change that we think you should turn on the screen
#define EVENT_CONTACT_FROM_PHONE 9 // the phone just talked to us over bluetooth
#define EVENT_LOW_BATTERY 10 // Battery is critically low, go to sleep
#define EVENT_SERIAL_CONNECTED 11
@@ -22,7 +22,7 @@
#define EVENT_SHUTDOWN 16 // force a full shutdown now (not just sleep)
#define EVENT_INPUT 17 // input broker wants something, we need to wake up and enable screen
-#if EXCLUDE_POWER_FSM
+#if MESHTASTIC_EXCLUDE_POWER_FSM
class FakeFsm
{
public:
diff --git a/src/PowerFSMThread.h b/src/PowerFSMThread.h
index c842f4515..135f53298 100644
--- a/src/PowerFSMThread.h
+++ b/src/PowerFSMThread.h
@@ -18,7 +18,7 @@ class PowerFSMThread : public OSThread
protected:
int32_t runOnce() override
{
-#if !EXCLUDE_POWER_FSM
+#if !MESHTASTIC_EXCLUDE_POWER_FSM
powerFSM.run_machine();
/// If we are in power state we force the CPU to wake every 10ms to check for serial characters (we don't yet wake
diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp
index 07f873864..7c8d77651 100644
--- a/src/RedirectablePrint.cpp
+++ b/src/RedirectablePrint.cpp
@@ -352,8 +352,8 @@ void RedirectablePrint::hexDump(const char *logLevel, unsigned char *buf, uint16
for (uint16_t i = 0; i < len; i += 16) {
if (i % 128 == 0)
log(logLevel, " +------------------------------------------------+ +----------------+");
- char s[] = "| | | |\n";
- uint8_t ix = 1, iy = 52;
+ char s[] = " | | | |\n";
+ uint8_t ix = 5, iy = 56;
for (uint8_t j = 0; j < 16; j++) {
if (i + j < len) {
uint8_t c = buf[i + j];
@@ -367,10 +367,8 @@ void RedirectablePrint::hexDump(const char *logLevel, unsigned char *buf, uint16
}
}
uint8_t index = i / 16;
- if (i < 256)
- log(logLevel, " ");
- log(logLevel, "%02x", index);
- log(logLevel, ".");
+ sprintf(s, "%03x", index);
+ s[3] = '.';
log(logLevel, s);
}
log(logLevel, " +------------------------------------------------+ +----------------+");
@@ -393,4 +391,4 @@ std::string RedirectablePrint::mt_sprintf(const std::string fmt_str, ...)
break;
}
return std::string(formatted.get());
-}
\ No newline at end of file
+}
diff --git a/src/buzz/BuzzerFeedbackThread.cpp b/src/buzz/BuzzerFeedbackThread.cpp
new file mode 100644
index 000000000..2bd3158a3
--- /dev/null
+++ b/src/buzz/BuzzerFeedbackThread.cpp
@@ -0,0 +1,79 @@
+#include "BuzzerFeedbackThread.h"
+#include "NodeDB.h"
+#include "buzz.h"
+#include "configuration.h"
+
+BuzzerFeedbackThread *buzzerFeedbackThread;
+
+BuzzerFeedbackThread::BuzzerFeedbackThread() : OSThread("BuzzerFeedback")
+{
+ if (inputBroker)
+ inputObserver.observe(inputBroker);
+}
+
+int BuzzerFeedbackThread::handleInputEvent(const InputEvent *event)
+{
+ // Only provide feedback if buzzer is enabled for notifications
+ if (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED ||
+ config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_NOTIFICATIONS_ONLY) {
+ return 0; // Let other handlers process the event
+ }
+
+ // Track last event time for potential future use
+ lastEventTime = millis();
+ needsUpdate = true;
+
+ // Handle different input events with appropriate buzzer feedback
+ switch (event->inputEvent) {
+ case INPUT_BROKER_USER_PRESS:
+ case INPUT_BROKER_ALT_PRESS:
+ case INPUT_BROKER_SELECT:
+ playBeep(); // Confirmation feedback
+ break;
+
+ case INPUT_BROKER_UP:
+ case INPUT_BROKER_DOWN:
+ case INPUT_BROKER_LEFT:
+ case INPUT_BROKER_RIGHT:
+ playChirp(); // Navigation feedback
+ break;
+
+ case INPUT_BROKER_CANCEL:
+ case INPUT_BROKER_BACK:
+ playBoop(); // Cancel/back feedback
+ break;
+
+ case INPUT_BROKER_SEND_PING:
+ playComboTune(); // Ping sent feedback
+ break;
+
+ case INPUT_BROKER_SHUTDOWN:
+ playShutdownMelody(); // Shutdown feedback
+ break;
+
+ default:
+ // For other events, check if it's a printable character
+ if (event->kbchar >= 32 && event->kbchar <= 126) {
+ // Typing feedback - very short boop
+ // Removing this for now, too chatty
+ // playChirp();
+ }
+ break;
+ }
+
+ return 0; // Allow other handlers to process the event
+}
+
+int32_t BuzzerFeedbackThread::runOnce()
+{
+ // This thread is primarily event-driven, but we can use runOnce
+ // for any periodic tasks if needed in the future
+
+ if (needsUpdate) {
+ needsUpdate = false;
+ // Could add any periodic processing here
+ }
+
+ // Run every 100ms when active, less frequently when idle
+ return needsUpdate ? 100 : 1000;
+}
diff --git a/src/buzz/BuzzerFeedbackThread.h b/src/buzz/BuzzerFeedbackThread.h
new file mode 100644
index 000000000..dedea9860
--- /dev/null
+++ b/src/buzz/BuzzerFeedbackThread.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include "Observer.h"
+#include "concurrency/OSThread.h"
+#include "input/InputBroker.h"
+
+class BuzzerFeedbackThread : public concurrency::OSThread
+{
+ CallbackObserver inputObserver =
+ CallbackObserver(this, &BuzzerFeedbackThread::handleInputEvent);
+
+ public:
+ BuzzerFeedbackThread();
+ int handleInputEvent(const InputEvent *event);
+
+ protected:
+ virtual int32_t runOnce() override;
+
+ private:
+ uint32_t lastEventTime = 0;
+ bool needsUpdate = false;
+};
+
+extern BuzzerFeedbackThread *buzzerFeedbackThread;
diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp
index 6ba2f4140..b09d7a82c 100644
--- a/src/buzz/buzz.cpp
+++ b/src/buzz/buzz.cpp
@@ -38,6 +38,11 @@ const int DURATION_1_1 = 1000; // 1/1 note
void playTones(const ToneDuration *tone_durations, int size)
{
+ if (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED ||
+ config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_NOTIFICATIONS_ONLY) {
+ // Buzzer is disabled or not set to system tones
+ return;
+ }
#ifdef PIN_BUZZER
if (!config.device.buzzer_gpio)
config.device.buzzer_gpio = PIN_BUZZER;
@@ -54,7 +59,7 @@ void playTones(const ToneDuration *tone_durations, int size)
void playBeep()
{
- ToneDuration melody[] = {{NOTE_B3, DURATION_1_4}};
+ ToneDuration melody[] = {{NOTE_B3, DURATION_1_8}};
playTones(melody, sizeof(melody) / sizeof(ToneDuration));
}
@@ -87,3 +92,72 @@ void playShutdownMelody()
ToneDuration melody[] = {{NOTE_CS4, DURATION_1_8}, {NOTE_AS3, DURATION_1_8}, {NOTE_FS3, DURATION_1_4}};
playTones(melody, sizeof(melody) / sizeof(ToneDuration));
}
+
+void playChirp()
+{
+ // A short, friendly "chirp" sound for key presses
+ ToneDuration melody[] = {{NOTE_AS3, 20}}; // Very short AS3 note
+ playTones(melody, sizeof(melody) / sizeof(ToneDuration));
+}
+
+void playBoop()
+{
+ // A short, friendly "boop" sound for button presses
+ ToneDuration melody[] = {{NOTE_A3, 50}}; // Very short A3 note
+ playTones(melody, sizeof(melody) / sizeof(ToneDuration));
+}
+
+void playLongPressLeadUp()
+{
+ // An ascending lead-up sequence for long press - builds anticipation
+ ToneDuration melody[] = {
+ {NOTE_C3, 100}, // Start low
+ {NOTE_E3, 100}, // Step up
+ {NOTE_G3, 100}, // Keep climbing
+ {NOTE_B3, 150} // Peak with longer note for emphasis
+ };
+ playTones(melody, sizeof(melody) / sizeof(ToneDuration));
+}
+
+// Static state for progressive lead-up notes
+static int leadUpNoteIndex = 0;
+static const ToneDuration leadUpNotes[] = {
+ {NOTE_C3, 100}, // Start low
+ {NOTE_E3, 100}, // Step up
+ {NOTE_G3, 100}, // Keep climbing
+ {NOTE_B3, 150} // Peak with longer note for emphasis
+};
+static const int leadUpNotesCount = sizeof(leadUpNotes) / sizeof(ToneDuration);
+
+bool playNextLeadUpNote()
+{
+ if (leadUpNoteIndex >= leadUpNotesCount) {
+ return false; // All notes have been played
+ }
+
+ // Use playTones to handle buzzer logic consistently
+ const auto ¬e = leadUpNotes[leadUpNoteIndex];
+ playTones(¬e, 1); // Play single note using existing playTones function
+
+ leadUpNoteIndex++;
+ return true; // Note was played (playTones handles buzzer availability internally)
+}
+
+void resetLeadUpSequence()
+{
+ leadUpNoteIndex = 0;
+}
+
+void playComboTune()
+{
+ // Quick high-pitched notes with trills
+ ToneDuration melody[] = {
+ {NOTE_G3, 80}, // Quick chirp
+ {NOTE_B3, 60}, // Higher chirp
+ {NOTE_CS4, 80}, // Even higher
+ {NOTE_G3, 60}, // Quick trill down
+ {NOTE_CS4, 60}, // Quick trill up
+ {NOTE_B3, 120} // Ending chirp
+ };
+ playTones(melody, sizeof(melody) / sizeof(ToneDuration));
+}
diff --git a/src/buzz/buzz.h b/src/buzz/buzz.h
index adeaca73d..c25a54a5b 100644
--- a/src/buzz/buzz.h
+++ b/src/buzz/buzz.h
@@ -5,4 +5,10 @@ void playLongBeep();
void playStartMelody();
void playShutdownMelody();
void playGPSEnableBeep();
-void playGPSDisableBeep();
\ No newline at end of file
+void playGPSDisableBeep();
+void playComboTune();
+void playBoop();
+void playChirp();
+void playLongPressLeadUp();
+bool playNextLeadUpNote(); // Play the next note in the lead-up sequence
+void resetLeadUpSequence(); // Reset the lead-up sequence to start from beginning
\ No newline at end of file
diff --git a/src/commands.h b/src/commands.h
index f2b783010..e0bfab330 100644
--- a/src/commands.h
+++ b/src/commands.h
@@ -12,7 +12,6 @@ enum class Cmd {
STOP_ALERT_FRAME,
START_FIRMWARE_UPDATE_SCREEN,
STOP_BOOT_SCREEN,
- PRINT,
SHOW_PREV_FRAME,
SHOW_NEXT_FRAME
};
\ No newline at end of file
diff --git a/src/concurrency/Lock.cpp b/src/concurrency/Lock.cpp
index 11501359b..0fe80e455 100644
--- a/src/concurrency/Lock.cpp
+++ b/src/concurrency/Lock.cpp
@@ -9,17 +9,23 @@ namespace concurrency
Lock::Lock() : handle(xSemaphoreCreateBinary())
{
assert(handle);
- assert(xSemaphoreGive(handle));
+ if (xSemaphoreGive(handle) == false) {
+ abort();
+ }
}
void Lock::lock()
{
- assert(xSemaphoreTake(handle, portMAX_DELAY));
+ if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
+ abort();
+ }
}
void Lock::unlock()
{
- assert(xSemaphoreGive(handle));
+ if (xSemaphoreGive(handle) == false) {
+ abort();
+ }
}
#else
Lock::Lock() {}
diff --git a/src/configuration.h b/src/configuration.h
index d319ddb0a..89257ff2f 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -80,10 +80,46 @@ along with this program. If not, see .
// Override user saved region, for producing region-locked builds
// #define REGULATORY_LORA_REGIONCODE meshtastic_Config_LoRaConfig_RegionCode_SG_923
-// Total system gain in dBm to subtract from Tx power to remain within regulatory ERP limit for non-licensed operators
-// This value should be set in variant.h and is PA gain + antenna gain (if system ships with an antenna)
-#ifndef REGULATORY_GAIN_LORA
-#define REGULATORY_GAIN_LORA 0
+// Total system gain in dBm to subtract from Tx power to remain within regulatory and Tx PA limits
+// The value consists of PA gain + antenna gain (if variant has a non-removable antenna)
+// TX_GAIN_LORA should be set with definitions below for common modules, or in variant.h.
+
+// Gain for common modules with transmit PAs
+#ifdef EBYTE_E22_900M30S
+// 10dB PA gain and 30dB rated output; based on measurements from
+// https://github.com/S5NC/EBYTE_ESP32-S3/blob/main/E22-900M30S%20power%20output%20testing.txt
+#define TX_GAIN_LORA 7
+#define SX126X_MAX_POWER 22
+#endif
+
+#ifdef EBYTE_E22_900M33S
+// 25dB PA gain and 33dB rated output; based on TX Power Curve from E22-900M33S_UserManual_EN_v1.0.pdf
+#define TX_GAIN_LORA 25
+#define SX126X_MAX_POWER 8
+#endif
+
+#ifdef NICERF_MINIF27
+// Note that datasheet power level of 9 corresponds with SX1262 at 22dBm
+// Maximum output power of 29dBm with VCC_PA = 5V
+#define TX_GAIN_LORA 7
+#define SX126X_MAX_POWER 22
+#endif
+
+#ifdef NICERF_F30_HF
+// Maximum output power of 29.6dBm with VCC = 5V and SX1262 at 22dBm
+#define TX_GAIN_LORA 8
+#define SX126X_MAX_POWER 22
+#endif
+
+#ifdef NICERF_F30_LF
+// Maximum output power of 32.0dBm with VCC = 5V and SX1262 at 22dBm
+#define TX_GAIN_LORA 10
+#define SX126X_MAX_POWER 22
+#endif
+
+// Default system gain to 0 if not defined
+#ifndef TX_GAIN_LORA
+#define TX_GAIN_LORA 0
#endif
// -----------------------------------------------------------------------------
@@ -99,8 +135,12 @@ along with this program. If not, see .
// -----------------------------------------------------------------------------
// OLED & Input
// -----------------------------------------------------------------------------
-
+#if defined(SEEED_WIO_TRACKER_L1)
+#define SSD1306_ADDRESS 0x3D
+#define USE_SH1106
+#else
#define SSD1306_ADDRESS 0x3C
+#endif
#define ST7567_ADDRESS 0x3F
// The SH1106 controller is almost, but not quite, the same as SSD1306
@@ -153,6 +193,7 @@ along with this program. If not, see .
#define CGRADSENS_ADDR 0x66
#define LTR390UV_ADDR 0x53
#define XPOWERS_AXP192_AXP2101_ADDRESS 0x34 // same adress as TCA8418
+#define PCT2075_ADDR 0x37
// -----------------------------------------------------------------------------
// ACCELEROMETER
@@ -166,6 +207,7 @@ along with this program. If not, see .
#define BMX160_ADDR 0x69
#define ICM20948_ADDR 0x69
#define ICM20948_ADDR_ALT 0x68
+#define BMM150_ADDR 0x13
// -----------------------------------------------------------------------------
// LED
@@ -188,6 +230,15 @@ along with this program. If not, see .
// -----------------------------------------------------------------------------
#define FT6336U_ADDR 0x48
+// -----------------------------------------------------------------------------
+// RAK12035VB Soil Monitor (using RAK12023 up to 3 RAK12035 monitors can be connected)
+// - the default i2c address for this sensor is 0x20, and users are instructed to
+// set 0x21 and 0x22 for the second and third sensor if present.
+// -----------------------------------------------------------------------------
+#define RAK120351_ADDR 0x20
+#define RAK120352_ADDR 0x21
+#define RAK120353_ADDR 0x22
+
// -----------------------------------------------------------------------------
// BIAS-T Generator
// -----------------------------------------------------------------------------
@@ -297,11 +348,41 @@ along with this program. If not, see .
#error HW_VENDOR must be defined
#endif
+#ifndef TB_DOWN
+#define TB_DOWN 255
+#endif
+#ifndef TB_UP
+#define TB_UP 255
+#endif
+#ifndef TB_LEFT
+#define TB_LEFT 255
+#endif
+#ifndef TB_RIGHT
+#define TB_RIGHT 255
+#endif
+#ifndef TB_PRESS
+#define TB_PRESS 255
+#endif
+
// Support multiple RGB LED configuration
#if defined(HAS_NCP5623) || defined(HAS_LP5562) || defined(RGBLED_RED) || defined(HAS_NEOPIXEL) || defined(UNPHONE)
#define HAS_RGB_LED
#endif
+// default mapping of pins
+#if defined(PIN_BUTTON2) && !defined(CANCEL_BUTTON_PIN)
+#define ALT_BUTTON_PIN PIN_BUTTON2
+#endif
+#if defined ALT_BUTTON_PIN
+
+#ifndef ALT_BUTTON_ACTIVE_LOW
+#define ALT_BUTTON_ACTIVE_LOW true
+#endif
+#ifndef ALT_BUTTON_ACTIVE_PULLUP
+#define ALT_BUTTON_ACTIVE_PULLUP true
+#endif
+#endif
+
// -----------------------------------------------------------------------------
// Global switches to turn off features for a minimized build
// -----------------------------------------------------------------------------
diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp
index 5bd5c0d12..e6236251c 100644
--- a/src/detect/ScanI2C.cpp
+++ b/src/detect/ScanI2C.cpp
@@ -37,8 +37,8 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
- ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948, QMA6100P};
- return firstOfOrNONE(8, types);
+ ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948, QMA6100P, BMM150};
+ return firstOfOrNONE(9, types);
}
ScanI2C::FoundDevice ScanI2C::firstRGBLED() const
diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h
index c363db1b5..90467abd0 100644
--- a/src/detect/ScanI2C.h
+++ b/src/detect/ScanI2C.h
@@ -70,7 +70,10 @@ class ScanI2C
DFROBOT_RAIN,
DPS310,
LTR390UV,
+ RAK12035,
TCA8418KB,
+ PCT2075,
+ BMM150,
} DeviceType;
// typedef uint8_t DeviceAddress;
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index 9781cbf56..fd3d1c80b 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -358,7 +358,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT
case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x89), 2);
- if (registerValue == 0x11a2 || registerValue == 0x11da || registerValue == 0xe9c) {
+ if (registerValue == 0x11a2 || registerValue == 0x11da || registerValue == 0xe9c || registerValue == 0xc8d) {
type = SHT4X;
logFoundDevice("SHT4X", (uint8_t)addr.address);
} else if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) {
@@ -423,9 +423,21 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
logFoundDevice("BMA423", (uint8_t)addr.address);
}
break;
+ case TCA9535_ADDR:
+ case RAK120352_ADDR:
+ case RAK120353_ADDR:
+ registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x02), 1);
+ if (registerValue == addr.address) { // RAK12035 returns its I2C address at 0x02 (eg 0x20)
+ type = RAK12035;
+ logFoundDevice("RAK12035", (uint8_t)addr.address);
+ } else {
+ type = TCA9535;
+ logFoundDevice("TCA9535", (uint8_t)addr.address);
+ }
+
+ break;
SCAN_SIMPLE_CASE(LSM6DS3_ADDR, LSM6DS3, "LSM6DS3", (uint8_t)addr.address);
- SCAN_SIMPLE_CASE(TCA9535_ADDR, TCA9535, "TCA9535", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(TCA9555_ADDR, TCA9555, "TCA9555", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(VEML7700_ADDR, VEML7700, "VEML7700", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(TSL25911_ADDR, TSL2591, "TSL2591", (uint8_t)addr.address);
@@ -434,6 +446,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
SCAN_SIMPLE_CASE(MAX1704X_ADDR, MAX17048, "MAX17048", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(DFROBOT_RAIN_ADDR, DFROBOT_RAIN, "DFRobot Rain Gauge", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(LTR390UV_ADDR, LTR390UV, "LTR390UV", (uint8_t)addr.address);
+ SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address);
+ SCAN_SIMPLE_CASE(BMM150_ADDR, BMM150, "BMM150", (uint8_t)addr.address);
#ifdef HAS_TPS65233
SCAN_SIMPLE_CASE(TPS65233_ADDR, TPS65233, "TPS65233", (uint8_t)addr.address);
#endif
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index fba3e75e9..9c1b34e5a 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -1,9 +1,10 @@
/*
+BaseUI
-SSD1306 - Screen module
-
-Copyright (C) 2018 by Xose Pérez
-
+Developed and Maintained By:
+- Ronald Garcia (HarukiToreda) – Lead development and implementation.
+- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing.
+- TonyG (Tropho) – Project management, structural planning, and testing
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -27,25 +28,36 @@ along with this program. If not, see .
#include
#include "DisplayFormatters.h"
+#include "TimeFormatters.h"
+#include "draw/ClockRenderer.h"
+#include "draw/DebugRenderer.h"
+#include "draw/MessageRenderer.h"
+#include "draw/NodeListRenderer.h"
+#include "draw/NotificationRenderer.h"
+#include "draw/UIRenderer.h"
+#include "modules/CannedMessageModule.h"
+
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
+#include "buzz.h"
#endif
-#include "ButtonThread.h"
+#include "FSCommon.h"
#include "MeshService.h"
#include "NodeDB.h"
+#include "RadioLibInterface.h"
#include "error.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/emotes.h"
#include "graphics/images.h"
-#include "input/ScanAndSelect.h"
#include "input/TouchScreenImpl1.h"
#include "main.h"
#include "mesh-pb-constants.h"
#include "mesh/Channels.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "meshUtils.h"
-#include "modules/AdminModule.h"
#include "modules/ExternalNotificationModule.h"
#include "modules/TextMessageModule.h"
#include "modules/WaypointModule.h"
@@ -55,12 +67,19 @@ along with this program. If not, see .
#include "sleep.h"
#include "target_specific.h"
+using graphics::Emote;
+using graphics::emotes;
+using graphics::numEmotes;
+
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
#include "mesh/wifi/WiFiAPClient.h"
#endif
#ifdef ARCH_ESP32
+<<<<<<< store-and-forward
#include "esp_task_wdt.h"
+=======
+>>>>>>> master
#endif
#if ARCH_PORTDUINO
@@ -83,14 +102,10 @@ namespace graphics
// A text message frame + debug frame + all the node infos
FrameCallback *normalFrames;
static uint32_t targetFramerate = IDLE_FRAMERATE;
+// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
-uint32_t hours_in_month = 730;
-
-// This image definition is here instead of images.h because it's modified dynamically by the drawBattery function
-uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C};
-
// Threshold values for the GPS lock accuracy bar display
uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100};
@@ -98,13 +113,9 @@ uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100};
// we'll need to hold onto pointers for the modules that can draw a frame.
std::vector moduleFrames;
-// Stores the last 4 of our hardware ID, to make finding the device for pairing easier
-static char ourId[5];
-
-// vector where symbols (string) are displayed in bottom corner of display.
+// Global variables for screen function overlay symbols
std::vector functionSymbol;
-// string displayed in bottom right corner of display. Created from elements in functionSymbol vector
-std::string functionSymbolString = "";
+std::string functionSymbolString;
#if HAS_GPS
// GeoCoord object for the screen
@@ -115,258 +126,38 @@ GeoCoord geoCoord;
static bool heartbeat = false;
#endif
-// Quick access to screen dimensions from static drawing functions
-// DEPRECATED. To-do: move static functions inside Screen class
-#define SCREEN_WIDTH display->getWidth()
-#define SCREEN_HEIGHT display->getHeight()
-
#include "graphics/ScreenFonts.h"
#include
-#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
+// Usage: int stringWidth = formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display);
+// End Functions to write date/time to the screen
-// Check if the display can render a string (detect special chars; emoji)
-static bool haveGlyphs(const char *str)
+extern bool hasUnreadMessage;
+
+// ==============================
+// Overlay Alert Banner Renderer
+// ==============================
+// Displays a temporary centered banner message (e.g., warning, status, etc.)
+// The banner appears in the center of the screen and disappears after the specified duration
+
+// Called to trigger a banner with custom message and duration
+void Screen::showOverlayBanner(const char *message, uint32_t durationMs, uint8_t options, std::function bannerCallback,
+ int8_t InitialSelected)
{
-#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;
+ // Store the message and set the expiration timestamp
+ strncpy(NotificationRenderer::alertBannerMessage, message, 255);
+ NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
+ NotificationRenderer::alertBannerOptions = options;
+ NotificationRenderer::alertBannerCallback = bannerCallback;
+ NotificationRenderer::curSelected = InitialSelected;
+ NotificationRenderer::pauseBanner = false;
+ static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawAlertBannerOverlay};
+ ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
+ setFastFramerate(); // Draw ASAP
+ ui->update();
}
-/**
- * Draw the icon with extra info printed around the corners
- */
-static void 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
- 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), 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
-}
-
-#ifdef USERPREFS_OEM_TEXT
-
-static void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA;
- 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);
-
- 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;
- 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
-}
-
-static void 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
-
-void Screen::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);
-}
-
-// Used on boot when a certificate is being created
-static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->setFont(FONT_SMALL);
- display->drawString(64 + x, y, "Creating SSL certificate");
-
-#ifdef ARCH_ESP32
- yield();
- esp_task_wdt_reset();
-#endif
-
- display->setFont(FONT_SMALL);
- if ((millis() / 1000) % 2) {
- display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . .");
- } else {
- display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ");
- }
-}
-
-// Used when booting without a region set
-static void drawWelcomeScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- display->setFont(FONT_SMALL);
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->drawString(64 + x, y, "//\\ E S H T /\\ S T / C");
- display->drawString(64 + x, y + FONT_HEIGHT_SMALL, getDeviceName());
- display->setTextAlignment(TEXT_ALIGN_LEFT);
-
- if ((millis() / 10000) % 2) {
- display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Set the region using the");
- display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "Meshtastic Android, iOS,");
- display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, "Web or CLI clients.");
- } else {
- display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Visit meshtastic.org");
- display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "for more information.");
- display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, "");
- }
-
-#ifdef ARCH_ESP32
- yield();
- esp_task_wdt_reset();
-#endif
-}
-
-// draw overlay in bottom right corner of screen to show when notifications are muted or modifier key is active
-static void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
-{
- // LOG_DEBUG("Draw function overlay");
- if (functionSymbol.begin() != functionSymbol.end()) {
- char buf[64];
- display->setFont(FONT_SMALL);
- snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str());
- display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf);
- }
-}
-
-#ifdef USE_EINK
-/// Used on eink displays while in deep sleep
-static void drawDeepSleepScreen(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
- drawIconScreen("Sleeping", display, state, x, y);
-}
-
-/// Used on eink displays when screen updates are paused
-static void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
-{
- LOG_DEBUG("Draw screensaver overlay");
-
- EINK_ADD_FRAMEFLAG(display, COSMETIC); // Take the opportunity for a full-refresh
-
- // 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); // This bool is used to hide the idText box if we can't render the short name
- constexpr uint16_t padding = 5;
- constexpr uint8_t dividerGap = 1;
- constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in.
-
- // Dimensions
- const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars
- const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
- const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding;
- const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding;
-
- // Position
- const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1);
- // const int16_t boxRight = boxLeft + boxWidth - 1;
- const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1));
- const int16_t boxBottom = boxTop + boxHeight - 1;
- const int16_t idTextLeft = boxLeft + padding;
- const int16_t idTextTop = boxTop + padding;
- const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding;
- const int16_t pauseTextTop = boxTop + padding;
- const int16_t dividerX = boxLeft + padding + idTextWidth + padding;
- const int16_t dividerTop = boxTop + 1 + dividerGap;
- const int16_t dividerBottom = boxBottom - 1 - dividerGap;
-
- // Draw: box
- display->setColor(EINK_WHITE);
- display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box
- 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
-
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
uint8_t module_frame;
@@ -389,875 +180,12 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
pi.drawFrame(display, state, x, y);
}
-static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->setFont(FONT_MEDIUM);
- display->drawString(64 + x, y, "Updating");
-
- display->setFont(FONT_SMALL);
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(),
- "Please be patient and do not power off.");
-}
-
-/// Draw the last text message we received
-static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_MEDIUM);
-
- char tempBuf[24];
- snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code);
- display->drawString(0 + x, 0 + y, tempBuf);
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_SMALL);
- display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org");
-}
-
// Ignore messages originating from phone (from the current node 0x0) unless range test or store and forward module are enabled
static bool shouldDrawMessage(const meshtastic_MeshPacket *packet)
{
return packet->from != 0 && !moduleConfig.store_forward.enabled;
}
-// Draw power bars or a charging indicator on an image of a battery, determined by battery charge voltage or percentage.
-static void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, const PowerStatus *powerStatus)
-{
- static const uint8_t powerBar[3] = {0x81, 0xBD, 0xBD};
- static const uint8_t lightning[8] = {0xA1, 0xA1, 0xA5, 0xAD, 0xB5, 0xA5, 0x85, 0x85};
- // Clear the bar area on the battery image
- for (int i = 1; i < 14; i++) {
- imgBuffer[i] = 0x81;
- }
- // If charging, draw a charging indicator
- if (powerStatus->getIsCharging()) {
- memcpy(imgBuffer + 3, lightning, 8);
- // If not charging, Draw power bars
- } else {
- for (int i = 0; i < 4; i++) {
- if (powerStatus->getBatteryChargePercent() >= 25 * i)
- memcpy(imgBuffer + 1 + (i * 3), powerBar, 3);
- }
- }
- display->drawFastImage(x, y, 16, 8, imgBuffer);
-}
-
-#if defined(DISPLAY_CLOCK_FRAME)
-
-void Screen::drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode, float scale)
-{
- uint16_t segmentWidth = SEGMENT_WIDTH * scale;
- uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
-
- if (digitalMode) {
- uint16_t radius = (segmentWidth + (segmentHeight * 2) + 4) / 2;
- uint16_t centerX = (x + segmentHeight + 2) + (radius / 2);
- uint16_t centerY = (y + segmentHeight + 2) + (radius / 2);
-
- display->drawCircle(centerX, centerY, radius);
- display->drawCircle(centerX, centerY, radius + 1);
- display->drawLine(centerX, centerY, centerX, centerY - radius + 3);
- display->drawLine(centerX, centerY, centerX + radius - 3, centerY);
- } else {
- uint16_t segmentOneX = x + segmentHeight + 2;
- uint16_t segmentOneY = y;
-
- uint16_t segmentTwoX = segmentOneX + segmentWidth + 2;
- uint16_t segmentTwoY = segmentOneY + segmentHeight + 2;
-
- uint16_t segmentThreeX = segmentOneX;
- uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2;
-
- uint16_t segmentFourX = x;
- uint16_t segmentFourY = y + segmentHeight + 2;
-
- drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight);
- drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight);
- drawHorizontalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight);
- drawVerticalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight);
- }
-}
-
-// Draw a digital clock
-void Screen::drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- display->setTextAlignment(TEXT_ALIGN_LEFT);
-
- drawBattery(display, x, y + 7, imgBattery, powerStatus);
-
- if (powerStatus->getHasBattery()) {
- String batteryPercent = String(powerStatus->getBatteryChargePercent()) + "%";
-
- display->setFont(FONT_SMALL);
-
- display->drawString(x + 20, y + 2, batteryPercent);
- }
-
- if (nimbleBluetooth && nimbleBluetooth->isConnected()) {
- drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2);
- }
-
- drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, screen->digitalWatchFace, 1);
-
- display->setColor(OLEDDISPLAY_COLOR::WHITE);
-
- uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
- if (rtc_sec > 0) {
- long hms = rtc_sec % SEC_PER_DAY;
- hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
-
- int hour = hms / SEC_PER_HOUR;
- int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
- int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN
-
- hour = hour > 12 ? hour - 12 : hour;
-
- if (hour == 0) {
- hour = 12;
- }
-
- // hours string
- String hourString = String(hour);
-
- // minutes string
- String minuteString = minute < 10 ? "0" + String(minute) : String(minute);
-
- String timeString = hourString + ":" + minuteString;
-
- // seconds string
- String secondString = second < 10 ? "0" + String(second) : String(second);
-
- float scale = 1.5;
-
- uint16_t segmentWidth = SEGMENT_WIDTH * scale;
- uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
-
- // calculate hours:minutes string width
- uint16_t timeStringWidth = timeString.length() * 5;
-
- for (uint8_t i = 0; i < timeString.length(); i++) {
- String character = String(timeString[i]);
-
- if (character == ":") {
- timeStringWidth += segmentHeight;
- } else {
- timeStringWidth += segmentWidth + (segmentHeight * 2) + 4;
- }
- }
-
- // calculate seconds string width
- uint16_t secondStringWidth = (secondString.length() * 12) + 4;
-
- // sum these to get total string width
- uint16_t totalWidth = timeStringWidth + secondStringWidth;
-
- uint16_t hourMinuteTextX = (display->getWidth() / 2) - (totalWidth / 2);
-
- uint16_t startingHourMinuteTextX = hourMinuteTextX;
-
- uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2);
-
- // iterate over characters in hours:minutes string and draw segmented characters
- for (uint8_t i = 0; i < timeString.length(); i++) {
- String character = String(timeString[i]);
-
- if (character == ":") {
- drawSegmentedDisplayColon(display, hourMinuteTextX, hourMinuteTextY, scale);
-
- hourMinuteTextX += segmentHeight + 6;
- } else {
- drawSegmentedDisplayCharacter(display, hourMinuteTextX, hourMinuteTextY, character.toInt(), scale);
-
- hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4;
- }
-
- hourMinuteTextX += 5;
- }
-
- // draw seconds string
- display->setFont(FONT_MEDIUM);
- display->drawString(startingHourMinuteTextX + timeStringWidth + 4,
- (display->getHeight() - hourMinuteTextY) - FONT_HEIGHT_MEDIUM + 6, secondString);
- }
-}
-
-void Screen::drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale)
-{
- uint16_t segmentWidth = SEGMENT_WIDTH * scale;
- uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
-
- uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8;
-
- uint16_t topAndBottomX = x + (4 * scale);
-
- uint16_t quarterCellHeight = cellHeight / 4;
-
- uint16_t topY = y + quarterCellHeight;
- uint16_t bottomY = y + (quarterCellHeight * 3);
-
- display->fillRect(topAndBottomX, topY, segmentHeight, segmentHeight);
- display->fillRect(topAndBottomX, bottomY, segmentHeight, segmentHeight);
-}
-
-void Screen::drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale)
-{
- // the numbers 0-9, each expressed as an array of seven boolean (0|1) values encoding the on/off state of
- // segment {innerIndex + 1}
- // e.g., to display the numeral '0', segments 1-6 are on, and segment 7 is off.
- uint8_t numbers[10][7] = {
- {1, 1, 1, 1, 1, 1, 0}, // 0 Display segment key
- {0, 1, 1, 0, 0, 0, 0}, // 1 1
- {1, 1, 0, 1, 1, 0, 1}, // 2 ___
- {1, 1, 1, 1, 0, 0, 1}, // 3 6 | | 2
- {0, 1, 1, 0, 0, 1, 1}, // 4 |_7̲_|
- {1, 0, 1, 1, 0, 1, 1}, // 5 5 | | 3
- {1, 0, 1, 1, 1, 1, 1}, // 6 |___|
- {1, 1, 1, 0, 0, 1, 0}, // 7
- {1, 1, 1, 1, 1, 1, 1}, // 8 4
- {1, 1, 1, 1, 0, 1, 1}, // 9
- };
-
- // the width and height of each segment's central rectangle:
- // _____________________
- // ⋰| (only this part, |⋱
- // ⋰ | not including | ⋱
- // ⋱ | the triangles | ⋰
- // ⋱| on the ends) |⋰
- // ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
-
- uint16_t segmentWidth = SEGMENT_WIDTH * scale;
- uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
-
- // segment x and y coordinates
- uint16_t segmentOneX = x + segmentHeight + 2;
- uint16_t segmentOneY = y;
-
- uint16_t segmentTwoX = segmentOneX + segmentWidth + 2;
- uint16_t segmentTwoY = segmentOneY + segmentHeight + 2;
-
- uint16_t segmentThreeX = segmentTwoX;
- uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2 + segmentHeight + 2;
-
- uint16_t segmentFourX = segmentOneX;
- uint16_t segmentFourY = segmentThreeY + segmentWidth + 2;
-
- uint16_t segmentFiveX = x;
- uint16_t segmentFiveY = segmentThreeY;
-
- uint16_t segmentSixX = x;
- uint16_t segmentSixY = segmentTwoY;
-
- uint16_t segmentSevenX = segmentOneX;
- uint16_t segmentSevenY = segmentTwoY + segmentWidth + 2;
-
- if (numbers[number][0]) {
- drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight);
- }
-
- if (numbers[number][1]) {
- drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight);
- }
-
- if (numbers[number][2]) {
- drawVerticalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight);
- }
-
- if (numbers[number][3]) {
- drawHorizontalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight);
- }
-
- if (numbers[number][4]) {
- drawVerticalSegment(display, segmentFiveX, segmentFiveY, segmentWidth, segmentHeight);
- }
-
- if (numbers[number][5]) {
- drawVerticalSegment(display, segmentSixX, segmentSixY, segmentWidth, segmentHeight);
- }
-
- if (numbers[number][6]) {
- drawHorizontalSegment(display, segmentSevenX, segmentSevenY, segmentWidth, segmentHeight);
- }
-}
-
-void Screen::drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height)
-{
- int halfHeight = height / 2;
-
- // draw central rectangle
- display->fillRect(x, y, width, height);
-
- // draw end triangles
- display->fillTriangle(x, y, x, y + height - 1, x - halfHeight, y + halfHeight);
-
- display->fillTriangle(x + width, y, x + width + halfHeight, y + halfHeight, x + width, y + height - 1);
-}
-
-void Screen::drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height)
-{
- int halfHeight = height / 2;
-
- // draw central rectangle
- display->fillRect(x, y, height, width);
-
- // draw end triangles
- display->fillTriangle(x + halfHeight, y - halfHeight, x + height - 1, y, x, y);
-
- display->fillTriangle(x, y + width, x + height - 1, y + width, x + halfHeight, y + width + halfHeight);
-}
-
-void Screen::drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y)
-{
- display->drawFastImage(x, y, 18, 14, bluetoothConnectedIcon);
-}
-
-// Draw an analog clock
-void Screen::drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- display->setTextAlignment(TEXT_ALIGN_LEFT);
-
- drawBattery(display, x, y + 7, imgBattery, powerStatus);
-
- if (powerStatus->getHasBattery()) {
- String batteryPercent = String(powerStatus->getBatteryChargePercent()) + "%";
-
- display->setFont(FONT_SMALL);
-
- display->drawString(x + 20, y + 2, batteryPercent);
- }
-
- if (nimbleBluetooth && nimbleBluetooth->isConnected()) {
- drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2);
- }
-
- drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, screen->digitalWatchFace, 1);
-
- // clock face center coordinates
- int16_t centerX = display->getWidth() / 2;
- int16_t centerY = display->getHeight() / 2;
-
- // clock face radius
- int16_t radius = (display->getWidth() / 2) * 0.8;
-
- // noon (0 deg) coordinates (outermost circle)
- int16_t noonX = centerX;
- int16_t noonY = centerY - radius;
-
- // second hand radius and y coordinate (outermost circle)
- int16_t secondHandNoonY = noonY + 1;
-
- // tick mark outer y coordinate; (first nested circle)
- int16_t tickMarkOuterNoonY = secondHandNoonY;
-
- // seconds tick mark inner y coordinate; (second nested circle)
- double secondsTickMarkInnerNoonY = (double)noonY + 8;
-
- // hours tick mark inner y coordinate; (third nested circle)
- double hoursTickMarkInnerNoonY = (double)noonY + 16;
-
- // minute hand y coordinate
- int16_t minuteHandNoonY = secondsTickMarkInnerNoonY + 4;
-
- // hour string y coordinate
- int16_t hourStringNoonY = minuteHandNoonY + 18;
-
- // hour hand radius and y coordinate
- int16_t hourHandRadius = radius * 0.55;
- int16_t hourHandNoonY = centerY - hourHandRadius;
-
- display->setColor(OLEDDISPLAY_COLOR::WHITE);
- display->drawCircle(centerX, centerY, radius);
-
- uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
- if (rtc_sec > 0) {
- long hms = rtc_sec % SEC_PER_DAY;
- hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
-
- // Tear apart hms into h:m:s
- int hour = hms / SEC_PER_HOUR;
- int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
- int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN
-
- hour = hour > 12 ? hour - 12 : hour;
-
- int16_t degreesPerHour = 30;
- int16_t degreesPerMinuteOrSecond = 6;
-
- double hourBaseAngle = hour * degreesPerHour;
- double hourAngleOffset = ((double)minute / 60) * degreesPerHour;
- double hourAngle = radians(hourBaseAngle + hourAngleOffset);
-
- double minuteBaseAngle = minute * degreesPerMinuteOrSecond;
- double minuteAngleOffset = ((double)second / 60) * degreesPerMinuteOrSecond;
- double minuteAngle = radians(minuteBaseAngle + minuteAngleOffset);
-
- double secondAngle = radians(second * degreesPerMinuteOrSecond);
-
- double hourX = sin(-hourAngle) * (hourHandNoonY - centerY) + noonX;
- double hourY = cos(-hourAngle) * (hourHandNoonY - centerY) + centerY;
-
- double minuteX = sin(-minuteAngle) * (minuteHandNoonY - centerY) + noonX;
- double minuteY = cos(-minuteAngle) * (minuteHandNoonY - centerY) + centerY;
-
- double secondX = sin(-secondAngle) * (secondHandNoonY - centerY) + noonX;
- double secondY = cos(-secondAngle) * (secondHandNoonY - centerY) + centerY;
-
- display->setFont(FONT_MEDIUM);
-
- // draw minute and hour tick marks and hour numbers
- for (uint16_t angle = 0; angle < 360; angle += 6) {
- double angleInRadians = radians(angle);
-
- double sineAngleInRadians = sin(-angleInRadians);
- double cosineAngleInRadians = cos(-angleInRadians);
-
- double endX = sineAngleInRadians * (tickMarkOuterNoonY - centerY) + noonX;
- double endY = cosineAngleInRadians * (tickMarkOuterNoonY - centerY) + centerY;
-
- if (angle % degreesPerHour == 0) {
- double startX = sineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + noonX;
- double startY = cosineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + centerY;
-
- // draw hour tick mark
- display->drawLine(startX, startY, endX, endY);
-
- static char buffer[2];
-
- uint8_t hourInt = (angle / 30);
-
- if (hourInt == 0) {
- hourInt = 12;
- }
-
- // hour number x offset needs to be adjusted for some cases
- int8_t hourStringXOffset;
- int8_t hourStringYOffset = 13;
-
- switch (hourInt) {
- case 3:
- hourStringXOffset = 5;
- break;
- case 9:
- hourStringXOffset = 7;
- break;
- case 10:
- case 11:
- hourStringXOffset = 8;
- break;
- case 12:
- hourStringXOffset = 13;
- break;
- default:
- hourStringXOffset = 6;
- break;
- }
-
- double hourStringX = (sineAngleInRadians * (hourStringNoonY - centerY) + noonX) - hourStringXOffset;
- double hourStringY = (cosineAngleInRadians * (hourStringNoonY - centerY) + centerY) - hourStringYOffset;
-
- // draw hour number
- display->drawStringf(hourStringX, hourStringY, buffer, "%d", hourInt);
- }
-
- if (angle % degreesPerMinuteOrSecond == 0) {
- double startX = sineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + noonX;
- double startY = cosineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + centerY;
-
- // draw minute tick mark
- display->drawLine(startX, startY, endX, endY);
- }
- }
-
- // draw hour hand
- display->drawLine(centerX, centerY, hourX, hourY);
-
- // draw minute hand
- display->drawLine(centerX, centerY, minuteX, minuteY);
-
- // draw second hand
- display->drawLine(centerX, centerY, secondX, secondY);
- }
-}
-
-#endif
-
-// Get an absolute time from "seconds ago" info. Returns false if no valid timestamp possible
-bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo)
-{
- // Cache the result - avoid frequent recalculation
- static uint8_t hoursCached = 0, minutesCached = 0;
- static uint32_t daysAgoCached = 0;
- static uint32_t secondsAgoCached = 0;
- static bool validCached = false;
-
- // Abort: if timezone not set
- if (strlen(config.device.tzdef) == 0) {
- validCached = false;
- return validCached;
- }
-
- // Abort: if invalid pointers passed
- if (hours == nullptr || minutes == nullptr || daysAgo == nullptr) {
- validCached = false;
- return validCached;
- }
-
- // Abort: if time seems invalid.. (> 6 months ago, probably seen before RTC set)
- if (secondsAgo > SEC_PER_DAY * 30UL * 6) {
- validCached = false;
- return validCached;
- }
-
- // If repeated request, don't bother recalculating
- if (secondsAgo - secondsAgoCached < 60 && secondsAgoCached != 0) {
- if (validCached) {
- *hours = hoursCached;
- *minutes = minutesCached;
- *daysAgo = daysAgoCached;
- }
- return validCached;
- }
-
- // Get local time
- uint32_t secondsRTC = getValidTime(RTCQuality::RTCQualityDevice, true); // Get local time
-
- // Abort: if RTC not set
- if (!secondsRTC) {
- validCached = false;
- return validCached;
- }
-
- // Get absolute time when last seen
- uint32_t secondsSeenAt = secondsRTC - secondsAgo;
-
- // Calculate daysAgo
- *daysAgo = (secondsRTC / SEC_PER_DAY) - (secondsSeenAt / SEC_PER_DAY); // How many "midnights" have passed
-
- // Get seconds since midnight
- uint32_t hms = (secondsRTC - secondsAgo) % SEC_PER_DAY;
- hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
-
- // Tear apart hms into hours and minutes
- *hours = hms / SEC_PER_HOUR;
- *minutes = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
-
- // Cache the result
- daysAgoCached = *daysAgo;
- hoursCached = *hours;
- minutesCached = *minutes;
- secondsAgoCached = secondsAgo;
-
- validCached = true;
- return validCached;
-}
-
-/// Draw the last text message we received
-static void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- // the max length of this buffer is much longer than we can possibly print
- static char tempBuf[237];
-
- const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
- meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
- // LOG_DEBUG("Draw text message from 0x%x: %s", mp.from,
- // mp.decoded.variant.data.decoded.bytes);
-
- // Demo for drawStringMaxWidth:
- // with the third parameter you can define the width after which words will
- // be wrapped. Currently only spaces and "-" are allowed for wrapping
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_SMALL);
- if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
- display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL);
- display->setColor(BLACK);
- }
-
- // For time delta
- uint32_t seconds = sinceReceived(&mp);
- uint32_t minutes = seconds / 60;
- uint32_t hours = minutes / 60;
- uint32_t days = hours / 24;
-
- // For timestamp
- uint8_t timestampHours, timestampMinutes;
- int32_t daysAgo;
- bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo);
-
- // If bold, draw twice, shifting right by one pixel
- for (uint8_t xOff = 0; xOff <= (config.display.heading_bold ? 1 : 0); xOff++) {
- // Show a timestamp if received today, but longer than 15 minutes ago
- if (useTimestamp && minutes >= 15 && daysAgo == 0) {
- display->drawStringf(xOff + x, 0 + y, tempBuf, "At %02hu:%02hu from %s", timestampHours, timestampMinutes,
- (node && node->has_user) ? node->user.short_name : "???");
- }
- // Timestamp yesterday (if display is wide enough)
- else if (useTimestamp && daysAgo == 1 && display->width() >= 200) {
- display->drawStringf(xOff + x, 0 + y, tempBuf, "Yesterday %02hu:%02hu from %s", timestampHours, timestampMinutes,
- (node && node->has_user) ? node->user.short_name : "???");
- }
- // Otherwise, show a time delta
- else {
- display->drawStringf(xOff + x, 0 + y, tempBuf, "%s ago from %s",
- screen->drawTimeDelta(days, hours, minutes, seconds).c_str(),
- (node && node->has_user) ? node->user.short_name : "???");
- }
- }
-
- display->setColor(WHITE);
-#ifndef EXCLUDE_EMOJI
- const char *msg = reinterpret_cast(mp.decoded.payload.bytes);
- if (strcmp(msg, "\U0001F44D") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - thumbs_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - thumbs_height) / 2 + 2 + 5, thumbs_width, thumbs_height,
- thumbup);
- } else if (strcmp(msg, "\U0001F44E") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - thumbs_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - thumbs_height) / 2 + 2 + 5, thumbs_width, thumbs_height,
- thumbdown);
- } else if (strcmp(msg, "\U0001F60A") == 0 || strcmp(msg, "\U0001F600") == 0 || strcmp(msg, "\U0001F642") == 0 ||
- strcmp(msg, "\U0001F609") == 0 ||
- strcmp(msg, "\U0001F601") == 0) { // matches 5 different common smileys, so that the phone user doesn't have to
- // remember which one is compatible
- display->drawXbm(x + (SCREEN_WIDTH - smiley_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - smiley_height) / 2 + 2 + 5, smiley_width, smiley_height,
- smiley);
- } else if (strcmp(msg, "❓") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - question_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - question_height) / 2 + 2 + 5, question_width, question_height,
- question);
- } else if (strcmp(msg, "‼️") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - bang_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - bang_height) / 2 + 2 + 5,
- bang_width, bang_height, bang);
- } else if (strcmp(msg, "\U0001F4A9") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - poo_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - poo_height) / 2 + 2 + 5,
- poo_width, poo_height, poo);
- } else if (strcmp(msg, "\U0001F923") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - haha_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - haha_height) / 2 + 2 + 5,
- haha_width, haha_height, haha);
- } else if (strcmp(msg, "\U0001F44B") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - wave_icon_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - wave_icon_height) / 2 + 2 + 5, wave_icon_width,
- wave_icon_height, wave_icon);
- } else if (strcmp(msg, "\U0001F920") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - cowboy_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - cowboy_height) / 2 + 2 + 5, cowboy_width, cowboy_height,
- cowboy);
- } else if (strcmp(msg, "\U0001F42D") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - deadmau5_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - deadmau5_height) / 2 + 2 + 5, deadmau5_width, deadmau5_height,
- deadmau5);
- } else if (strcmp(msg, "\xE2\x98\x80\xEF\xB8\x8F") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - sun_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - sun_height) / 2 + 2 + 5,
- sun_width, sun_height, sun);
- } else if (strcmp(msg, "\u2614") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - rain_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - rain_height) / 2 + 2 + 10,
- rain_width, rain_height, rain);
- } else if (strcmp(msg, "☁️") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - cloud_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - cloud_height) / 2 + 2 + 5, cloud_width, cloud_height, cloud);
- } else if (strcmp(msg, "🌫️") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - fog_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - fog_height) / 2 + 2 + 5,
- fog_width, fog_height, fog);
- } else if (strcmp(msg, "\U0001F608") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - devil_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - devil_height) / 2 + 2 + 5, devil_width, devil_height, devil);
- } else if (strcmp(msg, "♥️") == 0 || strcmp(msg, "\U0001F9E1") == 0 || strcmp(msg, "\U00002763") == 0 ||
- strcmp(msg, "\U00002764") == 0 || strcmp(msg, "\U0001F495") == 0 || strcmp(msg, "\U0001F496") == 0 ||
- strcmp(msg, "\U0001F497") == 0 || strcmp(msg, "\U0001F498") == 0) {
- display->drawXbm(x + (SCREEN_WIDTH - heart_width) / 2,
- y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - heart_height) / 2 + 2 + 5, heart_width, heart_height, heart);
- } else {
- snprintf(tempBuf, sizeof(tempBuf), "%s", mp.decoded.payload.bytes);
- display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), tempBuf);
- }
-#else
- snprintf(tempBuf, sizeof(tempBuf), "%s", mp.decoded.payload.bytes);
- display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), tempBuf);
-#endif
-}
-
-/// Draw a series of fields in a column, wrapping to multiple columns if needed
-void Screen::drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields)
-{
- // The coordinates define the left starting point of the text
- display->setTextAlignment(TEXT_ALIGN_LEFT);
-
- const char **f = fields;
- int xo = x, yo = y;
- while (*f) {
- display->drawString(xo, yo, *f);
- if ((display->getColor() == BLACK) && config.display.heading_bold)
- display->drawString(xo + 1, yo, *f);
-
- display->setColor(WHITE);
- yo += FONT_HEIGHT_SMALL;
- if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) {
- xo += SCREEN_WIDTH / 2;
- yo = 0;
- }
- f++;
- }
-}
-
-// Draw nodes status
-static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const NodeStatus *nodeStatus)
-{
- char usersString[20];
- snprintf(usersString, sizeof(usersString), "%d/%d", nodeStatus->getNumOnline(), nodeStatus->getNumTotal());
-#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(DISPLAY_FORCE_SMALL_FONTS)
- display->drawFastImage(x, y + 3, 8, 8, imgUser);
-#else
- display->drawFastImage(x, y, 8, 8, imgUser);
-#endif
- display->drawString(x + 10, y - 2, usersString);
- if (config.display.heading_bold)
- display->drawString(x + 11, y - 2, usersString);
-}
-#if HAS_GPS
-// Draw GPS status summary
-static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps)
-{
- if (config.position.fixed_position) {
- // GPS coordinates are currently fixed
- display->drawString(x - 1, y - 2, "Fixed GPS");
- if (config.display.heading_bold)
- display->drawString(x, y - 2, "Fixed GPS");
- return;
- }
- if (!gps->getIsConnected()) {
- display->drawString(x, y - 2, "No GPS");
- if (config.display.heading_bold)
- display->drawString(x + 1, y - 2, "No GPS");
- return;
- }
- display->drawFastImage(x, y, 6, 8, gps->getHasLock() ? imgPositionSolid : imgPositionEmpty);
- if (!gps->getHasLock()) {
- display->drawString(x + 8, y - 2, "No sats");
- if (config.display.heading_bold)
- display->drawString(x + 9, y - 2, "No sats");
- return;
- } else {
- char satsString[3];
- uint8_t bar[2] = {0};
-
- // Draw DOP signal bars
- for (int i = 0; i < 5; i++) {
- if (gps->getDOP() <= dopThresholds[i])
- bar[0] = ~((1 << (5 - i)) - 1);
- else
- bar[0] = 0b10000000;
- // bar[1] = bar[0];
- display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar);
- }
-
- // Draw satellite image
- display->drawFastImage(x + 24, y, 8, 8, imgSatellite);
-
- // Draw the number of satellites
- snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites());
- display->drawString(x + 34, y - 2, satsString);
- if (config.display.heading_bold)
- display->drawString(x + 35, y - 2, satsString);
- }
-}
-
-// Draw status when GPS is disabled or not present
-static void drawGPSpowerstat(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps)
-{
- String 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 = SCREEN_WIDTH - display->getStringWidth(displayLine);
- } else {
- displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present"
- : "GPS is disabled";
- pos = (SCREEN_WIDTH - display->getStringWidth(displayLine)) / 2;
- }
- display->drawString(x + pos, y, displayLine);
-}
-
-static void drawGPSAltitude(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps)
-{
- String displayLine = "";
- 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()));
- displayLine = "Altitude: " + String(geoCoord.getAltitude()) + "m";
- if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
- displayLine = "Altitude: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft";
- display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine);
- }
-}
-
-// Draw GPS status coordinates
-static void drawGPScoordinates(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps)
-{
- auto gpsFormat = config.display.gps_format;
- String displayLine = "";
-
- if (!gps->getIsConnected() && !config.position.fixed_position) {
- displayLine = "No GPS present";
- 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 (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) {
- char coordinateLine[22];
- if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees
- snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7,
- geoCoord.getLongitude() * 1e-7);
- } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator
- snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(),
- geoCoord.getUTMEasting(), geoCoord.getUTMNorthing());
- } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System
- snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(),
- geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(),
- geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing());
- } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code
- geoCoord.getOLCCode(coordinateLine);
- } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference
- if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region
- snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary");
- else
- snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(),
- geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing());
- }
-
- // If fixed position, display text "Fixed GPS" alternating with the coordinates.
- if (config.position.fixed_position) {
- if ((millis() / 10000) % 2) {
- display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine);
- } else {
- display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS");
- }
- } else {
- display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine);
- }
- } else {
- char latLine[22];
- char lonLine[22];
- snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(),
- geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP());
- snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(),
- geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP());
- display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1, latLine);
- display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(lonLine))) / 2, y, lonLine);
- }
- }
-}
-#endif
/**
* Given a recent lat/lon return a guess of the heading the user is walking on.
*
@@ -1290,239 +218,10 @@ float Screen::estimatedHeading(double lat, double lon)
/// We will skip one node - the one for us, so we just blindly loop over all
/// nodes
-static size_t nodeIndex;
static int8_t prevFrame = -1;
-// Draw the arrow pointing to a node's location
-void Screen::drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
-{
- Point tip(0.0f, 0.5f), tail(0.0f, -0.35f); // pointing up initially
- float arrowOffsetX = 0.14f, arrowOffsetY = 1.0f;
- Point leftArrow(tip.x - arrowOffsetX, tip.y - arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y - arrowOffsetY);
-
- Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
-
- for (int i = 0; i < 4; i++) {
- arrowPoints[i]->rotate(headingRadian);
- arrowPoints[i]->scale(compassDiam * 0.6);
- arrowPoints[i]->translate(compassX, compassY);
- }
- /* Old arrow
- display->drawLine(tip.x, tip.y, tail.x, tail.y);
- display->drawLine(leftArrow.x, leftArrow.y, tip.x, tip.y);
- display->drawLine(rightArrow.x, rightArrow.y, tip.x, tip.y);
- display->drawLine(leftArrow.x, leftArrow.y, tail.x, tail.y);
- display->drawLine(rightArrow.x, rightArrow.y, tail.x, tail.y);
- */
-#ifdef USE_EINK
- display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
-#else
- display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
-#endif
- display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
-}
-
-// Get a string representation of the time passed since something happened
-void Screen::getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength)
-{
- // Use an absolute timestamp in some cases.
- // Particularly useful with E-Ink displays. Static UI, fewer refreshes.
- uint8_t timestampHours, timestampMinutes;
- int32_t daysAgo;
- bool useTimestamp = deltaToTimestamp(agoSecs, ×tampHours, ×tampMinutes, &daysAgo);
-
- if (agoSecs < 120) // last 2 mins?
- snprintf(timeStr, maxLength, "%u seconds ago", agoSecs);
- // -- if suitable for timestamp --
- else if (useTimestamp && agoSecs < 15 * SECONDS_IN_MINUTE) // Last 15 minutes
- snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / SECONDS_IN_MINUTE);
- else if (useTimestamp && daysAgo == 0) // Today
- snprintf(timeStr, maxLength, "Last seen: %02u:%02u", (unsigned int)timestampHours, (unsigned int)timestampMinutes);
- else if (useTimestamp && daysAgo == 1) // Yesterday
- snprintf(timeStr, maxLength, "Seen yesterday");
- else if (useTimestamp && daysAgo > 1) // Last six months (capped by deltaToTimestamp method)
- snprintf(timeStr, maxLength, "%li days ago", (long)daysAgo);
- // -- if using time delta instead --
- else if (agoSecs < 120 * 60) // last 2 hrs
- snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / 60);
- // Only show hours ago if it's been less than 6 months. Otherwise, we may have bad data.
- else if ((agoSecs / 60 / 60) < (hours_in_month * 6))
- snprintf(timeStr, maxLength, "%u hours ago", agoSecs / 60 / 60);
- else
- snprintf(timeStr, maxLength, "unknown age");
-}
-
-void Screen::drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading)
-{
- // If north is supposed to be at the top of the compass we want rotation to be +0
- if (config.display.compass_north_top)
- myHeading = -0;
- /* N sign points currently not deleted*/
- Point N1(-0.04f, 0.65f), N2(0.04f, 0.65f); // N sign points (N1-N4)
- Point N3(-0.04f, 0.55f), N4(0.04f, 0.55f);
- Point NC1(0.00f, 0.50f); // north circle center point
- Point *rosePoints[] = {&N1, &N2, &N3, &N4, &NC1};
-
- uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT);
-
- for (int i = 0; i < 5; i++) {
- // North on compass will be negative of heading
- rosePoints[i]->rotate(-myHeading);
- rosePoints[i]->scale(compassDiam);
- rosePoints[i]->translate(compassX, compassY);
- }
-
- /* changed the N sign to a small circle on the compass circle.
- display->drawLine(N1.x, N1.y, N3.x, N3.y);
- display->drawLine(N2.x, N2.y, N4.x, N4.y);
- display->drawLine(N1.x, N1.y, N4.x, N4.y);
- */
- display->drawCircle(NC1.x, NC1.y, 4); // North sign circle, 4px radius is sufficient for all displays.
-}
-
-uint16_t Screen::getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
-{
- uint16_t diam = 0;
- uint16_t offset = 0;
-
- if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT)
- offset = FONT_HEIGHT_SMALL;
-
- // get the smaller of the 2 dimensions and subtract 20
- if (displayWidth > (displayHeight - offset)) {
- diam = displayHeight - offset;
- // if 2/3 of the other size would be smaller, use that
- if (diam > (displayWidth * 2 / 3)) {
- diam = displayWidth * 2 / 3;
- }
- } else {
- diam = displayWidth;
- if (diam > ((displayHeight - offset) * 2 / 3)) {
- diam = (displayHeight - offset) * 2 / 3;
- }
- }
-
- return diam - 20;
-};
-
-static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- // We only advance our nodeIndex if the frame # has changed - because
- // drawNodeInfo will be called repeatedly while the frame is shown
- if (state->currentFrame != prevFrame) {
- prevFrame = state->currentFrame;
-
- nodeIndex = (nodeIndex + 1) % nodeDB->getNumMeshNodes();
- meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(nodeIndex);
- if (n->num == nodeDB->getNodeNum()) {
- // Don't show our node, just skip to next
- nodeIndex = (nodeIndex + 1) % nodeDB->getNumMeshNodes();
- n = nodeDB->getMeshNodeByIndex(nodeIndex);
- }
- }
-
- meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(nodeIndex);
-
- display->setFont(FONT_SMALL);
-
- // The coordinates define the left starting point of the text
- display->setTextAlignment(TEXT_ALIGN_LEFT);
-
- if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
- display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL);
- }
-
- const char *username = node->has_user ? node->user.long_name : "Unknown Name";
-
- static char signalStr[20];
-
- // section here to choose whether to display hops away rather than signal strength if more than 0 hops away.
- if (node->hops_away > 0) {
- snprintf(signalStr, sizeof(signalStr), "Hops Away: %d", node->hops_away);
- } else {
- snprintf(signalStr, sizeof(signalStr), "Signal: %d%%", clamp((int)((node->snr + 10) * 5), 0, 100));
- }
-
- static char lastStr[20];
- screen->getTimeAgoStr(sinceLastSeen(node), lastStr, sizeof(lastStr));
-
- static char distStr[20];
- if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
- strncpy(distStr, "? mi ?°", sizeof(distStr)); // might not have location data
- } else {
- strncpy(distStr, "? km ?°", sizeof(distStr));
- }
- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
- const char *fields[] = {username, lastStr, signalStr, distStr, NULL};
- int16_t compassX = 0, compassY = 0;
- uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT);
-
- // coordinates for the center of the compass/circle
- if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) {
- compassX = x + SCREEN_WIDTH - compassDiam / 2 - 5;
- compassY = y + SCREEN_HEIGHT / 2;
- } else {
- compassX = x + SCREEN_WIDTH - compassDiam / 2 - 5;
- compassY = y + FONT_HEIGHT_SMALL + (SCREEN_HEIGHT - FONT_HEIGHT_SMALL) / 2;
- }
- bool hasNodeHeading = false;
-
- if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading())) {
- const meshtastic_PositionLite &op = ourNode->position;
- float myHeading;
- if (screen->hasHeading())
- myHeading = (screen->getHeading()) * PI / 180; // gotta convert compass degrees to Radians
- else
- myHeading = screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
- screen->drawCompassNorth(display, compassX, compassY, myHeading);
-
- if (nodeDB->hasValidPosition(node)) {
- // display direction toward node
- hasNodeHeading = true;
- const meshtastic_PositionLite &p = node->position;
- float d =
- GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
-
- float bearingToOther =
- GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
- // If the top of the compass is a static north then bearingToOther can be drawn on the compass directly
- // If the top of the compass is not a static north we need adjust bearingToOther based on heading
- if (!config.display.compass_north_top)
- bearingToOther -= myHeading;
- screen->drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther);
-
- float bearingToOtherDegrees = (bearingToOther < 0) ? bearingToOther + 2 * PI : bearingToOther;
- bearingToOtherDegrees = bearingToOtherDegrees * 180 / PI;
-
- if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
- if (d < (2 * MILES_TO_FEET))
- snprintf(distStr, sizeof(distStr), "%.0fft %.0f°", d * METERS_TO_FEET, bearingToOtherDegrees);
- else
- snprintf(distStr, sizeof(distStr), "%.1fmi %.0f°", d * METERS_TO_FEET / MILES_TO_FEET,
- bearingToOtherDegrees);
- } else {
- if (d < 2000)
- snprintf(distStr, sizeof(distStr), "%.0fm %.0f°", d, bearingToOtherDegrees);
- else
- snprintf(distStr, sizeof(distStr), "%.1fkm %.0f°", d / 1000, bearingToOtherDegrees);
- }
- }
- }
- if (!hasNodeHeading) {
- // direction to node is unknown so display question mark
- // Debug info for gps lock errors
- // LOG_DEBUG("ourNode %d, ourPos %d, theirPos %d", !!ourNode, ourNode && hasValidPosition(ourNode),
- // hasValidPosition(node));
- display->drawString(compassX - FONT_HEIGHT_SMALL / 4, compassY - FONT_HEIGHT_SMALL / 2, "?");
- }
- display->drawCircle(compassX, compassY, compassDiam / 2);
-
- if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
- display->setColor(BLACK);
- }
- // Must be after distStr is populated
- screen->drawColumns(display, x, y, fields);
-}
+// Combined dynamic node list frame cycling through LastHeard, HopSignal, and Distance modes
+// Uses a single frame and changes data every few seconds (E-Ink variant is separate)
#if defined(ESP_PLATFORM) && defined(USE_ST7789)
SPIClass SPI1(HSPI);
@@ -1541,6 +240,7 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
ST7789_MISO, ST7789_SCK);
#else
dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
+ static_cast(dispdev)->setRGB(COLOR565(255, 255, 128));
#endif
#elif defined(USE_SSD1306)
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
@@ -1558,15 +258,17 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
#elif defined(USE_ST7567)
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
-#elif ARCH_PORTDUINO && !HAS_TFT
- if (settingsMap[displayPanel] != no_screen) {
- LOG_DEBUG("Make TFTDisplay!");
- dispdev = new TFTDisplay(address.address, -1, -1, geometry,
- (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
- } else {
- dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
- (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
- isAUTOOled = true;
+#elif ARCH_PORTDUINO
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
+ if (settingsMap[displayPanel] != no_screen) {
+ LOG_DEBUG("Make TFTDisplay!");
+ dispdev = new TFTDisplay(address.address, -1, -1, geometry,
+ (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
+ } else {
+ dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
+ (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
+ isAUTOOled = true;
+ }
}
#else
dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
@@ -1590,7 +292,7 @@ Screen::~Screen()
void Screen::doDeepSleep()
{
#ifdef USE_EINK
- setOn(false, drawDeepSleepScreen);
+ setOn(false, graphics::UIRenderer::drawDeepSleepFrame);
#ifdef PIN_EINK_EN
digitalWrite(PIN_EINK_EN, LOW); // power off backlight
#endif
@@ -1608,7 +310,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
if (on != screenOn) {
if (on) {
LOG_INFO("Turn on screen");
- buttonThread->setScreenFlag(true);
powerMon->setState(meshtastic_PowerMon_State_Screen_On);
#ifdef T_WATCH_S3
PMU->enablePowerOutput(XPOWERS_ALDO2);
@@ -1652,8 +353,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
// eInkScreensaver parameter is usually NULL (default argument), default frame used instead
setScreensaverFrames(einkScreensaver);
#endif
- LOG_INFO("Turn off screen");
- buttonThread->setScreenFlag(false);
#ifdef ELECROW_ThinkNode_M1
if (digitalRead(PIN_EINK_EN) == HIGH) {
digitalWrite(PIN_EINK_EN, LOW);
@@ -1688,10 +387,10 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
void Screen::setup()
{
- // We don't set useDisplay until setup() is called, because some boards have a declaration of this object but the device
- // is never found when probing i2c and therefore we don't call setup and never want to do (invalid) accesses to this device.
+ // === Enable display rendering ===
useDisplay = true;
+ // === Detect OLED subtype (if supported by board variant) ===
#ifdef AutoOLEDWire_h
if (isAUTOOled)
static_cast(dispdev)->setDetected(model);
@@ -1702,66 +401,62 @@ void Screen::setup()
#endif
#if defined(USE_ST7789) && defined(TFT_MESH)
- // Heltec T114 and T190: honor a custom text color, if defined in variant.h
+ // Apply custom RGB color (e.g. Heltec T114/T190)
static_cast(dispdev)->setRGB(TFT_MESH);
#endif
- // Initialising the UI will init the display too.
+ // === Initialize display and UI system ===
ui->init();
-
displayWidth = dispdev->width();
displayHeight = dispdev->height();
- ui->setTimePerTransition(0);
+ ui->setTimePerTransition(0); // Disable animation delays
+ ui->setIndicatorPosition(BOTTOM); // Not used (indicators disabled below)
+ ui->setIndicatorDirection(LEFT_RIGHT); // Not used (indicators disabled below)
+ ui->setFrameAnimation(SLIDE_LEFT); // Used only when indicators are active
+ ui->disableAllIndicators(); // Disable page indicator dots
+ ui->getUiState()->userData = this; // Allow static callbacks to access Screen instance
- ui->setIndicatorPosition(BOTTOM);
- // Defines where the first frame is located in the bar.
- ui->setIndicatorDirection(LEFT_RIGHT);
- ui->setFrameAnimation(SLIDE_LEFT);
- // Don't show the page swipe dots while in boot screen.
- ui->disableAllIndicators();
- // Store a pointer to Screen so we can get to it from static functions.
- ui->getUiState()->userData = this;
+ // === Set custom overlay callbacks ===
+ static OverlayCallback overlays[] = {
+ graphics::UIRenderer::drawFunctionOverlay, // For mute/buzzer modifiers etc.
+ graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
+ };
+ ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
- // Set the utf8 conversion function
+ // === Enable UTF-8 to display mapping ===
dispdev->setFontTableLookupFunction(customFontTableLookup);
#ifdef USERPREFS_OEM_TEXT
- logo_timeout *= 2; // Double the time if we have a custom logo
+ logo_timeout *= 2; // Give more time for branded boot logos
#endif
- // Add frames.
- EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST);
- alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void {
+ // === Configure alert frames (e.g., "Resuming..." or region name) ===
+ EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip slow refresh
+ alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) {
#ifdef ARCH_ESP32
- if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) {
- drawFrameText(display, state, x, y, "Resuming...");
- } else
+ if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1)
+ graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming...");
+ else
#endif
{
- // Draw region in upper left
- const char *region = myRegion ? myRegion->name : NULL;
- drawIconScreen(region, display, state, x, y);
+ const char *region = myRegion ? myRegion->name : nullptr;
+ graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
}
};
ui->setFrames(alertFrames, 1);
- // No overlays.
- ui->setOverlays(nullptr, 0);
+ ui->disableAutoTransition(); // Require manual navigation between frames
- // Require presses to switch between frames.
- ui->disableAutoTransition();
-
- // Set up a log buffer with 3 lines, 32 chars each.
+ // === Log buffer for on-screen logs (3 lines max) ===
dispdev->setLogBuffer(3, 32);
+ // === Optional screen mirroring or flipping (e.g. for T-Beam orientation) ===
#ifdef SCREEN_MIRROR
dispdev->mirrorScreen();
#else
- // Standard behaviour is to FLIP the screen (needed on T-Beam). If this config item is set, unflip it, and thereby logically
- // flip it. If you have a headache now, you're welcome.
if (!config.display.flip_screen) {
-#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
- defined(ST7789_CS) || defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS)
+#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \
+ defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS)
static_cast(dispdev)->flipScreenVertically();
#elif defined(USE_ST7789)
static_cast(dispdev)->flipScreenVertically();
@@ -1771,30 +466,30 @@ void Screen::setup()
}
#endif
- // Get our hardware ID
+ // === Generate device ID from MAC address ===
uint8_t dmac[6];
getMacAddr(dmac);
- snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]);
+ snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]);
+
#if ARCH_PORTDUINO
- handleSetOn(false); // force clean init
+ handleSetOn(false); // Ensure proper init for Arduino targets
#endif
- // Turn on the display.
+ // === Turn on display and trigger first draw ===
handleSetOn(true);
-
- // On some ssd1306 clones, the first draw command is discarded, so draw it
- // twice initially. Skip this for EINK Displays to save a few seconds during boot
ui->update();
#ifndef USE_EINK
- ui->update();
+ ui->update(); // Some SSD1306 clones drop the first draw, so run twice
#endif
serialSinceMsec = millis();
-#if ARCH_PORTDUINO && !HAS_TFT
- if (settingsMap[touchscreenModule]) {
- touchScreenImpl1 =
- new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch);
- touchScreenImpl1->init();
+#if ARCH_PORTDUINO
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
+ if (settingsMap[touchscreenModule]) {
+ touchScreenImpl1 =
+ new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch);
+ touchScreenImpl1->init();
+ }
}
#elif HAS_TOUCHSCREEN
touchScreenImpl1 =
@@ -1802,10 +497,11 @@ void Screen::setup()
touchScreenImpl1->init();
#endif
- // Subscribe to status updates
+ // === Subscribe to device status updates ===
powerStatusObserver.observe(&powerStatus->onNewStatus);
gpsStatusObserver.observe(&gpsStatus->onNewStatus);
nodeStatusObserver.observe(&nodeStatus->onNewStatus);
+
#if !MESHTASTIC_EXCLUDE_ADMIN
adminMessageObserver.observe(adminModule);
#endif
@@ -1814,7 +510,7 @@ void Screen::setup()
if (inputBroker)
inputObserver.observe(inputBroker);
- // Modules can notify screen about refresh
+ // === Notify modules that support UI events ===
MeshModule::observeUIEvents(&uiFrameEventObserver);
}
@@ -1882,7 +578,7 @@ int32_t Screen::runOnce()
if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) {
LOG_INFO("Switch to OEM screen...");
// Change frames.
- static FrameCallback bootOEMFrames[] = {drawOEMBootScreen};
+ static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
ui->update();
@@ -1894,10 +590,13 @@ int32_t Screen::runOnce()
#endif
#ifndef DISABLE_WELCOME_UNSET
- if (showingNormalScreen && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
- setWelcomeFrames();
+ if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
+ LoraRegionPicker(0);
}
#endif
+ if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0) {
+ showOverlayBanner("Rebooting...", 0);
+ }
// Process incoming commands.
for (;;) {
@@ -1924,6 +623,7 @@ int32_t Screen::runOnce()
case Cmd::START_ALERT_FRAME: {
showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away
showingNormalScreen = false;
+ NotificationRenderer::pauseBanner = true;
alertFrames[0] = alertFrame;
#ifdef USE_EINK
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please
@@ -1937,14 +637,11 @@ int32_t Screen::runOnce()
handleStartFirmwareUpdateScreen();
break;
case Cmd::STOP_ALERT_FRAME:
+ NotificationRenderer::pauseBanner = false;
case Cmd::STOP_BOOT_SCREEN:
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame
setFrames();
break;
- case Cmd::PRINT:
- handlePrint(cmd.print_text);
- free(cmd.print_text);
- break;
default:
LOG_ERROR("Invalid screen cmd");
}
@@ -1963,6 +660,7 @@ int32_t Screen::runOnce()
// Switch to a low framerate (to save CPU) when we are not in transition
// but we should only call setTargetFPS when framestate changes, because
// otherwise that breaks animations.
+
if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) {
// oldFrameState = ui->getUiState()->frameState;
targetFramerate = IDLE_FRAMERATE;
@@ -1978,8 +676,8 @@ int32_t Screen::runOnce()
if (config.display.auto_screen_carousel_secs > 0 &&
!Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) {
-// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
-// Carousel is potentially a major source of E-Ink display wear
+ // If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
+ // Carousel is potentially a major source of E-Ink display wear
#if !defined(EINK_BACKGROUND_USES_FAST)
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC);
#endif
@@ -1997,47 +695,18 @@ int32_t Screen::runOnce()
return (1000 / targetFramerate);
}
-void Screen::drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- Screen *screen2 = reinterpret_cast(state->userData);
- screen2->debugInfo.drawFrame(display, state, x, y);
-}
-
-void Screen::drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- Screen *screen2 = reinterpret_cast(state->userData);
- screen2->debugInfo.drawFrameSettings(display, state, x, y);
-}
-
-void Screen::drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- Screen *screen2 = reinterpret_cast(state->userData);
- screen2->debugInfo.drawFrameWiFi(display, state, x, y);
-}
-
/* show a message that the SSL cert is being built
* it is expected that this will be used during the boot phase */
void Screen::setSSLFrames()
{
if (address_found.address) {
// LOG_DEBUG("Show SSL frames");
- static FrameCallback sslFrames[] = {drawSSLScreen};
+ static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
ui->setFrames(sslFrames, 1);
ui->update();
}
}
-/* show a message that the SSL cert is being built
- * it is expected that this will be used during the boot phase */
-void Screen::setWelcomeFrames()
-{
- if (address_found.address) {
- // LOG_DEBUG("Show Welcome frames");
- static FrameCallback frames[] = {drawWelcomeScreen};
- setFrameImmediateDraw(frames);
- }
-}
-
#ifdef USE_EINK
/// Determine which screensaver frame to use, then set the FrameCallback
void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
@@ -2060,7 +729,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
// Else, display the usual "overlay" screensaver
else {
- screensaverOverlay = drawScreensaverOverlay;
+ screensaverOverlay = graphics::UIRenderer::drawScreensaverOverlay;
ui->setOverlays(&screensaverOverlay, 1);
}
@@ -2096,33 +765,17 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
void Screen::setFrames(FrameFocus focus)
{
uint8_t originalPosition = ui->getUiState()->currentFrame;
+ uint8_t previousFrameCount = framesetInfo.frameCount;
FramesetInfo fsi; // Location of specific frames, for applying focus parameter
LOG_DEBUG("Show standard frames");
showingNormalScreen = true;
-#ifdef USE_EINK
- // If user has disabled the screensaver, warn them after boot
- static bool warnedScreensaverDisabled = false;
- if (config.display.screen_on_secs == 0 && !warnedScreensaverDisabled) {
- screen->print("Screensaver disabled\n");
- warnedScreensaverDisabled = true;
- }
-#endif
-
- moduleFrames = MeshModule::GetMeshModulesWithUIFrames();
- LOG_DEBUG("Show %d module frames", moduleFrames.size());
-#ifdef DEBUG_PORT
- int totalFrameCount = MAX_NUM_NODES + NUM_EXTRA_FRAMES + moduleFrames.size();
- LOG_DEBUG("Total frame count: %d", totalFrameCount);
-#endif
-
- // We don't show the node info of our node (if we have it yet - we should)
- size_t numMeshNodes = nodeDB->getNumMeshNodes();
- if (numMeshNodes > 0)
- numMeshNodes--;
+ indicatorIcons.clear();
size_t numframes = 0;
+ moduleFrames = MeshModule::GetMeshModulesWithUIFrames();
+ LOG_DEBUG("Show %d module frames", moduleFrames.size());
// put all of the module frames first.
// this is a little bit of a dirty hack; since we're going to call
@@ -2137,14 +790,12 @@ void Screen::setFrames(FrameFocus focus)
// Check if the module being drawn has requested focus
// We will honor this request later, if setFrames was triggered by a UIFrameEvent
MeshModule *m = *i;
- if (m->isRequestingFocus()) {
+ if (m->isRequestingFocus())
fsi.positions.focusedModule = numframes;
- }
-
- // Identify the position of specific modules, if we need to know this later
if (m == waypointModule)
fsi.positions.waypoint = numframes;
+ indicatorIcons.push_back(icon_module);
numframes++;
}
@@ -2153,55 +804,103 @@ void Screen::setFrames(FrameFocus focus)
// If we have a critical fault, show it first
fsi.positions.fault = numframes;
if (error_code) {
- normalFrames[numframes++] = drawCriticalFaultFrame;
+ normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame;
+ indicatorIcons.push_back(icon_error);
focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame
}
#if defined(DISPLAY_CLOCK_FRAME)
- normalFrames[numframes++] = screen->digitalWatchFace ? &Screen::drawDigitalClockFrame : &Screen::drawAnalogClockFrame;
+ fsi.positions.clock = numframes;
+ normalFrames[numframes++] = graphics::ClockRenderer::digitalWatchFace ? graphics::ClockRenderer::drawDigitalClockFrame
+ : &graphics::ClockRenderer::drawAnalogClockFrame;
+ indicatorIcons.push_back(icon_clock);
#endif
- // If we have a text message - show it next, unless it's a phone message and we aren't using any special modules
- if (devicestate.has_rx_text_message && shouldDrawMessage(&devicestate.rx_text_message)) {
- fsi.positions.textMessage = numframes;
- normalFrames[numframes++] = drawTextMessageFrame;
+ // Declare this early so it’s available in FOCUS_PRESERVE block
+ bool willInsertTextMessage = shouldDrawMessage(&devicestate.rx_text_message);
+
+ fsi.positions.home = numframes;
+ normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused;
+ indicatorIcons.push_back(icon_home);
+
+ fsi.positions.textMessage = numframes;
+ normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
+ indicatorIcons.push_back(icon_mail);
+
+#ifndef USE_EINK
+ normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen;
+ indicatorIcons.push_back(icon_nodes);
+#endif
+
+// Show detailed node views only on E-Ink builds
+#ifdef USE_EINK
+ normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen;
+ indicatorIcons.push_back(icon_nodes);
+
+ normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen;
+ indicatorIcons.push_back(icon_signal);
+
+ normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen;
+ indicatorIcons.push_back(icon_distance);
+#endif
+#if HAS_GPS
+ normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses;
+ indicatorIcons.push_back(icon_list);
+
+ fsi.positions.gps = numframes;
+ normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen;
+ indicatorIcons.push_back(icon_compass);
+#endif
+ if (RadioLibInterface::instance) {
+ fsi.positions.lora = numframes;
+ normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused;
+ indicatorIcons.push_back(icon_radio);
+ }
+ if (!dismissedFrames.memory) {
+ fsi.positions.memory = numframes;
+ normalFrames[numframes++] = graphics::DebugRenderer::drawMemoryUsage;
+ indicatorIcons.push_back(icon_memory);
+ }
+#if !defined(DISPLAY_CLOCK_FRAME)
+ fsi.positions.clock = numframes;
+ normalFrames[numframes++] = graphics::ClockRenderer::drawDigitalClockFrame;
+ indicatorIcons.push_back(icon_clock);
+#endif
+
+ // We don't show the node info of our node (if we have it yet - we should)
+ size_t numMeshNodes = nodeDB->getNumMeshNodes();
+ if (numMeshNodes > 0)
+ numMeshNodes--;
+
+ for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
+ const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
+ if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
+ if (fsi.positions.firstFavorite == 255)
+ fsi.positions.firstFavorite = numframes;
+ fsi.positions.lastFavorite = numframes;
+ normalFrames[numframes++] = graphics::UIRenderer::drawNodeInfo;
+ indicatorIcons.push_back(icon_node);
+ }
}
- // then all the nodes
- // We only show a few nodes in our scrolling list - because meshes with many nodes would have too many screens
- size_t numToShow = min(numMeshNodes, 4U);
- for (size_t i = 0; i < numToShow; i++)
- normalFrames[numframes++] = drawNodeInfo;
-
- // then the debug info
- //
- // Since frames are basic function pointers, we have to use a helper to
- // call a method on debugInfo object.
- fsi.positions.log = numframes;
- normalFrames[numframes++] = &Screen::drawDebugInfoTrampoline;
-
- // call a method on debugInfoScreen object (for more details)
- fsi.positions.settings = numframes;
- normalFrames[numframes++] = &Screen::drawDebugInfoSettingsTrampoline;
-
- fsi.positions.wifi = numframes;
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
- if (isWifiAvailable()) {
- // call a method on debugInfoScreen object (for more details)
- normalFrames[numframes++] = &Screen::drawDebugInfoWiFiTrampoline;
+ if (!dismissedFrames.wifi && isWifiAvailable()) {
+ fsi.positions.wifi = numframes;
+ normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline;
+ indicatorIcons.push_back(icon_wifi);
}
#endif
- fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE
+ fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE
+ this->frameCount = numframes; // ✅ Save frame count for use in custom overlay
LOG_DEBUG("Finished build frames. numframes: %d", numframes);
ui->setFrames(normalFrames, numframes);
- ui->enableAllIndicators();
+ ui->disableAllIndicators();
- // Add function overlay here. This can show when notifications muted, modifier key is active etc
- static OverlayCallback functionOverlay[] = {drawFunctionOverlay};
- static const int functionOverlayCount = sizeof(functionOverlay) / sizeof(functionOverlay[0]);
- ui->setOverlays(functionOverlay, functionOverlayCount);
+ // Add overlays: frame icons and alert banner)
+ static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawAlertBannerOverlay};
+ ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list
// just changed)
@@ -2209,12 +908,13 @@ void Screen::setFrames(FrameFocus focus)
// Focus on a specific frame, in the frame set we just created
switch (focus) {
case FOCUS_DEFAULT:
- ui->switchToFrame(0); // First frame
+ ui->switchToFrame(fsi.positions.deviceFocused);
break;
case FOCUS_FAULT:
ui->switchToFrame(fsi.positions.fault);
break;
case FOCUS_TEXTMESSAGE:
+ hasUnreadMessage = false; // ✅ Clear when message is *viewed*
ui->switchToFrame(fsi.positions.textMessage);
break;
case FOCUS_MODULE:
@@ -2224,31 +924,14 @@ void Screen::setFrames(FrameFocus focus)
break;
case FOCUS_PRESERVE:
- // If we can identify which type of frame "originalPosition" was, can move directly to it in the new frameset
- const FramesetInfo &oldFsi = this->framesetInfo;
- if (originalPosition == oldFsi.positions.log)
- ui->switchToFrame(fsi.positions.log);
- else if (originalPosition == oldFsi.positions.settings)
- ui->switchToFrame(fsi.positions.settings);
- else if (originalPosition == oldFsi.positions.wifi)
- ui->switchToFrame(fsi.positions.wifi);
-
- // If frame count has decreased
- else if (fsi.frameCount < oldFsi.frameCount) {
- uint8_t numDropped = oldFsi.frameCount - fsi.frameCount;
- // Move n frames backwards
- if (numDropped <= originalPosition)
- ui->switchToFrame(originalPosition - numDropped);
- // Unless that would put us "out of bounds" (< 0)
- else
- ui->switchToFrame(0);
- }
-
- // If we're not sure exactly which frame we were on, at least return to the same frame number
- // (node frames; module frames)
- else
+ // No more adjustment — force stay on same index
+ if (previousFrameCount > fsi.frameCount) {
+ ui->switchToFrame(originalPosition - 1);
+ } else if (previousFrameCount < fsi.frameCount) {
+ ui->switchToFrame(originalPosition + 1);
+ } else {
ui->switchToFrame(originalPosition);
-
+ }
break;
}
@@ -2276,18 +959,25 @@ void Screen::dismissCurrentFrame()
if (currentFrame == framesetInfo.positions.textMessage && devicestate.has_rx_text_message) {
LOG_INFO("Dismiss Text Message");
devicestate.has_rx_text_message = false;
- dismissed = true;
- }
-
- else if (currentFrame == framesetInfo.positions.waypoint && devicestate.has_rx_waypoint) {
+ memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message));
+ } else if (currentFrame == framesetInfo.positions.waypoint && devicestate.has_rx_waypoint) {
LOG_DEBUG("Dismiss Waypoint");
devicestate.has_rx_waypoint = false;
+ dismissedFrames.waypoint = true;
+ dismissed = true;
+ } else if (currentFrame == framesetInfo.positions.wifi) {
+ LOG_DEBUG("Dismiss WiFi Screen");
+ dismissedFrames.wifi = true;
+ dismissed = true;
+ } else if (currentFrame == framesetInfo.positions.memory) {
+ LOG_INFO("Dismiss Memory");
+ dismissedFrames.memory = true;
dismissed = true;
}
- // If we did make changes to dismiss, we now need to regenerate the frameset
- if (dismissed)
- setFrames();
+ if (dismissed) {
+ setFrames(FOCUS_DEFAULT); // You could also use FOCUS_PRESERVE
+ }
}
void Screen::handleStartFirmwareUpdateScreen()
@@ -2296,7 +986,7 @@ void Screen::handleStartFirmwareUpdateScreen()
showingNormalScreen = false;
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame
- static FrameCallback frames[] = {drawFrameFirmware};
+ static FrameCallback frames[] = {graphics::NotificationRenderer::drawFrameFirmware};
setFrameImmediateDraw(frames);
}
@@ -2363,41 +1053,8 @@ void Screen::removeFunctionSymbol(std::string sym)
setFastFramerate();
}
-std::string Screen::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;
-}
-
-void Screen::handlePrint(const char *text)
-{
- // the string passed into us probably has a newline, but that would confuse the logging system
- // so strip it
- LOG_DEBUG("Screen: %.*s", strlen(text) - 1, text);
- if (!useDisplay || !showingNormalScreen)
- return;
-
- dispdev->print(text);
-}
-
void Screen::handleOnPress()
{
- // If Canned Messages is using the "Scan and Select" input, dismiss the canned message frame when user button is pressed
- // Minimize impact as a courtesy, as "scan and select" may be used as default config for some boards
- if (scanAndSelectInput != nullptr && scanAndSelectInput->dismissCannedMessageFrame())
- return;
-
// If screen was off, just wake it, otherwise advance to next frame
// If we are in a transition, the press must have bounced, drop it.
if (ui->getUiState()->frameState == FIXED) {
@@ -2443,6 +1100,7 @@ void Screen::setFastFramerate()
runASAP = true;
}
+<<<<<<< store-and-forward
void DebugInfo::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
display->setFont(FONT_SMALL);
@@ -2761,6 +1419,8 @@ void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *stat
#endif
}
+=======
+>>>>>>> master
int Screen::handleStatusUpdate(const meshtastic::Status *arg)
{
// LOG_DEBUG("Screen got status update %d", arg->getStatusType());
@@ -2776,16 +1436,58 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg)
return 0;
}
+// Handles when message is received; will jump to text message frame.
int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
{
if (showingNormalScreen) {
- // Outgoing message
- if (packet->from == 0)
- setFrames(FOCUS_PRESERVE); // Return to same frame (quietly hiding the rx text message frame)
+ if (packet->from == 0) {
+ // Outgoing message (likely sent from phone)
+ devicestate.has_rx_text_message = false;
+ memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message));
+ dismissedFrames.textMessage = true;
+ hasUnreadMessage = false; // Clear unread state when user replies
- // Incoming message
- else
- setFrames(FOCUS_TEXTMESSAGE); // Focus on the new message
+ setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list
+ } else {
+ // Incoming message
+ devicestate.has_rx_text_message = true; // Needed to include the message frame
+ hasUnreadMessage = true; // Enables mail icon in the header
+ setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view
+ forceDisplay(); // Forces screen redraw
+
+ // === Prepare banner content ===
+ const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from);
+ const char *longName = (node && node->has_user) ? node->user.long_name : nullptr;
+
+ const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes);
+
+ char banner[256];
+
+ // Check for bell character in message to determine alert type
+ bool isAlert = false;
+ for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) {
+ if (msgRaw[i] == '\x07') {
+ isAlert = true;
+ break;
+ }
+ }
+
+ if (isAlert) {
+ if (longName && longName[0]) {
+ snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
+ } else {
+ strcpy(banner, "Alert Received");
+ }
+ } else {
+ if (longName && longName[0]) {
+ snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
+ } else {
+ strcpy(banner, "New Message");
+ }
+ }
+
+ screen->showOverlayBanner(banner, 3000);
+ }
}
return 0;
@@ -2813,20 +1515,39 @@ int Screen::handleUIFrameEvent(const UIFrameEvent *event)
int Screen::handleInputEvent(const InputEvent *event)
{
+ if (!screenOn)
+ return 0;
-#if defined(DISPLAY_CLOCK_FRAME)
- // For the T-Watch, intercept touches to the 'toggle digital/analog watch face' button
- uint8_t watchFaceFrame = error_code ? 1 : 0;
-
- if (this->ui->getUiState()->currentFrame == watchFaceFrame && event->touchX >= 204 && event->touchX <= 240 &&
- event->touchY >= 204 && event->touchY <= 240) {
- screen->digitalWatchFace = !screen->digitalWatchFace;
-
- setFrames();
-
+#ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw.
+ EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please
+ EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update
+ handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?)
+ setFastFramerate(); // Draw ASAP
+#endif
+ if (NotificationRenderer::isOverlayBannerShowing()) {
+ NotificationRenderer::inEvent = event->inputEvent;
+ static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar,
+ NotificationRenderer::drawAlertBannerOverlay};
+ ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
+ setFastFramerate(); // Draw ASAP
+ ui->update();
return 0;
}
-#endif
+ /*
+ #if defined(DISPLAY_CLOCK_FRAME)
+ // For the T-Watch, intercept touches to the 'toggle digital/analog watch face' button
+ uint8_t watchFaceFrame = error_code ? 1 : 0;
+
+ if (this->ui->getUiState()->currentFrame == watchFaceFrame && event->touchX >= 204 && event->touchX <= 240 &&
+ event->touchY >= 204 && event->touchY <= 240) {
+ screen->digitalWatchFace = !screen->digitalWatchFace;
+
+ setFrames();
+
+ return 0;
+ }
+ #endif
+ */
// Use left or right input from a keyboard to move between frames,
// so long as a mesh module isn't using these events for some other purpose
@@ -2841,22 +1562,154 @@ int Screen::handleInputEvent(const InputEvent *event)
// If no modules are using the input, move between frames
if (!inputIntercepted) {
- if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT))
+ if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) {
showPrevFrame();
- else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT))
+ } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) {
showNextFrame();
+ } else if (event->inputEvent == INPUT_BROKER_SELECT) {
+ if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) {
+ const char *banner_message;
+ int options;
+ if (kb_found) {
+ banner_message = "Action?\nBack\nSleep Screen\nNew Preset Msg\nNew Freetext Msg";
+ options = 4;
+ } else {
+ banner_message = "Action?\nBack\nSleep Screen\nNew Preset Msg";
+ options = 3;
+ }
+ showOverlayBanner(banner_message, 30000, options, [](int selected) -> void {
+ if (selected == 1) {
+ screen->setOn(false);
+ } else if (selected == 2) {
+ cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
+ } else if (selected == 3) {
+ cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST);
+ }
+ });
+#if HAS_TFT
+ } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.memory) {
+ showOverlayBanner("Switch to MUI?\nYes\nNo", 30000, 2, [](int selected) -> void {
+ if (selected == 0) {
+ config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
+ config.bluetooth.enabled = false;
+ service->reloadConfig(SEGMENT_CONFIG);
+ rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ }
+ });
+#else
+ } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.memory) {
+ showOverlayBanner(
+ "Beeps Mode\nAll Enabled\nDisabled\nNotifications\nSystem Only", 30000, 4,
+ [](int selected) -> void {
+ config.device.buzzer_mode = (meshtastic_Config_DeviceConfig_BuzzerMode)selected;
+ service->reloadConfig(SEGMENT_CONFIG);
+ },
+ config.device.buzzer_mode);
+#endif
+#if HAS_GPS
+ } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) {
+ showOverlayBanner(
+ "Toggle GPS\nBack\nEnabled\nDisabled", 30000, 3,
+ [](int selected) -> void {
+ if (selected == 1) {
+ config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
+ playGPSEnableBeep();
+ gps->enable();
+ service->reloadConfig(SEGMENT_CONFIG);
+ } else if (selected == 2) {
+ config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED;
+ playGPSDisableBeep();
+ gps->disable();
+ service->reloadConfig(SEGMENT_CONFIG);
+ }
+ },
+ config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED ? 1
+ : 2); // set inital selection
+#endif
+ } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) {
+ TZPicker();
+ } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) {
+ LoraRegionPicker();
+ } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage &&
+ devicestate.rx_text_message.from) {
+ const char *banner_message;
+ int options;
+ if (kb_found) {
+ banner_message = "Message Action?\nBack\nDismiss\nReply via Preset\nReply via Freetext";
+ options = 4;
+ } else {
+ banner_message = "Message Action?\nBack\nDismiss\nReply via Preset";
+ options = 3;
+ }
+#ifdef HAS_I2S
+ banner_message = "Message Action?\nBack\nDismiss\nReply via Preset\nReply via Freetext\nRead Aloud";
+ options = 5;
+#endif
+ showOverlayBanner(banner_message, 30000, options, [](int selected) -> void {
+ if (selected == 1) {
+ screen->dismissCurrentFrame();
+ } else if (selected == 2) {
+ if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
+ cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST,
+ devicestate.rx_text_message.channel);
+ } else {
+ cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from);
+ }
+ } else if (selected == 3) {
+ if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
+ cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST,
+ devicestate.rx_text_message.channel);
+ } else {
+ cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from);
+ }
+ }
+#ifdef HAS_I2S
+ else if (selected == 4) {
+ const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
+ const char *msg = reinterpret_cast(mp.decoded.payload.bytes);
+
+ audioThread->readAloud(msg);
+ }
+#endif
+ });
+ } else if (framesetInfo.positions.firstFavorite != 255 &&
+ this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite &&
+ this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) {
+ const char *banner_message;
+ int options;
+ if (kb_found) {
+ banner_message = "Message Node?\nCancel\nNew Preset Msg\nNew Freetext Msg";
+ options = 3;
+ } else {
+ banner_message = "Message Node?\nCancel\nConfirm";
+ options = 2;
+ }
+ showOverlayBanner(banner_message, 30000, options, [](int selected) -> void {
+ if (selected == 1) {
+ cannedMessageModule->LaunchWithDestination(graphics::UIRenderer::currentFavoriteNodeNum);
+ } else if (selected == 2) {
+ cannedMessageModule->LaunchFreetextWithDestination(graphics::UIRenderer::currentFavoriteNodeNum);
+ }
+ });
+ }
+ } else if (event->inputEvent == INPUT_BROKER_BACK) {
+ showPrevFrame();
+ } else if (event->inputEvent == INPUT_BROKER_CANCEL) {
+ setOn(false);
+ }
}
}
return 0;
}
-int Screen::handleAdminMessage(const meshtastic_AdminMessage *arg)
+int Screen::handleAdminMessage(AdminModule_ObserverData *arg)
{
- switch (arg->which_payload_variant) {
+ switch (arg->request->which_payload_variant) {
// Node removed manually (i.e. via app)
case meshtastic_AdminMessage_remove_by_nodenum_tag:
setFrames(FOCUS_PRESERVE);
+ *arg->result = AdminMessageHandleResult::HANDLED;
break;
// Default no-op, in case the admin message observable gets used by other classes in future
@@ -2866,7 +1719,103 @@ int Screen::handleAdminMessage(const meshtastic_AdminMessage *arg)
return 0;
}
+bool Screen::isOverlayBannerShowing()
+{
+ return NotificationRenderer::isOverlayBannerShowing();
+}
+
+void Screen::LoraRegionPicker(uint32_t duration)
+{
+ showOverlayBanner(
+ "Set the LoRa "
+ "region\nBack\nUS\nEU_433\nEU_868\nCN\nJP\nANZ\nKR\nTW\nRU\nIN\nNZ_865\nTH\nLORA_24\nUA_433\nUA_868\nMY_433\nMY_"
+ "919\nSG_"
+ "923\nPH_433\nPH_868\nPH_915\nANZ_433",
+ duration, 23,
+ [](int selected) -> void {
+ if (selected != 0 && config.lora.region != _meshtastic_Config_LoRaConfig_RegionCode(selected)) {
+ config.lora.region = _meshtastic_Config_LoRaConfig_RegionCode(selected);
+ // This is needed as we wait til picking the LoRa region to generate keys for the first time.
+ if (!owner.is_licensed) {
+ bool keygenSuccess = false;
+ if (config.security.private_key.size == 32) {
+ // public key is derived from private, so this will always have the same result.
+ if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
+ keygenSuccess = true;
+ }
+ } else {
+ LOG_INFO("Generate new PKI keys");
+ 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);
+ }
+ }
+ config.lora.tx_enabled = true;
+ initRegion();
+ if (myRegion->dutyCycle < 100) {
+ config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
+ }
+ service->reloadConfig(SEGMENT_CONFIG);
+ rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ }
+ },
+ 0);
+}
+
+void Screen::TZPicker()
+{
+ showOverlayBanner(
+ "Pick "
+ "Timezone\nBack\nUS/Hawaii\nUS/Alaska\nUS/Pacific\nUS/Mountain\nUS/Central\nUS/Eastern\nUTC\nEU/Western\nEU/"
+ "Central\nEU/Eastern\nAsia/Kolkata\nAsia/Hong_Kong\nAU/AWST\nAU/ACST\nAU/AEST\nPacific/NZ",
+ 30000, 17, [](int selected) -> void {
+ if (selected == 1) { // Hawaii
+ strncpy(config.device.tzdef, "HST10", sizeof(config.device.tzdef));
+ } else if (selected == 2) { // Alaska
+ strncpy(config.device.tzdef, "AKST9AKDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef));
+ } else if (selected == 3) { // Pacific
+ strncpy(config.device.tzdef, "PST8PDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef));
+ } else if (selected == 4) { // Mountain
+ strncpy(config.device.tzdef, "MST7MDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef));
+ } else if (selected == 5) { // Central
+ strncpy(config.device.tzdef, "CST6CDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef));
+ } else if (selected == 6) { // Eastern
+ strncpy(config.device.tzdef, "EST5EDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef));
+ } else if (selected == 7) { // UTC
+ strncpy(config.device.tzdef, "UTC", sizeof(config.device.tzdef));
+ } else if (selected == 8) { // EU/Western
+ strncpy(config.device.tzdef, "GMT0BST,M3.5.0/1,M10.5.0", sizeof(config.device.tzdef));
+ } else if (selected == 9) { // EU/Central
+ strncpy(config.device.tzdef, "CET-1CEST,M3.5.0,M10.5.0/3", sizeof(config.device.tzdef));
+ } else if (selected == 10) { // EU/Eastern
+ strncpy(config.device.tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4", sizeof(config.device.tzdef));
+ } else if (selected == 11) { // Asia/Kolkata
+ strncpy(config.device.tzdef, "IST-5:30", sizeof(config.device.tzdef));
+ } else if (selected == 12) { // China
+ strncpy(config.device.tzdef, "HKT-8", sizeof(config.device.tzdef));
+ } else if (selected == 13) { // AU/AWST
+ strncpy(config.device.tzdef, "AWST-8", sizeof(config.device.tzdef));
+ } else if (selected == 14) { // AU/ACST
+ strncpy(config.device.tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef));
+ } else if (selected == 15) { // AU/AEST
+ strncpy(config.device.tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef));
+ } else if (selected == 16) { // NZ
+ strncpy(config.device.tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3", sizeof(config.device.tzdef));
+ }
+ if (selected != 0) {
+ setenv("TZ", config.device.tzdef, 1);
+ service->reloadConfig(SEGMENT_CONFIG);
+ }
+ });
+}
+
} // namespace graphics
+
#else
graphics::Screen::Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY) {}
#endif // HAS_SCREEN
diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h
index ce416156f..8a836edfc 100644
--- a/src/graphics/Screen.h
+++ b/src/graphics/Screen.h
@@ -5,6 +5,10 @@
#include "detect/ScanI2C.h"
#include "mesh/generated/meshtastic/config.pb.h"
#include
+#include
+#include
+
+#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
#if !HAS_SCREEN
#include "power.h"
@@ -14,11 +18,18 @@ namespace graphics
class Screen
{
public:
+ enum FrameFocus : uint8_t {
+ FOCUS_DEFAULT, // No specific frame
+ FOCUS_PRESERVE, // Return to the previous frame
+ FOCUS_FAULT,
+ FOCUS_TEXTMESSAGE,
+ FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus
+ };
+
explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY);
void onPress() {}
void setup() {}
void setOn(bool) {}
- void print(const char *) {}
void doDeepSleep() {}
void forceDisplay(bool forceUiUpdate = false) {}
void startFirmwareUpdateScreen() {}
@@ -27,6 +38,11 @@ class Screen
void setFunctionSymbol(std::string) {}
void removeFunctionSymbol(std::string) {}
void startAlert(const char *) {}
+ void showOverlayBanner(const char *message, uint32_t durationMs = 3000, uint8_t options = 0,
+ std::function bannerCallback = NULL, int8_t InitialSelected = 0)
+ {
+ }
+ void setFrames(FrameFocus focus) {}
void endAlert() {}
};
} // namespace graphics
@@ -62,8 +78,10 @@ class Screen
#include "concurrency/OSThread.h"
#include "input/InputBroker.h"
#include "mesh/MeshModule.h"
+#include "modules/AdminModule.h"
#include "power.h"
#include
+#include
// 0 to 255, though particular variants might define different defaults
#ifndef BRIGHTNESS_DEFAULT
@@ -90,7 +108,7 @@ class Screen
/// Convert an integer GPS coords to a floating point
#define DegD(i) (i * 1e-7)
-
+extern bool hasUnreadMessage;
namespace
{
/// A basic 2D point class for drawing
@@ -176,14 +194,28 @@ class Screen : public concurrency::OSThread
CallbackObserver(this, &Screen::handleUIFrameEvent); // Sent by Mesh Modules
CallbackObserver inputObserver =
CallbackObserver(this, &Screen::handleInputEvent);
- CallbackObserver adminMessageObserver =
- CallbackObserver(this, &Screen::handleAdminMessage);
+ CallbackObserver adminMessageObserver =
+ CallbackObserver(this, &Screen::handleAdminMessage);
public:
explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY);
-
+ size_t frameCount = 0; // Total number of active frames
~Screen();
+ // Which frame we want to be displayed, after we regen the frameset by calling setFrames
+ enum FrameFocus : uint8_t {
+ FOCUS_DEFAULT, // No specific frame
+ FOCUS_PRESERVE, // Return to the previous frame
+ FOCUS_FAULT,
+ FOCUS_TEXTMESSAGE,
+ FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus
+ };
+
+ // Regenerate the normal set of frames, focusing a specific frame if requested
+ // Call when a frame should be added / removed, or custom frames should be cleared
+ void setFrames(FrameFocus focus = FOCUS_DEFAULT);
+
+ std::vector indicatorIcons; // Per-frame custom icon pointers
Screen(const Screen &) = delete;
Screen &operator=(const Screen &) = delete;
@@ -191,6 +223,12 @@ class Screen : public concurrency::OSThread
meshtastic_Config_DisplayConfig_OledType model;
OLEDDISPLAY_GEOMETRY geometry;
+ bool isOverlayBannerShowing();
+
+ // Stores the last 4 of our hardware ID, to make finding the device for pairing easier
+ // FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class
+ char ourId[5];
+
/// Initializes the UI, turns on the display, starts showing boot screen.
//
// Not thread safe - must be called before any other methods are called.
@@ -214,21 +252,9 @@ class Screen : public concurrency::OSThread
void blink();
- void drawFrameText(OLEDDisplay *, OLEDDisplayUiState *, int16_t, int16_t, const char *);
-
- void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength);
-
// Draw north
- void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading);
-
- static uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
-
float estimatedHeading(double lat, double lon);
- void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian);
-
- void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
-
/// Handle button press, trackball or swipe action)
void onPress() { enqueueCmd(ScreenCmd{.cmd = Cmd::ON_PRESS}); }
void showPrevFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_PREV_FRAME}); }
@@ -260,6 +286,9 @@ class Screen : public concurrency::OSThread
enqueueCmd(cmd);
}
+ void showOverlayBanner(const char *message, uint32_t durationMs = 3000, uint8_t options = 0,
+ std::function bannerCallback = NULL, int8_t InitialSelected = 0);
+
void startFirmwareUpdateScreen()
{
ScreenCmd cmd;
@@ -292,23 +321,6 @@ class Screen : public concurrency::OSThread
/// Stops showing the boot screen.
void stopBootScreen() { enqueueCmd(ScreenCmd{.cmd = Cmd::STOP_BOOT_SCREEN}); }
- /// Writes a string to the screen.
- void print(const char *text)
- {
- ScreenCmd cmd;
- cmd.cmd = Cmd::PRINT;
- // TODO(girts): strdup() here is scary, but we can't use std::string as
- // FreeRTOS queue is just dumbly copying memory contents. It would be
- // nice if we had a queue that could copy objects by value.
- cmd.print_text = strdup(text);
- if (!enqueueCmd(cmd)) {
- free(cmd.print_text);
- }
- }
-
- /// generates a very brief time delta display
- std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
-
/// Overrides the default utf8 character conversion, to replace empty space with question marks
static char customFontTableLookup(const uint8_t ch)
{
@@ -533,7 +545,7 @@ class Screen : public concurrency::OSThread
int handleTextMessage(const meshtastic_MeshPacket *arg);
int handleUIFrameEvent(const UIFrameEvent *arg);
int handleInputEvent(const InputEvent *arg);
- int handleAdminMessage(const meshtastic_AdminMessage *arg);
+ int handleAdminMessage(AdminModule_ObserverData *arg);
/// Used to force (super slow) eink displays to draw critical frames
void forceDisplay(bool forceUiUpdate = false);
@@ -541,8 +553,6 @@ class Screen : public concurrency::OSThread
/// Draws our SSL cert screen during boot (called from WebServer)
void setSSLFrames();
- void setWelcomeFrames();
-
// Dismiss the currently focussed frame, if possible (e.g. text message, waypoint)
void dismissCurrentFrame();
@@ -591,8 +601,9 @@ class Screen : public concurrency::OSThread
void handleOnPress();
void handleShowNextFrame();
void handleShowPrevFrame();
- void handlePrint(const char *text);
void handleStartFirmwareUpdateScreen();
+ void TZPicker();
+ void LoraRegionPicker(uint32_t duration = 30000);
// Info collected by setFrames method.
// Index location of specific frames.
@@ -600,30 +611,32 @@ class Screen : public concurrency::OSThread
// - Used to dismiss the currently shown frame (txt; waypoint) by CardKB combo
struct FramesetInfo {
struct FramePositions {
- uint8_t fault = 0;
- uint8_t textMessage = 0;
- uint8_t waypoint = 0;
- uint8_t focusedModule = 0;
- uint8_t log = 0;
- uint8_t settings = 0;
- uint8_t wifi = 0;
+ uint8_t fault = 255;
+ uint8_t textMessage = 255;
+ uint8_t waypoint = 255;
+ uint8_t focusedModule = 255;
+ uint8_t log = 255;
+ uint8_t settings = 255;
+ uint8_t wifi = 255;
+ uint8_t deviceFocused = 255;
+ uint8_t memory = 255;
+ uint8_t gps = 255;
+ uint8_t home = 255;
+ uint8_t clock = 255;
+ uint8_t firstFavorite = 255;
+ uint8_t lastFavorite = 255;
+ uint8_t lora = 255;
} positions;
uint8_t frameCount = 0;
} framesetInfo;
- // Which frame we want to be displayed, after we regen the frameset by calling setFrames
- enum FrameFocus : uint8_t {
- FOCUS_DEFAULT, // No specific frame
- FOCUS_PRESERVE, // Return to the previous frame
- FOCUS_FAULT,
- FOCUS_TEXTMESSAGE,
- FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus
- };
-
- // Regenerate the normal set of frames, focusing a specific frame if requested
- // Call when a frame should be added / removed, or custom frames should be cleared
- void setFrames(FrameFocus focus = FOCUS_DEFAULT);
+ struct DismissedFrames {
+ bool textMessage = false;
+ bool waypoint = false;
+ bool wifi = false;
+ bool memory = false;
+ } dismissedFrames;
/// Try to start drawing ASAP
void setFastFramerate();
@@ -631,34 +644,6 @@ class Screen : public concurrency::OSThread
// Sets frame up for immediate drawing
void setFrameImmediateDraw(FrameCallback *drawFrames);
- /// Called when debug screen is to be drawn, calls through to debugInfo.drawFrame.
- static void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
-
- static void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
-
- static void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
-
-#if defined(DISPLAY_CLOCK_FRAME)
- static void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
-
- static void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
-
- static void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale = 1);
-
- static void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height);
-
- static void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height);
-
- static void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale = 1);
-
- static void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode = true, float scale = 1);
-
- static void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y);
-
- // Whether we are showing the digital watch face or the analog one
- bool digitalWatchFace = true;
-#endif
-
/// callback for current alert frame
FrameCallback alertFrame;
@@ -691,4 +676,8 @@ class Screen : public concurrency::OSThread
} // namespace graphics
+// Extern declarations for function symbols used in UIRenderer
+extern std::vector functionSymbol;
+extern std::string functionSymbolString;
+
#endif
\ No newline at end of file
diff --git a/src/graphics/ScreenGlobals.cpp b/src/graphics/ScreenGlobals.cpp
new file mode 100644
index 000000000..bc139faaf
--- /dev/null
+++ b/src/graphics/ScreenGlobals.cpp
@@ -0,0 +1,6 @@
+#include
+#include
+
+// Global variables for screen function overlay
+std::vector functionSymbol;
+std::string functionSymbolString;
diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp
new file mode 100644
index 000000000..af427cae4
--- /dev/null
+++ b/src/graphics/SharedUIDisplay.cpp
@@ -0,0 +1,323 @@
+#include "graphics/SharedUIDisplay.h"
+#include "RTC.h"
+#include "graphics/ScreenFonts.h"
+#include "main.h"
+#include "meshtastic/config.pb.h"
+#include "power.h"
+#include
+#include
+
+namespace graphics
+{
+
+// === Shared External State ===
+bool hasUnreadMessage = false;
+bool isMuted = false;
+
+// === Internal State ===
+bool isBoltVisibleShared = true;
+uint32_t lastBlinkShared = 0;
+bool isMailIconVisible = true;
+uint32_t lastMailBlink = 0;
+
+// *********************************
+// * Rounded Header when inverted *
+// *********************************
+void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r)
+{
+ // Draw the center and side rectangles
+ display->fillRect(x + r, y, w - 2 * r, h); // center bar
+ display->fillRect(x, y + r, r, h - 2 * r); // left edge
+ display->fillRect(x + w - r, y + r, r, h - 2 * r); // right edge
+
+ // Draw the rounded corners using filled circles
+ display->fillCircle(x + r + 1, y + r, r); // top-left
+ display->fillCircle(x + w - r - 1, y + r, r); // top-right
+ display->fillCircle(x + r + 1, y + h - r - 1, r); // bottom-left
+ display->fillCircle(x + w - r - 1, y + h - r - 1, r); // bottom-right
+}
+
+// *************************
+// * Common Header Drawing *
+// *************************
+void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr)
+{
+ constexpr int HEADER_OFFSET_Y = 1;
+ y += HEADER_OFFSET_Y;
+
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ const int xOffset = 4;
+ const int highlightHeight = FONT_HEIGHT_SMALL - 1;
+ const bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
+ const bool isBold = config.display.heading_bold;
+
+ const int screenW = display->getWidth();
+ const int screenH = display->getHeight();
+
+ const bool useBigIcons = (screenW > 128);
+
+ // === Inverted Header Background ===
+ if (isInverted) {
+ drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
+ display->setColor(BLACK);
+ } else {
+ display->setColor(BLACK);
+ display->fillRect(0, 0, screenW, highlightHeight + 3);
+ display->setColor(WHITE);
+ if (screenW > 128) {
+ display->drawLine(0, 20, screenW, 20);
+ } else {
+ display->drawLine(0, 14, screenW, 14);
+ }
+ }
+
+ // === Screen Title ===
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(SCREEN_WIDTH / 2, y, titleStr);
+ if (config.display.heading_bold) {
+ display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr);
+ }
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ // === Battery State ===
+ int chargePercent = powerStatus->getBatteryChargePercent();
+ bool isCharging = powerStatus->getIsCharging() == meshtastic::OptionalBool::OptTrue;
+ uint32_t now = millis();
+
+#ifndef USE_EINK
+ if (isCharging && now - lastBlinkShared > 500) {
+ isBoltVisibleShared = !isBoltVisibleShared;
+ lastBlinkShared = now;
+ }
+#endif
+
+ bool useHorizontalBattery = (screenW > 128 && screenW >= screenH);
+ const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
+
+ // === Battery Icons ===
+ if (useHorizontalBattery) {
+ int batteryX = 2;
+ int batteryY = HEADER_OFFSET_Y + 2;
+ display->drawXbm(batteryX, batteryY, 29, 15, batteryBitmap_h);
+ if (isCharging && isBoltVisibleShared)
+ display->drawXbm(batteryX + 9, batteryY + 1, 9, 13, lightning_bolt_h);
+ else {
+ display->drawXbm(batteryX + 8, batteryY, 12, 15, batteryBitmap_sidegaps_h);
+ int fillWidth = 24 * chargePercent / 100;
+ display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 13);
+ }
+ } else {
+ int batteryX = 1;
+ int batteryY = HEADER_OFFSET_Y + 1;
+#ifdef USE_EINK
+ batteryY += 2;
+#endif
+ display->drawXbm(batteryX, batteryY, 7, 11, batteryBitmap_v);
+ if (isCharging && isBoltVisibleShared)
+ display->drawXbm(batteryX + 1, batteryY + 3, 5, 5, lightning_bolt_v);
+ else {
+ display->drawXbm(batteryX - 1, batteryY + 4, 8, 3, batteryBitmap_sidegaps_v);
+ int fillHeight = 8 * chargePercent / 100;
+ int fillY = batteryY - fillHeight;
+ display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
+ }
+ }
+
+ // === Battery % Display ===
+ char chargeStr[4];
+ snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
+ int chargeNumWidth = display->getStringWidth(chargeStr);
+ const int batteryOffset = useHorizontalBattery ? 28 : 6;
+#ifdef USE_EINK
+ const int percentX = x + xOffset + batteryOffset - 2;
+#else
+ const int percentX = x + xOffset + batteryOffset;
+#endif
+ display->drawString(percentX, textY, chargeStr);
+ display->drawString(percentX + chargeNumWidth - 1, textY, "%");
+ if (isBold) {
+ display->drawString(percentX + 1, textY, chargeStr);
+ display->drawString(percentX + chargeNumWidth, textY, "%");
+ }
+
+ // === Time and Right-aligned Icons ===
+ uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true);
+ char timeStr[10] = "--:--"; // Fallback display
+ int timeStrWidth = display->getStringWidth("12:34"); // Default alignment
+ int timeX = screenW - xOffset - timeStrWidth + 4;
+
+ if (rtc_sec > 0) {
+ // === Build Time String ===
+ long hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY;
+ int hour = hms / SEC_PER_HOUR;
+ int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
+ snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute);
+
+ if (config.display.use_12h_clock) {
+ bool isPM = hour >= 12;
+ hour %= 12;
+ if (hour == 0)
+ hour = 12;
+ snprintf(timeStr, sizeof(timeStr), "%d:%02d%s", hour, minute, isPM ? "p" : "a");
+ }
+
+ timeStrWidth = display->getStringWidth(timeStr);
+ timeX = screenW - xOffset - timeStrWidth + 4;
+
+ // === Show Mail or Mute Icon to the Left of Time ===
+ int iconRightEdge = timeX - 1;
+
+ bool showMail = false;
+
+#ifndef USE_EINK
+ if (hasUnreadMessage) {
+ if (now - lastMailBlink > 500) {
+ isMailIconVisible = !isMailIconVisible;
+ lastMailBlink = now;
+ }
+ showMail = isMailIconVisible;
+ }
+#else
+ if (hasUnreadMessage) {
+ showMail = true;
+ }
+#endif
+
+ if (showMail) {
+ if (useHorizontalBattery) {
+ int iconW = 16, iconH = 12;
+ int iconX = iconRightEdge - iconW;
+ int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
+ if (isInverted) {
+ display->setColor(WHITE);
+ display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
+ display->setColor(BLACK);
+ } else {
+ display->setColor(BLACK);
+ display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
+ display->setColor(WHITE);
+ }
+ display->drawRect(iconX, iconY, iconW + 1, iconH);
+ display->drawLine(iconX, iconY, iconX + iconW / 2, iconY + iconH - 4);
+ display->drawLine(iconX + iconW, iconY, iconX + iconW / 2, iconY + iconH - 4);
+ } else {
+ int iconX = iconRightEdge - (mail_width - 2);
+ int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
+ if (isInverted) {
+ display->setColor(WHITE);
+ display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
+ display->setColor(BLACK);
+ } else {
+ display->setColor(BLACK);
+ display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
+ display->setColor(WHITE);
+ }
+ display->drawXbm(iconX, iconY, mail_width, mail_height, mail);
+ }
+ } else if (isMuted) {
+ if (useBigIcons) {
+ int iconX = iconRightEdge - mute_symbol_big_width;
+ int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
+
+ if (isInverted) {
+ display->setColor(WHITE);
+ display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
+ display->setColor(BLACK);
+ } else {
+ display->setColor(BLACK);
+ display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
+ display->setColor(WHITE);
+ }
+ display->drawXbm(iconX, iconY, mute_symbol_big_width, mute_symbol_big_height, mute_symbol_big);
+ } else {
+ int iconX = iconRightEdge - mute_symbol_width;
+ int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
+
+ if (isInverted) {
+ display->setColor(WHITE);
+ display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
+ display->setColor(BLACK);
+ } else {
+ display->setColor(BLACK);
+ display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
+ display->setColor(WHITE);
+ }
+ display->drawXbm(iconX, iconY, mute_symbol_width, mute_symbol_height, mute_symbol);
+ }
+ }
+
+ // === Draw Time ===
+ display->drawString(timeX, textY, timeStr);
+ if (isBold)
+ display->drawString(timeX - 1, textY, timeStr);
+
+ } else {
+ // === No Time Available: Mail/Mute Icon Moves to Far Right ===
+ int iconRightEdge = screenW - xOffset;
+
+ bool showMail = false;
+
+ if (hasUnreadMessage) {
+ if (now - lastMailBlink > 500) {
+ isMailIconVisible = !isMailIconVisible;
+ lastMailBlink = now;
+ }
+ showMail = isMailIconVisible;
+ }
+
+ if (showMail) {
+ if (useHorizontalBattery) {
+ int iconW = 16, iconH = 12;
+ int iconX = iconRightEdge - iconW;
+ int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
+ display->drawRect(iconX, iconY, iconW + 1, iconH);
+ display->drawLine(iconX, iconY, iconX + iconW / 2, iconY + iconH - 4);
+ display->drawLine(iconX + iconW, iconY, iconX + iconW / 2, iconY + iconH - 4);
+ } else {
+ int iconX = iconRightEdge - mail_width;
+ int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
+ display->drawXbm(iconX, iconY, mail_width, mail_height, mail);
+ }
+ } else if (isMuted) {
+ if (useBigIcons) {
+ int iconX = iconRightEdge - mute_symbol_big_width;
+ int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
+ display->drawXbm(iconX, iconY, mute_symbol_big_width, mute_symbol_big_height, mute_symbol_big);
+ } else {
+ int iconX = iconRightEdge - mute_symbol_width;
+ int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
+ display->drawXbm(iconX, iconY, mute_symbol_width, mute_symbol_height, mute_symbol);
+ }
+ }
+ }
+
+ display->setColor(WHITE); // Reset for other UI
+}
+
+const int *getTextPositions(OLEDDisplay *display)
+{
+ static int textPositions[7]; // Static array that persists beyond function scope
+
+ if (display->getHeight() > 64) {
+ textPositions[0] = textZeroLine;
+ textPositions[1] = textFirstLine_medium;
+ textPositions[2] = textSecondLine_medium;
+ textPositions[3] = textThirdLine_medium;
+ textPositions[4] = textFourthLine_medium;
+ textPositions[5] = textFifthLine_medium;
+ textPositions[6] = textSixthLine_medium;
+ } else {
+ textPositions[0] = textZeroLine;
+ textPositions[1] = textFirstLine;
+ textPositions[2] = textSecondLine;
+ textPositions[3] = textThirdLine;
+ textPositions[4] = textFourthLine;
+ textPositions[5] = textFifthLine;
+ textPositions[6] = textSixthLine;
+ }
+ return textPositions;
+}
+
+} // namespace graphics
diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h
new file mode 100644
index 000000000..41411ba7f
--- /dev/null
+++ b/src/graphics/SharedUIDisplay.h
@@ -0,0 +1,53 @@
+#pragma once
+
+#include
+
+namespace graphics
+{
+
+// =======================
+// Shared UI Helpers
+// =======================
+
+#define textZeroLine 0
+// Consistent Line Spacing - this is standard for all display and the fall-back spacing
+#define textFirstLine (FONT_HEIGHT_SMALL - 1)
+#define textSecondLine (textFirstLine + (FONT_HEIGHT_SMALL - 5))
+#define textThirdLine (textSecondLine + (FONT_HEIGHT_SMALL - 5))
+#define textFourthLine (textThirdLine + (FONT_HEIGHT_SMALL - 5))
+#define textFifthLine (textFourthLine + (FONT_HEIGHT_SMALL - 5))
+#define textSixthLine (textFifthLine + (FONT_HEIGHT_SMALL - 5))
+
+// Consistent Line Spacing for devices like T114 and TEcho/ThinkNode M1 of devices
+#define textFirstLine_medium (FONT_HEIGHT_SMALL + 1)
+#define textSecondLine_medium (textFirstLine_medium + FONT_HEIGHT_SMALL)
+#define textThirdLine_medium (textSecondLine_medium + FONT_HEIGHT_SMALL)
+#define textFourthLine_medium (textThirdLine_medium + FONT_HEIGHT_SMALL)
+#define textFifthLine_medium (textFourthLine_medium + FONT_HEIGHT_SMALL)
+#define textSixthLine_medium (textFifthLine_medium + FONT_HEIGHT_SMALL)
+
+// Consistent Line Spacing for devices like VisionMaster T190
+#define textFirstLine_large (FONT_HEIGHT_SMALL + 1)
+#define textSecondLine_large (textFirstLine_large + (FONT_HEIGHT_SMALL + 5))
+#define textThirdLine_large (textSecondLine_large + (FONT_HEIGHT_SMALL + 5))
+#define textFourthLine_large (textThirdLine_large + (FONT_HEIGHT_SMALL + 5))
+#define textFifthLine_large (textFourthLine_large + (FONT_HEIGHT_SMALL + 5))
+#define textSixthLine_large (textFifthLine_large + (FONT_HEIGHT_SMALL + 5))
+
+// Quick screen access
+#define SCREEN_WIDTH display->getWidth()
+#define SCREEN_HEIGHT display->getHeight()
+
+// Shared state (declare inside namespace)
+extern bool hasUnreadMessage;
+extern bool isMuted;
+
+// Rounded highlight (used for inverted headers)
+void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r);
+
+// Shared battery/time/mail header
+void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "");
+
+const int *getTextPositions(OLEDDisplay *display);
+
+} // namespace graphics
diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp
index 14787baff..92b2c3d02 100644
--- a/src/graphics/TFTDisplay.cpp
+++ b/src/graphics/TFTDisplay.cpp
@@ -467,18 +467,27 @@ class LGFX : public lgfx::LGFX_Device
// The following setting values are general initial values for each panel, so please comment out any
// unknown items and try them.
-
- cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
- cfg.memory_height = TFT_HEIGHT; // Maximum height supported by the driver IC
- cfg.panel_width = TFT_WIDTH; // actual displayable width
- cfg.panel_height = TFT_HEIGHT; // actual displayable height
- cfg.offset_x = TFT_OFFSET_X; // Panel offset amount in X direction
- cfg.offset_y = TFT_OFFSET_Y; // Panel offset amount in Y direction
- cfg.offset_rotation = TFT_OFFSET_ROTATION; // Rotation direction value offset 0~7 (4~7 is mirrored)
+#if defined(T_WATCH_S3)
+ cfg.panel_width = 240;
+ cfg.panel_height = 240;
+ cfg.memory_width = 240;
+ cfg.memory_height = 320;
+ cfg.offset_x = 0;
+ cfg.offset_y = 0; // No vertical shift needed — panel is top-aligned
+ cfg.offset_rotation = 2; // Rotate 180° to correct upside-down layout
+#else
+ cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC
+ cfg.memory_height = TFT_HEIGHT; // Maximum height supported by the driver IC
+ cfg.panel_width = TFT_WIDTH; // actual displayable width
+ cfg.panel_height = TFT_HEIGHT; // actual displayable height
+ cfg.offset_x = TFT_OFFSET_X; // Panel offset amount in X direction
+ cfg.offset_y = TFT_OFFSET_Y; // Panel offset amount in Y direction
+ cfg.offset_rotation = TFT_OFFSET_ROTATION; // Rotation direction value offset 0~7 (4~7 is mirrored)
+#endif
#ifdef TFT_DUMMY_READ_PIXELS
cfg.dummy_read_pixel = TFT_DUMMY_READ_PIXELS; // Number of bits for dummy read before pixel readout
#else
- cfg.dummy_read_pixel = 9; // Number of bits for dummy read before pixel readout
+ cfg.dummy_read_pixel = 9; // Number of bits for dummy read before pixel readout
#endif
cfg.dummy_read_bits = 1; // Number of bits for dummy read before non-pixel data read
cfg.readable = true; // Set to true if data can be read
@@ -653,7 +662,7 @@ static LGFX *tft = nullptr;
#include // Graphics and font library for ILI9342 driver chip
static TFT_eSPI *tft = nullptr; // Invoke library, pins defined in User_Setup.h
-#elif ARCH_PORTDUINO && HAS_SCREEN != 0 && !HAS_TFT
+#elif ARCH_PORTDUINO
#include // Graphics and font library for ST7735 driver chip
class LGFX : public lgfx::LGFX_Device
@@ -697,11 +706,16 @@ class LGFX : public lgfx::LGFX_Device
_panel_instance->setBus(&_bus_instance); // set the bus on the panel.
auto cfg = _panel_instance->config(); // Gets a structure for display panel settings.
- LOG_DEBUG("Height: %d, Width: %d ", settingsMap[displayHeight], settingsMap[displayWidth]);
+ LOG_DEBUG("Width: %d, Height: %d", settingsMap[displayWidth], settingsMap[displayHeight]);
cfg.pin_cs = settingsMap[displayCS]; // Pin number where CS is connected (-1 = disable)
cfg.pin_rst = settingsMap[displayReset];
- cfg.panel_width = settingsMap[displayWidth]; // actual displayable width
- cfg.panel_height = settingsMap[displayHeight]; // actual displayable height
+ if (settingsMap[displayRotate]) {
+ cfg.panel_width = settingsMap[displayHeight]; // actual displayable width
+ cfg.panel_height = settingsMap[displayWidth]; // actual displayable height
+ } else {
+ cfg.panel_width = settingsMap[displayWidth]; // actual displayable width
+ cfg.panel_height = settingsMap[displayHeight]; // actual displayable height
+ }
cfg.offset_x = settingsMap[displayOffsetX]; // Panel offset amount in X direction
cfg.offset_y = settingsMap[displayOffsetY]; // Panel offset amount in Y direction
cfg.offset_rotation = settingsMap[displayOffsetRotate]; // Rotation direction value offset 0~7 (4~7 is mirrored)
@@ -978,9 +992,9 @@ TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY g
#if ARCH_PORTDUINO
if (settingsMap[displayRotate]) {
- setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayHeight], settingsMap[configNames::displayWidth]);
- } else {
setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayWidth], settingsMap[configNames::displayHeight]);
+ } else {
+ setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayHeight], settingsMap[configNames::displayWidth]);
}
#elif defined(SCREEN_ROTATE)
@@ -1169,6 +1183,8 @@ bool TFTDisplay::connect()
tft->setRotation(1); // T-Deck has the TFT in landscape
#elif defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR)
tft->setRotation(2); // T-Watch S3 left-handed orientation
+#elif ARCH_PORTDUINO
+ tft->setRotation(0); // use config.yaml to set rotation
#else
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
#endif
diff --git a/src/graphics/TimeFormatters.cpp b/src/graphics/TimeFormatters.cpp
new file mode 100644
index 000000000..47036078b
--- /dev/null
+++ b/src/graphics/TimeFormatters.cpp
@@ -0,0 +1,103 @@
+#include "TimeFormatters.h"
+#include "configuration.h"
+#include "gps/RTC.h"
+#include "mesh/NodeDB.h"
+#include
+
+bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo)
+{
+ // Cache the result - avoid frequent recalculation
+ static uint8_t hoursCached = 0, minutesCached = 0;
+ static uint32_t daysAgoCached = 0;
+ static uint32_t secondsAgoCached = 0;
+ static bool validCached = false;
+
+ // Abort: if timezone not set
+ if (strlen(config.device.tzdef) == 0) {
+ validCached = false;
+ return validCached;
+ }
+
+ // Abort: if invalid pointers passed
+ if (hours == nullptr || minutes == nullptr || daysAgo == nullptr) {
+ validCached = false;
+ return validCached;
+ }
+
+ // Abort: if time seems invalid.. (> 6 months ago, probably seen before RTC set)
+ if (secondsAgo > SEC_PER_DAY * 30UL * 6) {
+ validCached = false;
+ return validCached;
+ }
+
+ // If repeated request, don't bother recalculating
+ if (secondsAgo - secondsAgoCached < 60 && secondsAgoCached != 0) {
+ if (validCached) {
+ *hours = hoursCached;
+ *minutes = minutesCached;
+ *daysAgo = daysAgoCached;
+ }
+ return validCached;
+ }
+
+ // Get local time
+ uint32_t secondsRTC = getValidTime(RTCQuality::RTCQualityDevice, true); // Get local time
+
+ // Abort: if RTC not set
+ if (!secondsRTC) {
+ validCached = false;
+ return validCached;
+ }
+
+ // Get absolute time when last seen
+ uint32_t secondsSeenAt = secondsRTC - secondsAgo;
+
+ // Calculate daysAgo
+ *daysAgo = (secondsRTC / SEC_PER_DAY) - (secondsSeenAt / SEC_PER_DAY); // How many "midnights" have passed
+
+ // Get seconds since midnight
+ uint32_t hms = (secondsRTC - secondsAgo) % SEC_PER_DAY;
+ hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
+
+ // Tear apart hms into hours and minutes
+ *hours = hms / SEC_PER_HOUR;
+ *minutes = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
+
+ // Cache the result
+ daysAgoCached = *daysAgo;
+ hoursCached = *hours;
+ minutesCached = *minutes;
+ secondsAgoCached = secondsAgo;
+
+ validCached = true;
+ return validCached;
+}
+
+void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength)
+{
+ // Use an absolute timestamp in some cases.
+ // Particularly useful with E-Ink displays. Static UI, fewer refreshes.
+ uint8_t timestampHours, timestampMinutes;
+ int32_t daysAgo;
+ bool useTimestamp = deltaToTimestamp(agoSecs, ×tampHours, ×tampMinutes, &daysAgo);
+
+ if (agoSecs < 120) // last 2 mins?
+ snprintf(timeStr, maxLength, "%u seconds ago", agoSecs);
+ // -- if suitable for timestamp --
+ else if (useTimestamp && agoSecs < 15 * SECONDS_IN_MINUTE) // Last 15 minutes
+ snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / SECONDS_IN_MINUTE);
+ else if (useTimestamp && daysAgo == 0) // Today
+ snprintf(timeStr, maxLength, "Last seen: %02u:%02u", (unsigned int)timestampHours, (unsigned int)timestampMinutes);
+ else if (useTimestamp && daysAgo == 1) // Yesterday
+ snprintf(timeStr, maxLength, "Seen yesterday");
+ else if (useTimestamp && daysAgo > 1) // Last six months (capped by deltaToTimestamp method)
+ snprintf(timeStr, maxLength, "%li days ago", (long)daysAgo);
+ // -- if using time delta instead --
+ else if (agoSecs < 120 * 60) // last 2 hrs
+ snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / 60);
+ // Only show hours ago if it's been less than 6 months. Otherwise, we may have bad data.
+ else if ((agoSecs / 60 / 60) < (730 * 6))
+ snprintf(timeStr, maxLength, "%u hours ago", agoSecs / 60 / 60);
+ else
+ snprintf(timeStr, maxLength, "unknown age");
+}
diff --git a/src/graphics/TimeFormatters.h b/src/graphics/TimeFormatters.h
new file mode 100644
index 000000000..b3d8413a2
--- /dev/null
+++ b/src/graphics/TimeFormatters.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include "configuration.h"
+#include "gps/RTC.h"
+#include
+#include
+
+/**
+ * Convert a delta in seconds ago to timestamp information (hours, minutes, days ago).
+ *
+ * @param secondsAgo Number of seconds ago to convert
+ * @param hours Pointer to store the hours (0-23)
+ * @param minutes Pointer to store the minutes (0-59)
+ * @param daysAgo Pointer to store the number of days ago
+ * @return true if conversion was successful, false if invalid input or time not available
+ */
+bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo);
+
+/**
+ * Get a human-readable string representing the time ago in a format like "2 days, 3 hours, 15 minutes".
+ *
+ * @param agoSecs Number of seconds ago to convert
+ * @param timeStr Pointer to store the resulting string
+ * @param maxLength Maximum length of the resulting string buffer
+ */
+void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength);
diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp
new file mode 100644
index 000000000..2e301b4e1
--- /dev/null
+++ b/src/graphics/draw/ClockRenderer.cpp
@@ -0,0 +1,473 @@
+#include "configuration.h"
+#if HAS_SCREEN
+#include "ClockRenderer.h"
+#include "NodeDB.h"
+#include "UIRenderer.h"
+#include "configuration.h"
+#include "gps/GeoCoord.h"
+#include "gps/RTC.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/emotes.h"
+#include "graphics/images.h"
+#include "main.h"
+
+#if !MESHTASTIC_EXCLUDE_BLUETOOTH
+#include "nimble/NimbleBluetooth.h"
+#endif
+
+namespace graphics
+{
+
+namespace ClockRenderer
+{
+
+void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale)
+{
+ uint16_t segmentWidth = SEGMENT_WIDTH * scale;
+ uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
+
+ uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8;
+
+ uint16_t topAndBottomX = x + (4 * scale);
+
+ uint16_t quarterCellHeight = cellHeight / 4;
+
+ uint16_t topY = y + quarterCellHeight;
+ uint16_t bottomY = y + (quarterCellHeight * 3);
+
+ display->fillRect(topAndBottomX, topY, segmentHeight, segmentHeight);
+ display->fillRect(topAndBottomX, bottomY, segmentHeight, segmentHeight);
+}
+
+void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale)
+{
+ // the numbers 0-9, each expressed as an array of seven boolean (0|1) values encoding the on/off state of
+ // segment {innerIndex + 1}
+ // e.g., to display the numeral '0', segments 1-6 are on, and segment 7 is off.
+ uint8_t numbers[10][7] = {
+ {1, 1, 1, 1, 1, 1, 0}, // 0 Display segment key
+ {0, 1, 1, 0, 0, 0, 0}, // 1 1
+ {1, 1, 0, 1, 1, 0, 1}, // 2 ___
+ {1, 1, 1, 1, 0, 0, 1}, // 3 6 | | 2
+ {0, 1, 1, 0, 0, 1, 1}, // 4 |_7̲_|
+ {1, 0, 1, 1, 0, 1, 1}, // 5 5 | | 3
+ {1, 0, 1, 1, 1, 1, 1}, // 6 |___|
+ {1, 1, 1, 0, 0, 1, 0}, // 7
+ {1, 1, 1, 1, 1, 1, 1}, // 8 4
+ {1, 1, 1, 1, 0, 1, 1}, // 9
+ };
+
+ // the width and height of each segment's central rectangle:
+ // _____________________
+ // ⋰| (only this part, |⋱
+ // ⋰ | not including | ⋱
+ // ⋱ | the triangles | ⋰
+ // ⋱| on the ends) |⋰
+ // ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+
+ uint16_t segmentWidth = SEGMENT_WIDTH * scale;
+ uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
+
+ // segment x and y coordinates
+ uint16_t segmentOneX = x + segmentHeight + 2;
+ uint16_t segmentOneY = y;
+
+ uint16_t segmentTwoX = segmentOneX + segmentWidth + 2;
+ uint16_t segmentTwoY = segmentOneY + segmentHeight + 2;
+
+ uint16_t segmentThreeX = segmentTwoX;
+ uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2 + segmentHeight + 2;
+
+ uint16_t segmentFourX = segmentOneX;
+ uint16_t segmentFourY = segmentThreeY + segmentWidth + 2;
+
+ uint16_t segmentFiveX = x;
+ uint16_t segmentFiveY = segmentThreeY;
+
+ uint16_t segmentSixX = x;
+ uint16_t segmentSixY = segmentTwoY;
+
+ uint16_t segmentSevenX = segmentOneX;
+ uint16_t segmentSevenY = segmentTwoY + segmentWidth + 2;
+
+ if (numbers[number][0]) {
+ graphics::ClockRenderer::drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight);
+ }
+
+ if (numbers[number][1]) {
+ graphics::ClockRenderer::drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight);
+ }
+
+ if (numbers[number][2]) {
+ graphics::ClockRenderer::drawVerticalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight);
+ }
+
+ if (numbers[number][3]) {
+ graphics::ClockRenderer::drawHorizontalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight);
+ }
+
+ if (numbers[number][4]) {
+ graphics::ClockRenderer::drawVerticalSegment(display, segmentFiveX, segmentFiveY, segmentWidth, segmentHeight);
+ }
+
+ if (numbers[number][5]) {
+ graphics::ClockRenderer::drawVerticalSegment(display, segmentSixX, segmentSixY, segmentWidth, segmentHeight);
+ }
+
+ if (numbers[number][6]) {
+ graphics::ClockRenderer::drawHorizontalSegment(display, segmentSevenX, segmentSevenY, segmentWidth, segmentHeight);
+ }
+}
+
+void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height)
+{
+ int halfHeight = height / 2;
+
+ // draw central rectangle
+ display->fillRect(x, y, width, height);
+
+ // draw end triangles
+ display->fillTriangle(x, y, x, y + height - 1, x - halfHeight, y + halfHeight);
+
+ display->fillTriangle(x + width, y, x + width + halfHeight, y + halfHeight, x + width, y + height - 1);
+}
+
+void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height)
+{
+ int halfHeight = height / 2;
+
+ // draw central rectangle
+ display->fillRect(x, y, height, width);
+
+ // draw end triangles
+ display->fillTriangle(x + halfHeight, y - halfHeight, x + height - 1, y, x, y);
+
+ display->fillTriangle(x, y + width, x + height - 1, y + width, x + halfHeight, y + width + halfHeight);
+}
+
+void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode, float scale)
+{
+ uint16_t segmentWidth = SEGMENT_WIDTH * scale;
+ uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
+
+ if (digitalMode) {
+ uint16_t radius = (segmentWidth + (segmentHeight * 2) + 4) / 2;
+ uint16_t centerX = (x + segmentHeight + 2) + (radius / 2);
+ uint16_t centerY = (y + segmentHeight + 2) + (radius / 2);
+
+ display->drawCircle(centerX, centerY, radius);
+ display->drawCircle(centerX, centerY, radius + 1);
+ display->drawLine(centerX, centerY, centerX, centerY - radius + 3);
+ display->drawLine(centerX, centerY, centerX + radius - 3, centerY);
+ } else {
+ uint16_t segmentOneX = x + segmentHeight + 2;
+ uint16_t segmentOneY = y;
+
+ uint16_t segmentTwoX = segmentOneX + segmentWidth + 2;
+ uint16_t segmentTwoY = segmentOneY + segmentHeight + 2;
+
+ uint16_t segmentThreeX = segmentOneX;
+ uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2;
+
+ uint16_t segmentFourX = x;
+ uint16_t segmentFourY = y + segmentHeight + 2;
+
+ drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight);
+ drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight);
+ drawHorizontalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight);
+ drawVerticalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight);
+ }
+}
+
+// Draw a digital clock
+void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->clear();
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ int line = 1;
+
+#ifdef T_WATCH_S3
+ if (nimbleBluetooth && nimbleBluetooth->isConnected()) {
+ graphics::ClockRenderer::drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2);
+ }
+
+ drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36,
+ graphics::ClockRenderer::digitalWatchFace, 1);
+#endif
+
+ uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
+ char timeString[16];
+ int hour = 0;
+ int minute = 0;
+ int second = 0;
+ if (rtc_sec > 0) {
+ long hms = rtc_sec % SEC_PER_DAY;
+ hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
+
+ hour = hms / SEC_PER_HOUR;
+ minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
+ second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN
+ }
+
+ bool isPM = hour >= 12;
+ // hour = hour > 12 ? hour - 12 : hour;
+ if (config.display.use_12h_clock) {
+ hour %= 12;
+ if (hour == 0)
+ hour = 12;
+ bool isPM = hour >= 12;
+ snprintf(timeString, sizeof(timeString), "%d:%02d", hour, minute);
+ } else {
+ snprintf(timeString, sizeof(timeString), "%02d:%02d", hour, minute);
+ }
+
+ // Format seconds string
+ char secondString[8];
+ snprintf(secondString, sizeof(secondString), "%02d", second);
+
+#ifdef T_WATCH_S3
+ float scale = 1.5;
+#else
+ float scale = 0.75;
+ if (SCREEN_WIDTH > 128) {
+ scale = 1.5;
+ }
+#endif
+
+ uint16_t segmentWidth = SEGMENT_WIDTH * scale;
+ uint16_t segmentHeight = SEGMENT_HEIGHT * scale;
+
+ // calculate hours:minutes string width
+ uint16_t timeStringWidth = strlen(timeString) * 5;
+
+ for (uint8_t i = 0; i < strlen(timeString); i++) {
+ char character = timeString[i];
+
+ if (character == ':') {
+ timeStringWidth += segmentHeight;
+ } else {
+ timeStringWidth += segmentWidth + (segmentHeight * 2) + 4;
+ }
+ }
+
+ uint16_t hourMinuteTextX = (display->getWidth() / 2) - (timeStringWidth / 2);
+
+ uint16_t startingHourMinuteTextX = hourMinuteTextX;
+
+ uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2);
+
+ // iterate over characters in hours:minutes string and draw segmented characters
+ for (uint8_t i = 0; i < strlen(timeString); i++) {
+ char character = timeString[i];
+
+ if (character == ':') {
+ drawSegmentedDisplayColon(display, hourMinuteTextX, hourMinuteTextY, scale);
+
+ hourMinuteTextX += segmentHeight + 6;
+ } else {
+ drawSegmentedDisplayCharacter(display, hourMinuteTextX, hourMinuteTextY, character - '0', scale);
+
+ hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4;
+ }
+
+ hourMinuteTextX += 5;
+ }
+
+ // draw seconds string
+ display->setFont(FONT_SMALL);
+ int xOffset = (SCREEN_WIDTH > 128) ? 0 : -1;
+ if (hour >= 10) {
+ xOffset += (SCREEN_WIDTH > 128) ? 32 : 18;
+ }
+ int yOffset = (SCREEN_WIDTH > 128) ? 3 : 1;
+ if (config.display.use_12h_clock) {
+ display->drawString(startingHourMinuteTextX + xOffset, (display->getHeight() - hourMinuteTextY) - yOffset - 2,
+ isPM ? "pm" : "am");
+ }
+#ifndef USE_EINK
+ xOffset = (SCREEN_WIDTH > 128) ? 18 : 10;
+ display->drawString(startingHourMinuteTextX + timeStringWidth - xOffset, (display->getHeight() - hourMinuteTextY) - yOffset,
+ secondString);
+#endif
+}
+
+void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->drawFastImage(x, y, 18, 14, bluetoothConnectedIcon);
+}
+
+// Draw an analog clock
+void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ graphics::UIRenderer::drawBattery(display, x, y + 7, imgBattery, powerStatus);
+
+ if (powerStatus->getHasBattery()) {
+ char batteryPercent[8];
+ snprintf(batteryPercent, sizeof(batteryPercent), "%d%%", powerStatus->getBatteryChargePercent());
+
+ display->setFont(FONT_SMALL);
+
+ display->drawString(x + 20, y + 2, batteryPercent);
+ }
+#ifdef T_WATCH_S3
+ if (nimbleBluetooth && nimbleBluetooth->isConnected()) {
+ drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2);
+ }
+#endif
+ drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36,
+ graphics::ClockRenderer::digitalWatchFace, 1);
+
+ // clock face center coordinates
+ int16_t centerX = display->getWidth() / 2;
+ int16_t centerY = display->getHeight() / 2;
+
+ // clock face radius
+ int16_t radius = (display->getWidth() / 2) * 0.8;
+
+ // noon (0 deg) coordinates (outermost circle)
+ int16_t noonX = centerX;
+ int16_t noonY = centerY - radius;
+
+ // second hand radius and y coordinate (outermost circle)
+ int16_t secondHandNoonY = noonY + 1;
+
+ // tick mark outer y coordinate; (first nested circle)
+ int16_t tickMarkOuterNoonY = secondHandNoonY;
+
+ // seconds tick mark inner y coordinate; (second nested circle)
+ double secondsTickMarkInnerNoonY = (double)noonY + 8;
+
+ // hours tick mark inner y coordinate; (third nested circle)
+ double hoursTickMarkInnerNoonY = (double)noonY + 16;
+
+ // minute hand y coordinate
+ int16_t minuteHandNoonY = secondsTickMarkInnerNoonY + 4;
+
+ // hour string y coordinate
+ int16_t hourStringNoonY = minuteHandNoonY + 18;
+
+ // hour hand radius and y coordinate
+ int16_t hourHandRadius = radius * 0.55;
+ int16_t hourHandNoonY = centerY - hourHandRadius;
+
+ display->setColor(OLEDDISPLAY_COLOR::WHITE);
+ display->drawCircle(centerX, centerY, radius);
+
+ uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
+ if (rtc_sec > 0) {
+ long hms = rtc_sec % SEC_PER_DAY;
+ hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
+
+ // Tear apart hms into h:m:s
+ int hour = hms / SEC_PER_HOUR;
+ int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
+ int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN
+
+ hour = hour > 12 ? hour - 12 : hour;
+
+ int16_t degreesPerHour = 30;
+ int16_t degreesPerMinuteOrSecond = 6;
+
+ double hourBaseAngle = hour * degreesPerHour;
+ double hourAngleOffset = ((double)minute / 60) * degreesPerHour;
+ double hourAngle = radians(hourBaseAngle + hourAngleOffset);
+
+ double minuteBaseAngle = minute * degreesPerMinuteOrSecond;
+ double minuteAngleOffset = ((double)second / 60) * degreesPerMinuteOrSecond;
+ double minuteAngle = radians(minuteBaseAngle + minuteAngleOffset);
+
+ double secondAngle = radians(second * degreesPerMinuteOrSecond);
+
+ double hourX = sin(-hourAngle) * (hourHandNoonY - centerY) + noonX;
+ double hourY = cos(-hourAngle) * (hourHandNoonY - centerY) + centerY;
+
+ double minuteX = sin(-minuteAngle) * (minuteHandNoonY - centerY) + noonX;
+ double minuteY = cos(-minuteAngle) * (minuteHandNoonY - centerY) + centerY;
+
+ double secondX = sin(-secondAngle) * (secondHandNoonY - centerY) + noonX;
+ double secondY = cos(-secondAngle) * (secondHandNoonY - centerY) + centerY;
+
+ display->setFont(FONT_MEDIUM);
+
+ // draw minute and hour tick marks and hour numbers
+ for (uint16_t angle = 0; angle < 360; angle += 6) {
+ double angleInRadians = radians(angle);
+
+ double sineAngleInRadians = sin(-angleInRadians);
+ double cosineAngleInRadians = cos(-angleInRadians);
+
+ double endX = sineAngleInRadians * (tickMarkOuterNoonY - centerY) + noonX;
+ double endY = cosineAngleInRadians * (tickMarkOuterNoonY - centerY) + centerY;
+
+ if (angle % degreesPerHour == 0) {
+ double startX = sineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + noonX;
+ double startY = cosineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + centerY;
+
+ // draw hour tick mark
+ display->drawLine(startX, startY, endX, endY);
+
+ static char buffer[2];
+
+ uint8_t hourInt = (angle / 30);
+
+ if (hourInt == 0) {
+ hourInt = 12;
+ }
+
+ // hour number x offset needs to be adjusted for some cases
+ int8_t hourStringXOffset;
+ int8_t hourStringYOffset = 13;
+
+ switch (hourInt) {
+ case 3:
+ hourStringXOffset = 5;
+ break;
+ case 9:
+ hourStringXOffset = 7;
+ break;
+ case 10:
+ case 11:
+ hourStringXOffset = 8;
+ break;
+ case 12:
+ hourStringXOffset = 13;
+ break;
+ default:
+ hourStringXOffset = 6;
+ break;
+ }
+
+ double hourStringX = (sineAngleInRadians * (hourStringNoonY - centerY) + noonX) - hourStringXOffset;
+ double hourStringY = (cosineAngleInRadians * (hourStringNoonY - centerY) + centerY) - hourStringYOffset;
+
+ // draw hour number
+ display->drawStringf(hourStringX, hourStringY, buffer, "%d", hourInt);
+ }
+
+ if (angle % degreesPerMinuteOrSecond == 0) {
+ double startX = sineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + noonX;
+ double startY = cosineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + centerY;
+
+ // draw minute tick mark
+ display->drawLine(startX, startY, endX, endY);
+ }
+ }
+
+ // draw hour hand
+ display->drawLine(centerX, centerY, hourX, hourY);
+
+ // draw minute hand
+ display->drawLine(centerX, centerY, minuteX, minuteY);
+
+ // draw second hand
+ display->drawLine(centerX, centerY, secondX, secondY);
+ }
+}
+
+} // namespace ClockRenderer
+
+} // namespace graphics
+#endif
\ No newline at end of file
diff --git a/src/graphics/draw/ClockRenderer.h b/src/graphics/draw/ClockRenderer.h
new file mode 100644
index 000000000..4660dcc35
--- /dev/null
+++ b/src/graphics/draw/ClockRenderer.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+namespace ClockRenderer
+{
+// Whether we are showing the digital watch face or the analog one
+static bool digitalWatchFace = true;
+
+// Clock frame functions
+void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Segmented display functions
+void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale = 1);
+void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale = 1);
+void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height);
+void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height);
+
+// UI elements for clock displays
+void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode = true, float scale = 1);
+void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y);
+
+} // namespace ClockRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp
new file mode 100644
index 000000000..fef993e2d
--- /dev/null
+++ b/src/graphics/draw/CompassRenderer.cpp
@@ -0,0 +1,140 @@
+#include "CompassRenderer.h"
+#include "NodeDB.h"
+#include "UIRenderer.h"
+#include "configuration.h"
+#include "gps/GeoCoord.h"
+#include "graphics/ScreenFonts.h"
+#include
+
+namespace graphics
+{
+namespace CompassRenderer
+{
+
+// Point helper class for compass calculations
+struct Point {
+ float x, y;
+ Point(float x, float y) : x(x), y(y) {}
+
+ void rotate(float angle)
+ {
+ float cos_a = cos(angle);
+ float sin_a = sin(angle);
+ float new_x = x * cos_a - y * sin_a;
+ float new_y = x * sin_a + y * cos_a;
+ x = new_x;
+ y = new_y;
+ }
+
+ void scale(float factor)
+ {
+ x *= factor;
+ y *= factor;
+ }
+
+ void translate(float dx, float dy)
+ {
+ x += dx;
+ y += dy;
+ }
+};
+
+void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
+{
+ // Show the compass heading (not implemented in original)
+ // This could draw a "N" indicator or north arrow
+ // For now, we'll draw a simple north indicator
+ // const float radius = 17.0f;
+ if (display->width() > 128) {
+ radius += 4;
+ }
+ Point north(0, -radius);
+ north.rotate(-myHeading);
+ north.translate(compassX, compassY);
+
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->setColor(BLACK);
+ if (display->width() > 128) {
+ display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6);
+ } else {
+ display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6);
+ }
+ display->setColor(WHITE);
+ display->drawString(north.x, north.y - 3, "N");
+}
+
+void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
+{
+ Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially
+ float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f;
+ Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY);
+
+ Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
+
+ for (int i = 0; i < 4; i++) {
+ arrowPoints[i]->rotate(headingRadian);
+ arrowPoints[i]->scale(compassDiam * 0.6);
+ arrowPoints[i]->translate(compassX, compassY);
+ }
+
+#ifdef USE_EINK
+ display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
+#else
+ display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
+#endif
+ display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
+}
+
+void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
+{
+ float radians = bearing * DEG_TO_RAD;
+
+ Point tip(0, -size / 2);
+ Point left(-size / 4, size / 4);
+ Point right(size / 4, size / 4);
+
+ tip.rotate(radians);
+ left.rotate(radians);
+ right.rotate(radians);
+
+ tip.translate(x, y);
+ left.translate(x, y);
+ right.translate(x, y);
+
+ display->drawTriangle(tip.x, tip.y, left.x, left.y, right.x, right.y);
+}
+
+float estimatedHeading(double lat, double lon)
+{
+ // Simple magnetic declination estimation
+ // This is a very basic implementation - the original might be more sophisticated
+ return 0.0f; // Return 0 for now, indicating no heading available
+}
+
+uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
+{
+ // Calculate appropriate compass diameter based on display size
+ uint16_t minDimension = (displayWidth < displayHeight) ? displayWidth : displayHeight;
+ uint16_t maxDiam = minDimension / 3; // Use 1/3 of the smaller dimension
+
+ // Ensure minimum and maximum bounds
+ if (maxDiam < 16)
+ maxDiam = 16;
+ if (maxDiam > 64)
+ maxDiam = 64;
+
+ return maxDiam;
+}
+
+float calculateBearing(double lat1, double lon1, double lat2, double lon2)
+{
+ double dLon = (lon2 - lon1) * DEG_TO_RAD;
+ double y = sin(dLon) * cos(lat2 * DEG_TO_RAD);
+ double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon);
+ double bearing = atan2(y, x) * RAD_TO_DEG;
+ return fmod(bearing + 360.0, 360.0);
+}
+
+} // namespace CompassRenderer
+} // namespace graphics
diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h
new file mode 100644
index 000000000..4b26e6463
--- /dev/null
+++ b/src/graphics/draw/CompassRenderer.h
@@ -0,0 +1,36 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief Compass and navigation drawing functions
+ *
+ * Contains all functions related to drawing compass elements, headings,
+ * navigation arrows, and location-based UI components.
+ */
+namespace CompassRenderer
+{
+// Compass drawing functions
+void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius);
+void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian);
+void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
+
+// Navigation and location functions
+float estimatedHeading(double lat, double lon);
+uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
+
+// Utility functions for bearing calculations
+float calculateBearing(double lat1, double lon1, double lat2, double lon2);
+
+} // namespace CompassRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp
new file mode 100644
index 000000000..2c3a3a3a8
--- /dev/null
+++ b/src/graphics/draw/DebugRenderer.cpp
@@ -0,0 +1,634 @@
+#include "configuration.h"
+#if HAS_SCREEN
+#include "../Screen.h"
+#include "DebugRenderer.h"
+#include "FSCommon.h"
+#include "NodeDB.h"
+#include "Throttle.h"
+#include "UIRenderer.h"
+#include "airtime.h"
+#include "gps/RTC.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/images.h"
+#include "main.h"
+#include "mesh/Channels.h"
+#include "mesh/generated/meshtastic/deviceonly.pb.h"
+#include "sleep.h"
+
+#if HAS_WIFI && !defined(ARCH_PORTDUINO)
+#include "mesh/wifi/WiFiAPClient.h"
+#include
+#ifdef ARCH_ESP32
+#include "mesh/wifi/WiFiAPClient.h"
+#endif
+#endif
+
+#ifdef ARCH_ESP32
+#include "modules/StoreForwardModule.h"
+#endif
+#include
+#include
+#include
+
+using namespace meshtastic;
+
+// External variables
+extern graphics::Screen *screen;
+extern PowerStatus *powerStatus;
+extern NodeStatus *nodeStatus;
+extern GPSStatus *gpsStatus;
+extern Channels channels;
+extern AirTime *airTime;
+
+// External functions from Screen.cpp
+extern bool heartbeat;
+
+#ifdef ARCH_ESP32
+extern StoreForwardModule *storeForwardModule;
+#endif
+
+namespace graphics
+{
+namespace DebugRenderer
+{
+
+void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->setFont(FONT_SMALL);
+
+ // The coordinates define the left starting point of the text
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
+ display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL);
+ display->setColor(BLACK);
+ }
+
+ char channelStr[20];
+ snprintf(channelStr, sizeof(channelStr), "#%s", channels.getName(channels.getPrimaryIndex()));
+
+ // Display power status
+ if (powerStatus->getHasBattery()) {
+ if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) {
+ UIRenderer::drawBattery(display, x, y + 2, imgBattery, powerStatus);
+ } else {
+ UIRenderer::drawBattery(display, x + 1, y + 3, imgBattery, powerStatus);
+ }
+ } else if (powerStatus->knowsUSB()) {
+ if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) {
+ display->drawFastImage(x, y + 2, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower);
+ } else {
+ display->drawFastImage(x + 1, y + 3, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower);
+ }
+ }
+ // Display nodes status
+ if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) {
+ UIRenderer::drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 2, nodeStatus);
+ } else {
+ UIRenderer::drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 3, nodeStatus);
+ }
+#if HAS_GPS
+ // Display GPS status
+ if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
+ UIRenderer::drawGpsPowerStatus(display, x, y + 2, gpsStatus);
+ } else {
+ if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) {
+ UIRenderer::drawGps(display, x + (SCREEN_WIDTH * 0.63), y + 2, gpsStatus);
+ } else {
+ UIRenderer::drawGps(display, x + (SCREEN_WIDTH * 0.63), y + 3, gpsStatus);
+ }
+ }
+#endif
+ display->setColor(WHITE);
+ // Draw the channel name
+ display->drawString(x, y + FONT_HEIGHT_SMALL, channelStr);
+ // Draw our hardware ID to assist with bluetooth pairing. Either prefix with Info or S&F Logo
+ if (moduleConfig.store_forward.enabled) {
+#ifdef ARCH_ESP32
+ if (!Throttle::isWithinTimespanMs(storeForwardModule->lastHeartbeat,
+ (storeForwardModule->heartbeatInterval * 1200))) { // no heartbeat, overlap a bit
+#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) || ARCH_PORTDUINO) && \
+ !defined(DISPLAY_FORCE_SMALL_FONTS)
+ display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 12,
+ 8, imgQuestionL1);
+ display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 12,
+ 8, imgQuestionL2);
+#else
+ display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(screen->ourId), y + 2 + FONT_HEIGHT_SMALL, 8,
+ 8, imgQuestion);
+#endif
+ } else {
+#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(DISPLAY_FORCE_SMALL_FONTS)
+ display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 16,
+ 8, imgSFL1);
+ display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 16,
+ 8, imgSFL2);
+#else
+ display->drawFastImage(x + SCREEN_WIDTH - 13 - display->getStringWidth(screen->ourId), y + 2 + FONT_HEIGHT_SMALL, 11,
+ 8, imgSF);
+#endif
+ }
+#endif
+ } else {
+ // TODO: Raspberry Pi supports more than just the one screen size
+#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) || ARCH_PORTDUINO) && \
+ !defined(DISPLAY_FORCE_SMALL_FONTS)
+ display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8,
+ imgInfoL1);
+ display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8,
+ imgInfoL2);
+#else
+ display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(screen->ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8,
+ imgInfo);
+#endif
+ }
+
+ display->drawString(x + SCREEN_WIDTH - display->getStringWidth(screen->ourId), y + FONT_HEIGHT_SMALL, screen->ourId);
+
+ // Draw any log messages
+ display->drawLogBuffer(x, y + (FONT_HEIGHT_SMALL * 2));
+
+ /* Display a heartbeat pixel that blinks every time the frame is redrawn */
+#ifdef SHOW_REDRAWS
+ if (heartbeat)
+ display->setPixel(0, 0);
+ heartbeat = !heartbeat;
+#endif
+}
+
+// ****************************
+// * WiFi Screen *
+// ****************************
+void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+#if HAS_WIFI && !defined(ARCH_PORTDUINO)
+ display->clear();
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ int line = 1;
+
+ // === Set Title
+ const char *titleStr = "WiFi";
+
+ // === Header ===
+ graphics::drawCommonHeader(display, x, y, titleStr);
+
+ const char *wifiName = config.network.wifi_ssid;
+
+ if (WiFi.status() != WL_CONNECTED) {
+ display->drawString(x, getTextPositions(display)[line++], "WiFi: Not Connected");
+ } else {
+ display->drawString(x, getTextPositions(display)[line++], "WiFi: Connected");
+
+ char rssiStr[32];
+ snprintf(rssiStr, sizeof(rssiStr), "RSSI: %d", WiFi.RSSI());
+ display->drawString(x, getTextPositions(display)[line++], rssiStr);
+ }
+
+ /*
+ - WL_CONNECTED: assigned when connected to a WiFi network;
+ - WL_NO_SSID_AVAIL: assigned when no SSID are available;
+ - WL_CONNECT_FAILED: assigned when the connection fails for all the attempts;
+ - WL_CONNECTION_LOST: assigned when the connection is lost;
+ - WL_DISCONNECTED: assigned when disconnected from a network;
+ - WL_IDLE_STATUS: it is a temporary status assigned when WiFi.begin() is called and remains active until the number of
+ attempts expires (resulting in WL_CONNECT_FAILED) or a connection is established (resulting in WL_CONNECTED);
+ - WL_SCAN_COMPLETED: assigned when the scan networks is completed;
+ - WL_NO_SHIELD: assigned when no WiFi shield is present;
+
+ */
+ if (WiFi.status() == WL_CONNECTED) {
+ char ipStr[64];
+ snprintf(ipStr, sizeof(ipStr), "IP: %s", WiFi.localIP().toString().c_str());
+ display->drawString(x, getTextPositions(display)[line++], ipStr);
+ } else if (WiFi.status() == WL_NO_SSID_AVAIL) {
+ display->drawString(x, getTextPositions(display)[line++], "SSID Not Found");
+ } else if (WiFi.status() == WL_CONNECTION_LOST) {
+ display->drawString(x, getTextPositions(display)[line++], "Connection Lost");
+ } else if (WiFi.status() == WL_IDLE_STATUS) {
+ display->drawString(x, getTextPositions(display)[line++], "Idle ... Reconnecting");
+ } else if (WiFi.status() == WL_CONNECT_FAILED) {
+ display->drawString(x, getTextPositions(display)[line++], "Connection Failed");
+ }
+#ifdef ARCH_ESP32
+ else {
+ // Codes:
+ // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code
+ display->drawString(x, getTextPositions(display)[line++],
+ WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason())));
+ }
+#else
+ else {
+ char statusStr[32];
+ snprintf(statusStr, sizeof(statusStr), "Unknown status: %d", WiFi.status());
+ display->drawString(x, getTextPositions(display)[line++], statusStr);
+ }
+#endif
+
+ char ssidStr[64];
+ snprintf(ssidStr, sizeof(ssidStr), "SSID: %s", wifiName);
+ display->drawString(x, getTextPositions(display)[line++], ssidStr);
+
+ display->drawString(x, getTextPositions(display)[line++], "URL: http://meshtastic.local");
+
+ /* Display a heartbeat pixel that blinks every time the frame is redrawn */
+#ifdef SHOW_REDRAWS
+ if (heartbeat)
+ display->setPixel(0, 0);
+ heartbeat = !heartbeat;
+#endif
+#endif
+}
+
+void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->setFont(FONT_SMALL);
+
+ // The coordinates define the left starting point of the text
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
+ display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL);
+ display->setColor(BLACK);
+ }
+
+ char batStr[20];
+ if (powerStatus->getHasBattery()) {
+ int batV = powerStatus->getBatteryVoltageMv() / 1000;
+ int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
+
+ snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(),
+ powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' ');
+
+ // Line 1
+ display->drawString(x, y, batStr);
+ if (config.display.heading_bold)
+ display->drawString(x + 1, y, batStr);
+ } else {
+ // Line 1
+ display->drawString(x, y, "USB");
+ if (config.display.heading_bold)
+ display->drawString(x + 1, y, "USB");
+ }
+
+ // auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, true);
+
+ // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode), y, mode);
+ // if (config.display.heading_bold)
+ // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode) - 1, y, mode);
+
+ uint32_t currentMillis = millis();
+ uint32_t seconds = currentMillis / 1000;
+ uint32_t minutes = seconds / 60;
+ uint32_t hours = minutes / 60;
+ uint32_t days = hours / 24;
+ // currentMillis %= 1000;
+ // seconds %= 60;
+ // minutes %= 60;
+ // hours %= 24;
+
+ // Show uptime as days, hours, minutes OR seconds
+ std::string uptime = UIRenderer::drawTimeDelta(days, hours, minutes, seconds);
+
+ // Line 1 (Still)
+ display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
+ if (config.display.heading_bold)
+ display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
+
+ display->setColor(WHITE);
+
+ // Setup string to assemble analogClock string
+ std::string analogClock = "";
+
+ uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
+ if (rtc_sec > 0) {
+ long hms = rtc_sec % SEC_PER_DAY;
+ // hms += tz.tz_dsttime * SEC_PER_HOUR;
+ // hms -= tz.tz_minuteswest * SEC_PER_MIN;
+ // mod `hms` to ensure in positive range of [0...SEC_PER_DAY)
+ hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
+
+ // Tear apart hms into h:m:s
+ int hour = hms / SEC_PER_HOUR;
+ int min = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
+ int sec = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN
+
+ char timebuf[12];
+
+ if (config.display.use_12h_clock) {
+ std::string meridiem = "am";
+ if (hour >= 12) {
+ if (hour > 12)
+ hour -= 12;
+ meridiem = "pm";
+ }
+ if (hour == 00) {
+ hour = 12;
+ }
+ snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str());
+ } else {
+ snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec);
+ }
+ analogClock += timebuf;
+ }
+
+ // Line 2
+ display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str());
+
+ // Display Channel Utilization
+ char chUtil[13];
+ snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent());
+ display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil);
+
+#if HAS_GPS
+ if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
+ // Line 3
+ if (config.display.gps_format !=
+ meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude
+ UIRenderer::drawGpsAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus);
+
+ // Line 4
+ UIRenderer::drawGpsCoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus);
+ } else {
+ UIRenderer::drawGpsPowerStatus(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus);
+ }
+#endif
+/* Display a heartbeat pixel that blinks every time the frame is redrawn */
+#ifdef SHOW_REDRAWS
+ if (heartbeat)
+ display->setPixel(0, 0);
+ heartbeat = !heartbeat;
+#endif
+}
+
+// Trampoline functions for DebugInfo class access
+void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ drawFrame(display, state, x, y);
+}
+
+void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ drawFrameSettings(display, state, x, y);
+}
+
+void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ drawFrameWiFi(display, state, x, y);
+}
+
+// ****************************
+// * LoRa Focused Screen *
+// ****************************
+void drawLoRaFocused(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 = (SCREEN_WIDTH > 128) ? "LoRa Info" : "LoRa";
+
+ // === Header ===
+ graphics::drawCommonHeader(display, x, y, titleStr);
+
+ // === First Row: Region / BLE Name ===
+ graphics::UIRenderer::drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, 0, true, "");
+
+ uint8_t dmac[6];
+ char shortnameble[35];
+ getMacAddr(dmac);
+ snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]);
+ snprintf(shortnameble, sizeof(shortnameble), "BLE: %s", screen->ourId);
+ int textWidth = display->getStringWidth(shortnameble);
+ int nameX = (SCREEN_WIDTH - textWidth);
+ display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
+
+ // === Second Row: Radio Preset ===
+ auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false);
+ char regionradiopreset[25];
+ const char *region = myRegion ? myRegion->name : NULL;
+ if (region != nullptr) {
+ snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
+ }
+ textWidth = display->getStringWidth(regionradiopreset);
+ nameX = (SCREEN_WIDTH - textWidth) / 2;
+ display->drawString(nameX, getTextPositions(display)[line++], regionradiopreset);
+
+ // === Third Row: Frequency / ChanNum ===
+ char frequencyslot[35];
+ char freqStr[16];
+ float freq = RadioLibInterface::instance->getFreq();
+ snprintf(freqStr, sizeof(freqStr), "%.3f", freq);
+ if (config.lora.channel_num == 0) {
+ snprintf(frequencyslot, sizeof(frequencyslot), "Freq: %smhz", freqStr);
+ } else {
+ snprintf(frequencyslot, sizeof(frequencyslot), "Freq/Ch: %smhz (%d)", freqStr, config.lora.channel_num);
+ }
+ size_t len = strlen(frequencyslot);
+ if (len >= 4 && strcmp(frequencyslot + len - 4, " (0)") == 0) {
+ frequencyslot[len - 4] = '\0'; // Remove the last three characters
+ }
+ textWidth = display->getStringWidth(frequencyslot);
+ nameX = (SCREEN_WIDTH - textWidth) / 2;
+ display->drawString(nameX, getTextPositions(display)[line++], frequencyslot);
+
+ // === Fourth Row: Channel Utilization ===
+ const char *chUtil = "ChUtil:";
+ char chUtilPercentage[10];
+ snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent());
+
+ int chUtil_x = (SCREEN_WIDTH > 128) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5;
+ int chUtil_y = getTextPositions(display)[line] + 3;
+
+ int chutil_bar_width = (SCREEN_WIDTH > 128) ? 100 : 50;
+ int chutil_bar_height = (SCREEN_WIDTH > 128) ? 12 : 7;
+ int extraoffset = (SCREEN_WIDTH > 128) ? 6 : 3;
+ 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;
+
+ 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)[4],
+ chUtilPercentage);
+}
+
+// ****************************
+// * Memory Screen *
+// ****************************
+void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->clear();
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ // === Set Title
+ const char *titleStr = "System";
+
+ // === Header ===
+ graphics::drawCommonHeader(display, x, y, titleStr);
+
+ // === Layout ===
+ int line = 1;
+ const int barHeight = 6;
+ const int labelX = x;
+ const int barsOffset = (SCREEN_WIDTH > 128) ? 24 : 0;
+ const int barX = x + 40 + barsOffset;
+
+ auto drawUsageRow = [&](const char *label, uint32_t used, uint32_t total, bool isHeap = false) {
+ if (total == 0)
+ return;
+
+ int percent = (used * 100) / total;
+
+ char combinedStr[24];
+ if (SCREEN_WIDTH > 128) {
+ snprintf(combinedStr, sizeof(combinedStr), "%s%3d%% %u/%uKB", (percent > 80) ? "! " : "", percent, used / 1024,
+ total / 1024);
+ } else {
+ snprintf(combinedStr, sizeof(combinedStr), "%s%3d%%", (percent > 80) ? "! " : "", percent);
+ }
+
+ int textWidth = display->getStringWidth(combinedStr);
+ int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6;
+ if (adjustedBarWidth < 10)
+ adjustedBarWidth = 10;
+
+ int fillWidth = (used * adjustedBarWidth) / total;
+
+ // Label
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->drawString(labelX, getTextPositions(display)[line], label);
+
+ // Bar
+ int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2;
+ display->setColor(WHITE);
+ display->drawRect(barX, barY, adjustedBarWidth, barHeight);
+
+ display->fillRect(barX, barY, fillWidth, barHeight);
+ display->setColor(WHITE);
+
+ // Value string
+ display->setTextAlignment(TEXT_ALIGN_RIGHT);
+ display->drawString(SCREEN_WIDTH - 2, getTextPositions(display)[line], combinedStr);
+ };
+
+ // === Memory values ===
+ uint32_t heapUsed = memGet.getHeapSize() - memGet.getFreeHeap();
+ uint32_t heapTotal = memGet.getHeapSize();
+
+ uint32_t psramUsed = memGet.getPsramSize() - memGet.getFreePsram();
+ uint32_t psramTotal = memGet.getPsramSize();
+
+ uint32_t flashUsed = 0, flashTotal = 0;
+#ifdef ESP32
+ flashUsed = FSCom.usedBytes();
+ flashTotal = FSCom.totalBytes();
+#endif
+
+ uint32_t sdUsed = 0, sdTotal = 0;
+ bool hasSD = false;
+ /*
+ #ifdef HAS_SDCARD
+ hasSD = SD.cardType() != CARD_NONE;
+ if (hasSD) {
+ sdUsed = SD.usedBytes();
+ sdTotal = SD.totalBytes();
+ }
+ #endif
+ */
+ // === Draw memory rows
+ drawUsageRow("Heap:", heapUsed, heapTotal, true);
+#ifdef ESP32
+ if (psramUsed > 0) {
+ line += 1;
+ drawUsageRow("PSRAM:", psramUsed, psramTotal);
+ }
+ if (flashTotal > 0) {
+ line += 1;
+ drawUsageRow("Flash:", flashUsed, flashTotal);
+ }
+#endif
+ if (hasSD && sdTotal > 0) {
+ line += 1;
+ drawUsageRow("SD:", sdUsed, sdTotal);
+ }
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ // System Uptime
+ if (line < 2) {
+ line += 1;
+ }
+ line += 1;
+ char appversionstr[35];
+ snprintf(appversionstr, sizeof(appversionstr), "Ver.: %s", optstr(APP_VERSION));
+ int textWidth = display->getStringWidth(appversionstr);
+ int nameX = (SCREEN_WIDTH - textWidth) / 2;
+ display->drawString(nameX, getTextPositions(display)[line], appversionstr);
+
+ if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line < 4)) { // Only show uptime if the screen can show it
+ line += 1;
+ char uptimeStr[32] = "";
+ uint32_t uptime = millis() / 1000;
+ uint32_t days = uptime / 86400;
+ uint32_t hours = (uptime % 86400) / 3600;
+ uint32_t mins = (uptime % 3600) / 60;
+ // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m"
+ if (days)
+ snprintf(uptimeStr, sizeof(uptimeStr), " Up: %ud %uh", days, hours);
+ else if (hours)
+ snprintf(uptimeStr, sizeof(uptimeStr), " Up: %uh %um", hours, mins);
+ else
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", mins);
+ textWidth = display->getStringWidth(uptimeStr);
+ nameX = (SCREEN_WIDTH - textWidth) / 2;
+ display->drawString(nameX, getTextPositions(display)[line], uptimeStr);
+ }
+}
+} // namespace DebugRenderer
+} // namespace graphics
+#endif
\ No newline at end of file
diff --git a/src/graphics/draw/DebugRenderer.h b/src/graphics/draw/DebugRenderer.h
new file mode 100644
index 000000000..f4d484f58
--- /dev/null
+++ b/src/graphics/draw/DebugRenderer.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+class DebugInfo;
+
+/**
+ * @brief Debug and diagnostic drawing functions
+ *
+ * Contains all functions related to drawing debug information,
+ * WiFi status, settings screens, and diagnostic data.
+ */
+namespace DebugRenderer
+{
+// Debug frame functions
+void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Trampoline functions for framework callback compatibility
+void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// LoRa information display
+void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Memory screen display
+void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+} // namespace DebugRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/DrawRenderers.h b/src/graphics/draw/DrawRenderers.h
new file mode 100644
index 000000000..6f1929ebd
--- /dev/null
+++ b/src/graphics/draw/DrawRenderers.h
@@ -0,0 +1,38 @@
+#pragma once
+
+/**
+ * @brief Master include file for all Screen draw renderers
+ *
+ * This file includes all the individual renderer headers to provide
+ * a convenient single include for accessing all draw functions.
+ */
+
+#include "graphics/draw/ClockRenderer.h"
+#include "graphics/draw/CompassRenderer.h"
+#include "graphics/draw/DebugRenderer.h"
+#include "graphics/draw/NodeListRenderer.h"
+#include "graphics/draw/ScreenRenderer.h"
+#include "graphics/draw/UIRenderer.h"
+
+namespace graphics
+{
+
+/**
+ * @brief Collection of all draw renderers
+ *
+ * This namespace provides access to all the specialized rendering
+ * functions organized by category.
+ */
+namespace DrawRenderers
+{
+// Re-export all renderer namespaces for convenience
+using namespace ClockRenderer;
+using namespace CompassRenderer;
+using namespace DebugRenderer;
+using namespace NodeListRenderer;
+using namespace ScreenRenderer;
+using namespace UIRenderer;
+
+} // namespace DrawRenderers
+
+} // namespace graphics
diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp
new file mode 100644
index 000000000..707517d82
--- /dev/null
+++ b/src/graphics/draw/MessageRenderer.cpp
@@ -0,0 +1,392 @@
+/*
+BaseUI
+
+Developed and Maintained By:
+- Ronald Garcia (HarukiToreda) – Lead development and implementation.
+- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing.
+- TonyG (Tropho) – Project management, structural planning, and testing
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+#include "configuration.h"
+#if HAS_SCREEN
+#include "MessageRenderer.h"
+
+// Core includes
+#include "NodeDB.h"
+#include "configuration.h"
+#include "gps/RTC.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/emotes.h"
+#include "main.h"
+#include "meshUtils.h"
+
+// Additional includes for UI rendering
+#include "UIRenderer.h"
+#include "graphics/TimeFormatters.h"
+
+// Additional includes for dependencies
+#include
+#include
+
+// External declarations
+extern bool hasUnreadMessage;
+extern meshtastic_DeviceState devicestate;
+
+using graphics::Emote;
+using graphics::emotes;
+using graphics::numEmotes;
+
+namespace graphics
+{
+namespace MessageRenderer
+{
+
+void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
+{
+ int cursorX = x;
+ const int fontHeight = FONT_HEIGHT_SMALL;
+
+ // === Step 1: Find tallest emote in the line ===
+ int maxIconHeight = fontHeight;
+ for (size_t i = 0; i < line.length();) {
+ bool matched = false;
+ for (int e = 0; e < emoteCount; ++e) {
+ size_t emojiLen = strlen(emotes[e].label);
+ if (line.compare(i, emojiLen, emotes[e].label) == 0) {
+ if (emotes[e].height > maxIconHeight)
+ maxIconHeight = emotes[e].height;
+ i += emojiLen;
+ matched = true;
+ break;
+ }
+ }
+ if (!matched) {
+ uint8_t c = static_cast(line[i]);
+ if ((c & 0xE0) == 0xC0)
+ i += 2;
+ else if ((c & 0xF0) == 0xE0)
+ i += 3;
+ else if ((c & 0xF8) == 0xF0)
+ i += 4;
+ else
+ i += 1;
+ }
+ }
+
+ // === Step 2: Baseline alignment ===
+ int lineHeight = std::max(fontHeight, maxIconHeight);
+ int baselineOffset = (lineHeight - fontHeight) / 2;
+ int fontY = y + baselineOffset;
+ int fontMidline = fontY + fontHeight / 2;
+
+ // === Step 3: Render line in segments ===
+ size_t i = 0;
+ bool inBold = false;
+
+ while (i < line.length()) {
+ // Check for ** start/end for faux bold
+ if (line.compare(i, 2, "**") == 0) {
+ inBold = !inBold;
+ i += 2;
+ continue;
+ }
+
+ // Look ahead for the next emote match
+ size_t nextEmotePos = std::string::npos;
+ const Emote *matchedEmote = nullptr;
+ size_t emojiLen = 0;
+
+ for (int e = 0; e < emoteCount; ++e) {
+ size_t pos = line.find(emotes[e].label, i);
+ if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) {
+ nextEmotePos = pos;
+ matchedEmote = &emotes[e];
+ emojiLen = strlen(emotes[e].label);
+ }
+ }
+
+ // Render normal text segment up to the emote or bold toggle
+ size_t nextControl = std::min(nextEmotePos, line.find("**", i));
+ if (nextControl == std::string::npos)
+ nextControl = line.length();
+
+ if (nextControl > i) {
+ std::string textChunk = line.substr(i, nextControl - i);
+ if (inBold) {
+ // Faux bold: draw twice, offset by 1px
+ display->drawString(cursorX + 1, fontY, textChunk.c_str());
+ }
+ display->drawString(cursorX, fontY, textChunk.c_str());
+ cursorX += display->getStringWidth(textChunk.c_str());
+ i = nextControl;
+ continue;
+ }
+
+ // Render the emote (if found)
+ if (matchedEmote && i == nextEmotePos) {
+ int iconY = fontMidline - matchedEmote->height / 2 - 1;
+ display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap);
+ cursorX += matchedEmote->width + 1;
+ i += emojiLen;
+ } else {
+ // No more emotes — render the rest of the line
+ std::string remaining = line.substr(i);
+ if (inBold) {
+ display->drawString(cursorX + 1, fontY, remaining.c_str());
+ }
+ display->drawString(cursorX, fontY, remaining.c_str());
+ cursorX += display->getStringWidth(remaining.c_str());
+ break;
+ }
+ }
+}
+
+void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ // Clear the unread message indicator when viewing the message
+ hasUnreadMessage = false;
+
+ const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
+ const char *msg = reinterpret_cast(mp.decoded.payload.bytes);
+
+ display->clear();
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+
+ const int navHeight = FONT_HEIGHT_SMALL;
+ const int scrollBottom = SCREEN_HEIGHT - navHeight;
+ const int usableHeight = scrollBottom;
+ const int textWidth = SCREEN_WIDTH;
+
+ bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
+ bool isBold = config.display.heading_bold;
+
+ // === Set Title
+ const char *titleStr = "Messages";
+
+ // Check if we have more than an empty message to show
+ char messageBuf[237];
+ snprintf(messageBuf, sizeof(messageBuf), "%s", msg);
+ if (strlen(messageBuf) == 0) {
+ // === Header ===
+ graphics::drawCommonHeader(display, x, y, titleStr);
+ const char *messageString = "No messages";
+ int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2);
+ display->drawString(center_text, getTextPositions(display)[2], messageString);
+ return;
+ }
+
+ // === Header Construction ===
+ meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
+ char headerStr[80];
+ const char *sender = "???";
+ if (node && node->has_user) {
+ if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) {
+ sender = node->user.long_name;
+ } else {
+ sender = node->user.short_name;
+ }
+ }
+ uint32_t seconds = sinceReceived(&mp), minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
+ uint8_t timestampHours, timestampMinutes;
+ int32_t daysAgo;
+ bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo);
+
+ if (useTimestamp && minutes >= 15 && daysAgo == 0) {
+ std::string prefix = (daysAgo == 1 && SCREEN_WIDTH >= 200) ? "Yesterday" : "At";
+ if (config.display.use_12h_clock) {
+ bool isPM = timestampHours >= 12;
+ timestampHours = timestampHours % 12;
+ if (timestampHours == 0)
+ timestampHours = 12;
+ snprintf(headerStr, sizeof(headerStr), "%s %d:%02d%s from %s", prefix.c_str(), timestampHours, timestampMinutes,
+ isPM ? "p" : "a", sender);
+ } else {
+ snprintf(headerStr, sizeof(headerStr), "%s %d:%02d from %s", prefix.c_str(), timestampHours, timestampMinutes,
+ sender);
+ }
+ } else {
+ snprintf(headerStr, sizeof(headerStr), "%s ago from %s", UIRenderer::drawTimeDelta(days, hours, minutes, seconds).c_str(),
+ sender);
+ }
+
+#ifndef EXCLUDE_EMOJI
+ // === Bounce animation setup ===
+ static uint32_t lastBounceTime = 0;
+ static int bounceY = 0;
+ const int bounceRange = 2; // Max pixels to bounce up/down
+ const int bounceInterval = 10; // How quickly to change bounce direction (ms)
+
+ uint32_t now = millis();
+ if (now - lastBounceTime >= bounceInterval) {
+ lastBounceTime = now;
+ bounceY = (bounceY + 1) % (bounceRange * 2);
+ }
+ for (int i = 0; i < numEmotes; ++i) {
+ const Emote &e = emotes[i];
+ if (strcmp(msg, e.label) == 0) {
+ int headerY = getTextPositions(display)[1]; // same as scrolling header line
+ display->drawString(x + 3, headerY, headerStr);
+ if (isInverted && isBold)
+ display->drawString(x + 4, headerY, headerStr);
+
+ // Draw separator (same as scroll version)
+ for (int separatorX = 0; separatorX <= (display->getStringWidth(headerStr) + 3); separatorX += 2) {
+ display->setPixel(separatorX, headerY + ((SCREEN_WIDTH > 128) ? 19 : 13));
+ }
+
+ // Center the emote below the header line + separator + nav
+ int remainingHeight = SCREEN_HEIGHT - (headerY + FONT_HEIGHT_SMALL) - navHeight;
+ int emoteY = headerY + FONT_HEIGHT_SMALL + (remainingHeight - e.height) / 2 + bounceY - bounceRange;
+ display->drawXbm((SCREEN_WIDTH - e.width) / 2, emoteY, e.width, e.height, e.bitmap);
+ return;
+ }
+ }
+#endif
+
+ // === Word-wrap and build line list ===
+ std::vector lines;
+ lines.push_back(std::string(headerStr)); // Header line is always first
+
+ std::string line, word;
+ for (int i = 0; messageBuf[i]; ++i) {
+ char ch = messageBuf[i];
+ if (ch == '\n') {
+ if (!word.empty())
+ line += word;
+ if (!line.empty())
+ lines.push_back(line);
+ line.clear();
+ word.clear();
+ } else if (ch == ' ') {
+ line += word + ' ';
+ word.clear();
+ } else {
+ word += ch;
+ std::string test = line + word;
+ if (display->getStringWidth(test.c_str()) > textWidth) {
+ if (!line.empty())
+ lines.push_back(line);
+ line = word;
+ word.clear();
+ }
+ }
+ }
+ if (!word.empty())
+ line += word;
+ if (!line.empty())
+ lines.push_back(line);
+
+ // === Scrolling logic ===
+ std::vector rowHeights;
+
+ for (const auto &_line : lines) {
+ int lineHeight = FONT_HEIGHT_SMALL;
+ bool hasEmote = false;
+
+ for (int i = 0; i < numEmotes; ++i) {
+ const Emote &e = emotes[i];
+ if (_line.find(e.label) != std::string::npos) {
+ lineHeight = std::max(lineHeight, e.height);
+ hasEmote = true;
+ }
+ }
+
+ // Apply tighter spacing if no emotes on this line
+ if (!hasEmote) {
+ lineHeight -= 2; // reduce by 2px for tighter spacing
+ if (lineHeight < 8)
+ lineHeight = 8; // minimum safety
+ }
+
+ rowHeights.push_back(lineHeight);
+ }
+ int totalHeight = 0;
+ for (size_t i = 1; i < rowHeights.size(); ++i) {
+ totalHeight += rowHeights[i];
+ }
+ int usableScrollHeight = usableHeight - rowHeights[0]; // remove header height
+ int scrollStop = std::max(0, totalHeight - usableScrollHeight + rowHeights.back());
+
+ static float scrollY = 0.0f;
+ static uint32_t lastTime = 0, scrollStartDelay = 0, pauseStart = 0;
+ static bool waitingToReset = false, scrollStarted = false;
+
+ // === Smooth scrolling adjustment ===
+ // You can tweak this divisor to change how smooth it scrolls.
+ // Lower = smoother, but can feel slow.
+ float delta = (now - lastTime) / 400.0f;
+ lastTime = now;
+
+ const float scrollSpeed = 2.0f; // pixels per second
+
+ // Delay scrolling start by 2 seconds
+ if (scrollStartDelay == 0)
+ scrollStartDelay = now;
+ if (!scrollStarted && now - scrollStartDelay > 2000)
+ scrollStarted = true;
+
+ if (totalHeight > usableScrollHeight) {
+ if (scrollStarted) {
+ if (!waitingToReset) {
+ scrollY += delta * scrollSpeed;
+ if (scrollY >= scrollStop) {
+ scrollY = scrollStop;
+ waitingToReset = true;
+ pauseStart = lastTime;
+ }
+ } else if (lastTime - pauseStart > 3000) {
+ scrollY = 0;
+ waitingToReset = false;
+ scrollStarted = false;
+ scrollStartDelay = lastTime;
+ }
+ }
+ } else {
+ scrollY = 0;
+ }
+
+ int scrollOffset = static_cast(scrollY);
+ int yOffset = -scrollOffset + getTextPositions(display)[1];
+ for (int separatorX = 0; separatorX <= (display->getStringWidth(headerStr) + 3); separatorX += 2) {
+ display->setPixel(separatorX, yOffset + ((SCREEN_WIDTH > 128) ? 19 : 13));
+ }
+
+ // === Render visible lines ===
+ for (size_t i = 0; i < lines.size(); ++i) {
+ int lineY = yOffset;
+ for (size_t j = 0; j < i; ++j)
+ lineY += rowHeights[j];
+ if (lineY > -rowHeights[i] && lineY < scrollBottom) {
+ if (i == 0 && isInverted) {
+ display->drawString(x + 3, lineY, lines[i].c_str());
+ if (isBold)
+ display->drawString(x + 4, lineY, lines[i].c_str());
+ } else {
+ drawStringWithEmotes(display, x, lineY, lines[i], emotes, numEmotes);
+ }
+ }
+ }
+
+ // Draw header at the end to sort out overlapping elements
+ graphics::drawCommonHeader(display, x, y, titleStr);
+}
+
+} // namespace MessageRenderer
+} // namespace graphics
+#endif
\ No newline at end of file
diff --git a/src/graphics/draw/MessageRenderer.h b/src/graphics/draw/MessageRenderer.h
new file mode 100644
index 000000000..d92b96014
--- /dev/null
+++ b/src/graphics/draw/MessageRenderer.h
@@ -0,0 +1,18 @@
+#pragma once
+#include "OLEDDisplay.h"
+#include "OLEDDisplayUi.h"
+#include "graphics/emotes.h"
+
+namespace graphics
+{
+namespace MessageRenderer
+{
+
+// Text and emote rendering
+void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount);
+
+/// Draws the text message frame for displaying received messages
+void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+} // namespace MessageRenderer
+} // namespace graphics
diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp
new file mode 100644
index 000000000..13b71546e
--- /dev/null
+++ b/src/graphics/draw/NodeListRenderer.cpp
@@ -0,0 +1,595 @@
+#include "configuration.h"
+#if HAS_SCREEN
+#include "CompassRenderer.h"
+#include "NodeDB.h"
+#include "NodeListRenderer.h"
+#include "UIRenderer.h"
+#include "gps/GeoCoord.h"
+#include "gps/RTC.h" // for getTime() function
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/images.h"
+#include "meshUtils.h"
+#include
+
+// Forward declarations for functions defined in Screen.cpp
+namespace graphics
+{
+extern bool haveGlyphs(const char *str);
+} // namespace graphics
+
+// Global screen instance
+extern graphics::Screen *screen;
+
+namespace graphics
+{
+namespace NodeListRenderer
+{
+
+// Function moved from Screen.cpp to NodeListRenderer.cpp since it's primarily used here
+void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display)
+{
+ for (int row = 0; row < height; row++) {
+ uint8_t rowMask = (1 << row);
+ for (int col = 0; col < width; col++) {
+ uint8_t colData = pgm_read_byte(&bitmapXBM[col]);
+ if (colData & rowMask) {
+ // Note: rows become X, columns become Y after transpose
+ display->fillRect(x + row * 2, y + col * 2, 2, 2);
+ }
+ }
+ }
+}
+
+// Static variables for dynamic cycling
+static NodeListMode currentMode = MODE_LAST_HEARD;
+static int scrollIndex = 0;
+
+// =============================
+// Utility Functions
+// =============================
+
+const char *getSafeNodeName(meshtastic_NodeInfoLite *node)
+{
+ static char nodeName[16] = "?";
+ if (node->has_user && strlen(node->user.short_name) > 0) {
+ bool valid = true;
+ const char *name = node->user.short_name;
+ for (size_t i = 0; i < strlen(name); i++) {
+ uint8_t c = (uint8_t)name[i];
+ if (c < 32 || c > 126) {
+ valid = false;
+ break;
+ }
+ }
+ if (valid) {
+ strncpy(nodeName, name, sizeof(nodeName) - 1);
+ nodeName[sizeof(nodeName) - 1] = '\0';
+ } else {
+ snprintf(nodeName, sizeof(nodeName), "%04X", (uint16_t)(node->num & 0xFFFF));
+ }
+ } else {
+ strcpy(nodeName, "?");
+ }
+ return nodeName;
+}
+
+const char *getCurrentModeTitle(int screenWidth)
+{
+ switch (currentMode) {
+ case MODE_LAST_HEARD:
+ return "Last Heard";
+ case MODE_HOP_SIGNAL:
+ return (screenWidth > 128) ? "Hops/Signal" : "Hops/Sig";
+ case MODE_DISTANCE:
+ return "Distance";
+ default:
+ return "Nodes";
+ }
+}
+
+// Use dynamic timing based on mode
+unsigned long getModeCycleIntervalMs()
+{
+ return 3000;
+}
+
+// Calculate bearing between two lat/lon points
+float calculateBearing(double lat1, double lon1, double lat2, double lon2)
+{
+ double dLon = (lon2 - lon1) * DEG_TO_RAD;
+ double y = sin(dLon) * cos(lat2 * DEG_TO_RAD);
+ double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon);
+ double bearing = atan2(y, x) * RAD_TO_DEG;
+ return fmod(bearing + 360.0, 360.0);
+}
+
+int calculateMaxScroll(int totalEntries, int visibleRows)
+{
+ return std::max(0, (totalEntries - 1) / (visibleRows * 2));
+}
+
+void retrieveAndSortNodes(std::vector &nodeList)
+{
+ size_t numNodes = nodeDB->getNumMeshNodes();
+ for (size_t i = 0; i < numNodes; i++) {
+ meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
+ if (!node || node->num == nodeDB->getNodeNum())
+ continue;
+
+ NodeEntry entry;
+ entry.node = node;
+ entry.sortValue = sinceLastSeen(node);
+
+ nodeList.push_back(entry);
+ }
+
+ // Sort nodes: favorites first, then by last heard (most recent first)
+ std::sort(nodeList.begin(), nodeList.end(), [](const NodeEntry &a, const NodeEntry &b) {
+ bool aFav = a.node->is_favorite;
+ bool bFav = b.node->is_favorite;
+ if (aFav != bFav)
+ return aFav;
+ if (a.sortValue == 0 || a.sortValue == UINT32_MAX)
+ return false;
+ if (b.sortValue == 0 || b.sortValue == UINT32_MAX)
+ return true;
+ return a.sortValue < b.sortValue;
+ });
+}
+
+void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
+{
+ int columnWidth = display->getWidth() / 2;
+ int separatorX = x + columnWidth - 2;
+ for (int y = yStart; y <= yEnd; y += 2) {
+ display->setPixel(separatorX, y);
+ }
+}
+
+void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY)
+{
+ if (totalEntries <= visibleNodeRows * columns)
+ return;
+
+ int scrollbarX = display->getWidth() - 2;
+ int scrollbarHeight = display->getHeight() - scrollStartY - 10;
+ int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries);
+ int maxScroll = calculateMaxScroll(totalEntries, visibleNodeRows);
+ int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll);
+
+ for (int i = 0; i < thumbHeight; i++) {
+ display->setPixel(scrollbarX, thumbY + i);
+ }
+}
+
+// =============================
+// Entry Renderers
+// =============================
+
+void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int timeOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
+
+ const char *nodeName = getSafeNodeName(node);
+
+ char timeStr[10];
+ uint32_t seconds = sinceLastSeen(node);
+ if (seconds == 0 || seconds == UINT32_MAX) {
+ snprintf(timeStr, sizeof(timeStr), "?");
+ } else {
+ uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
+ snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"),
+ (days ? days
+ : hours ? hours
+ : minutes),
+ (days ? 'd'
+ : hours ? 'h'
+ : 'm'));
+ }
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawString(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+
+ int rightEdge = x + columnWidth - timeOffset;
+ if (timeStr[strlen(timeStr) - 1] == 'm') // Fix the fact that our fonts don't line up well all the time
+ rightEdge -= 1;
+ int textWidth = display->getStringWidth(timeStr);
+ display->drawString(rightEdge - textWidth, y, timeStr);
+}
+
+void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+
+ int nameMaxWidth = columnWidth - 25;
+ int barsOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
+ int hopOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
+
+ int barsXOffset = columnWidth - barsOffset;
+
+ const char *nodeName = getSafeNodeName(node);
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+
+ display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+
+ // Draw signal strength bars
+ int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
+ int barWidth = 2;
+ int barStartX = x + barsXOffset;
+ int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
+
+ for (int b = 0; b < 4; b++) {
+ if (b < bars) {
+ int height = (b * 2);
+ display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
+ }
+ }
+
+ // Draw hop count
+ char hopStr[6] = "";
+ if (node->has_hops_away && node->hops_away > 0)
+ snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
+
+ if (hopStr[0] != '\0') {
+ int rightEdge = x + columnWidth - hopOffset;
+ int textWidth = display->getStringWidth(hopStr);
+ display->drawString(rightEdge - textWidth, y, hopStr);
+ }
+}
+
+void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
+
+ const char *nodeName = getSafeNodeName(node);
+ char distStr[10] = "";
+
+ meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
+ 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 < 1000)
+ snprintf(distStr, sizeof(distStr), "%dft", feet);
+ else
+ snprintf(distStr, sizeof(distStr), "¼mi"); // 4-char max
+ } else {
+ int roundedMiles = (int)(miles + 0.5);
+ if (roundedMiles < 1000)
+ snprintf(distStr, sizeof(distStr), "%dmi", roundedMiles);
+ else
+ snprintf(distStr, sizeof(distStr), "999"); // Max display cap
+ }
+ } else {
+ if (distanceKm < 1.0) {
+ int meters = (int)(distanceKm * 1000);
+ if (meters < 1000)
+ snprintf(distStr, sizeof(distStr), "%dm", meters);
+ else
+ snprintf(distStr, sizeof(distStr), "1k");
+ } else {
+ int km = (int)(distanceKm + 0.5);
+ if (km < 1000)
+ snprintf(distStr, sizeof(distStr), "%dk", km);
+ else
+ snprintf(distStr, sizeof(distStr), "999");
+ }
+ }
+ }
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+
+ if (strlen(distStr) > 0) {
+ int offset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
+ : (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column)
+ int rightEdge = x + columnWidth - offset;
+ int textWidth = display->getStringWidth(distStr);
+ display->drawString(rightEdge - textWidth, y, distStr);
+ }
+}
+
+void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ switch (currentMode) {
+ case MODE_LAST_HEARD:
+ drawEntryLastHeard(display, node, x, y, columnWidth);
+ break;
+ case MODE_HOP_SIGNAL:
+ drawEntryHopSignal(display, node, x, y, columnWidth);
+ break;
+ case MODE_DISTANCE:
+ drawNodeDistance(display, node, x, y, columnWidth);
+ break;
+ default:
+ break;
+ }
+}
+
+void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+
+ // Adjust max text width depending on column and screen width
+ int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
+
+ const char *nodeName = getSafeNodeName(node);
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+}
+
+void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
+ double userLat, double userLon)
+{
+ if (!nodeDB->hasValidPosition(node))
+ return;
+
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int arrowXOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18);
+
+ int centerX = x + columnWidth - arrowXOffset;
+ int centerY = y + FONT_HEIGHT_SMALL / 2;
+
+ double nodeLat = node->position.latitude_i * 1e-7;
+ double nodeLon = node->position.longitude_i * 1e-7;
+ float bearingToNode = calculateBearing(userLat, userLon, nodeLat, nodeLon);
+ float relativeBearing = fmod((bearingToNode - myHeading + 360), 360);
+ float angle = relativeBearing * DEG_TO_RAD;
+
+ // Shrink size by 2px
+ int size = FONT_HEIGHT_SMALL - 5;
+ float halfSize = size / 2.0;
+
+ // Point of the arrow
+ int tipX = centerX + halfSize * cos(angle);
+ int tipY = centerY - halfSize * sin(angle);
+
+ float baseAngle = radians(35);
+ float sideLen = halfSize * 0.95;
+ float notchInset = halfSize * 0.35;
+
+ // Left and right corners
+ int leftX = centerX + sideLen * cos(angle + PI - baseAngle);
+ int leftY = centerY - sideLen * sin(angle + PI - baseAngle);
+
+ int rightX = centerX + sideLen * cos(angle + PI + baseAngle);
+ int rightY = centerY - sideLen * sin(angle + PI + baseAngle);
+
+ // Center notch (cut-in)
+ int notchX = centerX - notchInset * cos(angle);
+ int notchY = centerY + notchInset * sin(angle);
+
+ // Draw the chevron-style arrowhead
+ display->fillTriangle(tipX, tipY, leftX, leftY, notchX, notchY);
+ display->fillTriangle(tipX, tipY, notchX, notchY, rightX, rightY);
+}
+
+// =============================
+// Main Screen Functions
+// =============================
+
+void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
+ EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
+{
+ const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
+ const int rowYOffset = FONT_HEIGHT_SMALL - 3;
+
+ int columnWidth = display->getWidth() / 2;
+
+ display->clear();
+
+ // Draw the battery/time header
+ graphics::drawCommonHeader(display, x, y, title);
+
+ // Space below header
+ y += COMMON_HEADER_HEIGHT;
+
+ // Fetch and display sorted node list
+ std::vector nodeList;
+ retrieveAndSortNodes(nodeList);
+
+ int totalEntries = nodeList.size();
+ int totalRowsAvailable = (display->getHeight() - y) / rowYOffset;
+#ifdef USE_EINK
+ totalRowsAvailable -= 1;
+#endif
+ int visibleNodeRows = totalRowsAvailable;
+ int totalColumns = 2;
+
+ int startIndex = scrollIndex * visibleNodeRows * totalColumns;
+ int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries);
+
+ int yOffset = 0;
+ int col = 0;
+ int lastNodeY = y;
+ int shownCount = 0;
+ int rowCount = 0;
+
+ for (int i = startIndex; i < endIndex; ++i) {
+ int xPos = x + (col * columnWidth);
+ int yPos = y + yOffset;
+ renderer(display, nodeList[i].node, xPos, yPos, columnWidth);
+
+ if (extras) {
+ extras(display, nodeList[i].node, xPos, yPos, columnWidth, heading, lat, lon);
+ }
+
+ lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
+ yOffset += rowYOffset;
+ shownCount++;
+ rowCount++;
+
+ if (rowCount >= totalRowsAvailable) {
+ yOffset = 0;
+ rowCount = 0;
+ col++;
+ if (col > (totalColumns - 1))
+ break;
+ }
+ }
+
+ // Draw column separator
+ if (shownCount > 0) {
+ const int firstNodeY = y + 3;
+ drawColumnSeparator(display, x, firstNodeY, lastNodeY);
+ }
+
+ const int scrollStartY = y + 3;
+ drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY);
+}
+
+// =============================
+// Screen Frame Functions
+// =============================
+
+#ifndef USE_EINK
+void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ // Static variables to track mode and duration
+ static NodeListMode lastRenderedMode = MODE_COUNT;
+ static unsigned long modeStartTime = 0;
+
+ unsigned long now = millis();
+
+ // On very first call (on boot or state enter)
+ if (lastRenderedMode == MODE_COUNT) {
+ currentMode = MODE_LAST_HEARD;
+ modeStartTime = now;
+ }
+
+ // Time to switch to next mode?
+ if (now - modeStartTime >= getModeCycleIntervalMs()) {
+ currentMode = static_cast((currentMode + 1) % MODE_COUNT);
+ modeStartTime = now;
+ }
+
+ // Render screen based on currentMode
+ const char *title = getCurrentModeTitle(display->getWidth());
+ drawNodeListScreen(display, state, x, y, title, drawEntryDynamic);
+
+ // Track the last mode to avoid reinitializing modeStartTime
+ lastRenderedMode = currentMode;
+}
+#endif
+
+#ifdef USE_EINK
+void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ const char *title = "Last Heard";
+ drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard);
+}
+
+void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ const char *title = "Hops/Signal";
+ drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal);
+}
+
+void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ const char *title = "Distance";
+ drawNodeListScreen(display, state, x, y, title, drawNodeDistance);
+}
+#endif
+
+void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ float heading = 0;
+ bool validHeading = false;
+ double lat = 0;
+ double lon = 0;
+
+#if HAS_GPS
+ if (screen->hasHeading()) {
+ heading = screen->getHeading(); // degrees
+ validHeading = true;
+ } else {
+ heading = screen->estimatedHeading(lat, lon);
+ validHeading = !isnan(heading);
+ }
+#endif
+
+ if (!validHeading)
+ return;
+
+ drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
+}
+
+/// Draw a series of fields in a column, wrapping to multiple columns if needed
+void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields)
+{
+ // The coordinates define the left starting point of the text
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ const char **f = fields;
+ int xo = x, yo = y;
+ while (*f) {
+ display->drawString(xo, yo, *f);
+ if ((display->getColor() == BLACK) && config.display.heading_bold)
+ display->drawString(xo + 1, yo, *f);
+
+ display->setColor(WHITE);
+ yo += FONT_HEIGHT_SMALL;
+ if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) {
+ xo += SCREEN_WIDTH / 2;
+ yo = 0;
+ }
+ f++;
+ }
+}
+
+} // namespace NodeListRenderer
+} // namespace graphics
+#endif
\ No newline at end of file
diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h
new file mode 100644
index 000000000..63f0d1c69
--- /dev/null
+++ b/src/graphics/draw/NodeListRenderer.h
@@ -0,0 +1,69 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief Node list and entry rendering functions
+ *
+ * Contains all functions related to drawing node lists and individual node entries
+ * including last heard, hop signal, distance, and compass views.
+ */
+namespace NodeListRenderer
+{
+// Entry renderer function types
+typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int);
+typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double);
+
+// Node entry structure
+struct NodeEntry {
+ meshtastic_NodeInfoLite *node;
+ uint32_t sortValue;
+};
+
+// Node list mode enumeration
+enum NodeListMode { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_DISTANCE = 2, MODE_COUNT = 3 };
+
+// Main node list screen function
+void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
+ EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
+ double lon = 0);
+
+// Entry renderers
+void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+
+// Extras renderers
+void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
+ double userLat, double userLon);
+
+// Screen frame functions
+void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Utility functions
+const char *getCurrentModeTitle(int screenWidth);
+void retrieveAndSortNodes(std::vector &nodeList);
+const char *getSafeNodeName(meshtastic_NodeInfoLite *node);
+void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
+
+// Bitmap drawing function
+void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display);
+
+} // namespace NodeListRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp
new file mode 100644
index 000000000..ed5257012
--- /dev/null
+++ b/src/graphics/draw/NotificationRenderer.cpp
@@ -0,0 +1,265 @@
+#include "configuration.h"
+#if HAS_SCREEN
+
+#include "DisplayFormatters.h"
+#include "NodeDB.h"
+#include "NotificationRenderer.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/images.h"
+#include "main.h"
+#include
+#include
+#include
+
+#ifdef ARCH_ESP32
+#include "esp_task_wdt.h"
+#endif
+
+using namespace meshtastic;
+
+// External references to global variables from Screen.cpp
+extern std::vector functionSymbol;
+extern std::string functionSymbolString;
+extern bool hasUnreadMessage;
+
+namespace graphics
+{
+
+char NotificationRenderer::inEvent = INPUT_BROKER_NONE;
+int8_t NotificationRenderer::curSelected = 0;
+char NotificationRenderer::alertBannerMessage[256] = {0};
+uint32_t NotificationRenderer::alertBannerUntil = 0; // 0 is a special case meaning forever
+uint8_t NotificationRenderer::alertBannerOptions = 0; // last x lines are seelctable options
+std::function NotificationRenderer::alertBannerCallback = NULL;
+bool NotificationRenderer::pauseBanner = false;
+
+// Used on boot when a certificate is being created
+void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->setFont(FONT_SMALL);
+ display->drawString(64 + x, y, "Creating SSL certificate");
+
+#ifdef ARCH_ESP32
+ yield();
+ esp_task_wdt_reset();
+#endif
+
+ display->setFont(FONT_SMALL);
+ if ((millis() / 1000) % 2) {
+ display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . .");
+ } else {
+ display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ");
+ }
+}
+
+void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
+{
+ // Exit if no message is active or duration has passed
+ if (!isOverlayBannerShowing())
+ return;
+
+ if (pauseBanner)
+ return;
+
+ // === Layout Configuration ===
+ constexpr uint16_t padding = 5; // Padding around text inside the box
+ constexpr uint16_t vPadding = 2; // Padding around text inside the box
+ constexpr uint8_t lineSpacing = 1; // Extra space between lines
+
+ // Search the message to determine if we need the bell added
+ bool needs_bell = (strstr(alertBannerMessage, "Alert Received") != nullptr);
+
+ uint8_t firstOption = 0;
+ uint8_t firstOptionToShow = 0;
+
+ // Setup font and alignment
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_LEFT); // We will manually center per line
+ const int MAX_LINES = 24;
+
+ uint16_t maxWidth = 0;
+ uint16_t arrowsWidth = display->getStringWidth("> <", 4, true);
+ uint16_t lineWidths[MAX_LINES] = {0};
+ uint16_t lineLengths[MAX_LINES] = {0};
+ char *lineStarts[MAX_LINES + 1];
+ uint16_t lineCount = 0;
+ char lineBuffer[40] = {0};
+ // pointer to the terminating null
+ char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
+ lineStarts[lineCount] = alertBannerMessage;
+
+ // loop through lines finding \n characters
+ while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
+ lineStarts[lineCount + 1] = std::find(lineStarts[lineCount], alertEnd, '\n');
+ lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount];
+ if (lineStarts[lineCount + 1][0] == '\n') {
+ lineStarts[lineCount + 1] += 1; // Move the start pointer beyond the \n
+ }
+ lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true);
+ if (lineWidths[lineCount] > maxWidth) {
+ maxWidth = lineWidths[lineCount];
+ }
+ if (alertBannerOptions > 0 && lineCount > 0 && lineWidths[lineCount] + arrowsWidth > maxWidth) {
+ maxWidth = lineWidths[lineCount] + arrowsWidth;
+ }
+ lineCount++;
+ // if we are doing a selection, add extra width for arrows
+ }
+
+ if (alertBannerOptions > 0) {
+ // respond to input
+ if (inEvent == INPUT_BROKER_UP || inEvent == INPUT_BROKER_ALT_PRESS) {
+ curSelected--;
+ } else if (inEvent == INPUT_BROKER_DOWN || inEvent == INPUT_BROKER_USER_PRESS) {
+ curSelected++;
+ } else if (inEvent == INPUT_BROKER_SELECT) {
+ alertBannerCallback(curSelected);
+ alertBannerMessage[0] = '\0';
+ } else if ((inEvent == INPUT_BROKER_CANCEL || inEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) {
+ alertBannerMessage[0] = '\0';
+ }
+ if (curSelected == -1)
+ curSelected = alertBannerOptions - 1;
+ if (curSelected == alertBannerOptions)
+ curSelected = 0;
+ // compare number of options to number of lines
+ if (lineCount < alertBannerOptions)
+ return;
+ firstOption = lineCount - alertBannerOptions;
+ if (curSelected > 1 && alertBannerOptions > 3) {
+ firstOptionToShow = curSelected + firstOption - 1;
+ // put the selected option in the middle
+ } else {
+ firstOptionToShow = firstOption;
+ }
+ } else { // not in an alert with a callback
+ // TODO: check that at least a second has passed since the alert started
+ if (inEvent == INPUT_BROKER_SELECT || inEvent == INPUT_BROKER_ALT_LONG || inEvent == INPUT_BROKER_CANCEL) {
+ alertBannerMessage[0] = '\0'; // end the alert early
+ }
+ }
+ inEvent = INPUT_BROKER_NONE;
+ if (alertBannerMessage[0] == '\0')
+ return;
+
+ // set width from longest line
+ uint16_t boxWidth = padding * 2 + maxWidth;
+ if (needs_bell) {
+ if (SCREEN_WIDTH > 128 && boxWidth <= 150) {
+ boxWidth += 26;
+ }
+ if (SCREEN_WIDTH <= 128 && boxWidth <= 100) {
+ boxWidth += 20;
+ }
+ }
+ // calculate max lines on screen? for now it's 4
+ // set height from line count
+ uint16_t boxHeight;
+ if (lineCount <= 4) {
+ boxHeight = vPadding * 2 + lineCount * FONT_HEIGHT_SMALL + (lineCount - 1) * lineSpacing;
+ } else {
+ boxHeight = vPadding * 2 + 4 * FONT_HEIGHT_SMALL + 4 * lineSpacing;
+ }
+
+ int16_t boxLeft = (display->width() / 2) - (boxWidth / 2);
+ int16_t boxTop = (display->height() / 2) - (boxHeight / 2);
+ // === Draw background box ===
+ display->setColor(BLACK);
+ display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Slightly oversized box
+ display->fillRect(boxLeft, boxTop - 2, boxWidth, 1); // Top Line
+ display->fillRect(boxLeft, boxTop + boxHeight + 1, boxWidth, 1); // Bottom Line
+ display->fillRect(boxLeft - 2, boxTop, 1, boxHeight); // Left Line
+ display->fillRect(boxLeft + boxWidth + 1, boxTop, 1, boxHeight); // Right Line
+ display->setColor(WHITE);
+ display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); // Border
+ display->setColor(BLACK);
+ display->fillRect(boxLeft, boxTop, 1, 1); // Top Left
+ display->fillRect(boxLeft + boxWidth - 1, boxTop, 1, 1); // Top Right
+ display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); // Bottom Left
+ display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); // Bottom Right
+ display->setColor(WHITE);
+
+ // === Draw each line centered in the box ===
+ int16_t lineY = boxTop + vPadding;
+
+ for (int i = 0; i < lineCount; i++) {
+ // is this line selected?
+ // if so, start the buffer with -> and strncpy to the 4th location
+ if (i < lineCount - alertBannerOptions || alertBannerOptions == 0) {
+ strncpy(lineBuffer, lineStarts[i], 40);
+ if (lineLengths[i] > 39)
+ lineBuffer[39] = '\0';
+ else
+ lineBuffer[lineLengths[i]] = '\0';
+ } else if (i >= firstOptionToShow && i < firstOptionToShow + 3) {
+ if (i == curSelected + firstOption) {
+ if (lineLengths[i] > 35)
+ lineLengths[i] = 35;
+ strncpy(lineBuffer, "> ", 3);
+ strncpy(lineBuffer + 2, lineStarts[i], 36);
+ strncpy(lineBuffer + lineLengths[i] + 2, " <", 3);
+ lineLengths[i] += 4;
+ lineWidths[i] += display->getStringWidth("> <", 4, true);
+ if (lineLengths[i] > 35)
+ lineBuffer[39] = '\0';
+ else
+ lineBuffer[lineLengths[i]] = '\0';
+ } else {
+ strncpy(lineBuffer, lineStarts[i], 40);
+ if (lineLengths[i] > 39)
+ lineBuffer[39] = '\0';
+ else
+ lineBuffer[lineLengths[i]] = '\0';
+ }
+ } else { // add break for the additional lines
+ continue;
+ }
+
+ int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2;
+
+ if (needs_bell && i == 0) {
+ int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2;
+ display->drawXbm(textX - 10, bellY, 8, 8, bell_alert);
+ display->drawXbm(textX + lineWidths[i] + 2, bellY, 8, 8, bell_alert);
+ }
+
+ display->drawString(textX, lineY, lineBuffer);
+ lineY += FONT_HEIGHT_SMALL + lineSpacing;
+ }
+}
+
+/// Draw the last text message we received
+void NotificationRenderer::drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_MEDIUM);
+
+ char tempBuf[24];
+ snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code);
+ display->drawString(0 + x, 0 + y, tempBuf);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org");
+}
+
+void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->setFont(FONT_MEDIUM);
+ display->drawString(64 + x, y, "Updating");
+
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(),
+ "Please be patient and do not power off.");
+}
+
+bool NotificationRenderer::isOverlayBannerShowing()
+{
+ return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil);
+}
+
+} // namespace graphics
+#endif
\ No newline at end of file
diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h
new file mode 100644
index 000000000..3ed931dc6
--- /dev/null
+++ b/src/graphics/draw/NotificationRenderer.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include "OLEDDisplay.h"
+#include "OLEDDisplayUi.h"
+
+namespace graphics
+{
+
+class NotificationRenderer
+{
+ public:
+ static char inEvent;
+ static int8_t curSelected;
+ static char alertBannerMessage[256];
+ static uint32_t alertBannerUntil; // 0 is a special case meaning forever
+ static uint8_t alertBannerOptions; // last x lines are seelctable options
+ static std::function alertBannerCallback;
+
+ static bool pauseBanner;
+
+ static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
+ static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+ static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+ static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+ static bool isOverlayBannerShowing();
+};
+
+} // namespace graphics
diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp
new file mode 100644
index 000000000..a77d5b44b
--- /dev/null
+++ b/src/graphics/draw/UIRenderer.cpp
@@ -0,0 +1,1240 @@
+#include "configuration.h"
+#if HAS_SCREEN
+#include "CompassRenderer.h"
+#include "GPSStatus.h"
+#include "NodeDB.h"
+#include "NodeListRenderer.h"
+#include "UIRenderer.h"
+#include "airtime.h"
+#include "configuration.h"
+#include "gps/GeoCoord.h"
+#include "graphics/Screen.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/images.h"
+#include "main.h"
+#include "target_specific.h"
+#include
+#include
+#include
+
+#if !MESHTASTIC_EXCLUDE_GPS
+
+// External variables
+extern graphics::Screen *screen;
+
+namespace graphics
+{
+
+// GeoCoord object for coordinate conversions
+extern GeoCoord geoCoord;
+
+// Threshold values for the GPS lock accuracy bar display
+extern uint32_t dopThresholds[5];
+
+NodeNum UIRenderer::currentFavoriteNodeNum = 0;
+
+// Draw GPS status summary
+void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
+{
+ // Draw satellite image
+ if (SCREEN_WIDTH > 128) {
+ 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 (SCREEN_WIDTH > 128) {
+ 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)
+{
+ auto gpsFormat = config.display.gps_format;
+ char displayLine[32];
+
+ if (!gps->getIsConnected() && !config.position.fixed_position) {
+ strcpy(displayLine, "No GPS present");
+ display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine);
+ } else if (!gps->getHasLock() && !config.position.fixed_position) {
+ strcpy(displayLine, "No GPS Lock");
+ display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine);
+ } else {
+
+ geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude()));
+
+ if (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) {
+ char coordinateLine[22];
+ if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees
+ snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7,
+ geoCoord.getLongitude() * 1e-7);
+ } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator
+ snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(),
+ geoCoord.getUTMEasting(), geoCoord.getUTMNorthing());
+ } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System
+ snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(),
+ geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(),
+ geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing());
+ } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code
+ geoCoord.getOLCCode(coordinateLine);
+ } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference
+ if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region
+ snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary");
+ else
+ snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(),
+ geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing());
+ }
+
+ // If fixed position, display text "Fixed GPS" alternating with the coordinates.
+ if (config.position.fixed_position) {
+ if ((millis() / 10000) % 2) {
+ display->drawString(x + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y,
+ coordinateLine);
+ } else {
+ display->drawString(x + (display->getWidth() - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS");
+ }
+ } else {
+ display->drawString(x + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine);
+ }
+ } else {
+ char latLine[22];
+ char lonLine[22];
+ snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(),
+ geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP());
+ snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(),
+ geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP());
+ display->drawString(x + (display->getWidth() - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1,
+ latLine);
+ display->drawString(x + (display->getWidth() - (display->getStringWidth(lonLine))) / 2, y, lonLine);
+ }
+ }
+}
+
+void UIRenderer::drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer,
+ const meshtastic::PowerStatus *powerStatus)
+{
+ static const uint8_t powerBar[3] = {0x81, 0xBD, 0xBD};
+ static const uint8_t lightning[8] = {0xA1, 0xA1, 0xA5, 0xAD, 0xB5, 0xA5, 0x85, 0x85};
+
+ // Clear the bar area inside the battery image
+ for (int i = 1; i < 14; i++) {
+ imgBuffer[i] = 0x81;
+ }
+
+ // Fill with lightning or power bars
+ if (powerStatus->getIsCharging()) {
+ memcpy(imgBuffer + 3, lightning, 8);
+ } else {
+ for (int i = 0; i < 4; i++) {
+ if (powerStatus->getBatteryChargePercent() >= 25 * i)
+ memcpy(imgBuffer + 1 + (i * 3), powerBar, 3);
+ }
+ }
+
+ // Slightly more conservative scaling based on screen width
+ int scale = 1;
+
+ if (SCREEN_WIDTH >= 200)
+ scale = 2;
+ if (SCREEN_WIDTH >= 300)
+ scale = 2; // Do NOT go higher than 2
+
+ // Draw scaled battery image (16 columns × 8 rows)
+ for (int col = 0; col < 16; col++) {
+ uint8_t colBits = imgBuffer[col];
+ for (int row = 0; row < 8; row++) {
+ if (colBits & (1 << row)) {
+ display->fillRect(x + col * scale, y + row * scale, scale, scale);
+ }
+ }
+ }
+}
+
+// Draw nodes status
+void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset,
+ bool show_total, String 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.c_str());
+
+ 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.c_str());
+ }
+
+#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(DISPLAY_FORCE_SMALL_FONTS)
+
+ if (SCREEN_WIDTH > 128) {
+ NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
+ } else {
+ display->drawFastImage(x, y + 3, 8, 8, imgUser);
+ }
+#else
+ if (SCREEN_WIDTH > 128) {
+ NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
+ } else {
+ display->drawFastImage(x, y + 1, 8, 8, imgUser);
+ }
+#endif
+ int string_offset = (SCREEN_WIDTH > 128) ? 9 : 0;
+ display->drawString(x + 10 + string_offset, y - 2, usersString);
+}
+
+// **********************
+// * Favorite Node Info *
+// **********************
+void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ // --- Cache favorite nodes for the current frame only, to save computation ---
+ static std::vector favoritedNodes;
+ static int prevFrame = -1;
+
+ // --- Only rebuild favorites list if we're on a new frame ---
+ if (state->currentFrame != prevFrame) {
+ prevFrame = state->currentFrame;
+ favoritedNodes.clear();
+ size_t total = nodeDB->getNumMeshNodes();
+ for (size_t i = 0; i < total; i++) {
+ meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
+ // Skip nulls and ourself
+ if (!n || n->num == nodeDB->getNodeNum())
+ continue;
+ if (n->is_favorite)
+ favoritedNodes.push_back(n);
+ }
+ // Keep a stable, consistent display order
+ std::sort(favoritedNodes.begin(), favoritedNodes.end(),
+ [](const meshtastic_NodeInfoLite *a, const meshtastic_NodeInfoLite *b) { return a->num < b->num; });
+ }
+ 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();
+ 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), "Fav: %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
+
+ // === 1. Long Name (always try to show first) ===
+ const char *username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
+ if (username && line < 5) {
+ // Print node's long name (e.g. "Backpack Node")
+ display->drawString(x, getTextPositions(display)[line++], username);
+ }
+
+ // === 2. Signal and Hops (combined on one line, if available) ===
+ // If both are present: "Sig: 97% [2hops]"
+ // If only one: show only that one
+ char signalHopsStr[32] = "";
+ bool haveSignal = false;
+ int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100);
+
+ // Always use "Sig" for the label
+ const char *signalLabel = " Sig";
+
+ // --- Build the Signal/Hops line ---
+ // If SNR looks reasonable, show signal
+ if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) {
+ snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal);
+ haveSignal = true;
+ }
+ // If hops is valid (>0), show right after signal
+ if (node->hops_away > 0) {
+ size_t len = strlen(signalHopsStr);
+ // Decide between "1 Hop" and "N Hops"
+ if (haveSignal) {
+ snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away,
+ (node->hops_away == 1 ? "Hop" : "Hops"));
+ } else {
+ snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
+ }
+ }
+ if (signalHopsStr[0] && line < 5) {
+ display->drawString(x, getTextPositions(display)[line++], signalHopsStr);
+ }
+
+ // === 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: ?" : " Heard: %d%c ago"),
+ (days ? days
+ : hours ? hours
+ : minutes),
+ (days ? 'd'
+ : hours ? 'h'
+ : 'm'));
+ }
+ if (seenStr[0] && line < 5) {
+ display->drawString(x, getTextPositions(display)[line++], seenStr);
+ }
+
+ // === 4. Uptime (only show if metric is present) ===
+ char uptimeStr[32] = "";
+ if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
+ uint32_t uptime = node->device_metrics.uptime_seconds;
+ uint32_t days = uptime / 86400;
+ uint32_t hours = (uptime % 86400) / 3600;
+ uint32_t mins = (uptime % 3600) / 60;
+ // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m"
+ if (days)
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %ud %uh", days, hours);
+ else if (hours)
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %uh %um", hours, mins);
+ else
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", mins);
+ }
+ if (uptimeStr[0] && line < 5) {
+ 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), " Distance: %dft", feet);
+ haveDistance = true;
+ } else if (feet >= 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: ¼mi");
+ haveDistance = true;
+ }
+ } else {
+ int roundedMiles = (int)(miles + 0.5);
+ if (roundedMiles > 0 && roundedMiles < 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles);
+ haveDistance = true;
+ }
+ }
+ } else {
+ if (distanceKm < 1.0) {
+ int meters = (int)(distanceKm * 1000);
+ if (meters > 0 && meters < 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: %dm", meters);
+ haveDistance = true;
+ } else if (meters >= 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: 1km");
+ haveDistance = true;
+ }
+ } else {
+ int km = (int)(distanceKm + 0.5);
+ if (km > 0 && km < 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: %dkm", km);
+ haveDistance = true;
+ }
+ }
+ }
+ }
+ // Only display if we actually have a value!
+ if (haveDistance && distStr[0] && line < 5) {
+ display->drawString(x, getTextPositions(display)[line++], distStr);
+ }
+
+ // --- 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 (!config.display.compass_north_top)
+ 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 = (SCREEN_WIDTH > 128) ? 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 = 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 (!config.display.compass_north_top)
+ bearing -= myHeading;
+ graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
+
+ display->drawCircle(compassX, compassY, compassRadius);
+ }
+ // else show nothing
+ }
+}
+
+// ****************************
+// * 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;
+
+ // === Header ===
+ 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
+ drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
+
+ char uptimeStr[32] = "";
+ uint32_t uptime = millis() / 1000;
+ uint32_t days = uptime / 86400;
+ uint32_t hours = (uptime % 86400) / 3600;
+ uint32_t mins = (uptime % 3600) / 60;
+ // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m"
+ if (days)
+ snprintf(uptimeStr, sizeof(uptimeStr), "Up: %ud %uh", days, hours);
+ else if (hours)
+ snprintf(uptimeStr, sizeof(uptimeStr), "Up: %uh %um", hours, mins);
+ else
+ snprintf(uptimeStr, sizeof(uptimeStr), "Up: %um", mins);
+ 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";
+ }
+ int yOffset = (SCREEN_WIDTH > 128) ? 3 : 1;
+ if (SCREEN_WIDTH > 128) {
+ NodeListRenderer::drawScaledXBitmap16x16(x, getTextPositions(display)[line] + yOffset - 5, imgSatellite_width,
+ imgSatellite_height, imgSatellite, display);
+ } else {
+ display->drawXbm(x + 1, getTextPositions(display)[line] + yOffset, imgSatellite_width, imgSatellite_height,
+ imgSatellite);
+ }
+ int xOffset = (SCREEN_WIDTH > 128) ? 6 : 0;
+ display->drawString(x + 11 + xOffset, getTextPositions(display)[line], displayLine);
+ } else {
+ UIRenderer::drawGps(display, 0, getTextPositions(display)[line], gpsStatus);
+ }
+#endif
+
+ 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 = (SCREEN_WIDTH > 128) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5;
+ int chUtil_y = getTextPositions(display)[line] + 3;
+
+ int chutil_bar_width = (SCREEN_WIDTH > 128) ? 100 : 50;
+ if (!config.bluetooth.enabled) {
+ chutil_bar_width = (SCREEN_WIDTH > 128) ? 80 : 40;
+ }
+ int chutil_bar_height = (SCREEN_WIDTH > 128) ? 12 : 7;
+ int extraoffset = (SCREEN_WIDTH > 128) ? 6 : 3;
+ if (!config.bluetooth.enabled) {
+ extraoffset = (SCREEN_WIDTH > 128) ? 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 = (SCREEN_WIDTH > 128) ? 0 : 5;
+ const char *longName = nullptr;
+ meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
+ if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
+ longName = ourNode->user.long_name;
+ }
+ uint8_t dmac[6];
+ char shortnameble[35];
+ getMacAddr(dmac);
+ snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]);
+ snprintf(shortnameble, sizeof(shortnameble), "%s",
+ graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
+
+ char combinedName[50];
+ snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortnameble);
+ if (SCREEN_WIDTH - (display->getStringWidth(longName) + display->getStringWidth(shortnameble)) > 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(longName);
+ nameX = (SCREEN_WIDTH - textWidth) / 2;
+ display->drawString(nameX, getTextPositions(display)[line++], longName);
+
+ // === ShortName Centered ===
+ textWidth = display->getStringWidth(shortnameble);
+ nameX = (SCREEN_WIDTH - textWidth) / 2;
+ display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
+ }
+}
+
+// Start Functions to write date/time to the screen
+// Helper function to check if a year is a leap year
+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); // Take the opportunity for a full-refresh
+
+ // 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); // This bool is used to hide the idText box if we can't render the short name
+ constexpr uint16_t padding = 5;
+ constexpr uint8_t dividerGap = 1;
+ constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in.
+
+ // Dimensions
+ const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars
+ const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
+ const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding;
+ const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding;
+
+ // Position
+ const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1);
+ // const int16_t boxRight = boxLeft + boxWidth - 1;
+ const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1));
+ const int16_t boxBottom = boxTop + boxHeight - 1;
+ const int16_t idTextLeft = boxLeft + padding;
+ const int16_t idTextTop = boxTop + padding;
+ const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding;
+ const int16_t pauseTextTop = boxTop + padding;
+ const int16_t dividerX = boxLeft + padding + idTextWidth + padding;
+ const int16_t dividerTop = boxTop + 1 + dividerGap;
+ const int16_t dividerBottom = boxBottom - 1 - dividerGap;
+
+ // Draw: box
+ display->setColor(EINK_WHITE);
+ display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box
+ 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
+ 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
+}
+
+// ****************************
+// * 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";
+ }
+ int yOffset = (SCREEN_WIDTH > 128) ? 3 : 1;
+ if (SCREEN_WIDTH > 128) {
+ NodeListRenderer::drawScaledXBitmap16x16(x, getTextPositions(display)[line] + yOffset - 5, imgSatellite_width,
+ imgSatellite_height, imgSatellite, display);
+ } else {
+ display->drawXbm(x + 1, getTextPositions(display)[line] + yOffset, imgSatellite_width, imgSatellite_height,
+ imgSatellite);
+ }
+ int xOffset = (SCREEN_WIDTH > 128) ? 6 : 0;
+ display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine);
+ } else {
+ 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;
+ bool validHeading = false;
+
+ 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: Date ===
+ uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true);
+ char datetimeStr[25];
+ bool showTime = false; // set to true for full datetime
+ UIRenderer::formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, showTime);
+ char fullLine[40];
+ snprintf(fullLine, sizeof(fullLine), " Date: %s", datetimeStr);
+ display->drawString(0, getTextPositions(display)[line++], fullLine);
+
+ // === Third Row: Latitude ===
+ char latStr[32];
+ snprintf(latStr, sizeof(latStr), " Lat: %.5f", geoCoord.getLatitude() * 1e-7);
+ display->drawString(x, getTextPositions(display)[line++], latStr);
+
+ // === Fourth Row: Longitude ===
+ char lonStr[32];
+ snprintf(lonStr, sizeof(lonStr), " Lon: %.5f", geoCoord.getLongitude() * 1e-7);
+ display->drawString(x, getTextPositions(display)[line++], lonStr);
+
+ // === Fifth Row: Altitude ===
+ char DisplayLineTwo[32] = {0};
+ if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
+ snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET);
+ } else {
+ snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0im", geoCoord.getAltitude());
+ }
+ display->drawString(x, getTextPositions(display)[line++], DisplayLineTwo);
+ }
+
+ // === 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 = -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 = -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
+}
+
+#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;
+ 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);
+
+ 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;
+ 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
+
+// Function overlay for showing mute/buzzer modifiers etc.
+void UIRenderer::drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
+{
+ // LOG_DEBUG("Draw function overlay");
+ if (functionSymbol.begin() != functionSymbol.end()) {
+ char buf[64];
+ display->setFont(FONT_SMALL);
+ snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str());
+ display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf);
+ }
+}
+
+// Navigation bar overlay implementation
+static int8_t lastFrameIndex = -1;
+static uint32_t lastFrameChangeTime = 0;
+constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
+
+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 bool useBigIcons = (SCREEN_WIDTH > 128);
+ const int iconSize = useBigIcons ? 16 : 8;
+ const int spacing = useBigIcons ? 8 : 4;
+ const int bigOffset = useBigIcons ? 1 : 0;
+
+ const size_t totalIcons = screen->indicatorIcons.size();
+ if (totalIcons == 0)
+ return;
+
+ const size_t iconsPerPage = (SCREEN_WIDTH + spacing) / (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;
+
+ // Only show bar briefly after switching frames (unless on E-Ink)
+#if defined(USE_EINK)
+ int y = SCREEN_HEIGHT - iconSize - 1;
+#else
+ int y = SCREEN_HEIGHT - iconSize - 1;
+ if (millis() - lastFrameChangeTime > ICON_DISPLAY_DURATION_MS) {
+ y = SCREEN_HEIGHT;
+ }
+#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(currentFrame));
+
+ if (isActive) {
+ display->setColor(WHITE);
+ display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
+ display->setColor(BLACK);
+ }
+
+ if (useBigIcons) {
+ NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display);
+ } else {
+ display->drawXbm(x, y, iconSize, iconSize, icon);
+ }
+
+ if (isActive) {
+ display->setColor(WHITE);
+ }
+ }
+
+ // 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 // !MESHTASTIC_EXCLUDE_GPS
+#endif // HAS_SCREEN
\ No newline at end of file
diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h
new file mode 100644
index 000000000..21e4aef61
--- /dev/null
+++ b/src/graphics/draw/UIRenderer.h
@@ -0,0 +1,93 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include "graphics/emotes.h"
+#include
+#include
+#include
+
+#define HOURS_IN_MONTH 730
+
+// Forward declarations for status types
+namespace meshtastic
+{
+class PowerStatus;
+class NodeStatus;
+class GPSStatus;
+} // namespace meshtastic
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief UI utility drawing functions
+ *
+ * Contains utility functions for drawing common UI elements, overlays,
+ * battery indicators, and other shared graphical components.
+ */
+class UIRenderer
+{
+ public:
+ // Common UI elements
+ static void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer,
+ const meshtastic::PowerStatus *powerStatus);
+ static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus,
+ int node_offset = 0, bool show_total = true, String additional_words = "");
+
+ // GPS status functions
+ static void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+ static void drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+ static void drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+ static void drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+
+ // Layout and utility functions
+ static void drawScrollbar(OLEDDisplay *display, int visibleItems, int totalItems, int scrollIndex, int x, int startY);
+
+ // Overlay and special screens
+ static void drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *text);
+
+ // Function overlay for showing mute/buzzer modifiers etc.
+ static void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
+
+ // Navigation bar overlay
+ static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
+
+ static void drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+ static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+ // Icon and screen drawing functions
+ static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+ // Compass and location screen
+ static void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+ static NodeNum currentFavoriteNodeNum;
+
+// OEM screens
+#ifdef USERPREFS_OEM_TEXT
+ static void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+ static void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+#endif
+
+#ifdef USE_EINK
+ /// Used on eink displays while in deep sleep
+ static void drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+ /// Used on eink displays when screen updates are paused
+ static void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
+#endif
+
+ static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
+ static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime);
+
+ // Message filtering
+ static bool shouldDrawMessage(const meshtastic_MeshPacket *packet);
+ // Check if the display can render a string (detect special chars; emoji)
+ static bool haveGlyphs(const char *str);
+}; // namespace UIRenderer
+
+} // namespace graphics
diff --git a/src/graphics/emotes.cpp b/src/graphics/emotes.cpp
new file mode 100644
index 000000000..205d5c660
--- /dev/null
+++ b/src/graphics/emotes.cpp
@@ -0,0 +1,225 @@
+#include "emotes.h"
+
+namespace graphics
+{
+
+// Always define Emote list and count
+const Emote emotes[] = {
+#ifndef EXCLUDE_EMOJI
+ // --- Thumbs ---
+ {"\U0001F44D", thumbup, thumbs_width, thumbs_height}, // 👍 Thumbs Up
+ {"\U0001F44E", thumbdown, thumbs_width, thumbs_height}, // 👎 Thumbs Down
+
+ // --- Smileys (Multiple Unicode Aliases) ---
+ {"\U0001F60A", smiley, smiley_width, smiley_height}, // 😊 Smiling Face with Smiling Eyes
+ {"\U0001F600", smiley, smiley_width, smiley_height}, // 😀 Grinning Face
+ {"\U0001F642", smiley, smiley_width, smiley_height}, // 🙂 Slightly Smiling Face
+ {"\U0001F609", smiley, smiley_width, smiley_height}, // 😉 Winking Face
+ {"\U0001F601", smiley, smiley_width, smiley_height}, // 😁 Grinning Face with Smiling Eyes
+
+ // --- Question/Alert ---
+ {"\u2753", question, question_width, question_height}, // ❓ Question Mark
+ {"\u203C\uFE0F", bang, bang_width, bang_height}, // ‼️ Double Exclamation Mark
+
+ // --- Laughing Faces ---
+ {"\U0001F602", haha, haha_width, haha_height}, // 😂 Face with Tears of Joy
+ {"\U0001F923", haha, haha_width, haha_height}, // 🤣 Rolling on the Floor Laughing
+ {"\U0001F606", haha, haha_width, haha_height}, // 😆 Smiling with Open Mouth and Closed Eyes
+ {"\U0001F605", haha, haha_width, haha_height}, // 😅 Smiling with Sweat
+ {"\U0001F604", haha, haha_width, haha_height}, // 😄 Grinning Face with Smiling Eyes
+
+ // --- Gestures and People ---
+ {"\U0001F44B", wave_icon, wave_icon_width, wave_icon_height}, // 👋 Waving Hand
+ {"\U0001F920", cowboy, cowboy_width, cowboy_height}, // 🤠 Cowboy Hat Face
+ {"\U0001F3A7", deadmau5, deadmau5_width, deadmau5_height}, // 🎧 Headphones
+
+ // --- Weather ---
+ {"\u2600", sun, sun_width, sun_height}, // ☀ Sun (without variation selector)
+ {"\u2600\uFE0F", sun, sun_width, sun_height}, // ☀️ Sun (with variation selector)
+ {"\U0001F327\uFE0F", rain, rain_width, rain_height}, // 🌧️ Cloud with Rain
+ {"\u2601\uFE0F", cloud, cloud_width, cloud_height}, // ☁️ Cloud
+ {"\U0001F32B\uFE0F", fog, fog_width, fog_height}, // 🌫️ Fog
+
+ // --- Misc Faces ---
+ {"\U0001F608", devil, devil_width, devil_height}, // 😈 Smiling Face with Horns
+
+ // --- Hearts (Multiple Unicode Aliases) ---
+ {"\u2764\uFE0F", heart, heart_width, heart_height}, // ❤️ Red Heart
+ {"\U0001F9E1", heart, heart_width, heart_height}, // 🧡 Orange Heart
+ {"\U00002763", heart, heart_width, heart_height}, // ❣ Heart Exclamation
+ {"\U00002764", heart, heart_width, heart_height}, // ❤ Red Heart (legacy)
+ {"\U0001F495", heart, heart_width, heart_height}, // 💕 Two Hearts
+ {"\U0001F496", heart, heart_width, heart_height}, // 💖 Sparkling Heart
+ {"\U0001F497", heart, heart_width, heart_height}, // 💗 Growing Heart
+ {"\U0001F498", heart, heart_width, heart_height}, // 💘 Heart with Arrow
+
+ // --- Objects ---
+ {"\U0001F4A9", poo, poo_width, poo_height}, // 💩 Pile of Poo
+ {"\U0001F514", bell_icon, bell_icon_width, bell_icon_height} // 🔔 Bell
+#endif
+};
+
+const int numEmotes = sizeof(emotes) / sizeof(emotes[0]);
+
+#ifndef EXCLUDE_EMOJI
+const unsigned char thumbup[] PROGMEM = {
+ 0x00, 0x1C, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x80, 0x09, 0x00, 0x00,
+ 0xC0, 0x08, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00,
+ 0x0C, 0xCE, 0x7F, 0x00, 0x04, 0x20, 0x80, 0x00, 0x02, 0x20, 0x80, 0x00, 0x02, 0x60, 0xC0, 0x00, 0x01, 0xF8, 0xFF, 0x01,
+ 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0x18, 0x80, 0x00,
+ 0x02, 0x30, 0xC0, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x38, 0x20, 0x10, 0x00, 0xE0, 0xCF, 0x1F, 0x00,
+};
+
+const unsigned char thumbdown[] PROGMEM = {
+ 0xE0, 0xCF, 0x1F, 0x00, 0x38, 0x20, 0x10, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x02, 0x30, 0xC0, 0x00,
+ 0x01, 0x18, 0x80, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01,
+ 0x01, 0xF8, 0xFF, 0x01, 0x02, 0x60, 0xC0, 0x00, 0x02, 0x20, 0x80, 0x00, 0x04, 0x20, 0x80, 0x00, 0x0C, 0xCE, 0x7F, 0x00,
+ 0x18, 0x02, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0xC0, 0x08, 0x00, 0x00,
+ 0x80, 0x09, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00,
+};
+
+const unsigned char smiley[] PROGMEM = {
+ 0x00, 0xfe, 0x0f, 0x00, 0x80, 0x01, 0x30, 0x00, 0x40, 0x00, 0xc0, 0x00, 0x20, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x02,
+ 0x08, 0x00, 0x00, 0x04, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x10, 0x02, 0x0e, 0x0e, 0x10, 0x02, 0x09, 0x12, 0x10,
+ 0x01, 0x09, 0x12, 0x20, 0x01, 0x0f, 0x1e, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20,
+ 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x81, 0x00, 0x20, 0x20,
+ 0x82, 0x00, 0x20, 0x10, 0x02, 0x01, 0x10, 0x10, 0x04, 0x02, 0x08, 0x08, 0x04, 0xfc, 0x07, 0x08, 0x08, 0x00, 0x00, 0x04,
+ 0x10, 0x00, 0x00, 0x02, 0x20, 0x00, 0x00, 0x01, 0x40, 0x00, 0xc0, 0x00, 0x80, 0x01, 0x30, 0x00, 0x00, 0xfe, 0x0f, 0x00};
+
+const unsigned char question[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x80, 0xFF, 0x01, 0x00, 0xC0, 0xFF, 0x07, 0x00, 0xE0, 0xFF, 0x07, 0x00,
+ 0xE0, 0xC3, 0x0F, 0x00, 0xF0, 0x81, 0x0F, 0x00, 0xF0, 0x01, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x80, 0x0F, 0x00,
+ 0x00, 0xC0, 0x0F, 0x00, 0x00, 0xE0, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xF8, 0x01, 0x00, 0x00, 0x7C, 0x00, 0x00,
+ 0x00, 0x3C, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+const unsigned char bang[] PROGMEM = {
+ 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x07, 0xF8, 0x3F, 0xFF, 0x07, 0xF8, 0x3F,
+ 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F,
+ 0xFE, 0x03, 0xF0, 0x1F, 0xFE, 0x03, 0xF0, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F,
+ 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x03, 0xFC, 0x03, 0xF0, 0x0F, 0xFE, 0x03, 0xF0, 0x1F,
+ 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xF8, 0x01, 0xE0, 0x07,
+};
+
+const unsigned char haha[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00,
+ 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x1F, 0x3E, 0x00, 0x80, 0x03, 0x70, 0x00, 0xC0, 0x01, 0xE0, 0x00, 0xC0, 0x00, 0xC2, 0x00,
+ 0x60, 0x00, 0x03, 0x00, 0x60, 0x00, 0xC1, 0x1F, 0x60, 0x80, 0x8F, 0x31, 0x30, 0x0E, 0x80, 0x31, 0x30, 0x10, 0x30, 0x1F,
+ 0x30, 0x08, 0x58, 0x00, 0x30, 0x04, 0x6C, 0x03, 0x60, 0x00, 0xF3, 0x01, 0x60, 0xC0, 0xFC, 0x01, 0x80, 0x38, 0xBF, 0x01,
+ 0xE0, 0xC5, 0xDF, 0x00, 0xB0, 0xF9, 0xEF, 0x00, 0x30, 0xF1, 0x73, 0x00, 0xB0, 0x1D, 0x3E, 0x00, 0xF0, 0xFD, 0x0F, 0x00,
+ 0xE0, 0xE0, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+const unsigned char wave_icon[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0xC0, 0x00,
+ 0x00, 0x0C, 0x9C, 0x01, 0x80, 0x17, 0x20, 0x01, 0x80, 0x26, 0x46, 0x02, 0x80, 0x44, 0x88, 0x02, 0xC0, 0x89, 0x8A, 0x02,
+ 0x40, 0x93, 0x8B, 0x02, 0x40, 0x26, 0x13, 0x00, 0x80, 0x44, 0x16, 0x00, 0xC0, 0x89, 0x24, 0x00, 0x40, 0x93, 0x60, 0x00,
+ 0x40, 0x26, 0x40, 0x00, 0x80, 0x0C, 0x80, 0x00, 0x00, 0x09, 0x80, 0x00, 0x00, 0x02, 0x80, 0x00, 0x40, 0x06, 0x80, 0x00,
+ 0x50, 0x0C, 0x80, 0x00, 0x50, 0x08, 0x40, 0x00, 0x90, 0x10, 0x20, 0x00, 0xB0, 0x21, 0x10, 0x00, 0x20, 0x47, 0x18, 0x00,
+ 0x40, 0x80, 0x0F, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+const unsigned char cowboy[] PROGMEM = {
+ 0x00, 0xF0, 0x03, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x3C, 0xFE, 0x1F, 0x0F,
+ 0xFE, 0xFE, 0xDF, 0x1F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F,
+ 0x3E, 0xC0, 0x00, 0x1F, 0x1E, 0x00, 0x00, 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x08, 0x0E, 0x1C, 0x04, 0x00, 0x0E, 0x1C, 0x00,
+ 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x04, 0x08, 0x08, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x08,
+ 0x8C, 0x07, 0x70, 0x0C, 0x88, 0xFC, 0x4F, 0x04, 0x88, 0x01, 0x40, 0x04, 0x90, 0xFF, 0x7F, 0x02, 0x30, 0x03, 0x30, 0x03,
+ 0x60, 0x0E, 0x9C, 0x01, 0xC0, 0xF8, 0xC7, 0x00, 0x80, 0x01, 0x60, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0xF8, 0x07, 0x00,
+};
+
+const unsigned char deadmau5[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x07, 0x00,
+ 0x00, 0xFC, 0x03, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0x00, 0xC0, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0x00,
+ 0xE0, 0xFF, 0xFF, 0x01, 0xF0, 0xFF, 0x7F, 0x00, 0xF0, 0xFF, 0xFF, 0x03, 0xF8, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x07,
+ 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFE, 0xFF, 0xFF, 0x00,
+ 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0xFC,
+ 0x0F, 0xFF, 0x7F, 0x00, 0xC0, 0xFF, 0x1F, 0xF8, 0x0F, 0xFC, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0xF8, 0x1F, 0xFC, 0x1F, 0x00,
+ 0x00, 0xFF, 0x0F, 0xFC, 0x3F, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x1F, 0xFF, 0xFF, 0xFE, 0x01, 0x00, 0x00, 0x00, 0xFC, 0xFF,
+ 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x18, 0x00,
+ 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x80, 0x07, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+const unsigned char sun[] PROGMEM = {
+ 0x00, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x30, 0xC0, 0x00, 0x03,
+ 0x70, 0x00, 0x80, 0x03, 0xF0, 0x00, 0xC0, 0x03, 0xF0, 0xF8, 0xC7, 0x03, 0xE0, 0xFC, 0xCF, 0x01, 0x00, 0xFE, 0x1F, 0x00,
+ 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x8E, 0xFF, 0x7F, 0x1C, 0x9F, 0xFF, 0x7F, 0x3E,
+ 0x9F, 0xFF, 0x7F, 0x3E, 0x8E, 0xFF, 0x7F, 0x1C, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00,
+ 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0xC0, 0xF9, 0xE7, 0x00, 0xE0, 0x01, 0xE0, 0x01, 0xF0, 0x01, 0xE0, 0x03,
+ 0xF0, 0xC0, 0xC0, 0x03, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xC0, 0x00, 0x00,
+};
+
+const unsigned char rain[] PROGMEM = {
+ 0xC0, 0x0F, 0xC0, 0x00, 0x40, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x03, 0x38, 0x00,
+ 0x00, 0x0E, 0x0C, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10, 0x03, 0x00, 0x00, 0x30, 0x01, 0x00, 0x00, 0x20,
+ 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x03, 0x00, 0x00, 0x30, 0x02, 0x00,
+ 0x00, 0x10, 0x06, 0x00, 0x00, 0x08, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x01, 0x80, 0x00, 0x01, 0x00,
+ 0xC0, 0xC0, 0x81, 0x03, 0xA0, 0x60, 0xC1, 0x03, 0x90, 0x20, 0x41, 0x01, 0xF0, 0xE0, 0xC0, 0x01, 0x60, 0x4C,
+ 0x98, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0x0B, 0x12, 0x00, 0x00, 0x09, 0x1A, 0x00, 0x00, 0x06, 0x0E, 0x00,
+};
+
+const unsigned char cloud[] PROGMEM = {
+ 0x00, 0x80, 0x07, 0x00, 0x00, 0xE0, 0x1F, 0x00, 0x00, 0x70, 0x30, 0x00, 0x00, 0x10, 0x60, 0x00, 0x80, 0x1F, 0x40, 0x00,
+ 0xC0, 0x0F, 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x00, 0x60, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x01,
+ 0x20, 0x00, 0x00, 0x07, 0x38, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x08, 0x06, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10,
+ 0x02, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20,
+ 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x10,
+ 0x02, 0x00, 0x00, 0x10, 0x06, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x0C, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03,
+};
+
+const unsigned char fog[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x78, 0x00, 0x3C, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01,
+ 0x00, 0x38, 0x00, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0xFF, 0x83, 0xFF, 0x01, 0x03, 0xFF, 0x81, 0x01, 0x00, 0x7C, 0x00, 0x00,
+ 0xF8, 0x00, 0x3E, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01, 0x00, 0x38, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+const unsigned char devil[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x10, 0x03, 0xC0, 0x01, 0x38, 0x07, 0x7C, 0x0F, 0x38, 0x1F, 0x03, 0x30, 0x1E,
+ 0xFE, 0x01, 0xE0, 0x1F, 0x7E, 0x00, 0x80, 0x1F, 0x3C, 0x00, 0x00, 0x0F, 0x1C, 0x00, 0x00, 0x0E, 0x18, 0x00, 0x00, 0x06,
+ 0x08, 0x00, 0x00, 0x04, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x0E, 0x1C, 0x0C,
+ 0x0C, 0x18, 0x06, 0x0C, 0x0C, 0x1C, 0x06, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x0C, 0x06, 0x0C,
+ 0x08, 0x00, 0x00, 0x06, 0x18, 0x02, 0x10, 0x06, 0x10, 0x0C, 0x0C, 0x03, 0x30, 0xF8, 0x07, 0x03, 0x60, 0xE0, 0x80, 0x01,
+ 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x01, 0x70, 0x00, 0x00, 0x06, 0x1C, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+const unsigned char heart[] PROGMEM = {
+ 0x00, 0x00, 0x00, 0x00, 0xC0, 0x03, 0xF0, 0x00, 0xF8, 0x0F, 0xFC, 0x07, 0xFC, 0x1F, 0x06, 0x0E, 0xFE, 0x3F, 0x03, 0x18,
+ 0xFE, 0xFF, 0x7F, 0x10, 0xFF, 0xFF, 0xFF, 0x31, 0xFF, 0xFF, 0xFF, 0x33, 0xFF, 0xFF, 0xFF, 0x37, 0xFF, 0xFF, 0xFF, 0x37,
+ 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFE, 0xFF, 0xFF, 0x1F, 0xFE, 0xFF, 0xFF, 0x1F,
+ 0xFC, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x0F, 0xF8, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03, 0xF0, 0xFF, 0xFF, 0x03,
+ 0xE0, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x00, 0xFE, 0x1F, 0x00,
+ 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0x80, 0x00, 0x00,
+};
+
+const unsigned char poo[] PROGMEM = {
+ 0x00, 0x1C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0xEC, 0x01, 0x00, 0x00, 0x8C, 0x07, 0x00, 0x00, 0x0C, 0x06, 0x00,
+ 0x00, 0x24, 0x0C, 0x00, 0x00, 0x34, 0x08, 0x00, 0x00, 0x1F, 0x08, 0x00, 0xC0, 0x0F, 0x08, 0x00, 0xC0, 0x00, 0x3C, 0x00,
+ 0x60, 0x00, 0x7C, 0x00, 0x60, 0x00, 0xC6, 0x00, 0x20, 0x00, 0xCB, 0x00, 0xA0, 0xC7, 0xFF, 0x00, 0xE0, 0x7F, 0xF7, 0x00,
+ 0xF0, 0x18, 0xE3, 0x03, 0x78, 0x18, 0x41, 0x03, 0x6C, 0x9B, 0x5D, 0x06, 0x64, 0x9B, 0x5D, 0x04, 0x44, 0x1A, 0x41, 0x04,
+ 0x4C, 0xD8, 0x63, 0x06, 0xF8, 0xFC, 0x36, 0x06, 0xFE, 0x0F, 0x9C, 0x1F, 0x07, 0x03, 0xC0, 0x30, 0x03, 0x00, 0x78, 0x20,
+ 0x01, 0x00, 0x1F, 0x20, 0x03, 0xE0, 0x03, 0x20, 0x07, 0x7E, 0x04, 0x30, 0xFE, 0x0F, 0xFC, 0x1F, 0xF0, 0x00, 0xF0, 0x0F,
+};
+
+const unsigned char bell_icon[] PROGMEM = {
+ 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b11110000,
+ 0b00000011, 0b00000000, 0b00000000, 0b11111100, 0b00001111, 0b00000000, 0b00000000, 0b00001111, 0b00111100, 0b00000000,
+ 0b00000000, 0b00000011, 0b00110000, 0b00000000, 0b10000000, 0b00000001, 0b01100000, 0b00000000, 0b11000000, 0b00000000,
+ 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000,
+ 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000,
+ 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000,
+ 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b01000000, 0b00000000, 0b10000000, 0b00000000, 0b01100000, 0b00000000,
+ 0b10000000, 0b00000001, 0b01110000, 0b00000000, 0b10000000, 0b00000011, 0b00110000, 0b00000000, 0b00000000, 0b00000011,
+ 0b00011000, 0b00000000, 0b00000000, 0b00000110, 0b11110000, 0b11111111, 0b11111111, 0b00000011, 0b00000000, 0b00001100,
+ 0b00001100, 0b00000000, 0b00000000, 0b00011000, 0b00000110, 0b00000000, 0b00000000, 0b11111000, 0b00000111, 0b00000000,
+ 0b00000000, 0b11100000, 0b00000001, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
+ 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000};
+#endif
+
+} // namespace graphics
diff --git a/src/graphics/emotes.h b/src/graphics/emotes.h
new file mode 100644
index 000000000..5640ac04a
--- /dev/null
+++ b/src/graphics/emotes.h
@@ -0,0 +1,86 @@
+#pragma once
+#include
+
+namespace graphics
+{
+
+// === Emote List ===
+struct Emote {
+ const char *label;
+ const unsigned char *bitmap;
+ int width;
+ int height;
+};
+
+extern const Emote emotes[/* numEmotes */];
+extern const int numEmotes;
+
+#ifndef EXCLUDE_EMOJI
+// === Emote Bitmaps ===
+#define thumbs_height 25
+#define thumbs_width 25
+extern const unsigned char thumbup[] PROGMEM;
+extern const unsigned char thumbdown[] PROGMEM;
+
+#define smiley_height 30
+#define smiley_width 30
+extern const unsigned char smiley[] PROGMEM;
+
+#define question_height 25
+#define question_width 25
+extern const unsigned char question[] PROGMEM;
+
+#define bang_height 30
+#define bang_width 30
+extern const unsigned char bang[] PROGMEM;
+
+#define haha_height 30
+#define haha_width 30
+extern const unsigned char haha[] PROGMEM;
+
+#define wave_icon_height 30
+#define wave_icon_width 30
+extern const unsigned char wave_icon[] PROGMEM;
+
+#define cowboy_height 30
+#define cowboy_width 30
+extern const unsigned char cowboy[] PROGMEM;
+
+#define deadmau5_height 30
+#define deadmau5_width 60
+extern const unsigned char deadmau5[] PROGMEM;
+
+#define sun_height 30
+#define sun_width 30
+extern const unsigned char sun[] PROGMEM;
+
+#define rain_height 30
+#define rain_width 30
+extern const unsigned char rain[] PROGMEM;
+
+#define cloud_height 30
+#define cloud_width 30
+extern const unsigned char cloud[] PROGMEM;
+
+#define fog_height 25
+#define fog_width 25
+extern const unsigned char fog[] PROGMEM;
+
+#define devil_height 30
+#define devil_width 30
+extern const unsigned char devil[] PROGMEM;
+
+#define heart_height 30
+#define heart_width 30
+extern const unsigned char heart[] PROGMEM;
+
+#define poo_height 30
+#define poo_width 30
+extern const unsigned char poo[] PROGMEM;
+
+#define bell_icon_width 30
+#define bell_icon_height 30
+extern const unsigned char bell_icon[] PROGMEM;
+#endif // EXCLUDE_EMOJI
+
+} // namespace graphics
diff --git a/src/graphics/images.h b/src/graphics/images.h
index 069839a16..e9c2f00ea 100644
--- a/src/graphics/images.h
+++ b/src/graphics/images.h
@@ -6,7 +6,12 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0
0xF8, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC8, 0x01, 0x9C, 0x54,
0x0E, 0x52, 0x07, 0x48, 0x02, 0x26, 0x00, 0x10, 0x00, 0x0E};
-const uint8_t imgSatellite[] PROGMEM = {0x70, 0x71, 0x22, 0xFA, 0xFA, 0x22, 0x71, 0x70};
+#define imgSatellite_width 8
+#define imgSatellite_height 8
+const uint8_t imgSatellite[] PROGMEM = {
+ 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000,
+};
+
const uint8_t imgUSB[] PROGMEM = {0x60, 0x60, 0x30, 0x18, 0x18, 0x18, 0x24, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x24, 0x24, 0x24, 0x3C};
const uint8_t imgPower[] PROGMEM = {0x40, 0x40, 0x40, 0x58, 0x48, 0x08, 0x08, 0x08,
0x1C, 0x22, 0x22, 0x41, 0x7F, 0x22, 0x22, 0x22};
@@ -14,11 +19,12 @@ const uint8_t imgUser[] PROGMEM = {0x3C, 0x42, 0x99, 0xA5, 0xA5, 0x99, 0x42, 0x3
const uint8_t imgPositionEmpty[] PROGMEM = {0x20, 0x30, 0x28, 0x24, 0x42, 0xFF};
const uint8_t imgPositionSolid[] PROGMEM = {0x20, 0x30, 0x38, 0x3C, 0x7E, 0xFF};
-#if defined(DISPLAY_CLOCK_FRAME)
const uint8_t bluetoothConnectedIcon[36] PROGMEM = {0xfe, 0x01, 0xff, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0xe3, 0x1f,
0xf3, 0x3f, 0x33, 0x30, 0x33, 0x33, 0x33, 0x33, 0x03, 0x33, 0xff, 0x33,
0xfe, 0x31, 0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0xf0, 0x3f, 0xe0, 0x1f};
-#endif
+
+// This image definition is here instead of images.h because it's modified dynamically by the drawBattery function
+static uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C};
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || ARCH_PORTDUINO) && \
@@ -37,181 +43,248 @@ const uint8_t imgQuestion[] PROGMEM = {0xbf, 0x41, 0xc0, 0x8b, 0xdb, 0x70, 0xa1,
const uint8_t imgSF[] PROGMEM = {0xd2, 0xb7, 0xad, 0xbb, 0x92, 0x01, 0xfd, 0xfd, 0x15, 0x85, 0xf5};
#endif
-#ifndef EXCLUDE_EMOJI
-#define thumbs_height 25
-#define thumbs_width 25
-static unsigned char thumbup[] PROGMEM = {
- 0x00, 0x1C, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x80, 0x09, 0x00, 0x00,
- 0xC0, 0x08, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00,
- 0x0C, 0xCE, 0x7F, 0x00, 0x04, 0x20, 0x80, 0x00, 0x02, 0x20, 0x80, 0x00, 0x02, 0x60, 0xC0, 0x00, 0x01, 0xF8, 0xFF, 0x01,
- 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0x18, 0x80, 0x00,
- 0x02, 0x30, 0xC0, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x38, 0x20, 0x10, 0x00, 0xE0, 0xCF, 0x1F, 0x00,
+// === Horizontal battery ===
+// Basic battery design and all related pieces
+const unsigned char batteryBitmap_h[] PROGMEM = {
+ 0b11111110, 0b00000000, 0b11110000, 0b00000111, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000,
+ 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000,
+ 0b00000001, 0b00000000, 0b00000000, 0b00011000, 0b00000001, 0b00000000, 0b00000000, 0b00011000, 0b00000001, 0b00000000,
+ 0b00000000, 0b00011000, 0b00000001, 0b00000000, 0b00000000, 0b00011000, 0b00000001, 0b00000000, 0b00000000, 0b00011000,
+ 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000,
+ 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b11111110, 0b00000000, 0b11110000, 0b00000111};
+
+// This is the left and right bars for the fill in
+const unsigned char batteryBitmap_sidegaps_h[] PROGMEM = {
+ 0b11111111, 0b00001111, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
+ 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
+ 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b11111111, 0b00001111};
+
+// Lightning Bolt
+const unsigned char lightning_bolt_h[] PROGMEM = {
+ 0b00000000, 0b00000000, 0b00100000, 0b00000000, 0b00110000, 0b00000000, 0b00111000, 0b00000000, 0b00111100,
+ 0b00000000, 0b00011110, 0b00000000, 0b11111111, 0b00000000, 0b01111000, 0b00000000, 0b00111100, 0b00000000,
+ 0b00011100, 0b00000000, 0b00001100, 0b00000000, 0b00000100, 0b00000000, 0b00000000, 0b00000000};
+
+// === Vertical battery ===
+// Basic battery design and all related pieces
+const unsigned char batteryBitmap_v[] PROGMEM = {0b00011100, 0b00111110, 0b01000001, 0b01000001, 0b00000000, 0b00000000,
+ 0b00000000, 0b01000001, 0b01000001, 0b01000001, 0b00111110};
+// This is the left and right bars for the fill in
+const unsigned char batteryBitmap_sidegaps_v[] PROGMEM = {0b10000010, 0b10000010, 0b10000010};
+// Lightning Bolt
+const unsigned char lightning_bolt_v[] PROGMEM = {0b00000100, 0b00000110, 0b00011111, 0b00001100, 0b00000100};
+
+#define mail_width 10
+#define mail_height 7
+static const unsigned char mail[] PROGMEM = {
+ 0b11111111, 0b00, // Top line
+ 0b10000001, 0b00, // Edges
+ 0b11000011, 0b00, // Diagonals start
+ 0b10100101, 0b00, // Inner M part
+ 0b10011001, 0b00, // Inner M part
+ 0b10000001, 0b00, // Edges
+ 0b11111111, 0b00 // Bottom line
};
-static unsigned char thumbdown[] PROGMEM = {
- 0xE0, 0xCF, 0x1F, 0x00, 0x38, 0x20, 0x10, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x02, 0x30, 0xC0, 0x00,
- 0x01, 0x18, 0x80, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01,
- 0x01, 0xF8, 0xFF, 0x01, 0x02, 0x60, 0xC0, 0x00, 0x02, 0x20, 0x80, 0x00, 0x04, 0x20, 0x80, 0x00, 0x0C, 0xCE, 0x7F, 0x00,
- 0x18, 0x02, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0xC0, 0x08, 0x00, 0x00,
- 0x80, 0x09, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00,
+// 📬 Mail / Message
+const uint8_t icon_mail[] PROGMEM = {
+ 0b11111111, // ████████ top border
+ 0b10000001, // █ █ sides
+ 0b11000011, // ██ ██ diagonal
+ 0b10100101, // █ █ █ █ inner M
+ 0b10011001, // █ ██ █ inner M
+ 0b10000001, // █ █ sides
+ 0b10000001, // █ █ sides
+ 0b11111111 // ████████ bottom
};
-#define smiley_height 30
-#define smiley_width 30
-static unsigned char smiley[] PROGMEM = {
- 0x00, 0xfe, 0x0f, 0x00, 0x80, 0x01, 0x30, 0x00, 0x40, 0x00, 0xc0, 0x00, 0x20, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x02,
- 0x08, 0x00, 0x00, 0x04, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x10, 0x02, 0x0e, 0x0e, 0x10, 0x02, 0x09, 0x12, 0x10,
- 0x01, 0x09, 0x12, 0x20, 0x01, 0x0f, 0x1e, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20,
- 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x81, 0x00, 0x20, 0x20,
- 0x82, 0x00, 0x20, 0x10, 0x02, 0x01, 0x10, 0x10, 0x04, 0x02, 0x08, 0x08, 0x04, 0xfc, 0x07, 0x08, 0x08, 0x00, 0x00, 0x04,
- 0x10, 0x00, 0x00, 0x02, 0x20, 0x00, 0x00, 0x01, 0x40, 0x00, 0xc0, 0x00, 0x80, 0x01, 0x30, 0x00, 0x00, 0xfe, 0x0f, 0x00};
-
-#define question_height 25
-#define question_width 25
-static unsigned char question[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x80, 0xFF, 0x01, 0x00, 0xC0, 0xFF, 0x07, 0x00, 0xE0, 0xFF, 0x07, 0x00,
- 0xE0, 0xC3, 0x0F, 0x00, 0xF0, 0x81, 0x0F, 0x00, 0xF0, 0x01, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x80, 0x0F, 0x00,
- 0x00, 0xC0, 0x0F, 0x00, 0x00, 0xE0, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xF8, 0x01, 0x00, 0x00, 0x7C, 0x00, 0x00,
- 0x00, 0x3C, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+// 📍 GPS Screen / Location Pin
+const unsigned char icon_compass[] PROGMEM = {
+ 0x3C, // Row 0: ..####..
+ 0x52, // Row 1: .#..#.#.
+ 0x91, // Row 2: #...#..#
+ 0x91, // Row 3: #...#..#
+ 0x91, // Row 4: #...#..#
+ 0x81, // Row 5: #......#
+ 0x42, // Row 6: .#....#.
+ 0x3C // Row 7: ..####..
};
-#define bang_height 30
-#define bang_width 30
-static unsigned char bang[] PROGMEM = {
- 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x07, 0xF8, 0x3F, 0xFF, 0x07, 0xF8, 0x3F,
- 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F,
- 0xFE, 0x03, 0xF0, 0x1F, 0xFE, 0x03, 0xF0, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F,
- 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x03, 0xFC, 0x03, 0xF0, 0x0F, 0xFE, 0x03, 0xF0, 0x1F,
- 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xF8, 0x01, 0xE0, 0x07,
+const uint8_t icon_radio[] PROGMEM = {
+ 0x0F, // Row 0: ####....
+ 0x10, // Row 1: ....#...
+ 0x27, // Row 2: ###..#..
+ 0x48, // Row 3: ...#..#.
+ 0x93, // Row 4: ##..#..#
+ 0xA4, // Row 5: ..#..#.#
+ 0xA8, // Row 6: ...#.#.#
+ 0xA9 // Row 7: #..#.#.#
};
-#define haha_height 30
-#define haha_width 30
-static unsigned char haha[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00,
- 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x1F, 0x3E, 0x00, 0x80, 0x03, 0x70, 0x00, 0xC0, 0x01, 0xE0, 0x00, 0xC0, 0x00, 0xC2, 0x00,
- 0x60, 0x00, 0x03, 0x00, 0x60, 0x00, 0xC1, 0x1F, 0x60, 0x80, 0x8F, 0x31, 0x30, 0x0E, 0x80, 0x31, 0x30, 0x10, 0x30, 0x1F,
- 0x30, 0x08, 0x58, 0x00, 0x30, 0x04, 0x6C, 0x03, 0x60, 0x00, 0xF3, 0x01, 0x60, 0xC0, 0xFC, 0x01, 0x80, 0x38, 0xBF, 0x01,
- 0xE0, 0xC5, 0xDF, 0x00, 0xB0, 0xF9, 0xEF, 0x00, 0x30, 0xF1, 0x73, 0x00, 0xB0, 0x1D, 0x3E, 0x00, 0xF0, 0xFD, 0x0F, 0x00,
- 0xE0, 0xE0, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+// 🪙 Memory Icon
+const uint8_t icon_memory[] PROGMEM = {
+ 0x24, // Row 0: ..#..#..
+ 0x3C, // Row 1: ..####..
+ 0xC3, // Row 2: ##....##
+ 0x5A, // Row 3: .#.##.#.
+ 0x5A, // Row 4: .#.##.#.
+ 0xC3, // Row 5: ##....##
+ 0x3C, // Row 6: ..####..
+ 0x24 // Row 7: ..#..#..
};
-#define wave_icon_height 30
-#define wave_icon_width 30
-static unsigned char wave_icon[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0xC0, 0x00,
- 0x00, 0x0C, 0x9C, 0x01, 0x80, 0x17, 0x20, 0x01, 0x80, 0x26, 0x46, 0x02, 0x80, 0x44, 0x88, 0x02, 0xC0, 0x89, 0x8A, 0x02,
- 0x40, 0x93, 0x8B, 0x02, 0x40, 0x26, 0x13, 0x00, 0x80, 0x44, 0x16, 0x00, 0xC0, 0x89, 0x24, 0x00, 0x40, 0x93, 0x60, 0x00,
- 0x40, 0x26, 0x40, 0x00, 0x80, 0x0C, 0x80, 0x00, 0x00, 0x09, 0x80, 0x00, 0x00, 0x02, 0x80, 0x00, 0x40, 0x06, 0x80, 0x00,
- 0x50, 0x0C, 0x80, 0x00, 0x50, 0x08, 0x40, 0x00, 0x90, 0x10, 0x20, 0x00, 0xB0, 0x21, 0x10, 0x00, 0x20, 0x47, 0x18, 0x00,
- 0x40, 0x80, 0x0F, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+// 🌐 Wi-Fi
+const uint8_t icon_wifi[] PROGMEM = {0b00000000, 0b00011000, 0b00111100, 0b01111110,
+ 0b11011011, 0b00011000, 0b00011000, 0b00000000};
+
+const uint8_t icon_nodes[] PROGMEM = {
+ 0xF9, // Row 0 #..#######
+ 0x00, // Row 1
+ 0xF9, // Row 2 #..#######
+ 0x00, // Row 3
+ 0xF9, // Row 4 #..#######
+ 0x00, // Row 5
+ 0xF9, // Row 6 #..#######
+ 0x00 // Row 7
};
-#define cowboy_height 30
-#define cowboy_width 30
-static unsigned char cowboy[] PROGMEM = {
- 0x00, 0xF0, 0x03, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x3C, 0xFE, 0x1F, 0x0F,
- 0xFE, 0xFE, 0xDF, 0x1F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F,
- 0x3E, 0xC0, 0x00, 0x1F, 0x1E, 0x00, 0x00, 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x08, 0x0E, 0x1C, 0x04, 0x00, 0x0E, 0x1C, 0x00,
- 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x04, 0x08, 0x08, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x08,
- 0x8C, 0x07, 0x70, 0x0C, 0x88, 0xFC, 0x4F, 0x04, 0x88, 0x01, 0x40, 0x04, 0x90, 0xFF, 0x7F, 0x02, 0x30, 0x03, 0x30, 0x03,
- 0x60, 0x0E, 0x9C, 0x01, 0xC0, 0xF8, 0xC7, 0x00, 0x80, 0x01, 0x60, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0xF8, 0x07, 0x00,
+// ➤ Chevron Triangle Arrow Icon (8x8)
+const uint8_t icon_list[] PROGMEM = {
+ 0x10, // Row 0: ...#....
+ 0x10, // Row 1: ...#....
+ 0x38, // Row 2: ..###...
+ 0x38, // Row 3: ..###...
+ 0x7C, // Row 4: .#####..
+ 0x6C, // Row 5: .##.##..
+ 0xC6, // Row 6: ##...##.
+ 0x82 // Row 7: #.....#.
};
-#define deadmau5_height 30
-#define deadmau5_width 60
-static unsigned char deadmau5[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x07, 0x00,
- 0x00, 0xFC, 0x03, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0x00, 0xC0, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0x00,
- 0xE0, 0xFF, 0xFF, 0x01, 0xF0, 0xFF, 0x7F, 0x00, 0xF0, 0xFF, 0xFF, 0x03, 0xF8, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x07,
- 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFE, 0xFF, 0xFF, 0x00,
- 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0xFC,
- 0x0F, 0xFF, 0x7F, 0x00, 0xC0, 0xFF, 0x1F, 0xF8, 0x0F, 0xFC, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0xF8, 0x1F, 0xFC, 0x1F, 0x00,
- 0x00, 0xFF, 0x0F, 0xFC, 0x3F, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x1F, 0xFF, 0xFF, 0xFE, 0x01, 0x00, 0x00, 0x00, 0xFC, 0xFF,
- 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x18, 0x00,
- 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x80, 0x07, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+// 📶 Signal Bars Icon (left to right, small to large with spacing)
+const uint8_t icon_signal[] PROGMEM = {
+ 0b00000000, // ░░░░░░░
+ 0b10000000, // ░░░░░░░
+ 0b10100000, // ░░░░█░█
+ 0b10100000, // ░░░░█░█
+ 0b10101000, // ░░█░█░█
+ 0b10101000, // ░░█░█░█
+ 0b10101010, // █░█░█░█
+ 0b11111111 // ███████
};
-#define sun_width 30
-#define sun_height 30
-static unsigned char sun[] PROGMEM = {
- 0x00, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x30, 0xC0, 0x00, 0x03,
- 0x70, 0x00, 0x80, 0x03, 0xF0, 0x00, 0xC0, 0x03, 0xF0, 0xF8, 0xC7, 0x03, 0xE0, 0xFC, 0xCF, 0x01, 0x00, 0xFE, 0x1F, 0x00,
- 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x8E, 0xFF, 0x7F, 0x1C, 0x9F, 0xFF, 0x7F, 0x3E,
- 0x9F, 0xFF, 0x7F, 0x3E, 0x8E, 0xFF, 0x7F, 0x1C, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00,
- 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0xC0, 0xF9, 0xE7, 0x00, 0xE0, 0x01, 0xE0, 0x01, 0xF0, 0x01, 0xE0, 0x03,
- 0xF0, 0xC0, 0xC0, 0x03, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xC0, 0x00, 0x00,
+// ↔️ Distance / Measurement Icon (double-ended arrow)
+const uint8_t icon_distance[] PROGMEM = {
+ 0b00000000, // ░░░░░░░░
+ 0b10000001, // █░░░░░█ arrowheads
+ 0b01000010, // ░█░░░█░
+ 0b00100100, // ░░█░█░░
+ 0b00011000, // ░░░██░░ center
+ 0b00100100, // ░░█░█░░
+ 0b01000010, // ░█░░░█░
+ 0b10000001 // █░░░░░█
};
-#define rain_width 30
-#define rain_height 30
-static unsigned char rain[] PROGMEM = {
- 0xC0, 0x0F, 0xC0, 0x00, 0x40, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x03, 0x38, 0x00,
- 0x00, 0x0E, 0x0C, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10, 0x03, 0x00, 0x00, 0x30, 0x01, 0x00, 0x00, 0x20,
- 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x03, 0x00, 0x00, 0x30, 0x02, 0x00,
- 0x00, 0x10, 0x06, 0x00, 0x00, 0x08, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x01, 0x80, 0x00, 0x01, 0x00,
- 0xC0, 0xC0, 0x81, 0x03, 0xA0, 0x60, 0xC1, 0x03, 0x90, 0x20, 0x41, 0x01, 0xF0, 0xE0, 0xC0, 0x01, 0x60, 0x4C,
- 0x98, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0x0B, 0x12, 0x00, 0x00, 0x09, 0x1A, 0x00, 0x00, 0x06, 0x0E, 0x00,
+// ⚠️ Error / Fault
+const uint8_t icon_error[] PROGMEM = {
+ 0b00011000, // ░░░██░░░
+ 0b00011000, // ░░░██░░░
+ 0b00011000, // ░░░██░░░
+ 0b00011000, // ░░░██░░░
+ 0b00000000, // ░░░░░░░░
+ 0b00011000, // ░░░██░░░
+ 0b00000000, // ░░░░░░░░
+ 0b00000000 // ░░░░░░░░
};
-#define cloud_height 30
-#define cloud_width 30
-static unsigned char cloud[] PROGMEM = {
- 0x00, 0x80, 0x07, 0x00, 0x00, 0xE0, 0x1F, 0x00, 0x00, 0x70, 0x30, 0x00, 0x00, 0x10, 0x60, 0x00, 0x80, 0x1F, 0x40, 0x00,
- 0xC0, 0x0F, 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x00, 0x60, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x01,
- 0x20, 0x00, 0x00, 0x07, 0x38, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x08, 0x06, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10,
- 0x02, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20,
- 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x10,
- 0x02, 0x00, 0x00, 0x10, 0x06, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x0C, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03,
+// 🏠 Optimized Home Icon (8x8)
+const uint8_t icon_home[] PROGMEM = {
+ 0b00011000, // ██
+ 0b00111100, // ████
+ 0b01111110, // ██████
+ 0b11111111, // ███████
+ 0b11000011, // ██ ██
+ 0b11011011, // ██ ██ ██
+ 0b11011011, // ██ ██ ██
+ 0b11111111 // ███████
};
-#define fog_height 25
-#define fog_width 25
-static unsigned char fog[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x78, 0x00, 0x3C, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01,
- 0x00, 0x38, 0x00, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0xFF, 0x83, 0xFF, 0x01, 0x03, 0xFF, 0x81, 0x01, 0x00, 0x7C, 0x00, 0x00,
- 0xF8, 0x00, 0x3E, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01, 0x00, 0x38, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+// 🔧 Generic module (gear-like shape)
+const uint8_t icon_module[] PROGMEM = {
+ 0b00011000, // ░░░██░░░
+ 0b00111100, // ░░████░░
+ 0b01111110, // ░██████░
+ 0b11011011, // ██░██░██
+ 0b11011011, // ██░██░██
+ 0b01111110, // ░██████░
+ 0b00111100, // ░░████░░
+ 0b00011000 // ░░░██░░░
};
-#define devil_height 30
-#define devil_width 30
-static unsigned char devil[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x10, 0x03, 0xC0, 0x01, 0x38, 0x07, 0x7C, 0x0F, 0x38, 0x1F, 0x03, 0x30, 0x1E,
- 0xFE, 0x01, 0xE0, 0x1F, 0x7E, 0x00, 0x80, 0x1F, 0x3C, 0x00, 0x00, 0x0F, 0x1C, 0x00, 0x00, 0x0E, 0x18, 0x00, 0x00, 0x06,
- 0x08, 0x00, 0x00, 0x04, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x0E, 0x1C, 0x0C,
- 0x0C, 0x18, 0x06, 0x0C, 0x0C, 0x1C, 0x06, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x0C, 0x06, 0x0C,
- 0x08, 0x00, 0x00, 0x06, 0x18, 0x02, 0x10, 0x06, 0x10, 0x0C, 0x0C, 0x03, 0x30, 0xF8, 0x07, 0x03, 0x60, 0xE0, 0x80, 0x01,
- 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x01, 0x70, 0x00, 0x00, 0x06, 0x1C, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
+#define mute_symbol_width 8
+#define mute_symbol_height 8
+const uint8_t mute_symbol[] PROGMEM = {
+ 0b00011001, // █
+ 0b00100110, // █
+ 0b00100100, // ████
+ 0b01001010, // █ █ █
+ 0b01010010, // █ █ █
+ 0b01100010, // ████████
+ 0b11111111, // █ █
+ 0b10011000, // █
};
-#define heart_height 30
-#define heart_width 30
-static unsigned char heart[] PROGMEM = {
- 0x00, 0x00, 0x00, 0x00, 0xC0, 0x03, 0xF0, 0x00, 0xF8, 0x0F, 0xFC, 0x07, 0xFC, 0x1F, 0x06, 0x0E, 0xFE, 0x3F, 0x03, 0x18,
- 0xFE, 0xFF, 0x7F, 0x10, 0xFF, 0xFF, 0xFF, 0x31, 0xFF, 0xFF, 0xFF, 0x33, 0xFF, 0xFF, 0xFF, 0x37, 0xFF, 0xFF, 0xFF, 0x37,
- 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFE, 0xFF, 0xFF, 0x1F, 0xFE, 0xFF, 0xFF, 0x1F,
- 0xFC, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x0F, 0xF8, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03, 0xF0, 0xFF, 0xFF, 0x03,
- 0xE0, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x00, 0xFE, 0x1F, 0x00,
- 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0x80, 0x00, 0x00,
+#define mute_symbol_big_width 16
+#define mute_symbol_big_height 16
+const uint8_t mute_symbol_big[] PROGMEM = {0b00000001, 0b00000000, 0b11000010, 0b00000011, 0b00110100, 0b00001100, 0b00011000,
+ 0b00001000, 0b00011000, 0b00010000, 0b00101000, 0b00010000, 0b01001000, 0b00010000,
+ 0b10001000, 0b00010000, 0b00001000, 0b00010001, 0b00001000, 0b00010010, 0b00001000,
+ 0b00010100, 0b00000100, 0b00101000, 0b11111100, 0b00111111, 0b01000000, 0b00100010,
+ 0b10000000, 0b01000001, 0b00000000, 0b10000000};
+
+// Bell icon for Alert Message
+#define bell_alert_width 8
+#define bell_alert_height 8
+const unsigned char bell_alert[] PROGMEM = {0b00011000, 0b00100100, 0b00100100, 0b01000010,
+ 0b01000010, 0b01000010, 0b11111111, 0b00011000};
+
+#define key_symbol_width 8
+#define key_symbol_height 8
+const uint8_t key_symbol[] PROGMEM = {0b00000000, 0b00000000, 0b00000110, 0b11111001,
+ 0b10101001, 0b10000110, 0b00000000, 0b00000000};
+
+#define placeholder_width 8
+#define placeholder_height 8
+const uint8_t placeholder[] PROGMEM = {0b11111111, 0b11111111, 0b11111111, 0b11111111,
+ 0b11111111, 0b11111111, 0b11111111, 0b11111111};
+
+#define icon_node_width 8
+#define icon_node_height 8
+static const uint8_t icon_node[] PROGMEM = {
+ 0x10, // #
+ 0x10, // # ← antenna
+ 0x10, // #
+ 0xFE, // ####### ← device top
+ 0x82, // # #
+ 0xAA, // # # # # ← body with pattern
+ 0x92, // # # #
+ 0xFE // ####### ← device base
};
-#define poo_width 30
-#define poo_height 30
-static unsigned char poo[] PROGMEM = {
- 0x00, 0x1C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0xEC, 0x01, 0x00, 0x00, 0x8C, 0x07, 0x00, 0x00, 0x0C, 0x06, 0x00,
- 0x00, 0x24, 0x0C, 0x00, 0x00, 0x34, 0x08, 0x00, 0x00, 0x1F, 0x08, 0x00, 0xC0, 0x0F, 0x08, 0x00, 0xC0, 0x00, 0x3C, 0x00,
- 0x60, 0x00, 0x7C, 0x00, 0x60, 0x00, 0xC6, 0x00, 0x20, 0x00, 0xCB, 0x00, 0xA0, 0xC7, 0xFF, 0x00, 0xE0, 0x7F, 0xF7, 0x00,
- 0xF0, 0x18, 0xE3, 0x03, 0x78, 0x18, 0x41, 0x03, 0x6C, 0x9B, 0x5D, 0x06, 0x64, 0x9B, 0x5D, 0x04, 0x44, 0x1A, 0x41, 0x04,
- 0x4C, 0xD8, 0x63, 0x06, 0xF8, 0xFC, 0x36, 0x06, 0xFE, 0x0F, 0x9C, 0x1F, 0x07, 0x03, 0xC0, 0x30, 0x03, 0x00, 0x78, 0x20,
- 0x01, 0x00, 0x1F, 0x20, 0x03, 0xE0, 0x03, 0x20, 0x07, 0x7E, 0x04, 0x30, 0xFE, 0x0F, 0xFC, 0x1F, 0xF0, 0x00, 0xF0, 0x0F,
-};
-#endif
+#define bluetoothdisabled_width 8
+#define bluetoothdisabled_height 8
+const uint8_t bluetoothdisabled[] PROGMEM = {0b11101100, 0b01010100, 0b01001100, 0b01010100,
+ 0b01001100, 0b00000000, 0b00000000, 0b00000000};
+
+#define smallbulletpoint_width 8
+#define smallbulletpoint_height 8
+const uint8_t smallbulletpoint[] PROGMEM = {0b00000011, 0b00000011, 0b00000000, 0b00000000,
+ 0b00000000, 0b00000000, 0b00000000, 0b00000000};
+
+// Clock
+#define icon_clock_width 8
+#define icon_clock_height 8
+const uint8_t icon_clock[] PROGMEM = {0b00111100, 0b01000010, 0b10000101, 0b10101001,
+ 0b10010001, 0b10000001, 0b01000010, 0b00111100};
#include "img/icon.xbm"
+static_assert(sizeof(icon_bits) >= 0, "Silence unused variable warning");
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/DEPG0213BNS800.h b/src/graphics/niche/Drivers/EInk/DEPG0213BNS800.h
index e1bb96450..3ce16e473 100644
--- a/src/graphics/niche/Drivers/EInk/DEPG0213BNS800.h
+++ b/src/graphics/niche/Drivers/EInk/DEPG0213BNS800.h
@@ -5,7 +5,7 @@ E-Ink display driver
- Manufacturer: DKE
- Size: 2.13 inch
- Resolution: 122px x 250px
- - Flex connector marking: FPC-7528B
+ - Flex connector marking (not a unique identifier): FPC-7528B
Note: this is from an older generation of DKE panels, which still used Solomon Systech controller ICs.
DKE's website suggests that the latest DEPG0213BN displays may use Fitipower controllers instead.
diff --git a/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h b/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h
index 72062e0d6..257fed1a6 100644
--- a/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h
+++ b/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h
@@ -5,7 +5,7 @@ E-Ink display driver
- Manufacturer: DKE
- Size: 2.9 inch
- Resolution: 128px x 296px
- - Flex connector marking: FPC-7519 rev.b
+ - Flex connector marking (not a unique identifier): FPC-7519 rev.b
*/
diff --git a/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp b/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp
index 2cab179b9..9a06fa841 100644
--- a/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp
+++ b/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp
@@ -9,12 +9,9 @@ void GDEY0154D67::configScanning()
{
// "Driver output control"
sendCommand(0x01);
- sendData(0xC7);
+ sendData(0xC7); // Scan until gate 199 (200px vertical res.)
sendData(0x00);
sendData(0x00);
-
- // To-do: delete this method?
- // Values set here might be redundant: C7, 00, 00 seems to be default
}
// Specify which information is used to control the sequence of voltages applied to move the pixels
@@ -52,10 +49,10 @@ void GDEY0154D67::detachFromUpdate()
{
switch (updateType) {
case FAST:
- return beginPolling(50, 500); // At least 500ms for fast refresh
+ return beginPolling(50, 300); // At least 300ms for fast refresh
case FULL:
default:
- return beginPolling(100, 2000); // At least 2 seconds for full refresh
+ return beginPolling(100, 1500); // At least 1.5 seconds for full refresh
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/GDEY0154D67.h b/src/graphics/niche/Drivers/EInk/GDEY0154D67.h
index fc4d93d12..e391eea50 100644
--- a/src/graphics/niche/Drivers/EInk/GDEY0154D67.h
+++ b/src/graphics/niche/Drivers/EInk/GDEY0154D67.h
@@ -5,7 +5,7 @@ E-Ink display driver
- Manufacturer: Goodisplay
- Size: 1.54 inch
- Resolution: 200px x 200px
- - Flex connector marking: FPC-B001
+ - Flex connector marking (not a unique identifier): FPC-B001
*/
@@ -31,9 +31,9 @@ class GDEY0154D67 : public SSD16XX
GDEY0154D67() : SSD16XX(width, height, supported) {}
protected:
- virtual void configScanning() override;
- virtual void configWaveform() override;
- virtual void configUpdateSequence() override;
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
void detachFromUpdate() override;
};
diff --git a/src/graphics/niche/Drivers/EInk/GDEY0213B74.cpp b/src/graphics/niche/Drivers/EInk/GDEY0213B74.cpp
new file mode 100644
index 000000000..b3a585eb7
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/GDEY0213B74.cpp
@@ -0,0 +1,58 @@
+#include "./GDEY0213B74.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void GDEY0213B74::configScanning()
+{
+ // "Driver output control"
+ sendCommand(0x01);
+ sendData(0xF9);
+ sendData(0x00);
+ sendData(0x00);
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void GDEY0213B74::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+void GDEY0213B74::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void GDEY0213B74::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 2000); // At least 2 seconds for full refresh
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/GDEY0213B74.h b/src/graphics/niche/Drivers/EInk/GDEY0213B74.h
new file mode 100644
index 000000000..1c36f295d
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/GDEY0213B74.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - GDEY0213B74
+ - Manufacturer: Goodisplay
+ - Size: 2.13 inch
+ - Resolution: 250px x 122px
+ - Flex connector marking (not a unique identifier):
+ - FPC-A002
+ - FPC-A005 20.06.15 TRX
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEY0213B74 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEY0213B74() : SSD16XX(width, height, supported) {}
+
+ protected:
+ virtual void configScanning() override;
+ virtual void configWaveform() override;
+ virtual void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/HINK_E0213A289.cpp b/src/graphics/niche/Drivers/EInk/HINK_E0213A289.cpp
new file mode 100644
index 000000000..0509b0502
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/HINK_E0213A289.cpp
@@ -0,0 +1,61 @@
+#include "./HINK_E0213A289.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void HINK_E0213A289::configScanning()
+{
+ // "Driver output control"
+ // Scan gates from 0 to 249 (vertical resolution 250px)
+ sendCommand(0x01);
+ sendData(0xF9); // Maximum gate # (249, bits 0-7)
+ sendData(0x00); // Maximum gate # (bit 8)
+ sendData(0x00); // (Do not invert scanning order)
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void HINK_E0213A289::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+// Describes the sequence of events performed by the displays controller IC during a refresh
+// Includes "power up", "load settings from memory", "update the pixels", etc
+void HINK_E0213A289::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void HINK_E0213A289::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 1000); // At least 1 second for full refresh (quick; display only blinks pixels once)
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/HINK_E0213A289.h b/src/graphics/niche/Drivers/EInk/HINK_E0213A289.h
new file mode 100644
index 000000000..eab0bf59d
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/HINK_E0213A289.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - HINK_E0213A289
+ - Manufacturer: Holitech
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+ - Flex connector label (not a unique identifier): FPC-7528B
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 2.13in B&W" display modules
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class HINK_E0213A289 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ HINK_E0213A289() : SSD16XX(width, height, supported, 1) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/HINK_E042A87.h b/src/graphics/niche/Drivers/EInk/HINK_E042A87.h
index ac03b65ef..612072b50 100644
--- a/src/graphics/niche/Drivers/EInk/HINK_E042A87.h
+++ b/src/graphics/niche/Drivers/EInk/HINK_E042A87.h
@@ -5,7 +5,7 @@ E-Ink display driver
- Manufacturer: Holitech
- Size: 4.2 inch
- Resolution: 400px x 300px
- - Flex connector marking: HINK-E042A07-FPC-A1
+ - Flex connector marking (not a unique identifier): HINK-E042A07-FPC-A1
- Silver sticker with QR code, marked: HE042A87
Note: as of Feb. 2025, these panels are used for "WeActStudio 4.2in B&W" display modules
diff --git a/src/graphics/niche/Drivers/EInk/LCMEN2R13ECC1.h b/src/graphics/niche/Drivers/EInk/LCMEN2R13ECC1.h
index b78e3bcca..9fa6eaac9 100644
--- a/src/graphics/niche/Drivers/EInk/LCMEN2R13ECC1.h
+++ b/src/graphics/niche/Drivers/EInk/LCMEN2R13ECC1.h
@@ -5,7 +5,6 @@ E-Ink display driver
- Manufacturer: WISEVAST
- Size: 2.13 inch
- Resolution: 122px x 255px
- - Flex connector marking: Soldering connector, no connector is needed
*/
diff --git a/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h b/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h
index f9da202aa..499daef05 100644
--- a/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h
+++ b/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h
@@ -5,7 +5,7 @@ E-Ink display driver
- Manufacturer: Wisevast
- Size: 2.13 inch
- Resolution: 122px x 250px
- - Flex connector marking: HINK-E0213A162-FPC-A0 (Hidden, printed on back-side)
+ - Flex connector marking (not a unique identifier): HINK-E0213A162-FPC-A0 (Hidden, printed on back-side)
Note: this display uses an uncommon controller IC, Fitipower JD79656.
It is implemented as a "one-off", directly inheriting the EInk base class, unlike SSD16XX displays.
diff --git a/src/graphics/niche/Drivers/EInk/ZJY128296_029EAAMFGN.cpp b/src/graphics/niche/Drivers/EInk/ZJY128296_029EAAMFGN.cpp
new file mode 100644
index 000000000..a8f43420f
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/ZJY128296_029EAAMFGN.cpp
@@ -0,0 +1,59 @@
+#include "./ZJY128296_029EAAMFGN.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void ZJY128296_029EAAMFGN::configScanning()
+{
+ // "Driver output control"
+ // Scan gates from 0 to 295 (vertical resolution 296px)
+ sendCommand(0x01);
+ sendData(0x27); // Number of gates (295, bits 0-7)
+ sendData(0x01); // Number of gates (295, bit 8)
+ sendData(0x00); // (Do not invert scanning order)
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void ZJY128296_029EAAMFGN::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+void ZJY128296_029EAAMFGN::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void ZJY128296_029EAAMFGN::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 300); // At least 300ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 2000); // At least 2 seconds for full refresh
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/ZJY128296_029EAAMFGN.h b/src/graphics/niche/Drivers/EInk/ZJY128296_029EAAMFGN.h
new file mode 100644
index 000000000..27644e709
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/ZJY128296_029EAAMFGN.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - ZJY128296-029EAAMFGN
+ - Manufacturer: Zhongjingyuan
+ - Size: 2.9 inch
+ - Resolution: 128px x 296px
+ - Flex connector label (not a unique identifier): FPC-A005 20.06.15 TRX
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 2.9in B&W" display modules
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class ZJY128296_029EAAMFGN : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 128;
+ static constexpr uint32_t height = 296;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ ZJY128296_029EAAMFGN() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/Drivers/EInk/ZJY200200_0154DAAMFGN.h b/src/graphics/niche/Drivers/EInk/ZJY200200_0154DAAMFGN.h
new file mode 100644
index 000000000..fb16bcf2f
--- /dev/null
+++ b/src/graphics/niche/Drivers/EInk/ZJY200200_0154DAAMFGN.h
@@ -0,0 +1,32 @@
+/*
+
+E-Ink display driver
+ - ZJY200200-0154DAAMFGN
+ - Manufacturer: Zhongjingyuan
+ - Size: 1.54 inch
+ - Resolution: 200px x 200px
+ - Flex connector marking: FPC-B001
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 1.54in B&W" display modules
+
+ This *is* a distinct panel, however the driver is currently identical to GDEY0154D67
+ We recognize it as separate now, to avoid breaking any custom builds if the drivers do need to diverge in future.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./GDEY0154D67.h"
+
+namespace NicheGraphics::Drivers
+{
+
+typedef GDEY0154D67 ZJY200200_0154DAAMFGN;
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp
index 89bdb0bc7..fa85deab3 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp
@@ -111,9 +111,10 @@ void InkHUD::LogoApplet::onShutdown()
// Prepare for the powered-off screen now
// We can change these values because the initial "shutting down" screen has already rendered at this point
+ meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
textLeft = "";
textRight = "";
- textTitle = owner.short_name;
+ textTitle = parseShortName(ourNode);
fontTitle = fontLarge;
// This is then drawn by InkHUD::Events::onShutdown, with a blocking FULL update, after InkHUD's flash write is complete
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
index f162aa385..f42b9dc2c 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
@@ -19,6 +19,8 @@ namespace NicheGraphics::InkHUD
enum MenuAction {
NO_ACTION,
SEND_PING,
+ STORE_CANNEDMESSAGE_SELECTION,
+ SEND_CANNEDMESSAGE,
SHUTDOWN,
NEXT_TILE,
TOGGLE_BACKLIGHT,
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
index 9fdfad8ee..69965972f 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
@@ -5,6 +5,7 @@
#include "RTC.h"
#include "MeshService.h"
+#include "Router.h"
#include "airtime.h"
#include "main.h"
#include "power.h"
@@ -31,6 +32,12 @@ InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
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()
@@ -65,6 +72,10 @@ void InkHUD::MenuApplet::onForeground()
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
@@ -153,6 +164,16 @@ void InkHUD::MenuApplet::execute(MenuItem item)
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL);
break;
+ case STORE_CANNEDMESSAGE_SELECTION:
+ cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry
+ break;
+
+ case SEND_CANNEDMESSAGE:
+ cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
+ 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;
@@ -260,9 +281,11 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
break;
case SEND:
- items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
- // Todo: canned messages
- items.push_back(MenuItem("Exit", MenuPage::EXIT));
+ populateSendPage();
+ break;
+
+ case CANNEDMESSAGE_RECIPIENT:
+ populateRecipientPage();
break;
case OPTIONS:
@@ -497,6 +520,8 @@ void InkHUD::MenuApplet::populateAutoshowPage()
}
}
+// 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
@@ -510,6 +535,112 @@ void InkHUD::MenuApplet::populateRecentsPage()
}
}
+// 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));
+
+ // 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
+ 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));
+}
+
// 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.
@@ -619,4 +750,37 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
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
\ No newline at end of file
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
index d9297c8ed..4c974672a 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
@@ -6,10 +6,12 @@
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/Persistence.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
+#include "graphics/niche/Utils/CannedMessageStore.h"
#include "./MenuItem.h"
#include "./MenuPage.h"
+#include "Channels.h"
#include "concurrency/OSThread.h"
namespace NicheGraphics::InkHUD
@@ -36,12 +38,18 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any
void showPage(MenuPage page); // Load and display a MenuPage
+
+ void populateSendPage(); // Dynamically create MenuItems including canned messages
+ void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
+
uint16_t getSystemInfoPanelHeight();
void drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width,
- uint16_t *height = nullptr); // Info panel at top of root menu
+ uint16_t *height = nullptr); // Info panel at top of root menu
+ void sendText(NodeNum dest, ChannelIndex channel, const char *message); // Send a text message to mesh
+ void freeCannedMessageResources(); // Clear MenuApplet's canned message processing data
MenuPage currentPage = MenuPage::ROOT;
uint8_t cursor = 0; // Which menu item is currently highlighted
@@ -51,6 +59,37 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
std::vector