diff --git a/.clusterfuzzlite/router_fuzzer.cpp b/.clusterfuzzlite/router_fuzzer.cpp index bc4d248db..71e88dbff 100644 --- a/.clusterfuzzlite/router_fuzzer.cpp +++ b/.clusterfuzzlite/router_fuzzer.cpp @@ -76,7 +76,7 @@ bool loopCanSleep() // Called just prior to starting Meshtastic. Allows for setting config values before startup. void lateInitVariant() { - settingsMap[logoutputlevel] = level_error; + portduino_config.logoutputlevel = level_error; channelFile.channels[0] = meshtastic_Channel{ .has_settings = true, .settings = @@ -132,7 +132,7 @@ int portduino_main(int argc, char **argv); // Renamed "main" function from Mesht // Start Meshtastic in a thread and wait till it has reached the ON state. int LLVMFuzzerInitialize(int *argc, char ***argv) { - settingsMap[maxtophone] = 5; + portduino_config.maxtophone = 5; meshtasticThread = std::thread([program = *argv[0]]() { char nodeIdStr[12]; diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 5c1c453dd..350ca290c 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -23,7 +23,7 @@ runs: sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev lsb-release - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x cache: pip diff --git a/.github/workflows/daily_packaging.yml b/.github/workflows/daily_packaging.yml index eb61554f2..df5ed27d5 100644 --- a/.github/workflows/daily_packaging.yml +++ b/.github/workflows/daily_packaging.yml @@ -31,8 +31,8 @@ jobs: fail-fast: false matrix: series: - - jammy # 22.04 - - noble # 24.04 + - jammy # 22.04 LTS + - noble # 24.04 LTS - plucky # 25.04 - questing # 25.10 uses: ./.github/workflows/package_ppa.yml diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index ed14907dc..6ff12221b 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -43,11 +43,15 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.x cache: pip - run: pip install -U platformio + - name: Uncomment build epoch + shell: bash + run: | + sed -i 's/#-DBUILD_EPOCH=$UNIX_TIME/-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini - name: Generate matrix id: jsonStep run: | @@ -370,7 +374,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x @@ -439,7 +443,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x @@ -494,7 +498,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml index 13d3d1b4e..d8ff6e631 100644 --- a/.github/workflows/package_pio_deps.yml +++ b/.github/workflows/package_pio_deps.yml @@ -31,7 +31,7 @@ jobs: repository: ${{github.event.pull_request.head.repo.full_name}} - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x diff --git a/.github/workflows/pr_enforce_labels.yml b/.github/workflows/pr_enforce_labels.yml index 93114e2c7..5fca90961 100644 --- a/.github/workflows/pr_enforce_labels.yml +++ b/.github/workflows/pr_enforce_labels.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check for PR labels - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const labels = context.payload.pull_request.labels.map(label => label.name); diff --git a/.github/workflows/pr_tests.yml b/.github/workflows/pr_tests.yml index 786feeced..4e285852d 100644 --- a/.github/workflows/pr_tests.yml +++ b/.github/workflows/pr_tests.yml @@ -177,7 +177,7 @@ jobs: - name: Comment test results on PR if: github.event_name == 'pull_request' && needs.native-tests.result != 'skipped' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml index ccd99e792..d5d642db4 100644 --- a/.github/workflows/release_channels.yml +++ b/.github/workflows/release_channels.yml @@ -21,10 +21,10 @@ jobs: fail-fast: false matrix: series: - - jammy # 22.04 - - noble # 24.04 + - jammy # 22.04 LTS + - noble # 24.04 LTS - plucky # 25.04 - # - questing # 25.10 + - questing # 25.10 uses: ./.github/workflows/package_ppa.yml with: ppa_repo: |- @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x diff --git a/.github/workflows/stale_bot.yml b/.github/workflows/stale_bot.yml index 5a11fdfa8..32e2c2c8b 100644 --- a/.github/workflows/stale_bot.yml +++ b/.github/workflows/stale_bot.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Stale PR+Issues - uses: actions/stale@v9.1.0 + uses: actions/stale@v10.0.0 with: days-before-stale: 45 exempt-issue-labels: pinned,3.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 52f180aa2..942659348 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: pio upgrade - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 22 diff --git a/.github/workflows/trunk_format_pr.yml b/.github/workflows/trunk_format_pr.yml index 2d191fc44..51082fc5f 100644 --- a/.github/workflows/trunk_format_pr.yml +++ b/.github/workflows/trunk_format_pr.yml @@ -39,7 +39,7 @@ jobs: git push - name: Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index de38e3ec0..e10e20a04 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,28 +4,28 @@ cli: plugins: sources: - id: trunk - ref: v1.7.1 + ref: v1.7.2 uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.461 - - renovate@41.74.0 + - checkov@3.2.469 + - renovate@41.94.0 - prettier@3.6.2 - trufflehog@3.90.5 - yamllint@1.37.1 - bandit@1.8.6 - - trivy@0.64.1 - - taplo@0.9.3 - - ruff@0.12.7 + - trivy@0.66.0 + - taplo@0.10.0 + - ruff@0.12.11 - isort@6.0.1 - markdownlint@0.45.0 - oxipng@9.1.5 - svgo@4.0.0 - actionlint@1.7.7 - flake8@7.3.0 - - hadolint@2.12.1-beta + - hadolint@2.13.1 - shfmt@3.6.0 - - shellcheck@0.10.0 + - shellcheck@0.11.0 - black@25.1.0 - git-diff-check - gitleaks@8.28.0 diff --git a/arch/portduino/portduino.ini b/arch/portduino/portduino.ini index 20b3f8e3d..95c3bf3d9 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/37d986499ce24511952d7146db72d667c6bdaff7.zip + https://github.com/meshtastic/platform-native/archive/c490bcd019e0658404088a61b96e653c9da22c45.zip framework = arduino build_src_filter = @@ -31,6 +31,8 @@ lib_deps = https://github.com/pine64/libch341-spi-userspace/archive/af9bc27c9c30fa90772279925b7c5913dff789b4.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library adafruit/Adafruit seesaw Library@1.7.9 + # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main + https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip build_flags = ${arduino_base.build_flags} diff --git a/arch/stm32/stm32.ini b/arch/stm32/stm32.ini index d91607a7d..8b7d256b3 100644 --- a/arch/stm32/stm32.ini +++ b/arch/stm32/stm32.ini @@ -50,7 +50,7 @@ lib_deps = ${radiolib_base.lib_deps} # renovate: datasource=git-refs depName=caveman99-stm32-Crypto packageName=https://github.com/caveman99/Crypto gitBranch=main - https://github.com/caveman99/Crypto/archive/eae9c768054118a9399690f8af202853d1ae8516.zip + https://github.com/caveman99/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip lib_ignore = OneButton diff --git a/bin/device-install.bat b/bin/device-install.bat index 12bfd4f6e..9c206d718 100755 --- a/bin/device-install.bat +++ b/bin/device-install.bat @@ -7,6 +7,7 @@ SET "DEBUG=0" SET "PYTHON=" SET "TFT_BUILD=0" SET "BIGDB8=0" +SET "MUIDB8=0" SET "BIGDB16=0" SET "ESPTOOL_BAUD=115200" SET "ESPTOOL_CMD=" @@ -14,11 +15,12 @@ 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" +SET "S3=s3 v3 t-deck wireless-paper wireless-tracker station-g2 unphone t-eth-elite tlora-pager mesh-tab dreamcatcher ESP32-S3-Pico seeed-sensecap-indicator heltec_capsule_sensor_v3 vision-master icarus tracksenger elecrow-adv" SET "C3=esp32c3" @REM FIXME: Determine flash size from PlatformIO variant, this is unmaintainable. -SET "BIGDB_8MB=picomputer-s3 unphone seeed-sensecap-indicator crowpanel-esp32s3 heltec_capsule_sensor_v3 heltec-v3 heltec-vision-master-e213 heltec-vision-master-e290 heltec-vision-master-t190 heltec-wireless-paper heltec-wireless-tracker heltec-wsl-v3 icarus seeed-xiao-s3 tbeam-s3-core tracksenger" -SET "BIGDB_16MB=t-deck mesh-tab t-energy-s3 dreamcatcher ESP32-S3-Pico m5stack-cores3 station-g2 t-eth-elite t-watch-s3" +SET "BIGDB_8MB=crowpanel-esp32s3 heltec_capsule_sensor_v3 heltec-v3 heltec-vision-master-e213 heltec-vision-master-e290 heltec-vision-master-t190 heltec-wireless-paper heltec-wireless-tracker heltec-wsl-v3 icarus seeed-xiao-s3 tbeam-s3-core tracksenger" +SET "MUIDB_8MB=picomputer-s3 unphone seeed-sensecap-indicator" +SET "BIGDB_16MB=t-deck mesh-tab t-energy-s3 dreamcatcher ESP32-S3-Pico m5stack-cores3 station-g2 t-eth-elite tlora-pager t-watch-s3 elecrow-adv" GOTO getopts :help @@ -100,7 +102,6 @@ IF NOT "!FILENAME:update=!"=="!FILENAME!" ( ) :skip-filename -SET "ESPTOOL_BAUD=1200" CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..." IF NOT "__%PYTHON%__"=="____" ( @@ -120,11 +121,10 @@ IF NOT "__%PYTHON%__"=="____" ( CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..." !ESPTOOL_CMD! >nul 2>&1 -IF %ERRORLEVEL% GEQ 2 ( - @REM esptool exits with code 1 if help is displayed. +IF %ERRORLEVEL% EQU 9009 ( + @REM 9009 = command not found on Windows CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!" EXIT /B 1 - GOTO eof ) IF %DEBUG% EQU 1 ( CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps." @@ -142,7 +142,7 @@ 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 + CALL :RUN_ESPTOOL 1200 --after no_reset read_flash_status GOTO eof ) @@ -164,6 +164,15 @@ FOR %%a IN (%BIGDB_8MB%) DO ( ) :end_loop_bigdb_8mb +FOR %%a IN (%MUIDB_8MB%) DO ( + IF NOT "!FILENAME:%%a=!"=="!FILENAME!" ( + @REM We are working with any of %MUIDB_8MB%. + SET "MUIDB8=1" + GOTO end_loop_muidb_8mb + ) +) +:end_loop_muidb_8mb + FOR %%a IN (%BIGDB_16MB%) DO ( IF NOT "!FILENAME:%%a=!"=="!FILENAME!" ( @REM We are working with any of %BIGDB_16MB%. @@ -174,6 +183,7 @@ FOR %%a IN (%BIGDB_16MB%) DO ( :end_loop_bigdb_16mb IF %BIGDB8% EQU 1 CALL :LOG_MESSAGE INFO "BigDB 8mb partition selected." +IF %MUIDB8% EQU 1 CALL :LOG_MESSAGE INFO "MUIDB 8mb partition selected." IF %BIGDB16% EQU 1 CALL :LOG_MESSAGE INFO "BigDB 16mb partition selected." @REM Extract BASENAME from %FILENAME% for later use. @@ -218,6 +228,12 @@ IF %BIGDB8% EQU 1 ( SET "SPIFFS_OFFSET=0x670000" ) +@REM Offsets for MUIDB 8mb. +IF %MUIDB8% EQU 1 ( + SET "OTA_OFFSET=0x5D0000" + SET "SPIFFS_OFFSET=0x670000" +) + @REM Offsets for BigDB 16mb. IF %BIGDB16% EQU 1 ( SET "OTA_OFFSET=0x650000" diff --git a/bin/device-install.sh b/bin/device-install.sh index 4674113b6..594f9dd6b 100755 --- a/bin/device-install.sh +++ b/bin/device-install.sh @@ -5,38 +5,43 @@ BPS_RESET=false TFT_BUILD=false MCU="" +# Constants +RESET_BAUD=1200 +FIRMWARE_OFFSET=0x00 + # Variant groups BIGDB_8MB=( - "picomputer-s3" - "unphone" - "seeed-sensecap-indicator" - "crowpanel-esp32s3" - "heltec_capsule_sensor_v3" - "heltec-v3" - "heltec-vision-master-e213" - "heltec-vision-master-e290" - "heltec-vision-master-t190" - "heltec-wireless-paper" - "heltec-wireless-tracker" - "heltec-wsl-v3" - "icarus" - "seeed-xiao-s3" - "tbeam-s3-core" - "tracksenger" + "crowpanel-esp32s3" + "heltec_capsule_sensor_v3" + "heltec-v3" + "heltec-vision-master-e213" + "heltec-vision-master-e290" + "heltec-vision-master-t190" + "heltec-wireless-paper" + "heltec-wireless-tracker" + "heltec-wsl-v3" + "icarus" + "seeed-xiao-s3" + "tbeam-s3-core" + "tracksenger" +) +MUIDB_8MB=( + "picomputer-s3" + "unphone" + "seeed-sensecap-indicator" ) BIGDB_16MB=( - "t-deck" - "mesh-tab" - "t-energy-s3" - "dreamcatcher" - "ESP32-S3-Pico" - "m5stack-cores3" - "station-g2" + "t-deck" + "mesh-tab" + "t-energy-s3" + "dreamcatcher" + "ESP32-S3-Pico" + "m5stack-cores3" + "station-g2" "t-eth-elite" + "tlora-pager" "t-watch-s3" - "elecrow-adv-35-tft" - "elecrow-adv-24-28-tft" - "elecrow-adv1-43-50-70-tft" + "elecrow-adv" ) S3_VARIANTS=( "s3" @@ -47,6 +52,7 @@ S3_VARIANTS=( "station-g2" "unphone" "t-eth-elite" + "tlora-pager" "mesh-tab" "dreamcatcher" "ESP32-S3-Pico" @@ -106,8 +112,8 @@ while [ $# -gt 0 ]; do shift ;; --1200bps-reset) - BPS_RESET=true - ;; + BPS_RESET=true + ;; --) # Stop parsing options shift break @@ -121,7 +127,7 @@ while [ $# -gt 0 ]; do done if [[ $BPS_RESET == true ]]; then - $ESPTOOL_CMD --baud 1200 --after no_reset read_flash_status + $ESPTOOL_CMD --baud $RESET_BAUD --after no_reset read_flash_status exit 0 fi @@ -158,6 +164,13 @@ if [ -f "${FILENAME}" ] && [ -n "${FILENAME##*"update"*}" ]; then fi done + for variant in "${MUIDB_8MB[@]}"; do + if [ -z "${FILENAME##*"$variant"*}" ]; then + OFFSET=0x670000 + OTA_OFFSET=0x5D0000 + fi + done + # littlefs* offset for BigDB 16mb and OTA OFFSET. for variant in "${BIGDB_16MB[@]}"; do if [ -z "${FILENAME##*"$variant"*}" ]; then @@ -201,8 +214,8 @@ if [ -f "${FILENAME}" ] && [ -n "${FILENAME##*"update"*}" ]; then fi echo "Trying to flash ${FILENAME}, but first erasing and writing system information" - $ESPTOOL_CMD erase_flash - $ESPTOOL_CMD write_flash 0x00 "${FILENAME}" + $ESPTOOL_CMD erase-flash + $ESPTOOL_CMD write-flash $FIRMWARE_OFFSET "${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}" diff --git a/bin/device-update.bat b/bin/device-update.bat index 6d55294a7..a263da992 100755 --- a/bin/device-update.bat +++ b/bin/device-update.bat @@ -6,6 +6,8 @@ SET "SCRIPT_NAME=%~nx0" SET "DEBUG=0" SET "PYTHON=" SET "ESPTOOL_BAUD=115200" +SET "RESET_BAUD=1200" +SET "UPDATE_OFFSET=0x10000" SET "ESPTOOL_CMD=" SET "LOGCOUNTER=0" SET "CHANGE_MODE=0" @@ -85,14 +87,13 @@ IF "!FILENAME:update=!"=="!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" + SET "ESPTOOL_CMD=""!PYTHON!"" -m esptool" CALL :LOG_MESSAGE DEBUG "Python interpreter supplied." ) ELSE ( - CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool... + CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..." WHERE esptool >nul 2>&1 IF %ERRORLEVEL% EQU 0 ( @REM WHERE exits with code 0 if esptool is found. @@ -105,11 +106,11 @@ IF NOT "__%PYTHON%__"=="____" ( CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..." !ESPTOOL_CMD! >nul 2>&1 -IF %ERRORLEVEL% GEQ 2 ( - @REM esptool exits with code 1 if help is displayed. +CALL :LOG_MESSAGE DEBUG "esptool exit code: %ERRORLEVEL%" +IF %ERRORLEVEL% EQU 9009 ( + @REM 9009 = command not found on Windows CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!" EXIT /B 1 - GOTO eof ) IF %DEBUG% EQU 1 ( CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps." @@ -127,13 +128,13 @@ 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 + CALL :RUN_ESPTOOL !RESET_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 +CALL :LOG_MESSAGE INFO "Trying to flash update "!FILENAME!" at OFFSET !UPDATE_OFFSET!..." +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write-flash !UPDATE_OFFSET! "!FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Script complete!." @@ -145,9 +146,9 @@ EXIT /B %ERRORLEVEL% :RUN_ESPTOOL @REM Subroutine used to run ESPTOOL_CMD with arguments. @REM Also handles %ERRORLEVEL%. -@REM CALL :RUN_ESPTOOL [Baud] [erase_flash|write_flash] [OFFSET] [Filename] +@REM CALL :RUN_ESPTOOL [Baud] [erase-flash|write-flash] [OFFSET] [Filename] @REM. -@REM Example:: CALL :RUN_ESPTOOL 115200 write_flash 0x10000 "firmwarefile.bin" +@REM Example:: CALL :RUN_ESPTOOL 115200 write-flash 0x10000 "firmwarefile.bin" 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 diff --git a/bin/device-update.sh b/bin/device-update.sh index 2196d3af9..6f29496e9 100755 --- a/bin/device-update.sh +++ b/bin/device-update.sh @@ -3,6 +3,11 @@ PYTHON=${PYTHON:-$(which python3 python|head -n 1)} CHANGE_MODE=false +# Constants +FLASH_BAUD=115200 +RESET_BAUD=1200 +UPDATE_OFFSET=0x10000 + # Determine the correct esptool command to use if "$PYTHON" -m esptool version >/dev/null 2>&1; then ESPTOOL_CMD="$PYTHON -m esptool" @@ -64,7 +69,7 @@ done shift "$((OPTIND-1))" if [ "$CHANGE_MODE" = true ]; then - $ESPTOOL_CMD --baud 1200 --after no_reset read_flash_status + $ESPTOOL_CMD --baud $RESET_BAUD --after no_reset read_flash_status exit 0 fi @@ -75,7 +80,7 @@ fi if [ -f "${FILENAME}" ] && [ -z "${FILENAME##*"update"*}" ]; then echo "Trying to flash update ${FILENAME}" - $ESPTOOL_CMD --baud 115200 write_flash 0x10000 "${FILENAME}" + $ESPTOOL_CMD --baud $FLASH_BAUD write-flash $UPDATE_OFFSET "${FILENAME}" else show_help echo "Invalid file: ${FILENAME}" diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index f3b3bb14d..108ca4910 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -87,6 +87,15 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.9 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.8 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.7 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.6 diff --git a/boards/heltec_mesh_solar.json b/boards/heltec_mesh_solar.json new file mode 100644 index 000000000..9e551c082 --- /dev/null +++ b/boards/heltec_mesh_solar.json @@ -0,0 +1,54 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x4405"], + ["0x239A", "0x0029"], + ["0x239A", "0x002A"], + ["0x239A", "0x0071"] + ], + "usb_product": "HT-n5262", + "mcu": "nrf52840", + "variant": "heltec_mesh_solar", + "variants_dir": "variants", + "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", + "onboard_tools": ["jlink"], + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52840-mdk-rs" + }, + "frameworks": ["arduino"], + "name": "Heltec nrf (Adafruit BSP)", + "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": "https://heltec.org/project/meshsolar/", + "vendor": "Heltec" +} diff --git a/boards/seeed-sensecap-indicator.json b/boards/seeed-sensecap-indicator.json index 03bff35b5..37a97cdf1 100644 --- a/boards/seeed-sensecap-indicator.json +++ b/boards/seeed-sensecap-indicator.json @@ -2,7 +2,7 @@ "build": { "arduino": { "ldscript": "esp32s3_out.ld", - "partitions": "default_8MB.csv", + "partitions": "partition-table-8MB.csv", "memory_type": "qio_opi" }, "core": "esp32", diff --git a/boards/unphone.json b/boards/unphone.json index bf711993c..4d37f7bb5 100644 --- a/boards/unphone.json +++ b/boards/unphone.json @@ -3,7 +3,7 @@ "arduino": { "ldscript": "esp32s3_out.ld", "memory_type": "qio_opi", - "partitions": "default_8MB.csv" + "partitions": "partition-table-8MB.csv" }, "core": "esp32", "extra_flags": [ diff --git a/boards/wiscore_rak3172.json b/boards/wiscore_rak3172.json index 714e09115..69ee506b4 100644 --- a/boards/wiscore_rak3172.json +++ b/boards/wiscore_rak3172.json @@ -5,7 +5,7 @@ }, "core": "stm32", "cpu": "cortex-m4", - "extra_flags": "-DSTM32WLxx -DSTM32WLE5xx -DARDUINO_GENERIC_WLE5CCUX", + "extra_flags": "-DSTM32WLxx -DSTM32WLE5xx -DARDUINO_RAK3172_MODULE", "f_cpu": "48000000L", "mcu": "stm32wle5ccu", "variant": "STM32WLxx/WL54CCU_WL55CCU_WLE4C(8-B-C)U_WLE5C(8-B-C)U", diff --git a/debian/changelog b/debian/changelog index b36a22168..286349dd2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,43 +1,109 @@ -meshtasticd (2.7.6.0) UNRELEASED; urgency=medium +meshtasticd (2.7.9.0) unstable; urgency=medium + + * Version 2.7.9 + + -- GitHub Actions Wed, 03 Sep 2025 23:39:17 +0000 + +meshtasticd (2.7.8.0) unstable; urgency=medium + + * Version 2.7.8 + + -- GitHub Actions Sat, 30 Aug 2025 00:26:04 +0000 + +meshtasticd (2.7.7.0) unstable; urgency=medium + + * Version 2.7.7 + + -- GitHub Actions Thu, 28 Aug 2025 10:33:25 +0000 + +meshtasticd (2.7.6.0) unstable; urgency=medium + + * Version 2.7.6 + + -- GitHub Actions Tue, 12 Aug 2025 23:48:48 +0000 + +meshtasticd (2.7.5.0) unstable; urgency=medium + + * Version 2.7.5 + + -- GitHub Actions Sat, 09 Aug 2025 12:46:53 +0000 + +meshtasticd (2.7.4.0) unstable; urgency=medium + + * Version 2.7.4 + + -- GitHub Actions Sat, 19 Jul 2025 11:36:55 +0000 + +meshtasticd (2.7.3.0) unstable; urgency=medium + + * Version 2.7.3 + + -- GitHub Actions Thu, 10 Jul 2025 16:29:27 +0000 + +meshtasticd (2.7.2.0) unstable; urgency=medium + + * Version 2.7.2 + + -- GitHub Actions Fri, 04 Jul 2025 11:58:01 +0000 + +meshtasticd (2.7.1.0) unstable; urgency=medium + + * Version 2.7.1 + + -- GitHub Actions Fri, 27 Jun 2025 20:12:21 +0000 + +meshtasticd (2.6.13) unstable; urgency=medium + + * Version 2.6.13 + + -- GitHub Actions Mon, 16 Jun 2025 02:10:49 +0000 + +meshtasticd (2.6.11) unstable; urgency=medium + + * Version 2.6.11 + + -- GitHub Actions Mon, 02 Jun 2025 20:00:55 +0000 + +meshtasticd (2.6.10) unstable; urgency=medium + + * Version 2.6.10 + + -- GitHub Actions Sun, 25 May 2025 20:46:49 +0000 + +meshtasticd (2.6.9) unstable; urgency=medium + + * Version 2.6.9 + * Run as non-root user, 'meshtasticd' + + -- GitHub Actions Thu, 15 May 2025 11:13:30 +0000 + +meshtasticd (2.6.8) unstable; urgency=medium + + * Version 2.6.8 + + -- GitHub Actions Tue, 06 May 2025 01:32:49 +0000 + +meshtasticd (2.5.22) unstable; urgency=medium + + * Version 2.5.22 + + -- GitHub Actions Wed, 05 Feb 2025 01:10:33 +0000 + +meshtasticd (2.5.21) unstable; urgency=medium + + * Version 2.5.21 + + -- GitHub Actions Sat, 25 Jan 2025 01:39:16 +0000 + +meshtasticd (2.5.20) unstable; urgency=medium + + * Version 2.5.20 + + -- GitHub Actions Mon, 13 Jan 2025 19:24:14 +0000 + +meshtasticd (2.5.19) unstable; urgency=medium - [ Austin Lane ] * Initial packaging - * GitHub Actions Automatic version bump - * GitHub Actions Automatic version bump - * GitHub Actions Automatic version bump - * GitHub Actions Automatic version bump + * Version 2.5.19 - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ Ubuntu ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - [ ] - * GitHub Actions Automatic version bump - - -- Tue, 12 Aug 2025 23:48:48 +0000 + -- Austin Lane Thu, 02 Jan 2025 12:00:00 +0000 diff --git a/debian/ci_changelog.sh b/debian/ci_changelog.sh index f7e875977..16b33207c 100755 --- a/debian/ci_changelog.sh +++ b/debian/ci_changelog.sh @@ -1,7 +1,8 @@ #!/usr/bin/bash +export DEBFULLNAME="GitHub Actions" export DEBEMAIL="github-actions[bot]@users.noreply.github.com" PKG_VERSION=$(python3 bin/buildinfo.py short) dch --newversion "$PKG_VERSION.0" \ - --distribution UNRELEASED \ - "GitHub Actions Automatic version bump" + --distribution unstable \ + "Version $PKG_VERSION" diff --git a/debian/meshtasticd.postinst b/debian/meshtasticd.postinst index d569cb43e..fe0dbc332 100755 --- a/debian/meshtasticd.postinst +++ b/debian/meshtasticd.postinst @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # postinst script for meshtasticd # # see: dh_installdeb(1) diff --git a/debian/meshtasticd.postrm b/debian/meshtasticd.postrm index dc25680a8..bb2c32a5b 100755 --- a/debian/meshtasticd.postrm +++ b/debian/meshtasticd.postrm @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # postrm script for meshtasticd # # see: dh_installdeb(1) diff --git a/partition-table-8MB.csv b/partition-table-8MB.csv new file mode 100644 index 000000000..0bfbc22ba --- /dev/null +++ b/partition-table-8MB.csv @@ -0,0 +1,7 @@ +# This is a layout for 8MB of flash for MUI devices +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x5C0000, +flashApp, app, ota_1, 0x5D0000,0x0A0000, +spiffs, data, spiffs, 0x670000,0x180000 \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index cce4d2dcf..81f95a7e3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -118,7 +118,7 @@ lib_deps = [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/3dc7cf3e233aaa8cc23492cca50541fc099ebfa1.zip + https://github.com/meshtastic/device-ui/archive/3677476c8a823ee85056b5fb1d146a3e193f8276.zip ; Common libs for environmental measurements in telemetry module [environmental_base] @@ -157,8 +157,8 @@ lib_deps = emotibit/EmotiBit MLX90632@1.0.8 # renovate: datasource=custom.pio depName=Adafruit MLX90614 packageName=adafruit/library/Adafruit MLX90614 Library adafruit/Adafruit MLX90614 Library@2.1.5 - # renovate: datasource=github-tags depName=INA3221 packageName=KodinLanewave/INA3221 - https://github.com/KodinLanewave/INA3221/archive/1.0.1.zip + # renovate: datasource=github-tags depName=INA3221 packageName=sgtwilko/INA3221 + https://github.com/sgtwilko/INA3221#bb03d7e9bfcc74fc798838a54f4f99738f29fc6a # renovate: datasource=custom.pio depName=QMC5883L Compass packageName=mprograms/library/QMC5883LCompass mprograms/QMC5883LCompass@1.2.3 # renovate: datasource=custom.pio depName=DFRobot_RTU packageName=dfrobot/library/DFRobot_RTU @@ -177,6 +177,8 @@ lib_deps = adafruit/Adafruit PCT2075@1.0.5 # renovate: datasource=custom.pio depName=DFRobot_BMM150 packageName=dfrobot/library/DFRobot_BMM150 dfrobot/DFRobot_BMM150@1.0.0 + # renovate: datasource=custom.pio depName=Adafruit_TSL2561 packageName=adafruit/library/Adafruit TSL2561 + adafruit/Adafruit TSL2561@1.1.2 ; (not included in native / portduino) [environmental_extra] diff --git a/renovate.json b/renovate.json index e90462cc3..187cdc600 100644 --- a/renovate.json +++ b/renovate.json @@ -8,6 +8,7 @@ "replacements:all", "workarounds:all" ], + "baseBranchPatterns": ["master"], "forkProcessing": "enabled", "ignoreDeps": [ "protobufs" diff --git a/src/DebugConfiguration.cpp b/src/DebugConfiguration.cpp index 1c081ae29..d65c4f1e8 100644 --- a/src/DebugConfiguration.cpp +++ b/src/DebugConfiguration.cpp @@ -146,7 +146,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess { int result; #ifdef ARCH_PORTDUINO - bool utf = !settingsMap[ascii_logs]; + bool utf = !portduino_config.ascii_logs; #else bool utf = true; #endif diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index 44bc0897b..b2749806c 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -1,7 +1,14 @@ #include "DisplayFormatters.h" -const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName) +const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName, + bool usePreset) { + + // If use_preset is false, always return "Custom" + if (!usePreset) { + return "Custom"; + } + switch (preset) { case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: return useShortName ? "ShortT" : "ShortTurbo"; @@ -31,4 +38,46 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC return useShortName ? "Custom" : "Invalid"; break; } +} + +const char *DisplayFormatters::getDeviceRole(meshtastic_Config_DeviceConfig_Role role) +{ + switch (role) { + case meshtastic_Config_DeviceConfig_Role_CLIENT: + return "Client"; + break; + case meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE: + return "Client Mute"; + break; + case meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN: + return "Client Hidden"; + break; + case meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND: + return "Lost and Found"; + break; + case meshtastic_Config_DeviceConfig_Role_TRACKER: + return "Tracker"; + break; + case meshtastic_Config_DeviceConfig_Role_SENSOR: + return "Sensor"; + break; + case meshtastic_Config_DeviceConfig_Role_TAK: + return "TAK"; + break; + case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: + return "TAK Tracker"; + break; + case meshtastic_Config_DeviceConfig_Role_ROUTER: + return "Router"; + break; + case meshtastic_Config_DeviceConfig_Role_ROUTER_LATE: + return "Router Late"; + break; + case meshtastic_Config_DeviceConfig_Role_REPEATER: + return "Repeater"; + break; + default: + return "Unknown"; + break; + } } \ No newline at end of file diff --git a/src/DisplayFormatters.h b/src/DisplayFormatters.h index f8ccfcbb6..ad193e966 100644 --- a/src/DisplayFormatters.h +++ b/src/DisplayFormatters.h @@ -4,5 +4,9 @@ class DisplayFormatters { public: - static const char *getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName); + static const char *getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName, + bool usePreset); + + public: + static const char *getDeviceRole(meshtastic_Config_DeviceConfig_Role role); }; diff --git a/src/GPSStatus.h b/src/GPSStatus.h index 4b7997935..a1a9f2c56 100644 --- a/src/GPSStatus.h +++ b/src/GPSStatus.h @@ -22,6 +22,9 @@ class GPSStatus : public Status meshtastic_Position p = meshtastic_Position_init_default; + /// Time of last valid GPS fix (millis since boot) + uint32_t lastFixMillis = 0; + public: GPSStatus() { statusType = STATUS_TYPE_GPS; } @@ -83,6 +86,9 @@ class GPSStatus : public Status uint32_t getNumSatellites() const { return p.sats_in_view; } + /// Return millis() when the last GPS fix occurred (0 = never) + uint32_t getLastFixMillis() const { return lastFixMillis; } + bool matches(const GPSStatus *newStatus) const { #ifdef GPS_DEBUG @@ -114,6 +120,9 @@ class GPSStatus : public Status if (isDirty) { if (hasLock) { + // Record time of last valid GPS fix + lastFixMillis = millis(); + // In debug logs, identify position by @timestamp:stage (stage 3 = notify) LOG_DEBUG("New GPS pos@%x:3 lat=%f lon=%f alt=%d pdop=%.2f track=%.2f speed=%.2f sats=%d", p.timestamp, p.latitude_i * 1e-7, p.longitude_i * 1e-7, p.altitude, p.PDOP * 1e-2, p.ground_track * 1e-5, diff --git a/src/Power.cpp b/src/Power.cpp index 8a16132f1..06c6a9089 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -128,6 +128,7 @@ RAK9154Sensor rak9154Sensor; #ifdef HAS_PPM // note: XPOWERS_CHIP_XXX must be defined in variant.h #include +XPowersPPM *PPM = NULL; #endif #ifdef HAS_BQ27220 @@ -681,6 +682,8 @@ bool Power::setup() found = true; } else if (lipoChargerInit()) { found = true; + } else if (meshSolarInit()) { + found = true; } else if (analogInit()) { found = true; } @@ -743,7 +746,11 @@ void Power::shutdown() #if HAS_SCREEN if (screen) { +#ifdef T_DECK_PRO + screen->showSimpleBanner("Device is powered off.\nConnect USB to start!", 0); // T-Deck Pro has no power button +#else screen->showSimpleBanner("Shutting Down...", 0); // stays on screen +#endif } #endif #if !defined(ARCH_STM32WL) @@ -761,7 +768,7 @@ void Power::shutdown() #ifdef PIN_LED3 ledOff(PIN_LED3); #endif - doDeepSleep(DELAY_FOREVER, false, true); + doDeepSleep(DELAY_FOREVER, true, true); #elif defined(ARCH_PORTDUINO) exit(EXIT_SUCCESS); #else @@ -826,16 +833,25 @@ void Power::readPowerStatus() newStatus.notifyObservers(&powerStatus2); #ifdef DEBUG_HEAP if (lastheap != memGet.getFreeHeap()) { - std::string threadlist = "Threads running:"; + // Use stack-allocated buffer to avoid heap allocations in monitoring code + char threadlist[256] = "Threads running:"; + int threadlistLen = strlen(threadlist); int running = 0; for (int i = 0; i < MAX_THREADS; i++) { auto thread = concurrency::mainController.get(i); if ((thread != nullptr) && (thread->enabled)) { - threadlist += vformat(" %s", thread->ThreadName.c_str()); + // Use snprintf to safely append to stack buffer without heap allocation + int remaining = sizeof(threadlist) - threadlistLen - 1; + if (remaining > 0) { + int written = snprintf(threadlist + threadlistLen, remaining, " %s", thread->ThreadName.c_str()); + if (written > 0 && written < remaining) { + threadlistLen += written; + } + } running++; } } - LOG_DEBUG(threadlist.c_str()); + LOG_DEBUG(threadlist); LOG_DEBUG("Heap status: %d/%d bytes free (%d), running %d/%d threads", memGet.getFreeHeap(), memGet.getHeapSize(), memGet.getFreeHeap() - lastheap, running, concurrency::mainController.size(false)); lastheap = memGet.getFreeHeap(); @@ -849,15 +865,19 @@ void Power::readPowerStatus() sprintf(mac, "!%02x%02x%02x%02x", dmac[2], dmac[3], dmac[4], dmac[5]); auto newHeap = memGet.getFreeHeap(); - std::string heapTopic = - (*moduleConfig.mqtt.root ? moduleConfig.mqtt.root : "msh") + std::string("/2/heap/") + std::string(mac); - std::string heapString = std::to_string(newHeap); - mqtt->pubSub.publish(heapTopic.c_str(), heapString.c_str(), false); + // Use stack-allocated buffers to avoid heap allocations in monitoring code + char heapTopic[128]; + snprintf(heapTopic, sizeof(heapTopic), "%s/2/heap/%s", (*moduleConfig.mqtt.root ? moduleConfig.mqtt.root : "msh"), mac); + char heapString[16]; + snprintf(heapString, sizeof(heapString), "%u", newHeap); + mqtt->pubSub.publish(heapTopic, heapString, false); + auto wifiRSSI = WiFi.RSSI(); - std::string wifiTopic = - (*moduleConfig.mqtt.root ? moduleConfig.mqtt.root : "msh") + std::string("/2/wifi/") + std::string(mac); - std::string wifiString = std::to_string(wifiRSSI); - mqtt->pubSub.publish(wifiTopic.c_str(), wifiString.c_str(), false); + char wifiTopic[128]; + snprintf(wifiTopic, sizeof(wifiTopic), "%s/2/wifi/%s", (*moduleConfig.mqtt.root ? moduleConfig.mqtt.root : "msh"), mac); + char wifiString[16]; + snprintf(wifiString, sizeof(wifiString), "%d", wifiRSSI); + mqtt->pubSub.publish(wifiTopic, wifiString, false); } #endif @@ -1318,7 +1338,6 @@ bool Power::lipoInit() class LipoCharger : public HasBatteryLevel { private: - XPowersPPM *ppm = nullptr; BQ27220 *bq = nullptr; public: @@ -1327,41 +1346,41 @@ class LipoCharger : public HasBatteryLevel */ bool runOnce() { - if (ppm == nullptr) { - ppm = new XPowersPPM; - bool result = ppm->init(Wire, I2C_SDA, I2C_SCL, BQ25896_ADDR); + if (PPM == nullptr) { + PPM = new XPowersPPM; + bool result = PPM->init(Wire, I2C_SDA, I2C_SCL, BQ25896_ADDR); if (result) { LOG_INFO("PPM BQ25896 init succeeded"); // Set the minimum operating voltage. Below this voltage, the PPM will protect - // ppm->setSysPowerDownVoltage(3100); + // PPM->setSysPowerDownVoltage(3100); // Set input current limit, default is 500mA - // ppm->setInputCurrentLimit(800); + // PPM->setInputCurrentLimit(800); // Disable current limit pin - // ppm->disableCurrentLimitPin(); + // PPM->disableCurrentLimitPin(); // Set the charging target voltage, Range:3840 ~ 4608mV ,step:16 mV - ppm->setChargeTargetVoltage(4288); + PPM->setChargeTargetVoltage(4288); // Set the precharge current , Range: 64mA ~ 1024mA ,step:64mA - // ppm->setPrechargeCurr(64); + // PPM->setPrechargeCurr(64); // The premise is that limit pin is disabled, or it will // only follow the maximum charging current set by limit pin. // Set the charging current , Range:0~5056mA ,step:64mA - ppm->setChargerConstantCurr(1024); + PPM->setChargerConstantCurr(1024); // To obtain voltage data, the ADC must be enabled first - ppm->enableMeasure(); + PPM->enableMeasure(); // Turn on charging function // If there is no battery connected, do not turn on the charging function - ppm->enableCharge(); + PPM->enableCharge(); } else { LOG_WARN("PPM BQ25896 init failed"); - delete ppm; - ppm = nullptr; + delete PPM; + PPM = nullptr; return false; } } @@ -1402,23 +1421,23 @@ class LipoCharger : public HasBatteryLevel /** * return true if there is a battery installed in this unit */ - virtual bool isBatteryConnect() override { return ppm->getBattVoltage() > 0; } + virtual bool isBatteryConnect() override { return PPM->getBattVoltage() > 0; } /** * return true if there is an external power source detected */ - virtual bool isVbusIn() override { return ppm->getVbusVoltage() > 0; } + virtual bool isVbusIn() override { return PPM->getVbusVoltage() > 0; } /** * return true if the battery is currently charging */ virtual bool isCharging() override { - bool isCharging = ppm->isCharging(); + bool isCharging = PPM->isCharging(); if (isCharging) { LOG_DEBUG("BQ27220 time to full charge: %d min", bq->getTimeToFull()); } else { - if (!ppm->isVbusIn()) { + if (!PPM->isVbusIn()) { LOG_DEBUG("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); } } @@ -1450,3 +1469,73 @@ bool Power::lipoChargerInit() return false; } #endif + +#ifdef HELTEC_MESH_SOLAR +#include "meshSolarApp.h" + +/** + * meshSolar class for an SMBUS battery sensor. + */ +class meshSolarBatteryLevel : public HasBatteryLevel +{ + + public: + /** + * Init the I2C meshSolar battery level sensor + */ + bool runOnce() + { + meshSolarStart(); + return true; + } + + /** + * Battery state of charge, from 0 to 100 or -1 for unknown + */ + virtual int getBatteryPercent() override { return meshSolarGetBatteryPercent(); } + + /** + * The raw voltage of the battery in millivolts, or NAN if unknown + */ + virtual uint16_t getBattVoltage() override { return meshSolarGetBattVoltage(); } + + /** + * return true if there is a battery installed in this unit + */ + virtual bool isBatteryConnect() override { return meshSolarIsBatteryConnect(); } + + /** + * return true if there is an external power source detected + */ + virtual bool isVbusIn() override { return meshSolarIsVbusIn(); } + + /** + * return true if the battery is currently charging + */ + virtual bool isCharging() override { return meshSolarIsCharging(); } +}; + +meshSolarBatteryLevel meshSolarLevel; + +/** + * Init the meshSolar battery level sensor + */ +bool Power::meshSolarInit() +{ + bool result = meshSolarLevel.runOnce(); + LOG_DEBUG("Power::meshSolarInit mesh solar sensor is %s", result ? "ready" : "not ready yet"); + if (!result) + return false; + batteryLevel = &meshSolarLevel; + return true; +} + +#else +/** + * The meshSolar battery level sensor is unavailable - default to AnalogBatteryLevel + */ +bool Power::meshSolarInit() +{ + return false; +} +#endif \ No newline at end of file diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 7c8d77651..efab84399 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -57,7 +57,7 @@ size_t RedirectablePrint::vprintf(const char *logLevel, const char *format, va_l #endif #ifdef ARCH_PORTDUINO - bool color = !settingsMap[ascii_logs]; + bool color = !portduino_config.ascii_logs; #else bool color = true; #endif @@ -99,7 +99,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format, size_t r = 0; #ifdef ARCH_PORTDUINO - bool color = !settingsMap[ascii_logs]; + bool color = !portduino_config.ascii_logs; #else bool color = true; #endif @@ -288,7 +288,7 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...) #if ARCH_PORTDUINO // level trace is special, two possible ways to handle it. if (strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) { - if (settingsStrings[traceFilename] != "") { + if (portduino_config.traceFilename != "") { va_list arg; va_start(arg, format); try { @@ -297,18 +297,18 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...) } va_end(arg); } - if (settingsMap[logoutputlevel] < level_trace && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) { + if (portduino_config.logoutputlevel < level_trace && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) { delete[] newFormat; return; } } - if (settingsMap[logoutputlevel] < level_debug && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_DEBUG) == 0) { + if (portduino_config.logoutputlevel < level_debug && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_DEBUG) == 0) { delete[] newFormat; return; - } else if (settingsMap[logoutputlevel] < level_info && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_INFO) == 0) { + } else if (portduino_config.logoutputlevel < level_info && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_INFO) == 0) { delete[] newFormat; return; - } else if (settingsMap[logoutputlevel] < level_warn && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_WARN) == 0) { + } else if (portduino_config.logoutputlevel < level_warn && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_WARN) == 0) { delete[] newFormat; return; } diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index 68c41980d..093a24678 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -64,6 +64,14 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con int32_t SerialConsole::runOnce() { +#ifdef HELTEC_MESH_SOLAR + //After enabling the mesh solar serial port module configuration, command processing is handled by the serial port module. + if(moduleConfig.serial.enabled && moduleConfig.serial.override_console_serial_port + && moduleConfig.serial.mode==meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MS_CONFIG) + { + return 250; + } +#endif return runOncePart(); } diff --git a/src/buzz/BuzzerFeedbackThread.cpp b/src/buzz/BuzzerFeedbackThread.cpp index ce762c764..12b30a705 100644 --- a/src/buzz/BuzzerFeedbackThread.cpp +++ b/src/buzz/BuzzerFeedbackThread.cpp @@ -28,11 +28,14 @@ int BuzzerFeedbackThread::handleInputEvent(const InputEvent *event) case INPUT_BROKER_USER_PRESS: case INPUT_BROKER_ALT_PRESS: case INPUT_BROKER_SELECT: + case INPUT_BROKER_SELECT_LONG: playBeep(); // Confirmation feedback break; case INPUT_BROKER_UP: + case INPUT_BROKER_UP_LONG: case INPUT_BROKER_DOWN: + case INPUT_BROKER_DOWN_LONG: case INPUT_BROKER_LEFT: case INPUT_BROKER_RIGHT: playChirp(); // Navigation feedback diff --git a/src/configuration.h b/src/configuration.h index 0e24990b5..d5adba028 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -26,10 +26,10 @@ along with this program. If not, see . #include -#ifdef RV3028_RTC +#if __has_include("Melopero_RV3028.h") #include "Melopero_RV3028.h" #endif -#ifdef PCF8563_RTC +#if __has_include("pcf8563.h") #include "pcf8563.h" #endif @@ -135,7 +135,7 @@ along with this program. If not, see . // ----------------------------------------------------------------------------- // OLED & Input // ----------------------------------------------------------------------------- -#if defined(SEEED_WIO_TRACKER_L1) +#if defined(SEEED_WIO_TRACKER_L1) && !defined(SEEED_WIO_TRACKER_L1_EINK) #define SSD1306_ADDRESS 0x3D #define USE_SH1106 #else @@ -431,6 +431,7 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_SERIAL 1 #define MESHTASTIC_EXCLUDE_POWERSTRESS 1 #define MESHTASTIC_EXCLUDE_ADMIN 1 +#define MESHTASTIC_EXCLUDE_AMBIENTLIGHTING 1 #endif // // Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled) diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index c1358861b..470a416c0 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -79,7 +79,9 @@ class ScanI2C BQ27220, LTR553ALS, BHI260AP, - BMM150 + BMM150, + TSL2561, + DRV2605 } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 8b3670cd9..5cb4fca32 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -461,7 +461,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(LSM6DS3_ADDR, LSM6DS3, "LSM6DS3", (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); + case TSL25911_ADDR: + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x12), 1); + if (registerValue == 0x50) { + type = TSL2591; + logFoundDevice("TSL25911", (uint8_t)addr.address); + } else { + type = TSL2561; + logFoundDevice("TSL2561", (uint8_t)addr.address); + } + break; + SCAN_SIMPLE_CASE(MLX90632_ADDR, MLX90632, "MLX90632", (uint8_t)addr.address); SCAN_SIMPLE_CASE(NAU7802_ADDR, NAU7802, "NAU7802", (uint8_t)addr.address); SCAN_SIMPLE_CASE(MAX1704X_ADDR, MAX17048, "MAX17048", (uint8_t)addr.address); @@ -483,8 +493,14 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = MLX90614; logFoundDevice("MLX90614", (uint8_t)addr.address); } else { - type = MPR121KB; - logFoundDevice("MPR121KB", (uint8_t)addr.address); + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // DRV2605_REG_STATUS + if (registerValue == 0xe0) { + type = DRV2605; + logFoundDevice("DRV2605", (uint8_t)addr.address); + } else { + type = MPR121KB; + logFoundDevice("MPR121KB", (uint8_t)addr.address); + } } break; diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index ae74f0fe2..8d451be70 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1,5 +1,4 @@ #include // Include for strstr -#include #include #include "configuration.h" @@ -808,6 +807,14 @@ bool GPS::setup() } else { LOG_INFO("GNSS module configuration saved!"); } + } else if (gnssModel == GNSS_MODEL_CM121) { + // only ask for RMC and GGA + // enable GGA + _serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n"); + delay(250); + // enable RMC + _serial_gps->write("$CFGMSG,0,4,1,1*1F\r\n"); + delay(250); } didSerialInit = true; } @@ -843,9 +850,6 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime) setPowerPMU(true); // Power (PMU): on writePinStandby(false); // Standby (pin): awake (not standby) setPowerUBLOX(true); // Standby (UBLOX): awake -#ifdef GNSS_AIROHA - lastFixStartMsec = 0; -#endif break; case GPS_SOFTSLEEP: @@ -863,9 +867,7 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime) writePinStandby(true); // Standby (pin): asleep (not awake) setPowerUBLOX(false, sleepTime); // Standby (UBLOX): asleep, timed #ifdef GNSS_AIROHA - if (config.position.gps_update_interval * 1000 >= GPS_FIX_HOLD_TIME * 2) { - digitalWrite(PIN_GPS_EN, LOW); - } + digitalWrite(PIN_GPS_EN, LOW); #endif break; @@ -877,9 +879,7 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime) writePinStandby(true); // Standby (pin): asleep setPowerUBLOX(false, 0); // Standby (UBLOX): asleep, indefinitely #ifdef GNSS_AIROHA - if (config.position.gps_update_interval * 1000 >= GPS_FIX_HOLD_TIME * 2) { - digitalWrite(PIN_GPS_EN, LOW); - } + digitalWrite(PIN_GPS_EN, LOW); #endif break; } @@ -1062,6 +1062,8 @@ void GPS::down() } // If update interval long enough (or softsleep unsupported): hardsleep instead setPowerState(GPS_HARDSLEEP, sleepTime); + // Reset the fix quality to 0, since we're off. + fixQual = 0; } } @@ -1121,11 +1123,19 @@ int32_t GPS::runOnce() shouldPublish = true; } + uint8_t prev_fixQual = fixQual; bool gotLoc = lookForLocation(); if (gotLoc && !hasValidLocation) { // declare that we have location ASAP LOG_DEBUG("hasValidLocation RISING EDGE"); hasValidLocation = true; shouldPublish = true; + // Hold for 20secs after getting a lock to download ephemeris etc + fixHoldEnds = millis() + 20000; + } + + if (gotLoc && prev_fixQual == 0) { // just got a lock after turning back on. + fixHoldEnds = millis() + 20000; + shouldPublish = true; // Publish immediately, since next publish is at end of hold } bool tooLong = scheduling.searchedTooLong(); @@ -1134,8 +1144,7 @@ int32_t GPS::runOnce() // Once we get a location we no longer desperately want an update if ((gotLoc && gotTime) || tooLong) { - - if (tooLong) { + if (tooLong && !gotLoc) { // we didn't get a location during this ack window, therefore declare loss of lock if (hasValidLocation) { LOG_DEBUG("hasValidLocation FALLING EDGE"); @@ -1143,9 +1152,15 @@ int32_t GPS::runOnce() p = meshtastic_Position_init_default; hasValidLocation = false; } - - down(); - shouldPublish = true; // publish our update for this just finished acquisition window + if (millis() > fixHoldEnds) { + shouldPublish = true; // publish our update at the end of the lock hold + publishUpdate(); + down(); +#ifdef GPS_DEBUG + } else { + LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - millis(), p.sats_in_view); +#endif + } } // If state has changed do a publish @@ -1232,9 +1247,15 @@ GnssModel_t GPS::probe(int serialSpeed) _serial_gps->write("$PUBX,40,GSV,0,0,0,0,0,0*59\r\n"); _serial_gps->write("$PUBX,40,VTG,0,0,0,0,0,0*5E\r\n"); delay(20); + // Close NMEA sequences on CM121 + _serial_gps->write("$CFGMSG,0,1,0,1*1B\r\n"); + _serial_gps->write("$CFGMSG,0,2,0,1*18\r\n"); + _serial_gps->write("$CFGMSG,0,3,0,1*19\r\n"); + delay(20); - // Unicore UFirebirdII Series: UC6580, UM620, UM621, UM670A, UM680A, or UM681A - std::vector unicore = {{"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}}; + // Unicore UFirebirdII Series: UC6580, UM620, UM621, UM670A, UM680A, or UM681A,or CM121 + std::vector unicore = { + {"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}, {"CM121", "CM121", GNSS_MODEL_CM121}}; PROBE_FAMILY("Unicore Family", "$PDTINFO", unicore, 500); std::vector atgm = { @@ -1362,34 +1383,42 @@ GnssModel_t GPS::probe(int serialSpeed) GnssModel_t GPS::getProbeResponse(unsigned long timeout, const std::vector &responseMap) { - String response = ""; + char response[256] = {0}; // Fixed buffer instead of String + uint16_t responseLen = 0; unsigned long start = millis(); while (millis() - start < timeout) { if (_serial_gps->available()) { - response += (char)_serial_gps->read(); + char c = _serial_gps->read(); - if (response.endsWith(",") || response.endsWith("\r\n")) { + // Add char to buffer if there's space + if (responseLen < sizeof(response) - 1) { + response[responseLen++] = c; + response[responseLen] = '\0'; + } + + if (c == ',' || (responseLen >= 2 && response[responseLen - 2] == '\r' && response[responseLen - 1] == '\n')) { #ifdef GPS_DEBUG - LOG_DEBUG(response.c_str()); + LOG_DEBUG(response); #endif // check if we can see our chips for (const auto &chipInfo : responseMap) { - if (strstr(response.c_str(), chipInfo.detectionString.c_str()) != nullptr) { + if (strstr(response, chipInfo.detectionString.c_str()) != nullptr) { LOG_INFO("%s detected", chipInfo.chipName.c_str()); return chipInfo.driver; } } } - if (response.endsWith("\r\n")) { - response.trim(); - response = ""; // Reset the response string for the next potential message + if (responseLen >= 2 && response[responseLen - 2] == '\r' && response[responseLen - 1] == '\n') { + // Reset the response buffer for the next potential message + responseLen = 0; + response[0] = '\0'; } } } #ifdef GPS_DEBUG - LOG_DEBUG(response.c_str()); + LOG_DEBUG(response); #endif - return GNSS_MODEL_UNKNOWN; // Return empty string on timeout + return GNSS_MODEL_UNKNOWN; // Return unknown on timeout } GPS *GPS::createGps() @@ -1414,7 +1443,7 @@ GPS *GPS::createGps() _en_gpio = PIN_GPS_EN; #endif #ifdef ARCH_PORTDUINO - if (!settingsMap[has_gps]) + if (!portduino_config.has_gps) return nullptr; #endif if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all @@ -1504,28 +1533,10 @@ static int32_t toDegInt(RawDegrees d) * Perform any processing that should be done only while the GPS is awake and looking for a fix. * Override this method to check for new locations * - * @return true if we've acquired a new location + * @return true if we've set a new time */ bool GPS::lookForTime() { - -#ifdef GNSS_AIROHA - uint8_t fix = reader.fixQuality(); - if (fix >= 1 && fix <= 5) { - if (lastFixStartMsec > 0) { - if (Throttle::isWithinTimespanMs(lastFixStartMsec, GPS_FIX_HOLD_TIME)) { - return false; - } else { - clearBuffer(); - } - } else { - lastFixStartMsec = millis(); - return false; - } - } else { - return false; - } -#endif auto ti = reader.time; auto d = reader.date; if (ti.isValid() && d.isValid()) { // Note: we don't check for updated, because we'll only be called if needed @@ -1542,13 +1553,13 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s t.tm_year = d.year() - 1900; t.tm_isdst = false; if (t.tm_mon > -1) { - LOG_DEBUG("NMEA GPS time %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour, t.tm_min, - t.tm_sec, ti.age()); - if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultInvalidTime) { - // Clear the GPS buffer if we got an invalid time - clearBuffer(); + if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) { + LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour, + t.tm_min, t.tm_sec, ti.age()); + return true; + } else { + return false; } - return true; } else return false; } else @@ -1563,25 +1574,6 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s */ bool GPS::lookForLocation() { -#ifdef GNSS_AIROHA - if ((config.position.gps_update_interval * 1000) >= (GPS_FIX_HOLD_TIME * 2)) { - uint8_t fix = reader.fixQuality(); - if (fix >= 1 && fix <= 5) { - if (lastFixStartMsec > 0) { - if (Throttle::isWithinTimespanMs(lastFixStartMsec, GPS_FIX_HOLD_TIME)) { - return false; - } else { - clearBuffer(); - } - } else { - lastFixStartMsec = millis(); - return false; - } - } else { - return false; - } - } -#endif // By default, TinyGPS++ does not parse GPGSA lines, which give us // the 2D/3D fixType (see NMEAGPS.h) // At a minimum, use the fixQuality indicator in GPGGA (FIXME?) diff --git a/src/gps/GPS.h b/src/gps/GPS.h index 9be57017f..1233003c8 100644 --- a/src/gps/GPS.h +++ b/src/gps/GPS.h @@ -31,7 +31,8 @@ typedef enum { GNSS_MODEL_MTK_PA1616S, GNSS_MODEL_AG3335, GNSS_MODEL_AG3352, - GNSS_MODEL_LS20031 + GNSS_MODEL_LS20031, + GNSS_MODEL_CM121 } GnssModel_t; typedef enum { @@ -159,7 +160,7 @@ class GPS : private concurrency::OSThread uint8_t fixType = 0; // fix type from GPGSA #endif - uint32_t lastWakeStartMsec = 0, lastSleepStartMsec = 0, lastFixStartMsec = 0; + uint32_t fixHoldEnds = 0; uint32_t rx_gpio = 0; uint32_t tx_gpio = 0; diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index d574c9ad0..39b633e47 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -23,7 +23,7 @@ static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only upda * Reads the current date and time from the RTC module and updates the system time. * @return True if the RTC was successfully read and the system time was updated, false otherwise. */ -void readFromRTC() +RTCSetResult readFromRTC() { struct timeval tv; /* btw settimeofday() is helpful here too*/ #ifdef RV3028_RTC @@ -44,15 +44,23 @@ void readFromRTC() t.tm_sec = rtc.getSecond(); tv.tv_sec = gm_mktime(&t); tv.tv_usec = 0; - uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms + +#ifdef BUILD_EPOCH + if (tv.tv_sec < BUILD_EPOCH) { + LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + return RTCSetResultInvalidTime; + } +#endif + LOG_DEBUG("Read RTC time from RV3028 getTime as %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); - timeStartMsec = now; - zeroOffsetSecs = tv.tv_sec; if (currentQuality == RTCQualityNone) { + timeStartMsec = now; + zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; } + return RTCSetResultSuccess; } #elif defined(PCF8563_RTC) if (rtc_found.address == PCF8563_RTC) { @@ -75,15 +83,23 @@ void readFromRTC() t.tm_sec = tc.second; tv.tv_sec = gm_mktime(&t); tv.tv_usec = 0; - uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms + +#ifdef BUILD_EPOCH + if (tv.tv_sec < BUILD_EPOCH) { + LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + return RTCSetResultInvalidTime; + } +#endif + LOG_DEBUG("Read RTC time from PCF8563 getDateTime as %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); - timeStartMsec = now; - zeroOffsetSecs = tv.tv_sec; if (currentQuality == RTCQualityNone) { + timeStartMsec = now; + zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; } + return RTCSetResultSuccess; } #else if (!gettimeofday(&tv, NULL)) { @@ -92,8 +108,10 @@ void readFromRTC() LOG_DEBUG("Read RTC time as %ld", printableEpoch); timeStartMsec = now; zeroOffsetSecs = tv.tv_sec; + return RTCSetResultSuccess; } #endif + return RTCSetResultNotSet; } /** @@ -101,7 +119,7 @@ void readFromRTC() * * @param q The quality of the provided time. * @param tv A pointer to a timeval struct containing the time to potentially set the RTC to. - * @return True if the RTC was set, false otherwise. + * @return RTCSetResult * * If we haven't yet set our RTC this boot, set it from a GPS derived time */ @@ -112,7 +130,15 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd uint32_t printableEpoch = tv->tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms #ifdef BUILD_EPOCH if (tv->tv_sec < BUILD_EPOCH) { +#ifdef GPS_DEBUG LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); +#endif + return RTCSetResultInvalidTime; + } else if (tv->tv_sec > (BUILD_EPOCH + FORTY_YEARS)) { +#ifdef GPS_DEBUG + LOG_WARN("Ignore time (%ld) too far in the future (build epoch: %ld, max allowed: %ld)!", printableEpoch, BUILD_EPOCH, + BUILD_EPOCH + FORTY_YEARS); +#endif return RTCSetResultInvalidTime; } #endif @@ -230,7 +256,15 @@ RTCSetResult perhapsSetRTC(RTCQuality q, struct tm &t) uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { +#ifdef GPS_DEBUG LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); +#endif + return RTCSetResultInvalidTime; + } else if (tv.tv_sec > (BUILD_EPOCH + FORTY_YEARS)) { +#ifdef GPS_DEBUG + LOG_WARN("Ignore time (%ld) too far in the future (build epoch: %ld, max allowed: %ld)!", printableEpoch, BUILD_EPOCH, + BUILD_EPOCH + FORTY_YEARS); +#endif return RTCSetResultInvalidTime; } #endif diff --git a/src/gps/RTC.h b/src/gps/RTC.h index 96dec575b..03350823c 100644 --- a/src/gps/RTC.h +++ b/src/gps/RTC.h @@ -48,10 +48,13 @@ uint32_t getTime(bool local = false); /// Return time since 1970 in secs. If quality is RTCQualityNone return zero uint32_t getValidTime(RTCQuality minQuality, bool local = false); -void readFromRTC(); +RTCSetResult readFromRTC(); time_t gm_mktime(struct tm *tm); #define SEC_PER_DAY 86400 #define SEC_PER_HOUR 3600 #define SEC_PER_MIN 60 +#ifdef BUILD_EPOCH +#define FORTY_YEARS (40UL * 365 * SEC_PER_DAY) // probably time to update your firmware +#endif diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index 1c9f290b6..c0c09cc27 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -67,20 +67,28 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) // FIXME - only draw bits have changed (use backbuf similar to the other displays) const bool flipped = config.display.flip_screen; + // HACK for L1 EInk +#if defined(SEEED_WIO_TRACKER_L1_EINK) + // For SEEED_WIO_TRACKER_L1_EINK, setRotation(3) is correct but mirrored; flip both axes + for (uint32_t y = 0; y < displayHeight; y++) { + for (uint32_t x = 0; x < displayWidth; x++) { + auto b = buffer[x + (y / 8) * displayWidth]; + auto isset = b & (1 << (y & 7)); + adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE); + } + } +#else for (uint32_t y = 0; y < displayHeight; y++) { for (uint32_t x = 0; x < displayWidth; x++) { - // get src pixel in the page based ordering the OLED lib uses FIXME, super inefficient auto b = buffer[x + (y / 8) * displayWidth]; auto isset = b & (1 << (y & 7)); - - // Handle flip here, rather than with setRotation(), - // Avoids issues when display width is not a multiple of 8 if (flipped) adafruitDisplay->drawPixel((displayWidth - 1) - x, (displayHeight - 1) - y, isset ? GxEPD_BLACK : GxEPD_WHITE); else adafruitDisplay->drawPixel(x, y, isset ? GxEPD_BLACK : GxEPD_WHITE); } } +#endif // Trigger the refresh in GxEPD2 LOG_DEBUG("Update E-Paper"); @@ -235,7 +243,7 @@ bool EInkDisplay::connect() adafruitDisplay->setRotation(1); adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); } -#elif defined(HELTEC_MESH_POCKET) +#elif defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) { spi1 = &SPI1; spi1->begin(); @@ -249,6 +257,7 @@ bool EInkDisplay::connect() // Init GxEPD2 adafruitDisplay->init(); adafruitDisplay->setRotation(3); + adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); } #elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213) diff --git a/src/graphics/EInkDisplay2.h b/src/graphics/EInkDisplay2.h index b840ce9ba..b4cee81fe 100644 --- a/src/graphics/EInkDisplay2.h +++ b/src/graphics/EInkDisplay2.h @@ -84,7 +84,7 @@ class EInkDisplay : public OLEDDisplay SPIClass *hspi = NULL; #endif -#if defined(HELTEC_MESH_POCKET) +#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) SPIClass *spi1 = NULL; #endif diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index fa71e17d8..eb8093947 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -216,6 +216,44 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t ui->update(); } +void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, + std::function textCallback) +{ + LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs); + + if (NotificationRenderer::virtualKeyboard) { + delete NotificationRenderer::virtualKeyboard; + NotificationRenderer::virtualKeyboard = nullptr; + } + + NotificationRenderer::textInputCallback = nullptr; + + NotificationRenderer::virtualKeyboard = new VirtualKeyboard(); + if (header) { + NotificationRenderer::virtualKeyboard->setHeader(header); + } + if (initialText) { + NotificationRenderer::virtualKeyboard->setInputText(initialText); + } + + // Set up callback with safer cleanup mechanism + NotificationRenderer::textInputCallback = textCallback; + NotificationRenderer::virtualKeyboard->setCallback([textCallback](const std::string &text) { textCallback(text); }); + + // Store the message and set the expiration timestamp (use same pattern as other notifications) + strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::current_notification_type = notificationTypeEnum::text_input; + + // Set the overlay using the same pattern as other notification types + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { uint8_t module_frame; @@ -318,7 +356,7 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O dispdev = new SSD1306Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); #elif defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7789_CS) || \ - defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) + defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) dispdev = new TFTDisplay(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); #elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) @@ -332,7 +370,7 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); #elif ARCH_PORTDUINO if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - if (settingsMap[displayPanel] != no_screen) { + if (portduino_config.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); @@ -550,7 +588,7 @@ void Screen::setup() #else if (!config.display.flip_screen) { #if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ - defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) + defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) static_cast(dispdev)->flipScreenVertically(); #elif defined(USE_ST7789) static_cast(dispdev)->flipScreenVertically(); @@ -580,7 +618,7 @@ void Screen::setup() #if ARCH_PORTDUINO if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - if (settingsMap[touchscreenModule]) { + if (portduino_config.touchscreenModule) { touchScreenImpl1 = new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); touchScreenImpl1->init(); @@ -713,13 +751,19 @@ int32_t Screen::runOnce() handleSetOn(false); break; case Cmd::ON_PRESS: - handleOnPress(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleOnPress(); + } break; case Cmd::SHOW_PREV_FRAME: - handleShowPrevFrame(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleShowPrevFrame(); + } break; case Cmd::SHOW_NEXT_FRAME: - handleShowNextFrame(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleShowNextFrame(); + } break; case Cmd::START_ALERT_FRAME: { showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away @@ -741,7 +785,9 @@ int32_t Screen::runOnce() NotificationRenderer::pauseBanner = false; case Cmd::STOP_BOOT_SCREEN: EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame - setFrames(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + setFrames(); + } break; case Cmd::NOOP: break; @@ -777,6 +823,7 @@ int32_t Screen::runOnce() if (showingNormalScreen) { // standard screen loop handling here if (config.display.auto_screen_carousel_secs > 0 && + NotificationRenderer::current_notification_type != notificationTypeEnum::text_input && !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 @@ -867,6 +914,11 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) // Called when a frame should be added / removed, or custom frames should be cleared void Screen::setFrames(FrameFocus focus) { + // Block setFrames calls when virtual keyboard is active to prevent overlay interference + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return; + } + uint8_t originalPosition = ui->getUiState()->currentFrame; uint8_t previousFrameCount = framesetInfo.frameCount; FramesetInfo fsi; // Location of specific frames, for applying focus parameter @@ -889,71 +941,91 @@ void Screen::setFrames(FrameFocus focus) } #if defined(DISPLAY_CLOCK_FRAME) - fsi.positions.clock = numframes; - normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame - : graphics::ClockRenderer::drawDigitalClockFrame; - indicatorIcons.push_back(digital_icon_clock); + if (!hiddenFrames.clock) { + fsi.positions.clock = numframes; + normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame + : graphics::ClockRenderer::drawDigitalClockFrame; + indicatorIcons.push_back(digital_icon_clock); + } #endif // 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); + if (!hiddenFrames.home) { + 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 - fsi.positions.nodelist = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen; - indicatorIcons.push_back(icon_nodes); + if (!hiddenFrames.nodelist) { + fsi.positions.nodelist = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen; + indicatorIcons.push_back(icon_nodes); + } #endif // Show detailed node views only on E-Ink builds #ifdef USE_EINK - fsi.positions.nodelist_lastheard = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; - indicatorIcons.push_back(icon_nodes); - - fsi.positions.nodelist_hopsignal = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; - indicatorIcons.push_back(icon_signal); - - fsi.positions.nodelist_distance = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; - indicatorIcons.push_back(icon_distance); + if (!hiddenFrames.nodelist_lastheard) { + fsi.positions.nodelist_lastheard = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; + indicatorIcons.push_back(icon_nodes); + } + if (!hiddenFrames.nodelist_hopsignal) { + fsi.positions.nodelist_hopsignal = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; + indicatorIcons.push_back(icon_signal); + } + if (!hiddenFrames.nodelist_distance) { + fsi.positions.nodelist_distance = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; + indicatorIcons.push_back(icon_distance); + } #endif #if HAS_GPS - fsi.positions.nodelist_bearings = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; - indicatorIcons.push_back(icon_list); - - fsi.positions.gps = numframes; - normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; - indicatorIcons.push_back(icon_compass); + if (!hiddenFrames.nodelist_bearings) { + fsi.positions.nodelist_bearings = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; + indicatorIcons.push_back(icon_list); + } + if (!hiddenFrames.gps) { + fsi.positions.gps = numframes; + normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; + indicatorIcons.push_back(icon_compass); + } #endif - if (RadioLibInterface::instance) { + if (RadioLibInterface::instance && !hiddenFrames.lora) { 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 (!hiddenFrames.system) { + fsi.positions.system = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen; + indicatorIcons.push_back(icon_system); } #if !defined(DISPLAY_CLOCK_FRAME) - fsi.positions.clock = numframes; - normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame - : graphics::ClockRenderer::drawDigitalClockFrame; - indicatorIcons.push_back(digital_icon_clock); + if (!hiddenFrames.clock) { + fsi.positions.clock = numframes; + normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame + : graphics::ClockRenderer::drawDigitalClockFrame; + indicatorIcons.push_back(digital_icon_clock); + } #endif + if (!hiddenFrames.chirpy) { + fsi.positions.chirpy = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy; + indicatorIcons.push_back(small_chirpy); + } #if HAS_WIFI && !defined(ARCH_PORTDUINO) - if (!dismissedFrames.wifi && isWifiAvailable()) { + if (!hiddenFrames.wifi && isWifiAvailable()) { fsi.positions.wifi = numframes; normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline; indicatorIcons.push_back(icon_wifi); @@ -995,27 +1067,29 @@ void Screen::setFrames(FrameFocus focus) if (numMeshNodes > 0) numMeshNodes--; - // Temporary array to hold favorite node frames - std::vector favoriteFrames; + if (!hiddenFrames.show_favorites) { + // Temporary array to hold favorite node frames + std::vector favoriteFrames; - 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) { - favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo); + 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) { + favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo); + } } - } - // Insert favorite frames *after* collecting them all - if (!favoriteFrames.empty()) { - fsi.positions.firstFavorite = numframes; - for (const auto &f : favoriteFrames) { - normalFrames[numframes++] = f; - indicatorIcons.push_back(icon_node); + // Insert favorite frames *after* collecting them all + if (!favoriteFrames.empty()) { + fsi.positions.firstFavorite = numframes; + for (const auto &f : favoriteFrames) { + normalFrames[numframes++] = f; + indicatorIcons.push_back(icon_node); + } + fsi.positions.lastFavorite = numframes - 1; + } else { + fsi.positions.firstFavorite = 255; + fsi.positions.lastFavorite = 255; } - fsi.positions.lastFavorite = numframes - 1; - } else { - fsi.positions.firstFavorite = 255; - fsi.positions.lastFavorite = 255; } fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE @@ -1054,7 +1128,7 @@ void Screen::setFrames(FrameFocus focus) ui->switchToFrame(fsi.positions.clock); break; case FOCUS_SYSTEM: - ui->switchToFrame(fsi.positions.memory); + ui->switchToFrame(fsi.positions.system); break; case FOCUS_PRESERVE: @@ -1082,30 +1156,101 @@ void Screen::setFrameImmediateDraw(FrameCallback *drawFrames) setFastFramerate(); } +void Screen::toggleFrameVisibility(const std::string &frameName) +{ +#ifndef USE_EINK + if (frameName == "nodelist") { + hiddenFrames.nodelist = !hiddenFrames.nodelist; + } +#endif +#ifdef USE_EINK + if (frameName == "nodelist_lastheard") { + hiddenFrames.nodelist_lastheard = !hiddenFrames.nodelist_lastheard; + } + if (frameName == "nodelist_hopsignal") { + hiddenFrames.nodelist_hopsignal = !hiddenFrames.nodelist_hopsignal; + } + if (frameName == "nodelist_distance") { + hiddenFrames.nodelist_distance = !hiddenFrames.nodelist_distance; + } +#endif +#if HAS_GPS + if (frameName == "nodelist_bearings") { + hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings; + } + if (frameName == "gps") { + hiddenFrames.gps = !hiddenFrames.gps; + } +#endif + if (frameName == "lora") { + hiddenFrames.lora = !hiddenFrames.lora; + } + if (frameName == "clock") { + hiddenFrames.clock = !hiddenFrames.clock; + } + if (frameName == "show_favorites") { + hiddenFrames.show_favorites = !hiddenFrames.show_favorites; + } + if (frameName == "chirpy") { + hiddenFrames.chirpy = !hiddenFrames.chirpy; + } +} + +bool Screen::isFrameHidden(const std::string &frameName) const +{ +#ifndef USE_EINK + if (frameName == "nodelist") + return hiddenFrames.nodelist; +#endif +#ifdef USE_EINK + if (frameName == "nodelist_lastheard") + return hiddenFrames.nodelist_lastheard; + if (frameName == "nodelist_hopsignal") + return hiddenFrames.nodelist_hopsignal; + if (frameName == "nodelist_distance") + return hiddenFrames.nodelist_distance; +#endif +#if HAS_GPS + if (frameName == "nodelist_bearings") + return hiddenFrames.nodelist_bearings; + if (frameName == "gps") + return hiddenFrames.gps; +#endif + if (frameName == "lora") + return hiddenFrames.lora; + if (frameName == "clock") + return hiddenFrames.clock; + if (frameName == "show_favorites") + return hiddenFrames.show_favorites; + if (frameName == "chirpy") + return hiddenFrames.chirpy; + + return false; +} + // Dismisses the currently displayed screen frame, if possible // Relevant for text message, waypoint, others in future? // Triggered with a CardKB keycombo -void Screen::dismissCurrentFrame() +void Screen::hideCurrentFrame() { uint8_t currentFrame = ui->getUiState()->currentFrame; bool dismissed = false; - if (currentFrame == framesetInfo.positions.textMessage && devicestate.has_rx_text_message) { - LOG_INFO("Dismiss Text Message"); + LOG_INFO("Hide Text Message"); devicestate.has_rx_text_message = false; 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"); + LOG_DEBUG("Hide Waypoint"); devicestate.has_rx_waypoint = false; - dismissedFrames.waypoint = true; + hiddenFrames.waypoint = true; dismissed = true; } else if (currentFrame == framesetInfo.positions.wifi) { - LOG_DEBUG("Dismiss WiFi Screen"); - dismissedFrames.wifi = true; + LOG_DEBUG("Hide WiFi Screen"); + hiddenFrames.wifi = true; dismissed = true; - } else if (currentFrame == framesetInfo.positions.memory) { - LOG_INFO("Dismiss Memory"); - dismissedFrames.memory = true; + } else if (currentFrame == framesetInfo.positions.lora) { + LOG_INFO("Hide LoRa"); + hiddenFrames.lora = true; dismissed = true; } @@ -1257,7 +1402,7 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) // 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; + hiddenFrames.textMessage = true; hasUnreadMessage = false; // Clear unread state when user replies setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list @@ -1313,6 +1458,11 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) // Triggered by MeshModules int Screen::handleUIFrameEvent(const UIFrameEvent *event) { + // Block UI frame events when virtual keyboard is active + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return 0; + } + if (showingNormalScreen) { // Regenerate the frameset, potentially honoring a module's internal requestFocus() call if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET) @@ -1335,6 +1485,16 @@ int Screen::handleInputEvent(const InputEvent *event) if (!screenOn) return 0; + // Handle text input notifications specially - pass input to virtual keyboard + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + NotificationRenderer::inEvent = *event; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + setFastFramerate(); // Draw ASAP + ui->update(); + return 0; + } + #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 @@ -1372,7 +1532,7 @@ int Screen::handleInputEvent(const InputEvent *event) } else if (event->inputEvent == INPUT_BROKER_SELECT) { if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { menuHandler::homeBaseMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.memory) { + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) { menuHandler::systemBaseMenu(); #if HAS_GPS } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) { @@ -1381,7 +1541,7 @@ int Screen::handleInputEvent(const InputEvent *event) } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) { menuHandler::clockMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { - menuHandler::LoraRegionPicker(); + menuHandler::loraMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { if (devicestate.rx_text_message.from) { menuHandler::messageResponseMenu(); diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 265900131..55ce20052 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -12,7 +12,7 @@ #define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) namespace graphics { -enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker }; +enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input }; struct BannerOverlayOptions { const char *message; @@ -313,6 +313,8 @@ class Screen : public concurrency::OSThread void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showTextInput(const char *header, const char *initialText, uint32_t durationMs, + std::function textCallback); void requestMenu(graphics::menuHandler::screenMenus menuToShow) { @@ -591,7 +593,11 @@ class Screen : public concurrency::OSThread void setSSLFrames(); // Dismiss the currently focussed frame, if possible (e.g. text message, waypoint) - void dismissCurrentFrame(); + void hideCurrentFrame(); + + // Menu-driven Show / Hide Toggle + void toggleFrameVisibility(const std::string &frameName); + bool isFrameHidden(const std::string &frameName) const; #ifdef USE_EINK /// Draw an image to remain on E-Ink display after screen off @@ -653,7 +659,7 @@ class Screen : public concurrency::OSThread uint8_t settings = 255; uint8_t wifi = 255; uint8_t deviceFocused = 255; - uint8_t memory = 255; + uint8_t system = 255; uint8_t gps = 255; uint8_t home = 255; uint8_t textMessage = 255; @@ -663,6 +669,7 @@ class Screen : public concurrency::OSThread uint8_t nodelist_distance = 255; uint8_t nodelist_bearings = 255; uint8_t clock = 255; + uint8_t chirpy = 255; uint8_t firstFavorite = 255; uint8_t lastFavorite = 255; uint8_t lora = 255; @@ -671,12 +678,29 @@ class Screen : public concurrency::OSThread uint8_t frameCount = 0; } framesetInfo; - struct DismissedFrames { + struct hiddenFrames { bool textMessage = false; bool waypoint = false; bool wifi = false; - bool memory = false; - } dismissedFrames; + bool system = false; + bool home = false; + bool clock = false; +#ifndef USE_EINK + bool nodelist = false; +#endif +#ifdef USE_EINK + bool nodelist_lastheard = false; + bool nodelist_hopsignal = false; + bool nodelist_distance = false; +#endif +#if HAS_GPS + bool nodelist_bearings = false; + bool gps = false; +#endif + bool lora = false; + bool show_favorites = false; + bool chirpy = true; + } hiddenFrames; /// Try to start drawing ASAP void setFastFramerate(); diff --git a/src/graphics/ScreenFonts.h b/src/graphics/ScreenFonts.h index 84ec45977..a25417b05 100644 --- a/src/graphics/ScreenFonts.h +++ b/src/graphics/ScreenFonts.h @@ -73,7 +73,7 @@ #endif #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)) && \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS)) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) // The screen is bigger so use bigger fonts #define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19 diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index b458e54e4..3cf84f415 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -1,6 +1,7 @@ #include "graphics/SharedUIDisplay.h" #include "RTC.h" #include "graphics/ScreenFonts.h" +#include "graphics/draw/UIRenderer.h" #include "main.h" #include "meshtastic/config.pb.h" #include "power.h" @@ -16,6 +17,10 @@ void determineResolution(int16_t screenheight, int16_t screenwidth) isHighResolution = true; } + if (screenwidth > 128 && screenheight <= 64) { + isHighResolution = false; + } + // Special case for Heltec Wireless Tracker v1.1 if (screenwidth == 160 && screenheight == 80) { isHighResolution = false; @@ -53,7 +58,7 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, // ************************* // * Common Header Drawing * // ************************* -void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool battery_only) +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date) { constexpr int HEADER_OFFSET_Y = 1; y += HEADER_OFFSET_Y; @@ -69,7 +74,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti const int screenW = display->getWidth(); const int screenH = display->getHeight(); - if (!battery_only) { + if (!force_no_invert) { // === Inverted Header Background === if (isInverted) { display->setColor(BLACK); @@ -187,13 +192,28 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int timeStrWidth = display->getStringWidth("12:34"); // Default alignment int timeX = screenW - xOffset - timeStrWidth + 4; - if (rtc_sec > 0 && !battery_only) { + 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); + // === Build Date String === + char datetimeStr[25]; + UIRenderer::formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, false); + char dateLine[40]; + + if (isHighResolution) { + snprintf(dateLine, sizeof(dateLine), "%s", datetimeStr); + } else { + if (hasUnreadMessage) { + snprintf(dateLine, sizeof(dateLine), "%s", &datetimeStr[5]); + } else { + snprintf(dateLine, sizeof(dateLine), "%s", &datetimeStr[2]); + } + } + if (config.display.use_12h_clock) { bool isPM = hour >= 12; hour %= 12; @@ -202,7 +222,11 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti snprintf(timeStr, sizeof(timeStr), "%d:%02d%s", hour, minute, isPM ? "p" : "a"); } - timeStrWidth = display->getStringWidth(timeStr); + if (show_date) { + timeStrWidth = display->getStringWidth(dateLine); + } else { + timeStrWidth = display->getStringWidth(timeStr); + } timeX = screenW - xOffset - timeStrWidth + 3; // === Show Mail or Mute Icon to the Left of Time === @@ -229,7 +253,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconW = 16, iconH = 12; int iconX = iconRightEdge - iconW; int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; - if (isInverted) { + if (isInverted && !force_no_invert) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); display->setColor(BLACK); @@ -244,7 +268,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } else { int iconX = iconRightEdge - (mail_width - 2); int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; - if (isInverted) { + if (isInverted && !force_no_invert) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); display->setColor(BLACK); @@ -287,10 +311,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } } - // === Draw Time === - display->drawString(timeX, textY, timeStr); - if (isBold) - display->drawString(timeX - 1, textY, timeStr); + if (show_date) { + // === Draw Date === + display->drawString(timeX, textY, dateLine); + if (isBold) + display->drawString(timeX - 1, textY, dateLine); + } else { + // === 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 === diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index b8d82795e..e1a7c6383 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -49,7 +49,8 @@ void determineResolution(int16_t screenheight, int16_t screenwidth); 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 = "", bool battery_only = false); +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false, + bool show_date = false); const int *getTextPositions(OLEDDisplay *display); diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index f8787612f..3eeb17ef0 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -562,6 +562,91 @@ class LGFX : public lgfx::LGFX_Device static LGFX *tft = nullptr; +#elif defined(ST7796_CS) +#include // Graphics and font library for ST7796 driver chip + +class LGFX : public lgfx::LGFX_Device +{ + lgfx::Panel_ST7796 _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Light_PWM _light_instance; + + public: + LGFX(void) + { + { + auto cfg = _bus_instance.config(); + + // SPI + cfg.spi_host = ST7796_SPI_HOST; + cfg.spi_mode = 0; + cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing + // 80MHz by an integer) + cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving + cfg.spi_3wire = false; + cfg.use_lock = true; // Set to true to use transaction locking + cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / + // SPI_DMA_CH_AUTO=auto setting) + cfg.pin_sclk = ST7796_SCK; // Set SPI SCLK pin number + cfg.pin_mosi = ST7796_SDA; // Set SPI MOSI pin number + cfg.pin_miso = ST7796_MISO; // Set SPI MISO pin number (-1 = disable) + cfg.pin_dc = ST7796_RS; // Set SPI DC pin number (-1 = disable) + + _bus_instance.config(cfg); // applies the set value to the bus. + _panel_instance.setBus(&_bus_instance); // set the bus on the panel. + } + + { // Set the display panel control. + auto cfg = _panel_instance.config(); // Gets a structure for display panel settings. + + cfg.pin_cs = ST7796_CS; // Pin number where CS is connected (-1 = disable) + cfg.pin_rst = ST7796_RESET; // Pin number where RST is connected (-1 = disable) + cfg.pin_busy = ST7796_BUSY; // Pin number where BUSY is connected (-1 = disable) + + // 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) +#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 = 8; // 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 + cfg.invert = true; // Set to true if the light/darkness of the panel is reversed + cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped + cfg.dlen_16bit = + false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI + cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) + + _panel_instance.config(cfg); + } + +#ifdef ST7796_BL + // Set the backlight control. (delete if not necessary) + { + auto cfg = _light_instance.config(); // Gets a structure for backlight settings. + + cfg.pin_bl = ST7796_BL; // Pin number to which the backlight is connected + cfg.invert = false; // true to invert the brightness of the backlight + cfg.freq = 44100; + cfg.pwm_channel = 7; + + _light_instance.config(cfg); + _panel_instance.setLight(&_light_instance); // Set the backlight on the panel. + } +#endif + + setPanel(&_panel_instance); // Sets the panel to use. + } +}; + +static LGFX *tft = nullptr; + #elif defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) #include // Graphics and font library for ILI9341/ILI9342 driver chip @@ -682,24 +767,24 @@ class LGFX : public lgfx::LGFX_Device LGFX(void) { - if (settingsMap[displayPanel] == st7789) + if (portduino_config.displayPanel == st7789) _panel_instance = new lgfx::Panel_ST7789; - else if (settingsMap[displayPanel] == st7735) + else if (portduino_config.displayPanel == st7735) _panel_instance = new lgfx::Panel_ST7735; - else if (settingsMap[displayPanel] == st7735s) + else if (portduino_config.displayPanel == st7735s) _panel_instance = new lgfx::Panel_ST7735S; - else if (settingsMap[displayPanel] == st7796) + else if (portduino_config.displayPanel == st7796) _panel_instance = new lgfx::Panel_ST7796; - else if (settingsMap[displayPanel] == ili9341) + else if (portduino_config.displayPanel == ili9341) _panel_instance = new lgfx::Panel_ILI9341; - else if (settingsMap[displayPanel] == ili9342) + else if (portduino_config.displayPanel == ili9342) _panel_instance = new lgfx::Panel_ILI9342; - else if (settingsMap[displayPanel] == ili9488) + else if (portduino_config.displayPanel == ili9488) _panel_instance = new lgfx::Panel_ILI9488; - else if (settingsMap[displayPanel] == hx8357d) + else if (portduino_config.displayPanel == hx8357d) _panel_instance = new lgfx::Panel_HX8357D; #if defined(LGFX_SDL) - else if (settingsMap[displayPanel] == x11) { + else if (portduino_config.displayPanel == x11) { _panel_instance = new lgfx::Panel_sdl; } #endif @@ -710,61 +795,61 @@ class LGFX : public lgfx::LGFX_Device auto buscfg = _bus_instance.config(); buscfg.spi_mode = 0; - buscfg.spi_host = settingsMap[displayspidev]; + buscfg.spi_host = portduino_config.display_spi_dev_int; - buscfg.pin_dc = settingsMap[displayDC]; // Set SPI DC pin number (-1 = disable) + buscfg.pin_dc = portduino_config.displayDC.pin; // Set SPI DC pin number (-1 = disable) _bus_instance.config(buscfg); // applies the set value to the bus. _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("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]; - if (settingsMap[displayRotate]) { - cfg.panel_width = settingsMap[displayHeight]; // actual displayable width - cfg.panel_height = settingsMap[displayWidth]; // actual displayable height + LOG_DEBUG("Width: %d, Height: %d", portduino_config.displayWidth, portduino_config.displayHeight); + cfg.pin_cs = portduino_config.displayCS.pin; // Pin number where CS is connected (-1 = disable) + cfg.pin_rst = portduino_config.displayReset.pin; + if (portduino_config.displayRotate) { + cfg.panel_width = portduino_config.displayHeight; // actual displayable width + cfg.panel_height = portduino_config.displayWidth; // actual displayable height } else { - cfg.panel_width = settingsMap[displayWidth]; // actual displayable width - cfg.panel_height = settingsMap[displayHeight]; // actual displayable height + cfg.panel_width = portduino_config.displayWidth; // actual displayable width + cfg.panel_height = portduino_config.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) - cfg.invert = settingsMap[displayInvert]; // Set to true if the light/darkness of the panel is reversed + cfg.offset_x = portduino_config.displayOffsetX; // Panel offset amount in X direction + cfg.offset_y = portduino_config.displayOffsetY; // Panel offset amount in Y direction + cfg.offset_rotation = portduino_config.displayOffsetRotate; // Rotation direction value offset 0~7 (4~7 is mirrored) + cfg.invert = portduino_config.displayInvert; // Set to true if the light/darkness of the panel is reversed _panel_instance->config(cfg); // Configure settings for touch control. - if (settingsMap[touchscreenModule]) { - if (settingsMap[touchscreenModule] == xpt2046) { + if (portduino_config.touchscreenModule) { + if (portduino_config.touchscreenModule == xpt2046) { _touch_instance = new lgfx::Touch_XPT2046; - } else if (settingsMap[touchscreenModule] == stmpe610) { + } else if (portduino_config.touchscreenModule == stmpe610) { _touch_instance = new lgfx::Touch_STMPE610; - } else if (settingsMap[touchscreenModule] == ft5x06) { + } else if (portduino_config.touchscreenModule == ft5x06) { _touch_instance = new lgfx::Touch_FT5x06; } auto touch_cfg = _touch_instance->config(); - touch_cfg.pin_cs = settingsMap[touchscreenCS]; + touch_cfg.pin_cs = portduino_config.touchscreenCS.pin; touch_cfg.x_min = 0; - touch_cfg.x_max = settingsMap[displayHeight] - 1; + touch_cfg.x_max = portduino_config.displayHeight - 1; touch_cfg.y_min = 0; - touch_cfg.y_max = settingsMap[displayWidth] - 1; - touch_cfg.pin_int = settingsMap[touchscreenIRQ]; + touch_cfg.y_max = portduino_config.displayWidth - 1; + touch_cfg.pin_int = portduino_config.touchscreenIRQ.pin; touch_cfg.bus_shared = true; - touch_cfg.offset_rotation = settingsMap[touchscreenRotate]; - if (settingsMap[touchscreenI2CAddr] != -1) { - touch_cfg.i2c_addr = settingsMap[touchscreenI2CAddr]; + touch_cfg.offset_rotation = portduino_config.touchscreenRotate; + if (portduino_config.touchscreenI2CAddr != -1) { + touch_cfg.i2c_addr = portduino_config.touchscreenI2CAddr; } else { - touch_cfg.spi_host = settingsMap[touchscreenspidev]; + touch_cfg.spi_host = portduino_config.touchscreen_spi_dev_int; } _touch_instance->config(touch_cfg); _panel_instance->setTouch(_touch_instance); } #if defined(LGFX_SDL) - if (settingsMap[displayPanel] == x11) { + if (portduino_config.displayPanel == x11) { lgfx::Panel_sdl *sdl_panel_ = (lgfx::Panel_sdl *)_panel_instance; sdl_panel_->setup(); sdl_panel_->addKeyCodeMapping(SDLK_RETURN, SDL_SCANCODE_KP_ENTER); @@ -997,8 +1082,9 @@ static LGFX *tft = nullptr; #endif -#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \ - defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST72xx_DE) || (ARCH_PORTDUINO && HAS_SCREEN != 0) +#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ST7789_CS) || defined(ST7796_CS) || defined(ILI9341_DRIVER) || \ + defined(ILI9342_DRIVER) || defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST72xx_DE) || \ + (ARCH_PORTDUINO && HAS_SCREEN != 0) #include "SPILock.h" #include "TFTDisplay.h" #include @@ -1029,10 +1115,10 @@ TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY g backlightEnable = p; #if ARCH_PORTDUINO - if (settingsMap[displayRotate]) { - setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayWidth], settingsMap[configNames::displayHeight]); + if (portduino_config.displayRotate) { + setGeometry(GEOMETRY_RAWMODE, portduino_config.displayWidth, portduino_config.displayWidth); } else { - setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayHeight], settingsMap[configNames::displayWidth]); + setGeometry(GEOMETRY_RAWMODE, portduino_config.displayHeight, portduino_config.displayHeight); } #elif defined(SCREEN_ROTATE) @@ -1042,37 +1128,111 @@ TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY g #endif } +TFTDisplay::~TFTDisplay() +{ + // Clean up allocated line pixel buffer to prevent memory leak + if (linePixelBuffer != nullptr) { + free(linePixelBuffer); + linePixelBuffer = nullptr; + } +} + // Write the buffer to the display memory void TFTDisplay::display(bool fromBlank) { if (fromBlank) tft->fillScreen(TFT_BLACK); - // tft->clear(); + concurrency::LockGuard g(spiLock); - uint16_t x, y; + uint32_t x, y; + uint32_t y_byteIndex; + uint8_t y_byteMask; + uint32_t x_FirstPixelUpdate; + uint32_t x_LastPixelUpdate; + bool isset, dblbuf_isset; + uint16_t colorTftMesh, colorTftBlack; + bool somethingChanged = false; - for (y = 0; y < displayHeight; y++) { - for (x = 0; x < displayWidth; x++) { - auto isset = buffer[x + (y / 8) * displayWidth] & (1 << (y & 7)); + // Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step + colorTftMesh = (TFT_MESH >> 8) | ((TFT_MESH & 0xFF) << 8); + colorTftBlack = (TFT_BLACK >> 8) | ((TFT_BLACK & 0xFF) << 8); + + y = 0; + while (y < displayHeight) { + y_byteIndex = (y / 8) * displayWidth; + y_byteMask = (1 << (y & 7)); + + // Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas. + if (y_byteMask == 1) { if (!fromBlank) { - // get src pixel in the page based ordering the OLED lib uses FIXME, super inefficent - auto dblbuf_isset = buffer_back[x + (y / 8) * displayWidth] & (1 << (y & 7)); - if (isset != dblbuf_isset) { - tft->drawPixel(x, y, isset ? TFT_MESH : TFT_BLACK); + for (x = 0; x < displayWidth; x++) { + if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex]) + break; } - } else if (isset) { - tft->drawPixel(x, y, TFT_MESH); + } else { + for (x = 0; x < displayWidth; x++) { + if (buffer[x + y_byteIndex] != 0) + break; + } + } + if (x >= displayWidth) { + // No changed pixels found in these 8 rows, fast-forward to the next 8 + y = y + 8; + continue; } } + + // Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating + for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) { + isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask; + + if (!fromBlank) { + // get src pixel in the page based ordering the OLED lib uses + dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask; + if (isset != dblbuf_isset) { + break; + } + } else if (isset) { + break; + } + } + + // Did we find a pixel that needs updating on this row? + if (x_FirstPixelUpdate < displayWidth) { + + // Quickly write out the first changed pixel (saves another array lookup) + linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack; + x_LastPixelUpdate = x_FirstPixelUpdate; + + // Step 3: copy all remaining pixels in this row into the pixel line buffer, + // while also recording the last pixel in the row that needs updating + for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) { + isset = buffer[x + y_byteIndex] & y_byteMask; + linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack; + + if (!fromBlank) { + dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask; + if (isset != dblbuf_isset) { + x_LastPixelUpdate = x; + } + } else if (isset) { + x_LastPixelUpdate = x; + } + } + + // Step 4: Send the changed pixels on this line to the screen as a single block transfer. + // This function accepts pixel data MSB first so it can dump the memory straight out the SPI port. + tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1, + &linePixelBuffer[x_FirstPixelUpdate]); + + somethingChanged = true; + } + y++; } // Copy the Buffer to the Back Buffer - for (y = 0; y < (displayHeight / 8); y++) { - for (x = 0; x < displayWidth; x++) { - uint16_t pos = x + y * displayWidth; - buffer_back[pos] = buffer[pos]; - } - } + if (somethingChanged) + memcpy(buffer_back, buffer, displayBufferSize); } void TFTDisplay::sdlLoop() @@ -1080,7 +1240,7 @@ void TFTDisplay::sdlLoop() #if defined(LGFX_SDL) static int lastPressed = 0; static int shuttingDown = false; - if (settingsMap[displayPanel] == x11) { + if (portduino_config.displayPanel == x11) { lgfx::Panel_sdl *sdl_panel_ = (lgfx::Panel_sdl *)tft->_panel_instance; if (sdl_panel_->loop() && !shuttingDown) { LOG_WARN("Window Closed!"); @@ -1128,8 +1288,8 @@ void TFTDisplay::sendCommand(uint8_t com) backlightEnable->set(true); #if ARCH_PORTDUINO display(true); - if (settingsMap[displayBacklight] > 0) - digitalWrite(settingsMap[displayBacklight], TFT_BACKLIGHT_ON); + if (portduino_config.displayBacklight.pin > 0) + digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON); #elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) tft->wakeup(); tft->powerSaveOff(); @@ -1152,8 +1312,8 @@ void TFTDisplay::sendCommand(uint8_t com) backlightEnable->set(false); #if ARCH_PORTDUINO tft->clear(); - if (settingsMap[displayBacklight] > 0) - digitalWrite(settingsMap[displayBacklight], !TFT_BACKLIGHT_ON); + if (portduino_config.displayBacklight.pin > 0) + digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON); #elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) tft->sleep(); tft->powerSaveOn(); @@ -1264,13 +1424,21 @@ bool TFTDisplay::connect() tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) tft->setRotation(2); // T-Watch S3 left-handed orientation -#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) +#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) tft->setRotation(0); // use config.yaml to set rotation #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label #endif tft->fillScreen(TFT_BLACK); + if (this->linePixelBuffer == NULL) { + this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth); + + if (!this->linePixelBuffer) { + LOG_ERROR("Not enough memory to create TFT line buffer\n"); + return false; + } + } return true; } diff --git a/src/graphics/TFTDisplay.h b/src/graphics/TFTDisplay.h index 60adfdf7c..a64922d23 100644 --- a/src/graphics/TFTDisplay.h +++ b/src/graphics/TFTDisplay.h @@ -20,6 +20,9 @@ class TFTDisplay : public OLEDDisplay */ TFTDisplay(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C); + // Destructor to clean up allocated memory + ~TFTDisplay(); + // Write the buffer to the display memory virtual void display() override { display(false); }; virtual void display(bool fromBlank); @@ -58,4 +61,6 @@ class TFTDisplay : public OLEDDisplay // Connect to the display virtual bool connect() override; + + uint16_t *linePixelBuffer = nullptr; }; \ No newline at end of file diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp new file mode 100644 index 000000000..84d5551cb --- /dev/null +++ b/src/graphics/VirtualKeyboard.cpp @@ -0,0 +1,738 @@ +#include "VirtualKeyboard.h" +#include "configuration.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "main.h" +#include +#include + +namespace graphics +{ + +VirtualKeyboard::VirtualKeyboard() : cursorRow(0), cursorCol(0), lastActivityTime(millis()) +{ + initializeKeyboard(); + // Set cursor to H(2, 5) + cursorRow = 2; + cursorCol = 5; +} + +VirtualKeyboard::~VirtualKeyboard() {} + +void VirtualKeyboard::initializeKeyboard() +{ + // New 4 row, 11 column keyboard layout: + static const char LAYOUT[KEYBOARD_ROWS][KEYBOARD_COLS] = {{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b'}, + {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n'}, + {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', ' '}, + {'z', 'x', 'c', 'v', 'b', 'n', 'm', '.', ',', '?', '\x1b'}}; + + // Derive layout dimensions and assert they match the configured keyboard grid + constexpr int LAYOUT_ROWS = (int)(sizeof(LAYOUT) / sizeof(LAYOUT[0])); + constexpr int LAYOUT_COLS = (int)(sizeof(LAYOUT[0]) / sizeof(LAYOUT[0][0])); + static_assert(LAYOUT_ROWS == KEYBOARD_ROWS, "LAYOUT rows must equal KEYBOARD_ROWS"); + static_assert(LAYOUT_COLS == KEYBOARD_COLS, "LAYOUT cols must equal KEYBOARD_COLS"); + + // Initialize all keys to empty first + for (int row = 0; row < LAYOUT_ROWS; row++) { + for (int col = 0; col < LAYOUT_COLS; col++) { + keyboard[row][col] = {0, VK_CHAR, 0, 0, 0, 0}; + } + } + + // Fill keyboard from the 2D layout + for (int row = 0; row < LAYOUT_ROWS; row++) { + for (int col = 0; col < LAYOUT_COLS; col++) { + char ch = LAYOUT[row][col]; + // No empty slots in the simplified layout + + VirtualKeyType type = VK_CHAR; + if (ch == '\b') { + type = VK_BACKSPACE; + } else if (ch == '\n') { + type = VK_ENTER; + } else if (ch == '\x1b') { // ESC + type = VK_ESC; + } else if (ch == ' ') { + type = VK_SPACE; + } + + // Make action keys wider to fit text while keeping the last column aligned + uint8_t width = (type == VK_BACKSPACE || type == VK_ENTER || type == VK_SPACE) ? (KEY_WIDTH * 3) : KEY_WIDTH; + keyboard[row][col] = {ch, type, (uint8_t)(col * KEY_WIDTH), (uint8_t)(row * KEY_HEIGHT), width, KEY_HEIGHT}; + } + } +} + +void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY) +{ + // Repeat ticking is driven by NotificationRenderer once per frame + // Base styles + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Screen geometry + const int screenW = display->getWidth(); + const int screenH = display->getHeight(); + + // Decide wide-screen mode: if there is comfortable width, allow taller keys and reserve fixed width for last column labels + // Heuristic: if screen width >= 200px (e.g., 240x135), treat as wide + const bool isWide = screenW >= 200; + + // Determine last-column label max width + display->setFont(FONT_SMALL); + const int wENTER = display->getStringWidth("ENTER"); + int lastColLabelW = wENTER; // ENTER is usually the widest + // Smaller padding on very small screens to avoid excessive whitespace + const int lastColPad = (screenW <= 128 ? 2 : 6); + const int reservedLastColW = lastColLabelW + lastColPad; // reserved width for last column keys + + // Always reserve width for the rightmost text column to avoid overlap on small screens + int cellW = 0; + int leftoverW = 0; + { + const int leftCols = KEYBOARD_COLS - 1; // 10 input characters + int usableW = screenW - reservedLastColW; + if (usableW < leftCols) { + // Guard: ensure at least 1px per left cell if labels are extremely wide (unlikely) + usableW = leftCols; + } + cellW = usableW / leftCols; + leftoverW = usableW - cellW * leftCols; // distribute extra pixels over left columns (left to right) + } + + // Dynamic key geometry + int cellH = KEY_HEIGHT; + int keyboardStartY = 0; + if (screenH <= 64) { + const int headerHeight = headerText.empty() ? 0 : (FONT_HEIGHT_SMALL - 2); + const int gapBelowHeader = 0; + const int singleLineBoxHeight = FONT_HEIGHT_SMALL; + const int gapAboveKeyboard = 0; + keyboardStartY = offsetY + headerHeight + gapBelowHeader + singleLineBoxHeight + gapAboveKeyboard; + if (keyboardStartY < 0) + keyboardStartY = 0; + if (keyboardStartY > screenH) + keyboardStartY = screenH; + int keyboardHeight = screenH - keyboardStartY; + cellH = std::max(1, keyboardHeight / KEYBOARD_ROWS); + } else if (isWide) { + // For wide screens (e.g., T114 240x135), prefer square keys: height equals left-column key width. + cellH = std::max((int)KEY_HEIGHT, cellW); + + // Guarantee at least 2 lines of input are visible by reducing cell height minimally if needed. + // Replicate the spacing used in drawInputArea(): headerGap=1, box-to-header gap=1, gap above keyboard=1 + display->setFont(FONT_SMALL); + const int headerHeight = headerText.empty() ? 0 : (FONT_HEIGHT_SMALL + 1); + const int headerToBoxGap = 1; + const int gapAboveKb = 1; + const int minBoxHeightForTwoLines = 2 * FONT_HEIGHT_SMALL + 2; // inner 1px top/bottom + int maxKeyboardHeight = screenH - (offsetY + headerHeight + headerToBoxGap + minBoxHeightForTwoLines + gapAboveKb); + int maxCellHAllowed = maxKeyboardHeight / KEYBOARD_ROWS; + if (maxCellHAllowed < (int)KEY_HEIGHT) + maxCellHAllowed = KEY_HEIGHT; + if (maxCellHAllowed > 0 && cellH > maxCellHAllowed) { + cellH = maxCellHAllowed; + } + // Keyboard placement from bottom for wide screens + int keyboardHeight = KEYBOARD_ROWS * cellH; + keyboardStartY = screenH - keyboardHeight; + if (keyboardStartY < 0) + keyboardStartY = 0; + } else { + // Default (non-wide, non-64px) behavior: use key height heuristic and place at bottom + cellH = KEY_HEIGHT; + int keyboardHeight = KEYBOARD_ROWS * cellH; + keyboardStartY = screenH - keyboardHeight; + if (keyboardStartY < 0) + keyboardStartY = 0; + } + + // Draw input area above keyboard + drawInputArea(display, offsetX, offsetY, keyboardStartY); + + // Precompute per-column x and width with leftover distributed over left columns for even spacing + int colX[KEYBOARD_COLS]; + int colW[KEYBOARD_COLS]; + int runningX = offsetX; + for (int col = 0; col < KEYBOARD_COLS - 1; ++col) { + int wcol = cellW + (col < leftoverW ? 1 : 0); + colX[col] = runningX; + colW[col] = wcol; + runningX += wcol; + } + // Last column + colX[KEYBOARD_COLS - 1] = runningX; + colW[KEYBOARD_COLS - 1] = reservedLastColW; + + // Draw keyboard grid + for (int row = 0; row < KEYBOARD_ROWS; row++) { + for (int col = 0; col < KEYBOARD_COLS; col++) { + const VirtualKey &k = keyboard[row][col]; + if (k.character != 0 || k.type != VK_CHAR) { + const bool isLastCol = (col == KEYBOARD_COLS - 1); + int x = colX[col]; + int w = colW[col]; + int y = offsetY + keyboardStartY + row * cellH; + int h = cellH; + bool selected = (row == cursorRow && col == cursorCol); + drawKey(display, k, selected, x, y, (uint8_t)w, (uint8_t)h, isLastCol); + } + } + } +} + +void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, int16_t keyboardStartY) +{ + display->setColor(WHITE); + + const int screenWidth = display->getWidth(); + const int screenHeight = display->getHeight(); + // Use the standard small font metrics for input box sizing (restore original size) + const int inputLineH = FONT_HEIGHT_SMALL; + + // Header uses the standard small (which may be larger on big screens) + display->setFont(FONT_SMALL); + int headerHeight = 0; + if (!headerText.empty()) { + // Draw header and reserve exact font height (plus a tighter gap) to maximize input area + display->drawString(offsetX + 2, offsetY, headerText.c_str()); + if (screenHeight <= 64) { + headerHeight = FONT_HEIGHT_SMALL - 2; // 11px + } else { + headerHeight = FONT_HEIGHT_SMALL; // no extra padding baked in + } + } + + const int boxX = offsetX; + const int boxWidth = screenWidth; + int boxY; + int boxHeight; + if (screenHeight <= 64) { + const int gapBelowHeader = 0; + const int fixedBoxHeight = inputLineH; + const int gapAboveKeyboard = 0; + boxY = offsetY + headerHeight + gapBelowHeader; + boxHeight = fixedBoxHeight; + if (boxY + boxHeight + gapAboveKeyboard > keyboardStartY) { + int over = boxY + boxHeight + gapAboveKeyboard - keyboardStartY; + boxHeight = std::max(1, fixedBoxHeight - over); + } + } else { + const int gapBelowHeader = 1; + int gapAboveKeyboard = 1; + int tmpBoxY = offsetY + headerHeight + gapBelowHeader; + const int minBoxHeight = inputLineH + 2; + int availableH = keyboardStartY - tmpBoxY - gapAboveKeyboard; + if (availableH < minBoxHeight) + availableH = minBoxHeight; + boxY = tmpBoxY; + boxHeight = availableH; + } + + // Draw box border + display->drawRect(boxX, boxY, boxWidth, boxHeight); + + display->setFont(FONT_SMALL); + + // Text rendering: multi-line if space allows (>= 2 lines), else single-line with leading ellipsis + const int textX = boxX + 2; + const int maxTextWidth = boxWidth - 4; + const int maxLines = (boxHeight - 2) / inputLineH; + if (maxLines >= 2) { + // Inner bounds for caret clamping + const int innerLeft = boxX + 1; + const int innerRight = boxX + boxWidth - 2; + const int innerTop = boxY + 1; + const int innerBottom = boxY + boxHeight - 2; + + // Wrap text greedily into lines that fit maxTextWidth + std::vector lines; + { + std::string remaining = inputText; + while (!remaining.empty()) { + int bestLen = 0; + for (int len = 1; len <= (int)remaining.size(); ++len) { + int w = display->getStringWidth(remaining.substr(0, len).c_str()); + if (w <= maxTextWidth) + bestLen = len; + else + break; + } + if (bestLen == 0) { + // At least show one character to make progress + bestLen = 1; + } + lines.emplace_back(remaining.substr(0, bestLen)); + remaining.erase(0, bestLen); + } + } + + const bool scrolledUp = ((int)lines.size() > maxLines); + int caretX = textX; + int caretY = innerTop; + + // Leave a small top gap to render '...' without replacing the first line + const int topInset = 2; + const int lineStep = std::max(1, inputLineH - 1); // slightly tighter than font height + int lineY = innerTop + topInset; + + if (scrolledUp) { + // Draw three small dots centered horizontally, vertically at the midpoint of the gap + // between the inner top and the first line's top baseline. This avoids using a tall glyph. + const int firstLineTop = lineY; // baseline top for the first visible line + const int gapMidY = innerTop + (firstLineTop - innerTop) / 2 + 1; // shift down 1px as requested + const int centerX = boxX + boxWidth / 2; + const int dotSpacing = 3; // px between dots + const int dotSize = 1; // small square dot + display->fillRect(centerX - dotSpacing, gapMidY, dotSize, dotSize); + display->fillRect(centerX, gapMidY, dotSize, dotSize); + display->fillRect(centerX + dotSpacing, gapMidY, dotSize, dotSize); + } + + // How many lines fit with our top inset and tighter step + const int linesCapacity = std::max(1, (innerBottom - lineY + 1) / lineStep); + const int linesToShow = std::min((int)lines.size(), linesCapacity); + const int startIndex = scrolledUp ? ((int)lines.size() - linesToShow) : 0; + + for (int i = 0; i < linesToShow; ++i) { + const std::string &chunk = lines[startIndex + i]; + display->drawString(textX, lineY, chunk.c_str()); + caretX = textX + display->getStringWidth(chunk.c_str()); + caretY = lineY; + lineY += lineStep; + } + + // Draw caret at end of the last visible line + int caretPadY = 2; + if (boxHeight >= inputLineH + 4) + caretPadY = 3; + int cursorTop = caretY + caretPadY; + // Use lineStep so caret height matches the row spacing + int cursorH = lineStep - caretPadY * 2; + if (cursorH < 1) + cursorH = 1; + // Clamp vertical bounds to stay inside the inner rect + if (cursorTop < innerTop) + cursorTop = innerTop; + if (cursorTop + cursorH - 1 > innerBottom) + cursorH = innerBottom - cursorTop + 1; + if (cursorH < 1) + cursorH = 1; + // Only draw if cursor is inside inner bounds + if (caretX >= innerLeft && caretX <= innerRight) { + display->drawVerticalLine(caretX, cursorTop, cursorH); + } + } else { + std::string displayText = inputText; + int textW = display->getStringWidth(displayText.c_str()); + std::string scrolled = displayText; + if (textW > maxTextWidth) { + // Trim from the left until it fits + while (textW > maxTextWidth && !scrolled.empty()) { + scrolled.erase(0, 1); + textW = display->getStringWidth(scrolled.c_str()); + } + // Add leading ellipsis and ensure it still fits + if (scrolled != displayText) { + scrolled = "..." + scrolled; + textW = display->getStringWidth(scrolled.c_str()); + // If adding ellipsis causes overflow, trim more after the ellipsis + while (textW > maxTextWidth && scrolled.size() > 3) { + scrolled.erase(3, 1); // remove chars after the ellipsis + textW = display->getStringWidth(scrolled.c_str()); + } + } + } else { + // Keep textW in sync with what we draw + textW = display->getStringWidth(scrolled.c_str()); + } + + int textY; + if (screenHeight <= 64) { + textY = boxY + (boxHeight - inputLineH) / 2; + } else { + const int innerLeft = boxX + 1; + const int innerRight = boxX + boxWidth - 2; + const int innerTop = boxY + 1; + const int innerBottom = boxY + boxHeight - 2; + + // Center text vertically within inner box for single-line, then clamp so it never overlaps borders + int innerH = innerBottom - innerTop + 1; + textY = innerTop + std::max(0, (innerH - inputLineH) / 2); + // Clamp fully inside the inner rect + if (textY < innerTop) + textY = innerTop; + int maxTop = innerBottom - inputLineH + 1; + if (textY > maxTop) + textY = maxTop; + } + + if (!scrolled.empty()) { + display->drawString(textX, textY, scrolled.c_str()); + } + + int cursorX = textX + textW; + if (screenHeight > 64) { + const int innerRight = boxX + boxWidth - 2; + if (cursorX > innerRight) + cursorX = innerRight; + } + + int cursorTop, cursorH; + if (screenHeight <= 64) { + cursorH = 10; + cursorTop = boxY + (boxHeight - cursorH) / 2; + } else { + const int innerLeft = boxX + 1; + const int innerRight = boxX + boxWidth - 2; + const int innerTop = boxY + 1; + const int innerBottom = boxY + boxHeight - 2; + + cursorTop = boxY + 2; + cursorH = boxHeight - 4; + if (cursorH < 1) + cursorH = 1; + if (cursorTop < innerTop) + cursorTop = innerTop; + if (cursorTop + cursorH - 1 > innerBottom) + cursorH = innerBottom - cursorTop + 1; + if (cursorH < 1) + cursorH = 1; + + if (cursorX < innerLeft || cursorX > innerRight) + return; + } + + display->drawVerticalLine(cursorX, cursorTop, cursorH); + } +} + +void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t x, int16_t y, uint8_t width, + uint8_t height, bool isLastCol) +{ + // Draw key content + display->setFont(FONT_SMALL); + const int fontH = FONT_HEIGHT_SMALL; + // Build label and metrics first + std::string keyText; + if (key.type == VK_BACKSPACE || key.type == VK_ENTER || key.type == VK_SPACE || key.type == VK_ESC) { + // Keep literal text labels for the action keys on the rightmost column + keyText = (key.type == VK_BACKSPACE) ? "BACK" + : (key.type == VK_ENTER) ? "ENTER" + : (key.type == VK_SPACE) ? "SPACE" + : (key.type == VK_ESC) ? "ESC" + : ""; + } else { + char c = getCharForKey(key, false); + if (c >= 'a' && c <= 'z') { + c = c - 'a' + 'A'; + } + keyText = (key.character == ' ' || key.character == '_') ? "_" : std::string(1, c); + } + + int textWidth = display->getStringWidth(keyText.c_str()); + // Label alignment + // - Rightmost action column: right-align text with a small right padding (~2px) so it hugs screen edge neatly. + // - Other keys: center horizontally; use ceil-style rounding to avoid appearing left-biased on odd widths. + int textX; + if (isLastCol) { + const int rightPad = 1; + textX = x + width - textWidth - rightPad; + if (textX < x) + textX = x; // guard + } else { + if (display->getHeight() <= 64 && (key.character >= '0' && key.character <= '9')) { + textX = x + (width - textWidth + 1) / 2; + } else { + textX = x + (width - textWidth) / 2; + } + } + int contentTop = y; + int contentH = height; + if (selected) { + display->setColor(WHITE); + bool isAction = (key.type == VK_BACKSPACE || key.type == VK_ENTER || key.type == VK_SPACE || key.type == VK_ESC); + + if (display->getHeight() <= 64 && !isAction) { + display->fillRect(x, y, width, height); + } else if (isAction) { + const int padX = 1; + const int padY = 2; + int hlW = textWidth + padX * 2; + int hlX = textX - padX; + + if (hlX < x) { + hlW -= (x - hlX); + hlX = x; + } + int maxW = (x + width) - hlX; + if (hlW > maxW) + hlW = maxW; + if (hlW < 1) + hlW = 1; + + int hlH = std::min(fontH + padY * 2, (int)height); + int hlY = y + (height - hlH) / 2; + display->fillRect(hlX, hlY, hlW, hlH); + contentTop = hlY; + contentH = hlH; + } else { + display->fillRect(x, y, width, height); + } + display->setColor(BLACK); + } else { + display->setColor(WHITE); + } + + int centeredTextY; + if (display->getHeight() <= 64) { + centeredTextY = y + (height - fontH) / 2; + } else { + centeredTextY = contentTop + (contentH - fontH) / 2; + } + if (display->getHeight() > 64) { + if (centeredTextY < contentTop) + centeredTextY = contentTop; + if (centeredTextY + fontH > contentTop + contentH) + centeredTextY = std::max(contentTop, contentTop + contentH - fontH); + } + + if (display->getHeight() <= 64 && keyText.size() == 1) { + char ch = keyText[0]; + if (ch == '.' || ch == ',' || ch == ';') { + centeredTextY -= 1; + } + } + display->drawString(textX, centeredTextY, keyText.c_str()); +} + +char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress) +{ + if (key.type != VK_CHAR) { + return key.character; + } + + char c = key.character; + + // Long-press: only keep letter lowercase->uppercase conversion; remove other symbol mappings + if (isLongPress && c >= 'a' && c <= 'z') { + c = (char)(c - 'a' + 'A'); + } + + return c; +} + +void VirtualKeyboard::moveCursorDelta(int dRow, int dCol) +{ + resetTimeout(); + // wrap around rows and cols in the 4x11 grid + int r = (int)cursorRow + dRow; + int c = (int)cursorCol + dCol; + if (r < 0) + r = KEYBOARD_ROWS - 1; + else if (r >= KEYBOARD_ROWS) + r = 0; + if (c < 0) + c = KEYBOARD_COLS - 1; + else if (c >= KEYBOARD_COLS) + c = 0; + cursorRow = (uint8_t)r; + cursorCol = (uint8_t)c; +} + +void VirtualKeyboard::moveCursorUp() +{ + moveCursorDelta(-1, 0); +} +void VirtualKeyboard::moveCursorDown() +{ + moveCursorDelta(1, 0); +} +void VirtualKeyboard::moveCursorLeft() +{ + resetTimeout(); + + if (cursorCol > 0) { + cursorCol--; + } else { + if (cursorRow > 0) { + cursorRow--; + cursorCol = KEYBOARD_COLS - 1; + } else { + cursorRow = KEYBOARD_ROWS - 1; + cursorCol = KEYBOARD_COLS - 1; + } + } +} +void VirtualKeyboard::moveCursorRight() +{ + resetTimeout(); + + if (cursorCol < KEYBOARD_COLS - 1) { + cursorCol++; + } else { + if (cursorRow < KEYBOARD_ROWS - 1) { + cursorRow++; + cursorCol = 0; + } else { + cursorRow = 0; + cursorCol = 0; + } + } +} + +void VirtualKeyboard::handlePress() +{ + resetTimeout(); // Reset timeout on any input activity + + const VirtualKey &key = keyboard[cursorRow][cursorCol]; + + // Don't handle press if the key is empty (but allow special keys) + if (key.character == 0 && key.type == VK_CHAR) { + return; + } + + // For character keys, insert lowercase character + if (key.type == VK_CHAR) { + insertCharacter(getCharForKey(key, false)); // false = lowercase/normal char + return; + } + + // Handle non-character keys immediately + switch (key.type) { + case VK_BACKSPACE: + deleteCharacter(); + break; + case VK_ENTER: + submitText(); + break; + case VK_SPACE: + insertCharacter(' '); + break; + case VK_ESC: + if (onTextEntered) { + std::function callback = onTextEntered; + onTextEntered = nullptr; + inputText = ""; + callback(""); + } + return; + default: + break; + } +} + +void VirtualKeyboard::handleLongPress() +{ + resetTimeout(); // Reset timeout on any input activity + + const VirtualKey &key = keyboard[cursorRow][cursorCol]; + + // Don't handle press if the key is empty (but allow special keys) + if (key.character == 0 && key.type == VK_CHAR) { + return; + } + + // For character keys, insert uppercase/alternate character + if (key.type == VK_CHAR) { + insertCharacter(getCharForKey(key, true)); // true = uppercase/alternate char + return; + } + + switch (key.type) { + case VK_BACKSPACE: + // One-shot: delete up to 5 characters on long press + for (int i = 0; i < 5; ++i) { + if (inputText.empty()) + break; + deleteCharacter(); + } + break; + case VK_ENTER: + submitText(); + break; + case VK_SPACE: + insertCharacter(' '); + break; + case VK_ESC: + if (onTextEntered) { + onTextEntered(""); + } + break; + default: + break; + } +} + +void VirtualKeyboard::insertCharacter(char c) +{ + if (inputText.length() < 160) { // Reasonable text length limit + inputText += c; + } +} + +void VirtualKeyboard::deleteCharacter() +{ + if (!inputText.empty()) { + inputText.pop_back(); + } +} + +void VirtualKeyboard::submitText() +{ + LOG_INFO("Virtual keyboard: submitting text '%s'", inputText.c_str()); + + // Only submit if text is not empty + if (!inputText.empty() && onTextEntered) { + // Store callback and text to submit before clearing callback + std::function callback = onTextEntered; + std::string textToSubmit = inputText; + onTextEntered = nullptr; + // Don't clear inputText here - let the calling module handle cleanup + // inputText = ""; // Removed: keep text visible until module cleans up + callback(textToSubmit); + } else if (inputText.empty()) { + // For empty text, just ignore the submission - don't clear callback + // This keeps the virtual keyboard responsive for further input + LOG_INFO("Virtual keyboard: empty text submitted, ignoring - keyboard remains active"); + } else { + // No callback available + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + } +} + +void VirtualKeyboard::setInputText(const std::string &text) +{ + inputText = text; +} + +std::string VirtualKeyboard::getInputText() const +{ + return inputText; +} + +void VirtualKeyboard::setHeader(const std::string &header) +{ + headerText = header; +} + +void VirtualKeyboard::setCallback(std::function callback) +{ + onTextEntered = callback; +} + +void VirtualKeyboard::resetTimeout() +{ + lastActivityTime = millis(); +} + +bool VirtualKeyboard::isTimedOut() const +{ + return (millis() - lastActivityTime) > TIMEOUT_MS; +} + +} // namespace graphics diff --git a/src/graphics/VirtualKeyboard.h b/src/graphics/VirtualKeyboard.h new file mode 100644 index 000000000..169163b57 --- /dev/null +++ b/src/graphics/VirtualKeyboard.h @@ -0,0 +1,80 @@ +#pragma once + +#include "configuration.h" +#include +#include +#include + +namespace graphics +{ + +enum VirtualKeyType { VK_CHAR, VK_BACKSPACE, VK_ENTER, VK_SHIFT, VK_ESC, VK_SPACE }; + +struct VirtualKey { + char character; + VirtualKeyType type; + uint8_t x; + uint8_t y; + uint8_t width; + uint8_t height; +}; + +class VirtualKeyboard +{ + public: + VirtualKeyboard(); + ~VirtualKeyboard(); + + void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); + void setInputText(const std::string &text); + std::string getInputText() const; + void setHeader(const std::string &header); + void setCallback(std::function callback); + + // Navigation methods for encoder input + void moveCursorUp(); + void moveCursorDown(); + void moveCursorLeft(); + void moveCursorRight(); + void handlePress(); + void handleLongPress(); + + // Timeout management + void resetTimeout(); + bool isTimedOut() const; + + private: + static const uint8_t KEYBOARD_ROWS = 4; + static const uint8_t KEYBOARD_COLS = 11; + static const uint8_t KEY_WIDTH = 9; + static const uint8_t KEY_HEIGHT = 9; // Compressed to fit 4 rows on 64px displays + static const uint8_t KEYBOARD_START_Y = 26; // Start just below input box bottom + + VirtualKey keyboard[KEYBOARD_ROWS][KEYBOARD_COLS]; + + std::string inputText; + std::string headerText; + std::function onTextEntered; + + uint8_t cursorRow; + uint8_t cursorCol; + + // Timeout management for auto-exit + uint32_t lastActivityTime; + static const uint32_t TIMEOUT_MS = 60000; // 1 minute timeout + + void initializeKeyboard(); + void drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t x, int16_t y, uint8_t w, uint8_t h, + bool isLastCol); + void drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, int16_t keyboardStartY); + + // Unified cursor movement helper + void moveCursorDelta(int dRow, int dCol); + + char getCharForKey(const VirtualKey &key, bool isLongPress = false); + void insertCharacter(char c); + void deleteCharacter(); + void submitText(); +}; + +} // namespace graphics diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp index 08466662c..bb8cdd561 100644 --- a/src/graphics/draw/ClockRenderer.cpp +++ b/src/graphics/draw/ClockRenderer.cpp @@ -2,7 +2,6 @@ #if HAS_SCREEN #include "ClockRenderer.h" #include "NodeDB.h" -#include "UIRenderer.h" #include "configuration.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" @@ -190,7 +189,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1 // === Set Title, Blank for Clock const char *titleStr = ""; // === Header === - graphics::drawCommonHeader(display, x, y, titleStr, true); + graphics::drawCommonHeader(display, x, y, titleStr, true, true); #ifdef T_WATCH_S3 if (nimbleBluetooth && nimbleBluetooth->isConnected()) { @@ -294,6 +293,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1 display->drawString(startingHourMinuteTextX + xOffset, (display->getHeight() - hourMinuteTextY) - yOffset - 2, isPM ? "pm" : "am"); } + #ifndef USE_EINK xOffset = (isHighResolution) ? 18 : 10; display->drawString(startingHourMinuteTextX + timeStringWidth - xOffset, (display->getHeight() - hourMinuteTextY) - yOffset, @@ -313,7 +313,7 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 // === Set Title, Blank for Clock const char *titleStr = ""; // === Header === - graphics::drawCommonHeader(display, x, y, titleStr, true); + graphics::drawCommonHeader(display, x, y, titleStr, true, true); #ifdef T_WATCH_S3 if (nimbleBluetooth && nimbleBluetooth->isConnected()) { diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 5d9b5a33b..c93ef578c 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -94,7 +94,8 @@ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16 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(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_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); @@ -106,7 +107,7 @@ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16 #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(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS)) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 16, 8, imgSFL1); @@ -121,7 +122,8 @@ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16 } 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(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_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); @@ -261,12 +263,6 @@ void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t 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; @@ -395,18 +391,27 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, 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); + // === Second Row: Role === + auto role = DisplayFormatters::getDeviceRole(config.device.role); + char device_role[25]; + snprintf(device_role, sizeof(device_role), "Role: %s", role); + textWidth = display->getStringWidth(device_role); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line++], device_role); + + // === Third Row: Radio Preset === + auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset); + char regionradiopreset[25]; const char *region = myRegion ? myRegion->name : NULL; if (region != nullptr) { - snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); + snprintf(regionradiopreset, sizeof(regionradiopreset), "Reg: %s/%s", region, mode); } textWidth = display->getStringWidth(regionradiopreset); nameX = (SCREEN_WIDTH - textWidth) / 2; display->drawString(nameX, getTextPositions(display)[line++], regionradiopreset); - // === Third Row: Frequency / ChanNum === + // === Fourth Row: Frequency / ChanNum === char frequencyslot[35]; char freqStr[16]; float freq = RadioLibInterface::instance->getFreq(); @@ -424,7 +429,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, nameX = (SCREEN_WIDTH - textWidth) / 2; display->drawString(nameX, getTextPositions(display)[line++], frequencyslot); - // === Fourth Row: Channel Utilization === + // === Fifth Row: Channel Utilization === const char *chUtil = "ChUtil:"; char chUtilPercentage[10]; snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); @@ -441,7 +446,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, 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); + 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) { @@ -478,14 +483,14 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, 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], + display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], chUtilPercentage); } // **************************** // * System Screen * // **************************** -void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->clear(); display->setFont(FONT_SMALL); @@ -629,6 +634,33 @@ void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(nameX, getTextPositions(display)[line], uptimeStr); } } + +// **************************** +// * Chirpy Screen * +// **************************** +void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + int line = 1; + int iconX = SCREEN_WIDTH - chirpy_width - (chirpy_width / 3); + int iconY = (SCREEN_HEIGHT - chirpy_height) / 2; + int textX_offset = 10; + if (isHighResolution) { + iconX = SCREEN_WIDTH - chirpy_width_hirez - (chirpy_width_hirez / 3); + iconY = (SCREEN_HEIGHT - chirpy_height_hirez) / 2; + textX_offset = textX_offset * 4; + display->drawXbm(iconX, iconY, chirpy_width_hirez, chirpy_height_hirez, chirpy_hirez); + } else { + display->drawXbm(iconX, iconY, chirpy_width, chirpy_height, chirpy); + } + + int textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("Hello") / 2); + display->drawString(textX, getTextPositions(display)[line++], "Hello"); + textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("World!") / 2); + display->drawString(textX, getTextPositions(display)[line++], "World!"); +} } // 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 index f4d484f58..563a6c1ce 100644 --- a/src/graphics/draw/DebugRenderer.h +++ b/src/graphics/draw/DebugRenderer.h @@ -31,8 +31,11 @@ void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state // 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); +// System screen display +void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Chirpy screen display +void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); } // namespace DebugRenderer } // namespace graphics diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 512f650ec..dab3040f0 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -10,7 +10,10 @@ #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/UIRenderer.h" +#include "input/RotaryEncoderInterruptImpl1.h" +#include "input/UpDownInterruptImpl1.h" #include "main.h" +#include "mesh/MeshTypes.h" #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/KeyVerificationModule.h" @@ -26,6 +29,26 @@ menuHandler::screenMenus menuHandler::menuQueue = menu_none; bool test_enabled = false; uint8_t test_count = 0; +void menuHandler::loraMenu() +{ + static const char *optionsArray[] = {"Back", "Region Picker", "Device Role"}; + enum optionsNumbers { Back = 0, lora_picker = 1, device_role_picker = 2 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "LoRa Actions"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + // No action + } else if (selected == lora_picker) { + menuHandler::menuQueue = menuHandler::lora_picker; + } else if (selected == device_role_picker) { + menuHandler::menuQueue = menuHandler::device_role_picker; + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::OnboardMessage() { static const char *optionsArray[] = {"OK", "Got it!"}; @@ -119,6 +142,40 @@ void menuHandler::LoraRegionPicker(uint32_t duration) screen->showOverlayBanner(bannerOptions); } +void menuHandler::DeviceRolePicker() +{ + static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"}; + enum optionsNumbers { + Back = 0, + devicerole_client = 1, + devicerole_clientmute = 2, + devicerole_lostandfound = 3, + devicerole_tracker = 4 + }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Device Role"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 5; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + menuHandler::menuQueue = menuHandler::lora_Menu; + screen->runNow(); + return; + } else if (selected == devicerole_client) { + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + } else if (selected == devicerole_clientmute) { + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE; + } else if (selected == devicerole_lostandfound) { + config.device.role = meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND; + } else if (selected == devicerole_tracker) { + config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER; + } + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::TwelveHourPicker() { static const char *optionsArray[] = {"Back", "12-hour", "24-hour"}; @@ -305,7 +362,7 @@ void menuHandler::messageResponseMenu() bannerOptions.optionsCount = options; bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Dismiss) { - screen->dismissCurrentFrame(); + screen->hideCurrentFrame(); } else if (selected == Preset) { if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); @@ -346,8 +403,11 @@ void menuHandler::homeBaseMenu() optionsArray[options] = "Sleep Screen"; optionsEnumArray[options++] = Sleep; #endif - - optionsArray[options] = "Send Position"; + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + optionsArray[options] = "Send Position"; + } else { + optionsArray[options] = "Send Node Info"; + } optionsEnumArray[options++] = Position; optionsArray[options] = "New Preset Msg"; optionsEnumArray[options++] = Preset; @@ -427,19 +487,22 @@ void menuHandler::textMessageBaseMenu() void menuHandler::systemBaseMenu() { - enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, PowerMenu, Test, enumEnd }; + enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, PowerMenu, FrameToggles, Test, enumEnd }; static const char *optionsArray[enumEnd] = {"Back"}; static int optionsEnumArray[enumEnd] = {Back}; int options = 1; optionsArray[options] = "Notifications"; optionsEnumArray[options++] = Notifications; -#if defined(ST7789_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) || \ - defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || HAS_TFT +#if defined(ST7789_CS) || defined(ST7796_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || \ + defined(USE_SH1107) || defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || HAS_TFT optionsArray[options] = "Screen Options"; optionsEnumArray[options++] = ScreenOptions; #endif + optionsArray[options] = "Frame Visiblity Toggle"; + optionsEnumArray[options++] = FrameToggles; + optionsArray[options] = "Bluetooth Toggle"; optionsEnumArray[options++] = Bluetooth; @@ -466,6 +529,9 @@ void menuHandler::systemBaseMenu() } else if (selected == PowerMenu) { menuHandler::menuQueue = menuHandler::power_menu; screen->runNow(); + } else if (selected == FrameToggles) { + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); } else if (selected == Test) { menuHandler::menuQueue = menuHandler::test_menu; screen->runNow(); @@ -532,6 +598,7 @@ void menuHandler::positionBaseMenu() optionsArray[options] = "Compass Calibrate"; optionsEnumArray[options++] = CompassCalibrate; } + BannerOverlayOptions bannerOptions; bannerOptions.message = "Position Action"; bannerOptions.optionsArrayPtr = optionsArray; @@ -725,7 +792,7 @@ void menuHandler::BrightnessPickerMenu() #if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) // For HELTEC devices, use analogWrite to control backlight analogWrite(VTFT_LEDA, uiconfig.screen_brightness); -#elif defined(ST7789_CS) +#elif defined(ST7789_CS) || defined(ST7796_CS) static_cast(screen->getDisplayDevice())->setDisplayBrightness(uiconfig.screen_brightness); #elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) screen->getDisplayDevice()->setBrightness(uiconfig.screen_brightness); @@ -768,7 +835,7 @@ void menuHandler::TFTColorPickerMenu(OLEDDisplay *display) bannerOptions.optionsArrayPtr = optionsArray; bannerOptions.optionsCount = 10; bannerOptions.bannerCallback = [display](int selected) -> void { -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || HAS_TFT +#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || HAS_TFT uint8_t TFT_MESH_r = 0; uint8_t TFT_MESH_g = 0; uint8_t TFT_MESH_b = 0; @@ -937,16 +1004,33 @@ void menuHandler::traceRouteMenu() void menuHandler::testMenu() { - static const char *optionsArray[] = {"Back", "Number Picker"}; + enum optionsNumbers { Back, NumberPicker, ShowChirpy }; + static const char *optionsArray[4] = {"Back"}; + static int optionsEnumArray[4] = {Back}; + int options = 1; + + optionsArray[options] = "Number Picker"; + optionsEnumArray[options++] = NumberPicker; + + optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy"; + optionsEnumArray[options++] = ShowChirpy; + BannerOverlayOptions bannerOptions; - std::string message = "Test to Run?\n"; - bannerOptions.message = message.c_str(); + bannerOptions.message = "Hidden Test Menu"; bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { + if (selected == NumberPicker) { menuQueue = number_test; screen->runNow(); + } else if (selected == ShowChirpy) { + screen->toggleFrameVisibility("chirpy"); + screen->setFrames(Screen::FOCUS_SYSTEM); + + } else { + menuQueue = system_base_menu; + screen->runNow(); } }; screen->showOverlayBanner(bannerOptions); @@ -1045,7 +1129,7 @@ void menuHandler::screenOptionsMenu() } // Only show screen color for TFT displays -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || HAS_TFT +#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || HAS_TFT optionsArray[options] = "Screen Color"; optionsEnumArray[options++] = ScreenColor; #endif @@ -1143,6 +1227,116 @@ void menuHandler::keyVerificationFinalPrompt() } } +void menuHandler::FrameToggles_menu() +{ + enum optionsNumbers { + Finish, + nodelist, + nodelist_lastheard, + nodelist_hopsignal, + nodelist_distance, + nodelist_bearings, + gps, + lora, + clock, + show_favorites, + enumEnd + }; + static const char *optionsArray[enumEnd] = {"Finish"}; + static int optionsEnumArray[enumEnd] = {Finish}; + int options = 1; + + // Track last selected index (not enum value!) + static int lastSelectedIndex = 0; + +#ifndef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist") ? "Show Node List" : "Hide Node List"; + optionsEnumArray[options++] = nodelist; +#endif +#ifdef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist_lastheard") ? "Show NL - Last Heard" : "Hide NL - Last Heard"; + optionsEnumArray[options++] = nodelist_lastheard; + optionsArray[options] = screen->isFrameHidden("nodelist_hopsignal") ? "Show NL - Hops/Signal" : "Hide NL - Hops/Signal"; + optionsEnumArray[options++] = nodelist_hopsignal; + optionsArray[options] = screen->isFrameHidden("nodelist_distance") ? "Show NL - Distance" : "Hide NL - Distance"; + optionsEnumArray[options++] = nodelist_distance; +#endif +#if HAS_GPS + optionsArray[options] = screen->isFrameHidden("nodelist_bearings") ? "Show Bearings" : "Hide Bearings"; + optionsEnumArray[options++] = nodelist_bearings; + + optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position"; + optionsEnumArray[options++] = gps; +#endif + + optionsArray[options] = screen->isFrameHidden("lora") ? "Show LoRa" : "Hide LoRa"; + optionsEnumArray[options++] = lora; + + optionsArray[options] = screen->isFrameHidden("clock") ? "Show Clock" : "Hide Clock"; + optionsEnumArray[options++] = clock; + + optionsArray[options] = screen->isFrameHidden("show_favorites") ? "Show Favorites" : "Hide Favorites"; + optionsEnumArray[options++] = show_favorites; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Show/Hide Frames"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.InitialSelected = lastSelectedIndex; // Use index, not enum value + + bannerOptions.bannerCallback = [options](int selected) mutable -> void { + // Find the index of selected in optionsEnumArray + int idx = 0; + for (; idx < options; ++idx) { + if (optionsEnumArray[idx] == selected) + break; + } + lastSelectedIndex = idx; + + if (selected == Finish) { + screen->setFrames(Screen::FOCUS_DEFAULT); + } else if (selected == nodelist) { + screen->toggleFrameVisibility("nodelist"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_lastheard) { + screen->toggleFrameVisibility("nodelist_lastheard"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_hopsignal) { + screen->toggleFrameVisibility("nodelist_hopsignal"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_distance) { + screen->toggleFrameVisibility("nodelist_distance"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_bearings) { + screen->toggleFrameVisibility("nodelist_bearings"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == gps) { + screen->toggleFrameVisibility("gps"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == lora) { + screen->toggleFrameVisibility("lora"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == clock) { + screen->toggleFrameVisibility("clock"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == show_favorites) { + screen->toggleFrameVisibility("show_favorites"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::handleMenuSwitch(OLEDDisplay *display) { if (menuQueue != menu_none) @@ -1150,9 +1344,15 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) switch (menuQueue) { case menu_none: break; + case lora_Menu: + loraMenu(); + break; case lora_picker: LoraRegionPicker(); break; + case device_role_picker: + DeviceRolePicker(); + break; case no_timeout_lora_picker: LoraRegionPicker(0); break; @@ -1239,6 +1439,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case power_menu: powerMenu(); break; + case FrameToggles: + FrameToggles_menu(); + break; case throttle_message: screen->showSimpleBanner("Too Many Attempts\nTry again in 60 seconds.", 5000); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index b15cf237d..2be8e58a6 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -9,7 +9,9 @@ class menuHandler public: enum screenMenus { menu_none, + lora_Menu, lora_picker, + device_role_picker, no_timeout_lora_picker, TZ_picker, twelve_hour_picker, @@ -39,11 +41,14 @@ class menuHandler key_verification_final_prompt, trace_route_menu, throttle_message, + FrameToggles }; static screenMenus menuQueue; static void OnboardMessage(); static void LoraRegionPicker(uint32_t duration = 30000); + static void loraMenu(); + static void DeviceRolePicker(); static void handleMenuSwitch(OLEDDisplay *display); static void showConfirmationBanner(const char *message, std::function onConfirm); static void clockMenu(); @@ -76,6 +81,7 @@ class menuHandler static void notificationsMenu(); static void screenOptionsMenu(); static void powerMenu(); + static void FrameToggles_menu(); private: static void saveUIConfig(); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 3d635e588..b53cd2f3f 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -7,10 +7,18 @@ #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" +#include "input/RotaryEncoderInterruptImpl1.h" +#include "input/UpDownInterruptImpl1.h" +#if HAS_BUTTON +#include "input/ButtonThread.h" +#endif #include "main.h" #include #include #include +#if HAS_TRACKBALL +#include "input/TrackballInterruptImpl1.h" +#endif #ifdef ARCH_ESP32 #include "esp_task_wdt.h" @@ -18,6 +26,11 @@ using namespace meshtastic; +#if HAS_BUTTON +// Global button thread pointer defined in main.cpp +extern ::ButtonThread *UserButtonThread; +#endif + // External references to global variables from Screen.cpp extern std::vector functionSymbol; extern std::string functionSymbolString; @@ -38,6 +51,8 @@ bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; uint32_t NotificationRenderer::numDigits = 0; uint32_t NotificationRenderer::currentNumber = 0; +VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr; +std::function NotificationRenderer::textInputCallback = nullptr; uint32_t pow_of_10(uint32_t n) { @@ -89,14 +104,33 @@ void NotificationRenderer::resetBanner() void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state) { - if (!isOverlayBannerShowing() && alertBannerMessage[0] != '\0') - resetBanner(); - if (!isOverlayBannerShowing() || pauseBanner) + // Handle text_input notifications first - they have their own timeout/banner logic + if (current_notification_type == notificationTypeEnum::text_input) { + // Check for timeout and reset if needed for text input + if (millis() > alertBannerUntil && alertBannerUntil > 0) { + resetBanner(); + return; + } + drawTextInput(display, state); return; + } + + if (millis() > alertBannerUntil && alertBannerUntil > 0) { + resetBanner(); + } + + // Exit if no banner is showing or banner is paused + if (!isOverlayBannerShowing() || pauseBanner) { + return; + } + switch (current_notification_type) { case notificationTypeEnum::none: // Do nothing - no notification to display break; + case notificationTypeEnum::text_input: + // Already handled above with dedicated logic (early return). Keep a case here to satisfy -Wswitch. + break; case notificationTypeEnum::text_banner: case notificationTypeEnum::selection_picker: drawAlertBannerOverlay(display, state); @@ -267,12 +301,9 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta if (nodeDB->getMeshNodeByIndex(i + 1)->has_user) { std::string sanitized = sanitizeString(nodeDB->getMeshNodeByIndex(i + 1)->user.long_name); strncpy(temp_name, sanitized.c_str(), sizeof(temp_name) - 1); - } else { snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF)); } - // make temp buffer for name - // fi if (i == curSelected) { selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num; if (isHighResolution) { @@ -286,7 +317,8 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta } scratchLineBuffer[scratchLineNum][39] = '\0'; } else { - strncpy(scratchLineBuffer[scratchLineNum], temp_name, 36); + strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39); + scratchLineBuffer[scratchLineNum][39] = '\0'; } linePointers[linesShown] = scratchLineBuffer[scratchLineNum++]; } @@ -575,6 +607,99 @@ void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUi "Please be patient and do not power off."); } +void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + if (virtualKeyboard) { + // Check for timeout and auto-exit if needed + if (virtualKeyboard->isTimedOut()) { + LOG_INFO("Virtual keyboard timeout - auto-exiting"); + // Cancel virtual keyboard - call callback with empty string to indicate timeout + auto callback = textInputCallback; // Store callback before clearing + + // Clean up first to prevent re-entry + delete virtualKeyboard; + virtualKeyboard = nullptr; + textInputCallback = nullptr; + resetBanner(); + + // Call callback after cleanup + if (callback) { + callback(""); + } + + // Restore normal overlays + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + return; + } + + if (inEvent.inputEvent != INPUT_BROKER_NONE) { + if (inEvent.inputEvent == INPUT_BROKER_UP) { + // high frequency for move cursor left/right than up/down with encoders + extern ::RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; + extern ::UpDownInterruptImpl1 *upDownInterruptImpl1; + if (::rotaryEncoderInterruptImpl1 || ::upDownInterruptImpl1) { + virtualKeyboard->moveCursorLeft(); + } else { + virtualKeyboard->moveCursorUp(); + } + } else if (inEvent.inputEvent == INPUT_BROKER_DOWN) { + extern ::RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; + extern ::UpDownInterruptImpl1 *upDownInterruptImpl1; + if (::rotaryEncoderInterruptImpl1 || ::upDownInterruptImpl1) { + virtualKeyboard->moveCursorRight(); + } else { + virtualKeyboard->moveCursorDown(); + } + } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { + virtualKeyboard->moveCursorLeft(); + } else if (inEvent.inputEvent == INPUT_BROKER_RIGHT) { + virtualKeyboard->moveCursorRight(); + } else if (inEvent.inputEvent == INPUT_BROKER_UP_LONG) { + virtualKeyboard->moveCursorUp(); + } else if (inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { + virtualKeyboard->moveCursorDown(); + } else if (inEvent.inputEvent == INPUT_BROKER_ALT_PRESS) { + virtualKeyboard->moveCursorLeft(); + } else if (inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { + virtualKeyboard->moveCursorRight(); + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { + virtualKeyboard->handlePress(); + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT_LONG) { + virtualKeyboard->handleLongPress(); + } else if (inEvent.inputEvent == INPUT_BROKER_CANCEL) { + auto callback = textInputCallback; + delete virtualKeyboard; + virtualKeyboard = nullptr; + textInputCallback = nullptr; + resetBanner(); + if (callback) { + callback(""); + } + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + return; + } + + // Consume the event after processing for virtual keyboard + inEvent.inputEvent = INPUT_BROKER_NONE; + } + + // Clear the screen to avoid overlapping with underlying frames or overlays + display->setColor(BLACK); + display->fillRect(0, 0, display->getWidth(), display->getHeight()); + display->setColor(WHITE); + // Draw the virtual keyboard + virtualKeyboard->draw(display, 0, 0); + } else { + // If virtualKeyboard is null, reset the banner to avoid getting stuck + LOG_INFO("Virtual keyboard is null - resetting banner"); + resetBanner(); + } +} + bool NotificationRenderer::isOverlayBannerShowing() { return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 9c30b329c..edb069513 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -3,6 +3,9 @@ #include "OLEDDisplay.h" #include "OLEDDisplayUi.h" #include "graphics/Screen.h" +#include "graphics/VirtualKeyboard.h" +#include +#include #define MAX_LINES 5 namespace graphics @@ -22,6 +25,8 @@ class NotificationRenderer static std::function alertBannerCallback; static uint32_t numDigits; static uint32_t currentNumber; + static VirtualKeyboard *virtualKeyboard; + static std::function textInputCallback; static bool pauseBanner; @@ -30,6 +35,7 @@ class NotificationRenderer static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1], uint16_t totalLines, uint8_t firstOptionToShow, uint16_t maxWidth = 0); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 71d92616f..e9da66712 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -194,7 +194,7 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes } #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(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS)) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) if (isHighResolution) { @@ -879,7 +879,26 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU config.display.heading_bold = false; const char *displayLine = ""; // Initialize to empty string by default - if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + + bool usePhoneGPS = (ourNode && nodeDB->hasValidPosition(ourNode) && + config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED); + + if (usePhoneGPS) { + // Phone-provided GPS is active + displayLine = "Phone GPS"; + int yOffset = (isHighResolution) ? 3 : 1; + if (isHighResolution) { + 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 = (isHighResolution) ? 6 : 0; + display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine); + } else if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + // GPS disabled / not present if (config.position.fixed_position) { displayLine = "Fixed GPS"; } else { @@ -896,6 +915,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU int xOffset = (isHighResolution) ? 6 : 0; display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine); } else { + // Onboard GPS UIRenderer::drawGps(display, 0, getTextPositions(display)[line++], gpsStatus); } @@ -922,32 +942,61 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU // If GPS is off, no need to display these parts if (strcmp(displayLine, "GPS off") != 0 && strcmp(displayLine, "No GPS") != 0) { + // === Second Row: Last GPS Fix === + if (gpsStatus->getLastFixMillis() > 0) { + uint32_t delta = (millis() - gpsStatus->getLastFixMillis()) / 1000; // seconds since last fix + uint32_t days = delta / 86400; + uint32_t hours = (delta % 86400) / 3600; + uint32_t mins = (delta % 3600) / 60; + uint32_t secs = delta % 60; - // === 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); + char buf[32]; +#if defined(USE_EINK) + // E-Ink: skip seconds, show only days/hours/mins + if (days > 0) { + snprintf(buf, sizeof(buf), " Last: %ud %uh", days, hours); + } else if (hours > 0) { + snprintf(buf, sizeof(buf), " Last: %uh %um", hours, mins); + } else { + snprintf(buf, sizeof(buf), " Last: %um", mins); + } +#else + // Non E-Ink: include seconds where useful + if (days > 0) { + snprintf(buf, sizeof(buf), "Last: %ud %uh", days, hours); + } else if (hours > 0) { + snprintf(buf, sizeof(buf), "Last: %uh %um", hours, mins); + } else if (mins > 0) { + snprintf(buf, sizeof(buf), "Last: %um %us", mins, secs); + } else { + snprintf(buf, sizeof(buf), "Last: %us", secs); + } +#endif + + display->drawString(0, getTextPositions(display)[line++], buf); + } else { + display->drawString(0, getTextPositions(display)[line++], "Last: ?"); + } // === Third Row: Latitude === char latStr[32]; - snprintf(latStr, sizeof(latStr), " Lat: %.5f", geoCoord.getLatitude() * 1e-7); + 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); + snprintf(lonStr, sizeof(lonStr), "Lon: %.5f", geoCoord.getLongitude() * 1e-7); display->drawString(x, getTextPositions(display)[line++], lonStr); // === Fifth Row: Altitude === char DisplayLineTwo[32] = {0}; + int32_t alt = (strcmp(displayLine, "Phone GPS") == 0 && ourNode && nodeDB->hasValidPosition(ourNode)) + ? ourNode->position.altitude + : geoCoord.getAltitude(); if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET); + snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), "Alt: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET); } else { - snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0im", geoCoord.getAltitude()); + snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), "Alt: %.0im", geoCoord.getAltitude()); } display->drawString(x, getTextPositions(display)[line++], DisplayLineTwo); } diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index 3c8e1dd9d..eada150f9 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -1,5 +1,6 @@ #pragma once +#include "NodeDB.h" #include "graphics/Screen.h" #include "graphics/emotes.h" #include diff --git a/src/graphics/images.h b/src/graphics/images.h index beef3a1b2..168a9b716 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -27,7 +27,8 @@ const uint8_t bluetoothConnectedIcon[36] PROGMEM = {0xfe, 0x01, 0xff, 0x03, 0x03 0xfe, 0x31, 0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0xf0, 0x3f, 0xe0, 0x1f}; #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) && \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || \ + ARCH_PORTDUINO) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) const uint8_t imgQuestionL1[] PROGMEM = {0xff, 0x01, 0x01, 0x32, 0x7b, 0x49, 0x49, 0x6f, 0x26, 0x01, 0x01, 0xff}; const uint8_t imgQuestionL2[] PROGMEM = {0x0f, 0x08, 0x08, 0x08, 0x06, 0x0f, 0x0f, 0x06, 0x08, 0x08, 0x08, 0x0f}; @@ -117,8 +118,8 @@ const uint8_t icon_radio[] PROGMEM = { 0xA9 // Row 7: #..#.#.# }; -// 🪙 Memory Icon -const uint8_t icon_memory[] PROGMEM = { +// 🪙 System Icon +const uint8_t icon_system[] PROGMEM = { 0x24, // Row 0: ..#..#.. 0x3C, // Row 1: ..####.. 0xC3, // Row 2: ##....## @@ -287,5 +288,77 @@ const uint8_t digital_icon_clock[] PROGMEM = {0b00111100, 0b01000010, 0b10000101 const uint8_t analog_icon_clock[] PROGMEM = {0b11111111, 0b01000010, 0b00100100, 0b00011000, 0b00100100, 0b01000010, 0b01000010, 0b11111111}; +#define chirpy_width 38 +#define chirpy_height 50 +static unsigned char chirpy[] = { + 0xfe, 0xff, 0xff, 0xff, 0xdf, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, 0x00, 0x80, 0xe3, 0x01, + 0x00, 0x00, 0xc0, 0xe7, 0x01, 0x00, 0x00, 0xc0, 0xe7, 0x01, 0x00, 0x00, 0xc0, 0xe7, 0x01, 0x00, 0x00, 0x80, 0xe3, 0x01, 0x00, + 0x00, 0x00, 0xe0, 0x81, 0xff, 0xff, 0x7f, 0xe0, 0xc1, 0xff, 0xff, 0xff, 0xe0, 0xc1, 0xff, 0xff, 0xff, 0xe0, 0xc1, 0xcf, 0x7f, + 0xfe, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, + 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, + 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0x87, 0x3f, 0xfc, 0xe0, 0xc1, + 0x87, 0x3f, 0xfc, 0xe0, 0xc1, 0xcf, 0x7f, 0xfe, 0xe0, 0xc1, 0xff, 0xff, 0xff, 0xe0, 0xc1, 0xff, 0xff, 0xff, 0xe0, 0x81, 0xff, + 0xff, 0x7f, 0xe0, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, 0xc3, 0x00, 0xe0, 0x01, 0x00, 0xc3, + 0x00, 0xe0, 0x01, 0x80, 0xe1, 0x01, 0xe0, 0x01, 0x80, 0xe1, 0x01, 0xe0, 0x01, 0xc0, 0x30, 0x03, 0xe0, 0x01, 0xc0, 0x30, 0x03, + 0xe0, 0x01, 0x60, 0x18, 0x06, 0xe0, 0x01, 0x60, 0x18, 0x06, 0xe0, 0x01, 0x30, 0x0c, 0x0c, 0xe0, 0x01, 0x30, 0x0c, 0x0c, 0xe0, + 0x01, 0x18, 0x06, 0x18, 0xe0, 0x01, 0x18, 0x06, 0x18, 0xe0, 0x01, 0x0c, 0x03, 0x30, 0xe0, 0x01, 0x0c, 0x03, 0x30, 0xe0, 0x01, + 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, 0x00, 0x00, 0xe0, 0xfe, 0xff, 0xff, 0xff, 0xdf}; + +#define chirpy_width_hirez 76 +#define chirpy_height_hirez 100 +static unsigned char chirpy_hirez[] = { + 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xc0, 0x0f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x0f, 0xfc, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xf0, 0x3f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xf0, 0x3f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xf0, 0x3f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xc0, 0x0f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x0f, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0xc0, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, + 0xfc, 0x03, 0xc0, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, + 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, 0x03, + 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xf0, 0xff, 0x3f, 0xfc, 0xff, 0x00, 0xfc, 0x03, 0xf0, + 0xff, 0xf0, 0xff, 0x3f, 0xfc, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, + 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, + 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, + 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, + 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, + 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, + 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, + 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, + 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, + 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, + 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, + 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, + 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0x3f, 0xc0, 0xff, 0x0f, 0xf0, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xf0, 0xff, + 0x3f, 0xfc, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xf0, 0xff, 0x3f, 0xfc, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x00, 0xfc, 0x03, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, 0x03, 0xc0, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, + 0x00, 0xfc, 0x03, 0xc0, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0x00, 0x00, 0xfc, 0x03, + 0x00, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, + 0x00, 0x00, 0x0f, 0xf0, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xc0, 0x03, 0xfc, 0x03, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, + 0xc0, 0x03, 0xfc, 0x03, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xc0, 0x03, 0xfc, 0x03, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xc0, + 0x03, 0xfc, 0x03, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xf0, 0x00, 0x0f, 0x0f, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xf0, 0x00, + 0x0f, 0x0f, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xf0, 0x00, 0x0f, 0x0f, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0xf0, 0x00, 0x0f, + 0x0f, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x3c, 0xc0, 0x03, 0x3c, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x3c, 0xc0, 0x03, 0x3c, + 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x3c, 0xc0, 0x03, 0x3c, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x3c, 0xc0, 0x03, 0x3c, 0x00, + 0x00, 0xfc, 0x03, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0xf0, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0xf0, 0x00, 0x00, + 0xfc, 0x03, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0xf0, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x0f, 0xf0, 0x00, 0xf0, 0x00, 0x00, 0xfc, + 0x03, 0x00, 0xc0, 0x03, 0x3c, 0x00, 0xc0, 0x03, 0x00, 0xfc, 0x03, 0x00, 0xc0, 0x03, 0x3c, 0x00, 0xc0, 0x03, 0x00, 0xfc, 0x03, + 0x00, 0xc0, 0x03, 0x3c, 0x00, 0xc0, 0x03, 0x00, 0xfc, 0x03, 0x00, 0xc0, 0x03, 0x3c, 0x00, 0xc0, 0x03, 0x00, 0xfc, 0x03, 0x00, + 0xf0, 0x00, 0x0f, 0x00, 0x00, 0x0f, 0x00, 0xfc, 0x03, 0x00, 0xf0, 0x00, 0x0f, 0x00, 0x00, 0x0f, 0x00, 0xfc, 0x03, 0x00, 0xf0, + 0x00, 0x0f, 0x00, 0x00, 0x0f, 0x00, 0xfc, 0x03, 0x00, 0xf0, 0x00, 0x0f, 0x00, 0x00, 0x0f, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf3, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3}; + +#define chirpy_small_image_width 8 +#define chirpy_small_image_height 8 +static unsigned char small_chirpy[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f}; + #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/ZJY122250_0213BAAMFGN.cpp b/src/graphics/niche/Drivers/EInk/ZJY122250_0213BAAMFGN.cpp new file mode 100644 index 000000000..e83588905 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/ZJY122250_0213BAAMFGN.cpp @@ -0,0 +1,68 @@ +#include "./ZJY122250_0213BAAMFGN.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +using namespace NicheGraphics::Drivers; + +// Map the display controller IC's output to the connected panel +void ZJY122250_0213BAAMFGN::configScanning() +{ + // "Driver output control" + // Scan gates from 0 to 249 (vertical resolution 250px) + 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 ZJY122250_0213BAAMFGN::configWaveform() +{ + switch (updateType) { + case FAST: + sendCommand(0x3C); // Border waveform: + sendData(0x80); // VCOM + break; + case FULL: + default: + sendCommand(0x3C); // Border waveform: + sendData(0x01); // Follow LUT 1 (blink same as white pixels) + break; + } + + sendCommand(0x18); // Temperature sensor: + sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform +} + +void ZJY122250_0213BAAMFGN::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 ZJY122250_0213BAAMFGN::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/ZJY122250_0213BAAMFGN.h b/src/graphics/niche/Drivers/EInk/ZJY122250_0213BAAMFGN.h new file mode 100644 index 000000000..82c4ec107 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/ZJY122250_0213BAAMFGN.h @@ -0,0 +1,42 @@ +/* + +E-Ink display driver + - ZJY122250_0213BAAMFGN + - Manufacturer: Zhongjingyuan + - Size: 2.13 inch + - Resolution: 250px x 122px + - Flex connector marking (not a unique identifier): FPC-A002 + +*/ + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./SSD16XX.h" + +namespace NicheGraphics::Drivers +{ +class ZJY122250_0213BAAMFGN : 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: + ZJY122250_0213BAAMFGN() : 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/InkHUD/DisplayHealth.cpp b/src/graphics/niche/InkHUD/DisplayHealth.cpp index 7e1accafd..e8849b72e 100644 --- a/src/graphics/niche/InkHUD/DisplayHealth.cpp +++ b/src/graphics/niche/InkHUD/DisplayHealth.cpp @@ -7,12 +7,7 @@ using namespace NicheGraphics; // Timing for "maintenance" // Paying off full-refresh debt with unprovoked updates, if the display is not very active - -#ifdef SEEED_WIO_TRACKER_L1 -static constexpr uint32_t MAINTENANCE_MS_INITIAL = 5 * 1000UL; -#else static constexpr uint32_t MAINTENANCE_MS_INITIAL = 60 * 1000UL; -#endif static constexpr uint32_t MAINTENANCE_MS = 60 * 60 * 1000UL; InkHUD::DisplayHealth::DisplayHealth() : concurrency::OSThread("Mediator") diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index b2e92bdae..5654fa02a 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -41,78 +41,78 @@ void tftSetup(void) PacketAPI::create(PacketServer::init()); deviceScreen->init(new PacketClient); #else - if (settingsMap[displayPanel] != no_screen) { + if (portduino_config.displayPanel != no_screen) { DisplayDriverConfig displayConfig; static char *panels[] = {"NOSCREEN", "X11", "FB", "ST7789", "ST7735", "ST7735S", "ST7796", "ILI9341", "ILI9342", "ILI9486", "ILI9488", "HX8357D"}; static char *touch[] = {"NOTOUCH", "XPT2046", "STMPE610", "GT911", "FT5x06"}; #if defined(USE_X11) - if (settingsMap[displayPanel] == x11) { - if (settingsMap[displayWidth] && settingsMap[displayHeight]) - displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::X11, (uint16_t)settingsMap[displayWidth], - (uint16_t)settingsMap[displayHeight]); + if (portduino_config.displayPanel == x11) { + if (portduino_config.displayWidth && portduino_config.displayHeight) + displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::X11, (uint16_t)portduino_config.displayWidth, + (uint16_t)portduino_config.displayHeight); else displayConfig.device(DisplayDriverConfig::device_t::X11); } else #elif defined(USE_FRAMEBUFFER) - if (settingsMap[displayPanel] == fb) { - if (settingsMap[displayWidth] && settingsMap[displayHeight]) - displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::FB, (uint16_t)settingsMap[displayWidth], - (uint16_t)settingsMap[displayHeight]); + if (portduino_config.displayPanel == fb) { + if (portduino_config.displayWidth && portduino_config.displayHeight) + displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::FB, (uint16_t)portduino_config.displayWidth, + (uint16_t)portduino_config.displayHeight); else displayConfig.device(DisplayDriverConfig::device_t::FB); } else #endif { displayConfig.device(DisplayDriverConfig::device_t::CUSTOM_TFT) - .panel(DisplayDriverConfig::panel_config_t{.type = panels[settingsMap[displayPanel]], - .panel_width = (uint16_t)settingsMap[displayWidth], - .panel_height = (uint16_t)settingsMap[displayHeight], - .rotation = (bool)settingsMap[displayRotate], - .pin_cs = (int16_t)settingsMap[displayCS], - .pin_rst = (int16_t)settingsMap[displayReset], - .offset_x = (uint16_t)settingsMap[displayOffsetX], - .offset_y = (uint16_t)settingsMap[displayOffsetY], - .offset_rotation = (uint8_t)settingsMap[displayOffsetRotate], - .invert = settingsMap[displayInvert] ? true : false, - .rgb_order = (bool)settingsMap[displayRGBOrder], - .dlen_16bit = settingsMap[displayPanel] == ili9486 || - settingsMap[displayPanel] == ili9488}) - .bus(DisplayDriverConfig::bus_config_t{.freq_write = (uint32_t)settingsMap[displayBusFrequency], + .panel(DisplayDriverConfig::panel_config_t{.type = panels[portduino_config.displayPanel], + .panel_width = (uint16_t)portduino_config.displayWidth, + .panel_height = (uint16_t)portduino_config.displayHeight, + .rotation = (bool)portduino_config.displayRotate, + .pin_cs = (int16_t)portduino_config.displayCS.pin, + .pin_rst = (int16_t)portduino_config.displayReset.pin, + .offset_x = (uint16_t)portduino_config.displayOffsetX, + .offset_y = (uint16_t)portduino_config.displayOffsetY, + .offset_rotation = (uint8_t)portduino_config.displayOffsetRotate, + .invert = portduino_config.displayInvert ? true : false, + .rgb_order = (bool)portduino_config.displayRGBOrder, + .dlen_16bit = portduino_config.displayPanel == ili9486 || + portduino_config.displayPanel == ili9488}) + .bus(DisplayDriverConfig::bus_config_t{.freq_write = (uint32_t)portduino_config.displayBusFrequency, .freq_read = 16000000, - .spi{.pin_dc = (int8_t)settingsMap[displayDC], + .spi{.pin_dc = (int8_t)portduino_config.displayDC.pin, .use_lock = true, - .spi_host = (uint16_t)settingsMap[displayspidev]}}) - .input(DisplayDriverConfig::input_config_t{.keyboardDevice = settingsStrings[keyboardDevice], - .pointerDevice = settingsStrings[pointerDevice]}) - .light(DisplayDriverConfig::light_config_t{.pin_bl = (int16_t)settingsMap[displayBacklight], - .pwm_channel = (int8_t)settingsMap[displayBacklightPWMChannel], - .invert = (bool)settingsMap[displayBacklightInvert]}); - if (settingsMap[touchscreenI2CAddr] == -1) { + .spi_host = (uint16_t)portduino_config.display_spi_dev_int}}) + .input(DisplayDriverConfig::input_config_t{.keyboardDevice = portduino_config.keyboardDevice, + .pointerDevice = portduino_config.pointerDevice}) + .light(DisplayDriverConfig::light_config_t{.pin_bl = (int16_t)portduino_config.displayBacklight.pin, + .pwm_channel = (int8_t)portduino_config.displayBacklightPWMChannel.pin, + .invert = (bool)portduino_config.displayBacklightInvert}); + if (portduino_config.touchscreenI2CAddr == -1) { displayConfig.touch( - DisplayDriverConfig::touch_config_t{.type = touch[settingsMap[touchscreenModule]], - .freq = (uint32_t)settingsMap[touchscreenBusFrequency], - .pin_int = (int16_t)settingsMap[touchscreenIRQ], - .offset_rotation = (uint8_t)settingsMap[touchscreenRotate], + DisplayDriverConfig::touch_config_t{.type = touch[portduino_config.touchscreenModule], + .freq = (uint32_t)portduino_config.touchscreenBusFrequency, + .pin_int = (int16_t)portduino_config.touchscreenIRQ.pin, + .offset_rotation = (uint8_t)portduino_config.touchscreenRotate, .spi{ - .spi_host = (int8_t)settingsMap[touchscreenspidev], + .spi_host = (int8_t)portduino_config.touchscreen_spi_dev_int, }, - .pin_cs = (int16_t)settingsMap[touchscreenCS]}); + .pin_cs = (int16_t)portduino_config.touchscreenCS.pin}); } else { displayConfig.touch(DisplayDriverConfig::touch_config_t{ - .type = touch[settingsMap[touchscreenModule]], - .freq = (uint32_t)settingsMap[touchscreenBusFrequency], + .type = touch[portduino_config.touchscreenModule], + .freq = (uint32_t)portduino_config.touchscreenBusFrequency, .x_min = 0, - .x_max = - (int16_t)((settingsMap[touchscreenRotate] & 1 ? settingsMap[displayWidth] : settingsMap[displayHeight]) - - 1), + .x_max = (int16_t)((portduino_config.touchscreenRotate & 1 ? portduino_config.displayWidth + : portduino_config.displayHeight) - + 1), .y_min = 0, - .y_max = - (int16_t)((settingsMap[touchscreenRotate] & 1 ? settingsMap[displayHeight] : settingsMap[displayWidth]) - - 1), - .pin_int = (int16_t)settingsMap[touchscreenIRQ], - .offset_rotation = (uint8_t)settingsMap[touchscreenRotate], - .i2c{.i2c_addr = (uint8_t)settingsMap[touchscreenI2CAddr]}}); + .y_max = (int16_t)((portduino_config.touchscreenRotate & 1 ? portduino_config.displayHeight + : portduino_config.displayWidth) - + 1), + .pin_int = (int16_t)portduino_config.touchscreenIRQ.pin, + .offset_rotation = (uint8_t)portduino_config.touchscreenRotate, + .i2c{.i2c_addr = (uint8_t)portduino_config.touchscreenI2CAddr}}); } } deviceScreen = &DeviceScreen::create(&displayConfig); diff --git a/src/input/ButtonThread.h b/src/input/ButtonThread.h index c6d6557e2..7de38341c 100644 --- a/src/input/ButtonThread.h +++ b/src/input/ButtonThread.h @@ -76,6 +76,9 @@ class ButtonThread : public Observable, public concurrency:: return digitalRead(buttonPin); // Most buttons are active low by default } + // Returns true while this thread's button is physically held down + bool isHeld() { return isButtonPressed(_pinNum); } + // Disconnect and reconnect interrupts for light sleep #ifdef ARCH_ESP32 int beforeLightSleep(void *unused); diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 4487fa662..2cdfa2ae2 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -4,6 +4,9 @@ enum input_broker_event { INPUT_BROKER_NONE = 0, INPUT_BROKER_SELECT = 10, + INPUT_BROKER_SELECT_LONG = 11, + INPUT_BROKER_UP_LONG = 12, + INPUT_BROKER_DOWN_LONG = 13, INPUT_BROKER_UP = 17, INPUT_BROKER_DOWN = 18, INPUT_BROKER_LEFT = 19, diff --git a/src/input/LinuxInput.cpp b/src/input/LinuxInput.cpp index 90f06ecc9..1f80fd5d3 100644 --- a/src/input/LinuxInput.cpp +++ b/src/input/LinuxInput.cpp @@ -33,9 +33,9 @@ int32_t LinuxInput::runOnce() { if (firstTime) { - if (settingsStrings[keyboardDevice] == "") + if (portduino_config.keyboardDevice == "") return disable(); - fd = open(settingsStrings[keyboardDevice].c_str(), O_RDWR); + fd = open(portduino_config.keyboardDevice.c_str(), O_RDWR); if (fd < 0) return disable(); ret = ioctl(fd, EVIOCGRAB, (void *)1); diff --git a/src/input/RotaryEncoderImpl.cpp b/src/input/RotaryEncoderImpl.cpp new file mode 100644 index 000000000..7d638dd71 --- /dev/null +++ b/src/input/RotaryEncoderImpl.cpp @@ -0,0 +1,73 @@ +#ifdef T_LORA_PAGER + +#include "RotaryEncoderImpl.h" +#include "InputBroker.h" +#include "RotaryEncoder.h" + +#define ORIGIN_NAME "RotaryEncoder" + +RotaryEncoderImpl *rotaryEncoderImpl; + +RotaryEncoderImpl::RotaryEncoderImpl() : concurrency::OSThread(ORIGIN_NAME), originName(ORIGIN_NAME) +{ + rotary = nullptr; +} + +bool RotaryEncoderImpl::init() +{ + if (!moduleConfig.canned_message.updown1_enabled || moduleConfig.canned_message.inputbroker_pin_a == 0 || + moduleConfig.canned_message.inputbroker_pin_b == 0) { + // Input device is disabled. + disable(); + return false; + } + + eventCw = static_cast(moduleConfig.canned_message.inputbroker_event_cw); + eventCcw = static_cast(moduleConfig.canned_message.inputbroker_event_ccw); + eventPressed = static_cast(moduleConfig.canned_message.inputbroker_event_press); + + rotary = new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a, moduleConfig.canned_message.inputbroker_pin_b, + moduleConfig.canned_message.inputbroker_pin_press); + rotary->resetButton(); + + inputBroker->registerSource(this); + + LOG_INFO("RotaryEncoder initialized pins(%d, %d, %d), events(%d, %d, %d)", moduleConfig.canned_message.inputbroker_pin_a, + moduleConfig.canned_message.inputbroker_pin_b, moduleConfig.canned_message.inputbroker_pin_press, eventCw, eventCcw, + eventPressed); + return true; +} + +int32_t RotaryEncoderImpl::runOnce() +{ + InputEvent e{originName, INPUT_BROKER_NONE, 0, 0, 0}; + static uint32_t lastPressed = millis(); + if (rotary->readButton() == RotaryEncoder::ButtonState::BUTTON_PRESSED) { + if (lastPressed + 200 < millis()) { + LOG_DEBUG("Rotary event Press"); + lastPressed = millis(); + e.inputEvent = this->eventPressed; + } + } else { + switch (rotary->process()) { + case RotaryEncoder::DIRECTION_CW: + LOG_DEBUG("Rotary event CW"); + e.inputEvent = this->eventCw; + break; + case RotaryEncoder::DIRECTION_CCW: + LOG_DEBUG("Rotary event CCW"); + e.inputEvent = this->eventCcw; + break; + default: + break; + } + } + + if (e.inputEvent != INPUT_BROKER_NONE) { + this->notifyObservers(&e); + } + + return 10; +} + +#endif \ No newline at end of file diff --git a/src/input/RotaryEncoderImpl.h b/src/input/RotaryEncoderImpl.h new file mode 100644 index 000000000..ae2a7c6fd --- /dev/null +++ b/src/input/RotaryEncoderImpl.h @@ -0,0 +1,28 @@ +#pragma once + +// This is a non-interrupt version of RotaryEncoder which is based on a debounce inherent FSM table (see RotaryEncoder library) + +#include "InputBroker.h" +#include "concurrency/OSThread.h" +#include "mesh/NodeDB.h" + +class RotaryEncoder; + +class RotaryEncoderImpl : public Observable, public concurrency::OSThread +{ + public: + RotaryEncoderImpl(); + bool init(void); + + protected: + virtual int32_t runOnce() override; + + input_broker_event eventCw = INPUT_BROKER_NONE; + input_broker_event eventCcw = INPUT_BROKER_NONE; + input_broker_event eventPressed = INPUT_BROKER_NONE; + + RotaryEncoder *rotary; + const char *originName; +}; + +extern RotaryEncoderImpl *rotaryEncoderImpl; diff --git a/src/input/RotaryEncoderInterruptBase.cpp b/src/input/RotaryEncoderInterruptBase.cpp index 0557bc180..204a0fbf0 100644 --- a/src/input/RotaryEncoderInterruptBase.cpp +++ b/src/input/RotaryEncoderInterruptBase.cpp @@ -8,24 +8,35 @@ RotaryEncoderInterruptBase::RotaryEncoderInterruptBase(const char *name) : concu void RotaryEncoderInterruptBase::init( uint8_t pinA, uint8_t pinB, uint8_t pinPress, input_broker_event eventCw, input_broker_event eventCcw, - input_broker_event eventPressed, + input_broker_event eventPressed, input_broker_event eventPressedLong, // std::function onIntA, std::function onIntB, std::function onIntPress) : void (*onIntA)(), void (*onIntB)(), void (*onIntPress)()) { this->_pinA = pinA; this->_pinB = pinB; + this->_pinPress = pinPress; this->_eventCw = eventCw; this->_eventCcw = eventCcw; this->_eventPressed = eventPressed; + this->_eventPressedLong = eventPressedLong; - pinMode(pinPress, INPUT_PULLUP); - pinMode(this->_pinA, INPUT_PULLUP); - pinMode(this->_pinB, INPUT_PULLUP); + bool isRAK = false; +#ifdef RAK_4631 + isRAK = true; +#endif - // attachInterrupt(pinPress, onIntPress, RISING); - attachInterrupt(pinPress, onIntPress, RISING); - attachInterrupt(this->_pinA, onIntA, CHANGE); - attachInterrupt(this->_pinB, onIntB, CHANGE); + if (!isRAK || pinPress != 0) { + pinMode(pinPress, INPUT_PULLUP); + attachInterrupt(pinPress, onIntPress, RISING); + } + if (!isRAK || this->_pinA != 0) { + pinMode(this->_pinA, INPUT_PULLUP); + attachInterrupt(this->_pinA, onIntA, CHANGE); + } + if (!isRAK || this->_pinA != 0) { + pinMode(this->_pinB, INPUT_PULLUP); + attachInterrupt(this->_pinB, onIntB, CHANGE); + } this->rotaryLevelA = digitalRead(this->_pinA); this->rotaryLevelB = digitalRead(this->_pinB); @@ -37,10 +48,37 @@ int32_t RotaryEncoderInterruptBase::runOnce() InputEvent e; e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; + unsigned long now = millis(); + // Handle press long/short detection if (this->action == ROTARY_ACTION_PRESSED) { - LOG_DEBUG("Rotary event Press"); - e.inputEvent = this->_eventPressed; + bool buttonPressed = !digitalRead(_pinPress); + if (!pressDetected && buttonPressed) { + pressDetected = true; + pressStartTime = now; + } + + if (pressDetected) { + uint32_t duration = now - pressStartTime; + if (!buttonPressed) { + // released -> if short press, send short, else already sent long + if (duration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) { + lastPressKeyTime = now; + LOG_DEBUG("Rotary event Press short"); + e.inputEvent = this->_eventPressed; + } + pressDetected = false; + pressStartTime = 0; + lastPressLongEventTime = 0; + this->action = ROTARY_ACTION_NONE; + } else if (duration >= LONG_PRESS_DURATION && this->_eventPressedLong != INPUT_BROKER_NONE && + lastPressLongEventTime == 0) { + // fire single-shot long press + lastPressLongEventTime = now; + LOG_DEBUG("Rotary event Press long"); + e.inputEvent = this->_eventPressedLong; + } + } } else if (this->action == ROTARY_ACTION_CW) { LOG_DEBUG("Rotary event CW"); e.inputEvent = this->_eventCw; @@ -53,7 +91,9 @@ int32_t RotaryEncoderInterruptBase::runOnce() this->notifyObservers(&e); } - this->action = ROTARY_ACTION_NONE; + if (!pressDetected) { + this->action = ROTARY_ACTION_NONE; + } return INT32_MAX; } @@ -61,7 +101,7 @@ int32_t RotaryEncoderInterruptBase::runOnce() void RotaryEncoderInterruptBase::intPressHandler() { this->action = ROTARY_ACTION_PRESSED; - setIntervalFromNow(20); // TODO: this modifies a non-volatile variable! + setIntervalFromNow(20); // start checking for long/short } void RotaryEncoderInterruptBase::intAHandler() diff --git a/src/input/RotaryEncoderInterruptBase.h b/src/input/RotaryEncoderInterruptBase.h index 9bdab4730..4f9757609 100644 --- a/src/input/RotaryEncoderInterruptBase.h +++ b/src/input/RotaryEncoderInterruptBase.h @@ -13,7 +13,7 @@ class RotaryEncoderInterruptBase : public Observable, public public: explicit RotaryEncoderInterruptBase(const char *name); void init(uint8_t pinA, uint8_t pinB, uint8_t pinPress, input_broker_event eventCw, input_broker_event eventCcw, - input_broker_event eventPressed, + input_broker_event eventPressed, input_broker_event eventPressedLong, // std::function onIntA, std::function onIntB, std::function onIntPress); void (*onIntA)(), void (*onIntB)(), void (*onIntPress)()); void intPressHandler(); @@ -33,10 +33,22 @@ class RotaryEncoderInterruptBase : public Observable, public volatile RotaryEncoderInterruptBaseActionType action = ROTARY_ACTION_NONE; private: + // pins and events uint8_t _pinA = 0; uint8_t _pinB = 0; + uint8_t _pinPress = 0; input_broker_event _eventCw = INPUT_BROKER_NONE; input_broker_event _eventCcw = INPUT_BROKER_NONE; input_broker_event _eventPressed = INPUT_BROKER_NONE; + input_broker_event _eventPressedLong = INPUT_BROKER_NONE; const char *_originName; + + // Long press detection variables + uint32_t pressStartTime = 0; + bool pressDetected = false; + uint32_t lastPressLongEventTime = 0; + unsigned long lastPressKeyTime = 0; + static const uint32_t LONG_PRESS_DURATION = 300; // ms + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 0; // 0 = single-shot for rotary select + const unsigned long pressDebounceMs = 200; // ms }; diff --git a/src/input/RotaryEncoderInterruptImpl1.cpp b/src/input/RotaryEncoderInterruptImpl1.cpp index 4f19c8b0b..12cbc36fb 100644 --- a/src/input/RotaryEncoderInterruptImpl1.cpp +++ b/src/input/RotaryEncoderInterruptImpl1.cpp @@ -1,5 +1,6 @@ #include "RotaryEncoderInterruptImpl1.h" #include "InputBroker.h" +extern bool osk_found; RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; @@ -19,12 +20,14 @@ bool RotaryEncoderInterruptImpl1::init() input_broker_event eventCw = static_cast(moduleConfig.canned_message.inputbroker_event_cw); input_broker_event eventCcw = static_cast(moduleConfig.canned_message.inputbroker_event_ccw); input_broker_event eventPressed = static_cast(moduleConfig.canned_message.inputbroker_event_press); + input_broker_event eventPressedLong = INPUT_BROKER_SELECT_LONG; // moduleConfig.canned_message.ext_notification_module_output - RotaryEncoderInterruptBase::init(pinA, pinB, pinPress, eventCw, eventCcw, eventPressed, + RotaryEncoderInterruptBase::init(pinA, pinB, pinPress, eventCw, eventCcw, eventPressed, eventPressedLong, RotaryEncoderInterruptImpl1::handleIntA, RotaryEncoderInterruptImpl1::handleIntB, RotaryEncoderInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); + osk_found = true; return true; } diff --git a/src/input/TLoraPagerKeyboard.cpp b/src/input/TLoraPagerKeyboard.cpp new file mode 100644 index 000000000..b3f62a36c --- /dev/null +++ b/src/input/TLoraPagerKeyboard.cpp @@ -0,0 +1,230 @@ +#if defined(T_LORA_PAGER) + +#include "TLoraPagerKeyboard.h" +#include "main.h" + +#ifndef LEDC_BACKLIGHT_CHANNEL +#define LEDC_BACKLIGHT_CHANNEL 4 +#endif + +#ifndef LEDC_BACKLIGHT_BIT_WIDTH +#define LEDC_BACKLIGHT_BIT_WIDTH 8 +#endif + +#ifndef LEDC_BACKLIGHT_FREQ +#define LEDC_BACKLIGHT_FREQ 1000 // Hz +#endif + +#define _TCA8418_COLS 10 +#define _TCA8418_ROWS 4 +#define _TCA8418_NUM_KEYS 31 + +#define _TCA8418_MULTI_TAP_THRESHOLD 1500 + +using Key = TCA8418KeyboardBase::TCA8418Key; + +constexpr uint8_t modifierRightShiftKey = 29 - 1; // keynum -1 +constexpr uint8_t modifierRightShift = 0b0001; +constexpr uint8_t modifierSymKey = 21 - 1; +constexpr uint8_t modifierSym = 0b0010; + +// Num chars per key, Modulus for rotating through characters +static uint8_t TLoraPagerTapMod[_TCA8418_NUM_KEYS] = {3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}; + +static unsigned char TLoraPagerTapMap[_TCA8418_NUM_KEYS][3] = {{'q', 'Q', '1'}, + {'w', 'W', '2'}, + {'e', 'E', '3'}, + {'r', 'R', '4'}, + {'t', 'T', '5'}, + {'y', 'Y', '6'}, + {'u', 'U', '7'}, + {'i', 'I', '8'}, + {'o', 'O', '9'}, + {'p', 'P', '0'}, + {'a', 'A', '*'}, + {'s', 'S', '/'}, + {'d', 'D', '+'}, + {'f', 'F', '-'}, + {'g', 'G', '='}, + {'h', 'H', ':'}, + {'j', 'J', '\''}, + {'k', 'K', '"'}, + {'l', 'L', '@'}, + {Key::SELECT, 0x00, Key::TAB}, + {0x00, 0x00, 0x00}, + {'z', 'Z', '_'}, + {'x', 'X', '$'}, + {'c', 'C', ';'}, + {'v', 'V', '?'}, + {'b', 'B', '!'}, + {'n', 'N', ','}, + {'m', 'M', '.'}, + {0x00, 0x00, 0x00}, + {Key::BSP, 0x00, Key::ESC}, + {' ', 0x00, Key::BL_TOGGLE}}; + +TLoraPagerKeyboard::TLoraPagerKeyboard() + : TCA8418KeyboardBase(_TCA8418_ROWS, _TCA8418_COLS), modifierFlag(0), last_modifier_time(0), last_key(-1), next_key(-1), + last_tap(0L), char_idx(0), tap_interval(0) +{ +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) + ledcAttach(KB_BL_PIN, LEDC_BACKLIGHT_FREQ, LEDC_BACKLIGHT_BIT_WIDTH); +#else + ledcSetup(LEDC_BACKLIGHT_CHANNEL, LEDC_BACKLIGHT_FREQ, LEDC_BACKLIGHT_BIT_WIDTH); + ledcAttachPin(KB_BL_PIN, LEDC_BACKLIGHT_CHANNEL); +#endif + reset(); +} + +void TLoraPagerKeyboard::reset(void) +{ + TCA8418KeyboardBase::reset(); + pinMode(KB_BL_PIN, OUTPUT); + digitalWrite(KB_BL_PIN, LOW); + setBacklight(false); +} + +// handle multi-key presses (shift and alt) +void TLoraPagerKeyboard::trigger() +{ + uint8_t count = keyCount(); + if (count == 0) + return; + for (uint8_t i = 0; i < count; ++i) { + uint8_t k = readRegister(TCA8418_REG_KEY_EVENT_A + i); + uint8_t key = k & 0x7F; + if (k & 0x80) { + pressed(key); + } else { + released(); + state = Idle; + } + } +} + +void TLoraPagerKeyboard::setBacklight(bool on) +{ + toggleBacklight(!on); +} + +void TLoraPagerKeyboard::pressed(uint8_t key) +{ + if (state == Init || state == Busy) { + return; + } + if (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED || + config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_SYSTEM_ONLY) { + hapticFeedback(); + } + + if (modifierFlag && (millis() - last_modifier_time > _TCA8418_MULTI_TAP_THRESHOLD)) { + modifierFlag = 0; + } + + uint8_t next_key = 0; + int row = (key - 1) / 10; + int col = (key - 1) % 10; + + if (row >= _TCA8418_ROWS || col >= _TCA8418_COLS) { + return; // Invalid key + } + + next_key = row * _TCA8418_COLS + col; + state = Held; + + uint32_t now = millis(); + tap_interval = now - last_tap; + + updateModifierFlag(next_key); + if (isModifierKey(next_key)) { + last_modifier_time = now; + } + + if (tap_interval < 0) { + last_tap = 0; + state = Busy; + return; + } + + if (next_key != last_key || tap_interval > _TCA8418_MULTI_TAP_THRESHOLD) { + char_idx = 0; + } else { + char_idx += 1; + } + + last_key = next_key; + last_tap = now; +} + +void TLoraPagerKeyboard::released() +{ + if (state != Held) { + return; + } + + if (last_key < 0 || last_key >= _TCA8418_NUM_KEYS) { + last_key = -1; + state = Idle; + return; + } + + uint32_t now = millis(); + last_tap = now; + + if (TLoraPagerTapMap[last_key][modifierFlag % TLoraPagerTapMod[last_key]] == Key::BL_TOGGLE) { + toggleBacklight(); + return; + } + + queueEvent(TLoraPagerTapMap[last_key][modifierFlag % TLoraPagerTapMod[last_key]]); + if (isModifierKey(last_key) == false) + modifierFlag = 0; +} + +void TLoraPagerKeyboard::hapticFeedback() +{ + drv.setWaveform(0, 14); // strong buzz 100% + drv.setWaveform(1, 0); // end waveform + drv.go(); +} + +// toggle brightness of the backlight in three steps +void TLoraPagerKeyboard::toggleBacklight(bool off) +{ + static uint32_t brightness = 0; + if (off) { + brightness = 0; + } else { + if (brightness == 0) { + brightness = 40; + } else if (brightness == 40) { + brightness = 127; + } else if (brightness >= 127) { + brightness = 0; + } + } + LOG_DEBUG("Toggle backlight: %d", brightness); + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) + ledcWrite(KB_BL_PIN, brightness); +#else + ledcWrite(LEDC_BACKLIGHT_CHANNEL, brightness); +#endif +} + +void TLoraPagerKeyboard::updateModifierFlag(uint8_t key) +{ + if (key == modifierRightShiftKey) { + modifierFlag ^= modifierRightShift; + } else if (key == modifierSymKey) { + modifierFlag ^= modifierSym; + } +} + +bool TLoraPagerKeyboard::isModifierKey(uint8_t key) +{ + return (key == modifierRightShiftKey || key == modifierSymKey); +} + +#endif \ No newline at end of file diff --git a/src/input/TLoraPagerKeyboard.h b/src/input/TLoraPagerKeyboard.h index d31b05978..4dabbac64 100644 --- a/src/input/TLoraPagerKeyboard.h +++ b/src/input/TLoraPagerKeyboard.h @@ -4,9 +4,26 @@ class TLoraPagerKeyboard : public TCA8418KeyboardBase { public: TLoraPagerKeyboard(); - void setBacklight(bool on) override{}; + void reset(void); + void trigger(void) override; + void setBacklight(bool on) override; + virtual ~TLoraPagerKeyboard() {} protected: - void pressed(uint8_t key) override{}; - void released(void) override{}; + void pressed(uint8_t key) override; + void released(void) override; + void hapticFeedback(void); + + void updateModifierFlag(uint8_t key); + bool isModifierKey(uint8_t key); + void toggleBacklight(bool off = false); + + private: + uint8_t modifierFlag; // Flag to indicate if a modifier key is pressed + uint32_t last_modifier_time; // Timestamp of the last modifier key press + int8_t last_key; + int8_t next_key; + uint32_t last_tap; + uint8_t char_idx; + int32_t tap_interval; }; diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp index cea47faeb..c0e220941 100644 --- a/src/input/TouchScreenImpl1.cpp +++ b/src/input/TouchScreenImpl1.cpp @@ -18,7 +18,7 @@ TouchScreenImpl1::TouchScreenImpl1(uint16_t width, uint16_t height, bool (*getTo void TouchScreenImpl1::init() { #if ARCH_PORTDUINO - if (settingsMap[touchscreenModule]) { + if (portduino_config.touchscreenModule) { TouchScreenBase::init(true); inputBroker->registerSource(this); } else { diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index d41ad2fd6..4c8ce6409 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -1,12 +1,14 @@ #include "TrackballInterruptBase.h" #include "configuration.h" +extern bool osk_found; TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency::OSThread(name), _originName(name) {} void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, - input_broker_event eventRight, input_broker_event eventPressed, void (*onIntDown)(), - void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) + input_broker_event eventRight, input_broker_event eventPressed, + input_broker_event eventPressedLong, void (*onIntDown)(), void (*onIntUp)(), + void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) { this->_pinDown = pinDown; this->_pinUp = pinUp; @@ -18,6 +20,7 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef this->_eventLeft = eventLeft; this->_eventRight = eventRight; this->_eventPressed = eventPressed; + this->_eventPressedLong = eventPressedLong; if (pinPress != 255) { pinMode(pinPress, INPUT_PULLUP); @@ -40,9 +43,9 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef attachInterrupt(this->_pinRight, onIntRight, TB_DIRECTION); } - LOG_DEBUG("Trackball GPIO initialized (%d, %d, %d, %d, %d)", this->_pinUp, this->_pinDown, this->_pinLeft, this->_pinRight, - pinPress); - + LOG_DEBUG("Trackball GPIO initialized - UP:%d DOWN:%d LEFT:%d RIGHT:%d PRESS:%d", this->_pinUp, this->_pinDown, + this->_pinLeft, this->_pinRight, pinPress); + osk_found = true; this->setInterval(100); } @@ -50,10 +53,47 @@ int32_t TrackballInterruptBase::runOnce() { InputEvent e; e.inputEvent = INPUT_BROKER_NONE; + + // Handle long press detection for press button + if (pressDetected && pressStartTime > 0) { + uint32_t pressDuration = millis() - pressStartTime; + bool buttonStillPressed = false; + +#if defined(T_DECK) + buttonStillPressed = (this->action == TB_ACTION_PRESSED); +#else + buttonStillPressed = !digitalRead(_pinPress); +#endif + + if (!buttonStillPressed) { + // Button released + if (pressDuration < LONG_PRESS_DURATION) { + // Short press + e.inputEvent = this->_eventPressed; + } + // Reset state + pressDetected = false; + pressStartTime = 0; + lastLongPressEventTime = 0; + this->action = TB_ACTION_NONE; + } else if (pressDuration >= LONG_PRESS_DURATION) { + // Long press detected + uint32_t currentTime = millis(); + // Only trigger long press event if enough time has passed since the last one + if (lastLongPressEventTime == 0 || (currentTime - lastLongPressEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { + e.inputEvent = this->_eventPressedLong; + lastLongPressEventTime = currentTime; + } + this->action = TB_ACTION_PRESSED_LONG; + } + } + #if defined(T_DECK) // T-deck gets a super-simple debounce on trackball - if (this->action == TB_ACTION_PRESSED) { - // LOG_DEBUG("Trackball event Press"); - e.inputEvent = this->_eventPressed; + if (this->action == TB_ACTION_PRESSED && !pressDetected) { + // Start long press detection + pressDetected = true; + pressStartTime = millis(); + // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && lastEvent == TB_ACTION_UP) { // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; @@ -68,9 +108,11 @@ int32_t TrackballInterruptBase::runOnce() e.inputEvent = this->_eventRight; } #else - if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress)) { - // LOG_DEBUG("Trackball event Press"); - e.inputEvent = this->_eventPressed; + if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress) && !pressDetected) { + // Start long press detection + pressDetected = true; + pressStartTime = millis(); + // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && !digitalRead(_pinUp)) { // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; @@ -91,10 +133,16 @@ int32_t TrackballInterruptBase::runOnce() e.kbchar = 0x00; this->notifyObservers(&e); } - lastEvent = action; - this->action = TB_ACTION_NONE; - return 100; + // Only update lastEvent for non-press actions or completed press actions + if (this->action != TB_ACTION_PRESSED || !pressDetected) { + lastEvent = action; + if (!pressDetected) { + this->action = TB_ACTION_NONE; + } + } + + return 50; // Check more frequently for better long press detection } void TrackballInterruptBase::intPressHandler() diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 92db8720e..76a99f33d 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -6,7 +6,7 @@ #ifndef TB_DIRECTION #if ARCH_PORTDUINO #include "PortduinoGlue.h" -#define TB_DIRECTION (PinStatus) settingsMap[tbDirection] +#define TB_DIRECTION (PinStatus) portduino_config.lora_usb_vid #else #define TB_DIRECTION RISING #endif @@ -18,8 +18,8 @@ class TrackballInterruptBase : public Observable, public con explicit TrackballInterruptBase(const char *name); void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, input_broker_event eventRight, - input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), - void (*onIntPress)()); + input_broker_event eventPressed, input_broker_event eventPressedLong, void (*onIntDown)(), void (*onIntUp)(), + void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()); void intPressHandler(); void intDownHandler(); void intUpHandler(); @@ -33,6 +33,7 @@ class TrackballInterruptBase : public Observable, public con enum TrackballInterruptBaseActionType { TB_ACTION_NONE, TB_ACTION_PRESSED, + TB_ACTION_PRESSED_LONG, TB_ACTION_UP, TB_ACTION_DOWN, TB_ACTION_LEFT, @@ -46,12 +47,20 @@ class TrackballInterruptBase : public Observable, public con volatile TrackballInterruptBaseActionType action = TB_ACTION_NONE; + // Long press detection for press button + uint32_t pressStartTime = 0; + bool pressDetected = false; + uint32_t lastLongPressEventTime = 0; + static const uint32_t LONG_PRESS_DURATION = 500; // ms + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 500; // ms - interval between repeated long press events + private: input_broker_event _eventDown = INPUT_BROKER_NONE; input_broker_event _eventUp = INPUT_BROKER_NONE; input_broker_event _eventLeft = INPUT_BROKER_NONE; input_broker_event _eventRight = INPUT_BROKER_NONE; input_broker_event _eventPressed = INPUT_BROKER_NONE; + input_broker_event _eventPressedLong = INPUT_BROKER_NONE; const char *_originName; TrackballInterruptBaseActionType lastEvent = TB_ACTION_NONE; }; diff --git a/src/input/TrackballInterruptImpl1.cpp b/src/input/TrackballInterruptImpl1.cpp index 896238f38..594facdeb 100644 --- a/src/input/TrackballInterruptImpl1.cpp +++ b/src/input/TrackballInterruptImpl1.cpp @@ -13,11 +13,12 @@ void TrackballInterruptImpl1::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLe input_broker_event eventLeft = INPUT_BROKER_LEFT; input_broker_event eventRight = INPUT_BROKER_RIGHT; input_broker_event eventPressed = INPUT_BROKER_SELECT; + input_broker_event eventPressedLong = INPUT_BROKER_SELECT_LONG; TrackballInterruptBase::init(pinDown, pinUp, pinLeft, pinRight, pinPress, eventDown, eventUp, eventLeft, eventRight, - eventPressed, TrackballInterruptImpl1::handleIntDown, TrackballInterruptImpl1::handleIntUp, - TrackballInterruptImpl1::handleIntLeft, TrackballInterruptImpl1::handleIntRight, - TrackballInterruptImpl1::handleIntPressed); + eventPressed, eventPressedLong, TrackballInterruptImpl1::handleIntDown, + TrackballInterruptImpl1::handleIntUp, TrackballInterruptImpl1::handleIntLeft, + TrackballInterruptImpl1::handleIntRight, TrackballInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); } diff --git a/src/input/UpDownInterruptBase.cpp b/src/input/UpDownInterruptBase.cpp index c66eb13d0..0bf0f5cd4 100644 --- a/src/input/UpDownInterruptBase.cpp +++ b/src/input/UpDownInterruptBase.cpp @@ -7,26 +7,43 @@ UpDownInterruptBase::UpDownInterruptBase(const char *name) : concurrency::OSThre } void UpDownInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, input_broker_event eventDown, - input_broker_event eventUp, input_broker_event eventPressed, void (*onIntDown)(), + input_broker_event eventUp, input_broker_event eventPressed, input_broker_event eventPressedLong, + input_broker_event eventUpLong, input_broker_event eventDownLong, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)(), unsigned long updownDebounceMs) { this->_pinDown = pinDown; this->_pinUp = pinUp; + this->_pinPress = pinPress; this->_eventDown = eventDown; this->_eventUp = eventUp; this->_eventPressed = eventPressed; + this->_eventPressedLong = eventPressedLong; + this->_eventUpLong = eventUpLong; + this->_eventDownLong = eventDownLong; - pinMode(pinPress, INPUT_PULLUP); - pinMode(this->_pinDown, INPUT_PULLUP); - pinMode(this->_pinUp, INPUT_PULLUP); + // Store debounce configuration passed by caller + this->updownDebounceMs = updownDebounceMs; + bool isRAK = false; +#ifdef RAK_4631 + isRAK = true; +#endif - attachInterrupt(pinPress, onIntPress, RISING); - attachInterrupt(this->_pinDown, onIntDown, RISING); - attachInterrupt(this->_pinUp, onIntUp, RISING); + if (!isRAK || pinPress != 0) { + pinMode(pinPress, INPUT_PULLUP); + attachInterrupt(pinPress, onIntPress, FALLING); + } + if (!isRAK || this->_pinDown != 0) { + pinMode(this->_pinDown, INPUT_PULLUP); + attachInterrupt(this->_pinDown, onIntDown, FALLING); + } + if (!isRAK || this->_pinUp != 0) { + pinMode(this->_pinUp, INPUT_PULLUP); + attachInterrupt(this->_pinUp, onIntUp, FALLING); + } LOG_DEBUG("Up/down/press GPIO initialized (%d, %d, %d)", this->_pinUp, this->_pinDown, pinPress); - this->setInterval(100); + this->setInterval(20); } int32_t UpDownInterruptBase::runOnce() @@ -34,23 +51,88 @@ int32_t UpDownInterruptBase::runOnce() InputEvent e; e.inputEvent = INPUT_BROKER_NONE; unsigned long now = millis(); - if (this->action == UPDOWN_ACTION_PRESSED) { - if (now - lastPressKeyTime >= pressDebounceMs) { - lastPressKeyTime = now; - LOG_DEBUG("GPIO event Press"); - e.inputEvent = this->_eventPressed; + + // Read all button states once at the beginning + bool pressButtonPressed = !digitalRead(_pinPress); + bool upButtonPressed = !digitalRead(_pinUp); + bool downButtonPressed = !digitalRead(_pinDown); + + // Handle initial button press detection - only if not already detected + if (this->action == UPDOWN_ACTION_PRESSED && pressButtonPressed && !pressDetected) { + pressDetected = true; + pressStartTime = now; + } else if (this->action == UPDOWN_ACTION_UP && upButtonPressed && !upDetected) { + upDetected = true; + upStartTime = now; + } else if (this->action == UPDOWN_ACTION_DOWN && downButtonPressed && !downDetected) { + downDetected = true; + downStartTime = now; + } + + // Handle long press detection for press button + if (pressDetected && pressStartTime > 0) { + uint32_t pressDuration = now - pressStartTime; + + if (!pressButtonPressed) { + // Button released + if (pressDuration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) { + lastPressKeyTime = now; + e.inputEvent = this->_eventPressed; + } + // Reset state + pressDetected = false; + pressStartTime = 0; + lastPressLongEventTime = 0; + } else if (pressDuration >= LONG_PRESS_DURATION && lastPressLongEventTime == 0) { + // First long press event only - avoid repeated events causing lag + e.inputEvent = this->_eventPressedLong; + lastPressLongEventTime = now; } - } else if (this->action == UPDOWN_ACTION_UP) { - if (now - lastUpKeyTime >= updownDebounceMs) { - lastUpKeyTime = now; - LOG_DEBUG("GPIO event Up"); - e.inputEvent = this->_eventUp; + } + + // Handle long press detection for up button + if (upDetected && upStartTime > 0) { + uint32_t upDuration = now - upStartTime; + + if (!upButtonPressed) { + // Button released + if (upDuration < LONG_PRESS_DURATION && now - lastUpKeyTime >= updownDebounceMs) { + lastUpKeyTime = now; + e.inputEvent = this->_eventUp; + } + // Reset state + upDetected = false; + upStartTime = 0; + lastUpLongEventTime = 0; + } else if (upDuration >= LONG_PRESS_DURATION) { + // Auto-repeat long press events + if (lastUpLongEventTime == 0 || (now - lastUpLongEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { + e.inputEvent = this->_eventUpLong; + lastUpLongEventTime = now; + } } - } else if (this->action == UPDOWN_ACTION_DOWN) { - if (now - lastDownKeyTime >= updownDebounceMs) { - lastDownKeyTime = now; - LOG_DEBUG("GPIO event Down"); - e.inputEvent = this->_eventDown; + } + + // Handle long press detection for down button + if (downDetected && downStartTime > 0) { + uint32_t downDuration = now - downStartTime; + + if (!downButtonPressed) { + // Button released + if (downDuration < LONG_PRESS_DURATION && now - lastDownKeyTime >= updownDebounceMs) { + lastDownKeyTime = now; + e.inputEvent = this->_eventDown; + } + // Reset state + downDetected = false; + downStartTime = 0; + lastDownLongEventTime = 0; + } else if (downDuration >= LONG_PRESS_DURATION) { + // Auto-repeat long press events + if (lastDownLongEventTime == 0 || (now - lastDownLongEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { + e.inputEvent = this->_eventDownLong; + lastDownLongEventTime = now; + } } } @@ -60,8 +142,11 @@ int32_t UpDownInterruptBase::runOnce() this->notifyObservers(&e); } - this->action = UPDOWN_ACTION_NONE; - return 100; + if (!pressDetected && !upDetected && !downDetected) { + this->action = UPDOWN_ACTION_NONE; + } + + return 20; // This will control how the input frequency } void UpDownInterruptBase::intPressHandler() diff --git a/src/input/UpDownInterruptBase.h b/src/input/UpDownInterruptBase.h index a83a298f2..ae84efdaf 100644 --- a/src/input/UpDownInterruptBase.h +++ b/src/input/UpDownInterruptBase.h @@ -8,7 +8,8 @@ class UpDownInterruptBase : public Observable, public concur public: explicit UpDownInterruptBase(const char *name); void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, - input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)(), + input_broker_event eventPressed, input_broker_event eventPressedLong, input_broker_event eventUpLong, + input_broker_event eventDownLong, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)(), unsigned long updownDebounceMs = 50); void intPressHandler(); void intDownHandler(); @@ -17,16 +18,41 @@ class UpDownInterruptBase : public Observable, public concur int32_t runOnce() override; protected: - enum UpDownInterruptBaseActionType { UPDOWN_ACTION_NONE, UPDOWN_ACTION_PRESSED, UPDOWN_ACTION_UP, UPDOWN_ACTION_DOWN }; + enum UpDownInterruptBaseActionType { + UPDOWN_ACTION_NONE, + UPDOWN_ACTION_PRESSED, + UPDOWN_ACTION_PRESSED_LONG, + UPDOWN_ACTION_UP, + UPDOWN_ACTION_UP_LONG, + UPDOWN_ACTION_DOWN, + UPDOWN_ACTION_DOWN_LONG + }; volatile UpDownInterruptBaseActionType action = UPDOWN_ACTION_NONE; + // Long press detection variables + uint32_t pressStartTime = 0; + uint32_t upStartTime = 0; + uint32_t downStartTime = 0; + bool pressDetected = false; + bool upDetected = false; + bool downDetected = false; + uint32_t lastPressLongEventTime = 0; + uint32_t lastUpLongEventTime = 0; + uint32_t lastDownLongEventTime = 0; + static const uint32_t LONG_PRESS_DURATION = 300; + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 300; + private: uint8_t _pinDown = 0; uint8_t _pinUp = 0; + uint8_t _pinPress = 0; input_broker_event _eventDown = INPUT_BROKER_NONE; input_broker_event _eventUp = INPUT_BROKER_NONE; input_broker_event _eventPressed = INPUT_BROKER_NONE; + input_broker_event _eventPressedLong = INPUT_BROKER_NONE; + input_broker_event _eventUpLong = INPUT_BROKER_NONE; + input_broker_event _eventDownLong = INPUT_BROKER_NONE; const char *_originName; unsigned long lastUpKeyTime = 0; diff --git a/src/input/UpDownInterruptImpl1.cpp b/src/input/UpDownInterruptImpl1.cpp index 761b92348..9b0b1f39e 100644 --- a/src/input/UpDownInterruptImpl1.cpp +++ b/src/input/UpDownInterruptImpl1.cpp @@ -1,5 +1,6 @@ #include "UpDownInterruptImpl1.h" #include "InputBroker.h" +extern bool osk_found; UpDownInterruptImpl1 *upDownInterruptImpl1; @@ -17,13 +18,18 @@ bool UpDownInterruptImpl1::init() uint8_t pinDown = moduleConfig.canned_message.inputbroker_pin_b; uint8_t pinPress = moduleConfig.canned_message.inputbroker_pin_press; - input_broker_event eventDown = INPUT_BROKER_DOWN; - input_broker_event eventUp = INPUT_BROKER_UP; + input_broker_event eventDown = INPUT_BROKER_USER_PRESS; // acts like RIGHT/DOWN + input_broker_event eventUp = INPUT_BROKER_ALT_PRESS; // acts like LEFT/UP input_broker_event eventPressed = INPUT_BROKER_SELECT; + input_broker_event eventPressedLong = INPUT_BROKER_SELECT_LONG; + input_broker_event eventUpLong = INPUT_BROKER_UP_LONG; + input_broker_event eventDownLong = INPUT_BROKER_DOWN_LONG; - UpDownInterruptBase::init(pinDown, pinUp, pinPress, eventDown, eventUp, eventPressed, UpDownInterruptImpl1::handleIntDown, - UpDownInterruptImpl1::handleIntUp, UpDownInterruptImpl1::handleIntPressed); + UpDownInterruptBase::init(pinDown, pinUp, pinPress, eventDown, eventUp, eventPressed, eventPressedLong, eventUpLong, + eventDownLong, UpDownInterruptImpl1::handleIntDown, UpDownInterruptImpl1::handleIntUp, + UpDownInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); + osk_found = true; return true; } diff --git a/src/input/cardKbI2cImpl.cpp b/src/input/cardKbI2cImpl.cpp index fcbdd0a3f..cb03eb4ff 100644 --- a/src/input/cardKbI2cImpl.cpp +++ b/src/input/cardKbI2cImpl.cpp @@ -12,8 +12,12 @@ void CardKbI2cImpl::init() #if !MESHTASTIC_EXCLUDE_I2C && !defined(ARCH_PORTDUINO) && !defined(I2C_NO_RESCAN) if (cardkb_found.address == 0x00) { LOG_DEBUG("Rescan for I2C keyboard"); - uint8_t i2caddr_scan[] = {CARDKB_ADDR, TDECK_KB_ADDR, BBQ10_KB_ADDR, MPR121_KB_ADDR, XPOWERS_AXP192_AXP2101_ADDRESS}; + uint8_t i2caddr_scan[] = {CARDKB_ADDR, TDECK_KB_ADDR, BBQ10_KB_ADDR, MPR121_KB_ADDR, TCA8418_KB_ADDR}; +#if defined(T_LORA_PAGER) + uint8_t i2caddr_asize = sizeof(i2caddr_scan) / sizeof(i2caddr_scan[0]); +#else uint8_t i2caddr_asize = 5; +#endif auto i2cScanner = std::unique_ptr(new ScanI2CTwoWire()); #if WIRE_INTERFACES_COUNT == 2 diff --git a/src/main.cpp b/src/main.cpp index ef5f5a721..8d576f008 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -135,8 +135,9 @@ AccelerometerThread *accelerometerThread = nullptr; AudioThread *audioThread = nullptr; #endif -#ifdef USE_PCA9557 -PCA9557 IOEXP; +#ifdef USE_XL9555 +#include "ExtensionIOXL9555.hpp" +ExtensionIOXL9555 io; #endif #if HAS_TFT @@ -191,6 +192,8 @@ ScanI2C::DeviceAddress cardkb_found = ScanI2C::ADDRESS_NONE; uint8_t kb_model; // global bool to record that a kb is present bool kb_found = false; +// global bool to record that on-screen keyboard (OSK) is present +bool osk_found = false; // The I2C address of the RTC Module (if found) ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE; @@ -201,7 +204,7 @@ ScanI2C::FoundDevice rgb_found = ScanI2C::FoundDevice(ScanI2C::DeviceType::NONE, /// The I2C address of our Air Quality Indicator (if found) ScanI2C::DeviceAddress aqi_found = ScanI2C::ADDRESS_NONE; -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) || defined(T_LORA_PAGER) Adafruit_DRV2605 drv; #endif @@ -359,11 +362,35 @@ void setup() digitalWrite(SDCARD_CS, HIGH); pinMode(PIN_EINK_CS, OUTPUT); digitalWrite(PIN_EINK_CS, HIGH); +#elif defined(T_LORA_PAGER) + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + pinMode(TFT_CS, OUTPUT); + digitalWrite(TFT_CS, HIGH); + // io expander + io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL); + io.pinMode(EXPANDS_DRV_EN, OUTPUT); + io.digitalWrite(EXPANDS_DRV_EN, HIGH); + io.pinMode(EXPANDS_AMP_EN, OUTPUT); + io.digitalWrite(EXPANDS_AMP_EN, HIGH); + io.pinMode(EXPANDS_LORA_EN, OUTPUT); + io.digitalWrite(EXPANDS_LORA_EN, HIGH); + io.pinMode(EXPANDS_GPS_EN, OUTPUT); + io.digitalWrite(EXPANDS_GPS_EN, HIGH); + io.pinMode(EXPANDS_KB_EN, OUTPUT); + io.digitalWrite(EXPANDS_KB_EN, HIGH); + io.pinMode(EXPANDS_SD_EN, OUTPUT); + io.digitalWrite(EXPANDS_SD_EN, HIGH); + io.pinMode(EXPANDS_GPIO_EN, OUTPUT); + io.digitalWrite(EXPANDS_GPIO_EN, HIGH); + io.pinMode(EXPANDS_SD_PULLEN, INPUT); #endif concurrency::hasBeenSetup = true; #if ARCH_PORTDUINO - SPISettings spiSettings(settingsMap[spiSpeed], MSBFIRST, SPI_MODE0); + SPISettings spiSettings(portduino_config.spiSpeed, MSBFIRST, SPI_MODE0); #else SPISettings spiSettings(4000000, MSBFIRST, SPI_MODE0); #endif @@ -394,7 +421,7 @@ void setup() struct timeval tv; tv.tv_sec = time(NULL); tv.tv_usec = 0; - perhapsSetRTC(RTCQualityNTP, &tv); + perhapsSetRTC(RTCQualityDevice, &tv); #endif powerMonInit(); @@ -404,6 +431,16 @@ void setup() initDeepSleep(); +#if defined(MODEM_POWER_EN) + pinMode(MODEM_POWER_EN, OUTPUT); + digitalWrite(MODEM_POWER_EN, LOW); +#endif + +#if defined(MODEM_PWRKEY) + pinMode(MODEM_PWRKEY, OUTPUT); + digitalWrite(MODEM_PWRKEY, LOW); +#endif + #if defined(LORA_TCXO_GPIO) pinMode(LORA_TCXO_GPIO, OUTPUT); digitalWrite(LORA_TCXO_GPIO, HIGH); @@ -498,9 +535,9 @@ void setup() #elif defined(I2C_SDA) && !defined(ARCH_RP2040) Wire.begin(I2C_SDA, I2C_SCL); #elif defined(ARCH_PORTDUINO) - if (settingsStrings[i2cdev] != "") { - LOG_INFO("Use %s as I2C device", settingsStrings[i2cdev].c_str()); - Wire.begin(settingsStrings[i2cdev].c_str()); + if (portduino_config.i2cdev != "") { + LOG_INFO("Use %s as I2C device", portduino_config.i2cdev.c_str()); + Wire.begin(portduino_config.i2cdev.c_str()); } else { LOG_INFO("No I2C device configured, Skip"); } @@ -546,7 +583,7 @@ void setup() #if defined(I2C_SDA) i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); #elif defined(ARCH_PORTDUINO) - if (settingsStrings[i2cdev] != "") { + if (portduino_config.i2cdev != "") { LOG_INFO("Scan for i2c devices"); i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); } @@ -706,6 +743,7 @@ void setup() scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::RAK12035, meshtastic_TelemetrySensorType_RAK12035); scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::PCT2075, meshtastic_TelemetrySensorType_PCT2075); scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::SCD4X, meshtastic_TelemetrySensorType_SCD4X); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::TSL2561, meshtastic_TelemetrySensorType_TSL2561); i2cScanner.reset(); #endif @@ -795,7 +833,7 @@ void setup() #endif #endif -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) || defined(T_LORA_PAGER) drv.begin(); drv.selectLibrary(1); // I2C trigger by sending 'go' command @@ -818,7 +856,7 @@ void setup() SPI.begin(false); #endif // HW_SPI1_DEVICE #elif ARCH_PORTDUINO - if (settingsStrings[spidev] != "ch341") { + if (portduino_config.lora_spi_dev != "ch341") { SPI.begin(); } #elif !defined(ARCH_ESP32) // ARCH_RP2040 @@ -841,10 +879,10 @@ void setup() if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { #if defined(ST7701_CS) || defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \ - defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) + defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) screen = new graphics::Screen(screen_found, screen_model, screen_geometry); #elif defined(ARCH_PORTDUINO) - if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || settingsMap[displayPanel]) && + if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) && config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { screen = new graphics::Screen(screen_found, screen_model, screen_geometry); } @@ -945,13 +983,13 @@ void setup() #endif #if defined(ARCH_PORTDUINO) - if (settingsMap.count(userButtonPin) != 0 && settingsMap[userButtonPin] != RADIOLIB_NC) { + if (portduino_config.userButtonPin.enabled) { - LOG_DEBUG("Use GPIO%02d for button", settingsMap[userButtonPin]); + LOG_DEBUG("Use GPIO%02d for button", portduino_config.userButtonPin.pin); UserButtonThread = new ButtonThread("UserButton"); if (screen) { ButtonConfig config; - config.pinNumber = (uint8_t)settingsMap[userButtonPin]; + config.pinNumber = (uint8_t)portduino_config.userButtonPin.pin; config.activeLow = true; config.activePullup = true; config.pullupSense = INPUT_PULLUP; @@ -1104,11 +1142,11 @@ void setup() // Don't call screen setup until after nodedb is setup (because we need // the current region name) #if defined(ST7701_CS) || defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \ - defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) + defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) if (screen) screen->setup(); #elif defined(ARCH_PORTDUINO) - if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || settingsMap[displayPanel]) && + if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) && config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { screen->setup(); } @@ -1124,15 +1162,10 @@ void setup() #endif #ifdef ARCH_PORTDUINO - const struct { - configNames cfgName; - std::string strName; - } loraModules[] = {{use_rf95, "RF95"}, {use_sx1262, "sx1262"}, {use_sx1268, "sx1268"}, {use_sx1280, "sx1280"}, - {use_lr1110, "lr1110"}, {use_lr1120, "lr1120"}, {use_lr1121, "lr1121"}, {use_llcc68, "LLCC68"}}; // as one can't use a function pointer to the class constructor: - auto loraModuleInterface = [](configNames cfgName, LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, - RADIOLIB_PIN_TYPE rst, RADIOLIB_PIN_TYPE busy) { - switch (cfgName) { + auto loraModuleInterface = [](LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, + RADIOLIB_PIN_TYPE busy) { + switch (portduino_config.lora_module) { case use_rf95: return (RadioInterface *)new RF95Interface(hal, cs, irq, rst, busy); case use_sx1262: @@ -1149,31 +1182,34 @@ void setup() return (RadioInterface *)new LR1121Interface(hal, cs, irq, rst, busy); case use_llcc68: return (RadioInterface *)new LLCC68Interface(hal, cs, irq, rst, busy); + case use_simradio: + return (RadioInterface *)new SimRadio; default: assert(0); // shouldn't happen return (RadioInterface *)nullptr; } }; - for (auto &loraModule : loraModules) { - if (settingsMap[loraModule.cfgName] && !rIf) { - LOG_DEBUG("Activate %s radio on SPI port %s", loraModule.strName.c_str(), settingsStrings[spidev].c_str()); - if (settingsStrings[spidev] == "ch341") { - RadioLibHAL = ch341Hal; - } else { - RadioLibHAL = new LockingArduinoHal(SPI, spiSettings); - } - rIf = loraModuleInterface(loraModule.cfgName, (LockingArduinoHal *)RadioLibHAL, settingsMap[cs_pin], - settingsMap[irq_pin], settingsMap[reset_pin], settingsMap[busy_pin]); - if (!rIf->init()) { - LOG_WARN("No %s radio", loraModule.strName.c_str()); - delete rIf; - rIf = NULL; - exit(EXIT_FAILURE); - } else { - LOG_INFO("%s init success", loraModule.strName.c_str()); - } - } + + LOG_DEBUG("Activate %s radio on SPI port %s", portduino_config.loraModules[portduino_config.lora_module].c_str(), + portduino_config.lora_spi_dev.c_str()); + if (portduino_config.lora_spi_dev == "ch341") { + RadioLibHAL = ch341Hal; + } else { + RadioLibHAL = new LockingArduinoHal(SPI, spiSettings); } + rIf = + loraModuleInterface((LockingArduinoHal *)RadioLibHAL, portduino_config.lora_cs_pin.pin, portduino_config.lora_irq_pin.pin, + portduino_config.lora_reset_pin.pin, portduino_config.lora_busy_pin.pin); + + if (!rIf->init()) { + LOG_WARN("No %s radio", portduino_config.loraModules[portduino_config.lora_module].c_str()); + delete rIf; + rIf = NULL; + exit(EXIT_FAILURE); + } else { + LOG_INFO("%s init success", portduino_config.loraModules[portduino_config.lora_module].c_str()); + } + #elif defined(HW_SPI1_DEVICE) LockingArduinoHal *RadioLibHAL = new LockingArduinoHal(SPI1, spiSettings); #else // HW_SPI1_DEVICE @@ -1195,20 +1231,6 @@ void setup() } #endif -#if defined(ARCH_PORTDUINO) - if (!rIf) { - rIf = new SimRadio; - if (!rIf->init()) { - LOG_WARN("No simulated radio"); - delete rIf; - rIf = NULL; - } else { - LOG_INFO("Use SIMULATED radio!"); - radioType = SIM_RADIO; - } - } -#endif - #if defined(RF95_IRQ) && RADIOLIB_EXCLUDE_SX127X != 1 if ((!rIf) && (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) { rIf = new RF95Interface(RadioLibHAL, LORA_CS, RF95_IRQ, RF95_RESET, RF95_DIO1); @@ -1412,6 +1434,10 @@ void setup() #endif #endif +#if defined(HAS_TRACKBALL) || (defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2) + osk_found = true; +#endif + #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER // Start web server thread. webServerThread = new WebServerThread(); @@ -1419,7 +1445,7 @@ void setup() #ifdef ARCH_PORTDUINO #if __has_include() - if (settingsMap[webserverport] != -1) { + if (portduino_config.webserverport != -1) { piwebServerThread = new PiWebServerThread(); std::atexit([] { delete piwebServerThread; }); } @@ -1480,6 +1506,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() deviceMetadata.hw_model = HW_VENDOR; deviceMetadata.hasRemoteHardware = moduleConfig.remote_hardware.enabled; deviceMetadata.excluded_modules = meshtastic_ExcludedModules_EXCLUDED_NONE; +#if MESHTASTIC_EXCLUDE_MQTT + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_MQTT_CONFIG; +#endif #if MESHTASTIC_EXCLUDE_REMOTEHARDWARE deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_REMOTEHARDWARE_CONFIG; #endif @@ -1490,7 +1519,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if ((!HAS_SCREEN || NO_EXT_GPIO) || MESHTASTIC_EXCLUDE_CANNEDMESSAGES) && !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_CANNEDMSG_CONFIG; #endif -#if NO_EXT_GPIO +#if NO_EXT_GPIO || MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_EXTNOTIF_CONFIG; #endif // Only edge case here is if we apply this a device with built in Accelerometer and want to detect interrupts @@ -1502,10 +1531,21 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if NO_EXT_GPIO && NO_GPS || MESHTASTIC_EXCLUDE_SERIAL deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_SERIAL_CONFIG; #endif -#ifndef ARCH_ESP32 +#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_PAXCOUNTER + // PAXCOUNTER is only supported on ESP32 due to memory constraints +#else deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_PAXCOUNTER_CONFIG; #endif -#if !defined(HAS_RGB_LED) && !RAK_4631 +#if MESHTASTIC_EXCLUDE_STOREFORWARD + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_STOREFORWARD_CONFIG; +#endif +#if MESHTASTIC_EXCLUDE_RANGETEST + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_RANGETEST_CONFIG; +#endif +#if MESHTASTIC_EXCLUDE_NEIGHBORINFO + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NEIGHBORINFO_CONFIG; +#endif +#if (!defined(HAS_RGB_LED) && !defined(RAK_4631)) || defined(MESHTASTIC_EXCLUDE_AMBIENTLIGHTING) deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG; #endif diff --git a/src/main.h b/src/main.h index 3568daad2..414752b5c 100644 --- a/src/main.h +++ b/src/main.h @@ -32,6 +32,7 @@ extern ScanI2C::DeviceAddress screen_found; extern ScanI2C::DeviceAddress cardkb_found; extern uint8_t kb_model; extern bool kb_found; +extern bool osk_found; extern ScanI2C::DeviceAddress rtc_found; extern ScanI2C::DeviceAddress accelerometer_found; extern ScanI2C::FoundDevice rgb_found; @@ -41,7 +42,7 @@ extern bool eink_found; extern bool pmu_found; extern bool isUSBPowered; -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) || defined(T_LORA_PAGER) #include extern Adafruit_DRV2605 drv; #endif diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 3ada5fa0c..affe05285 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -378,7 +378,7 @@ const char *Channels::getName(size_t chIndex) // Per mesh.proto spec, if bandwidth is specified we must ignore modemPreset enum, we assume that in that case // the app effed up and forgot to set channelSettings.name if (config.lora.use_preset) { - channelName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false); + channelName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset); } else { channelName = "Custom"; } @@ -392,7 +392,8 @@ bool Channels::isDefaultChannel(ChannelIndex chIndex) const auto &ch = getByIndex(chIndex); if (ch.settings.psk.size == 1 && ch.settings.psk.bytes[0] == 1) { const char *name = getName(chIndex); - const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false); + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset); // Check if the name is the default derived from the modem preset if (strcmp(name, presetName) == 0) return true; diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index a0d992c42..f83522c8b 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -21,7 +21,7 @@ static const Module::RfSwitchMode_t rfswitch_table[] = { // Particular boards might define a different max power based on what their hardware can do, default to max power output if not // specified (may be dangerous if using external PA and LR11x0 power config forgotten) #if ARCH_PORTDUINO -#define LR1110_MAX_POWER settingsMap[lr1110_max_power] +#define LR1110_MAX_POWER portduino_config.lr1110_max_power #endif #ifndef LR1110_MAX_POWER #define LR1110_MAX_POWER 22 @@ -30,7 +30,7 @@ static const Module::RfSwitchMode_t rfswitch_table[] = { // the 2.4G part maxes at 13dBm #if ARCH_PORTDUINO -#define LR1120_MAX_POWER settingsMap[lr1120_max_power] +#define LR1120_MAX_POWER portduino_config.lr1120_max_power #endif #ifndef LR1120_MAX_POWER #define LR1120_MAX_POWER 13 @@ -55,7 +55,7 @@ template bool LR11x0Interface::init() #endif #if ARCH_PORTDUINO - float tcxoVoltage = (float)settingsMap[dio3_tcxo_voltage] / 1000; + float tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000; // FIXME: correct logic to default to not using TCXO if no voltage is specified for LR11x0_DIO3_TCXO_VOLTAGE #elif !defined(LR11X0_DIO3_TCXO_VOLTAGE) float tcxoVoltage = diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index 409c52179..22fcec663 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -85,11 +85,8 @@ meshtastic_MeshPacket *MeshModule::allocErrorResponse(meshtastic_Routing_Error e return r; } -void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src, const char *specificModule) +void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src) { - if (specificModule) { - LOG_DEBUG("Calling specific module: %s", specificModule); - } // LOG_DEBUG("In call modules"); bool moduleFound = false; @@ -103,15 +100,11 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src, const char // Was this message directed to us specifically? Will be false if we are sniffing someone elses packets auto ourNodeNum = nodeDB->getNodeNum(); bool toUs = isBroadcast(mp.to) || isToUs(&mp); + bool fromUs = mp.from == ourNodeNum; for (auto i = modules->begin(); i != modules->end(); ++i) { auto &pi = **i; - // If specificModule is provided, only call that specific module - if (specificModule && (!pi.name || strcmp(pi.name, specificModule) != 0)) { - continue; - } - pi.currentRequest = ∓ /// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious) diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index bf735439f..eda3f8881 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -73,7 +73,7 @@ class MeshModule /** For use only by MeshService */ - static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO, const char *specificModule = nullptr); + static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO); static std::vector GetMeshModulesWithUIFrames(int startIndex); static void observeUIEvents(Observer *observer); diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 2cc4197c1..7e35fccd8 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -61,8 +61,10 @@ Allocator &queueStatusPool = staticQueueStatusPool; #include "Router.h" MeshService::MeshService() - : toPhoneQueue(MAX_RX_TOPHONE), toPhoneQueueStatusQueue(MAX_RX_TOPHONE), toPhoneMqttProxyQueue(MAX_RX_TOPHONE), - toPhoneClientNotificationQueue(MAX_RX_TOPHONE / 2) +#ifdef ARCH_PORTDUINO + : toPhoneQueue(MAX_RX_TOPHONE), toPhoneQueueStatusQueue(MAX_RX_QUEUESTATUS_TOPHONE), + toPhoneMqttProxyQueue(MAX_RX_MQTTPROXY_TOPHONE), toPhoneClientNotificationQueue(MAX_RX_NOTIFICATION_TOPHONE) +#endif { lastQueueStatus = {0, 0, 16, 0}; } diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index f7d79366e..5d074368f 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -9,7 +9,12 @@ #include "MeshRadio.h" #include "MeshTypes.h" #include "Observer.h" +#ifdef ARCH_PORTDUINO #include "PointerQueue.h" +#else +#include "StaticPointerQueue.h" +#endif +#include "mesh-pb-constants.h" #if defined(ARCH_PORTDUINO) #include "../platform/portduino/SimRadio.h" #endif @@ -37,16 +42,32 @@ class MeshService /// FIXME, change to a DropOldestQueue and keep a count of the number of dropped packets to ensure /// we never hang because android hasn't been there in a while /// FIXME - save this to flash on deep sleep +#ifdef ARCH_PORTDUINO PointerQueue toPhoneQueue; +#else + StaticPointerQueue toPhoneQueue; +#endif // keep list of QueueStatus packets to be send to the phone +#ifdef ARCH_PORTDUINO PointerQueue toPhoneQueueStatusQueue; +#else + StaticPointerQueue toPhoneQueueStatusQueue; +#endif // keep list of MqttClientProxyMessages to be send to the client for delivery +#ifdef ARCH_PORTDUINO PointerQueue toPhoneMqttProxyQueue; +#else + StaticPointerQueue toPhoneMqttProxyQueue; +#endif // keep list of ClientNotifications to be send to the client (phone) +#ifdef ARCH_PORTDUINO PointerQueue toPhoneClientNotificationQueue; +#else + StaticPointerQueue toPhoneClientNotificationQueue; +#endif // This holds the last QueueStatus send meshtastic_QueueStatus lastQueueStatus; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 860250f75..7ceca2195 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -34,8 +34,11 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) bool weWereNextHop = false; if (wasSeenRecently(p, true, &wasFallback, &weWereNextHop)) { // Note: this will also add a recent packet record printPacket("Ignore dupe incoming msg", p); - rxDupe++; - stopRetransmission(p->from, p->id); + + if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { + rxDupe++; + stopRetransmission(p->from, p->id); + } // If it was a fallback to flooding, try to relay again if (wasFallback) { @@ -71,10 +74,11 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast if (p->from != 0) { meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from); if (origTx) { - // Either relayer of ACK was also a relayer of the packet, or we were the relayer and the ACK came directly from - // the destination + // Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came directly + // from the destination if (wasRelayer(p->relay_node, p->decoded.request_id, p->to) || - (wasRelayer(ourRelayID, p->decoded.request_id, p->to) && p->hop_start != 0 && p->hop_start == p->hop_limit)) { + (p->hop_start != 0 && p->hop_start == p->hop_limit && + wasSoleRelayer(ourRelayID, p->decoded.request_id, p->to))) { if (origTx->next_hop != p->relay_node) { // Not already set LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply", p->from, p->relay_node); origTx->next_hop = p->relay_node; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 18014eb02..52a18a53f 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -663,7 +663,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.bluetooth.fixed_pin = defaultBLEPin; #if defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ - defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) + defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) bool hasScreen = true; #ifdef HELTEC_MESH_NODE_T114 uint32_t st7789_id = get_st7789_id(ST7789_NSS, ST7789_SCK, ST7789_SDA, ST7789_RS, ST7789_RESET); @@ -673,7 +673,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #endif #elif ARCH_PORTDUINO bool hasScreen = false; - if (settingsMap[displayPanel]) + if (portduino_config.displayPanel) hasScreen = true; else hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C; @@ -775,7 +775,9 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.version = DEVICESTATE_CUR_VER; moduleConfig.has_mqtt = true; +#if !MESHTASTIC_EXCLUDE_RANGETEST moduleConfig.has_range_test = true; +#endif moduleConfig.has_serial = true; moduleConfig.has_store_forward = true; moduleConfig.has_telemetry = true; @@ -830,8 +832,23 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.external_notification.alert_message = true; moduleConfig.external_notification.output_ms = 1000; moduleConfig.external_notification.nag_timeout = 60; +#endif +#ifdef T_LORA_PAGER + moduleConfig.canned_message.updown1_enabled = true; + moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A; + moduleConfig.canned_message.inputbroker_pin_b = ROTARY_B; + moduleConfig.canned_message.inputbroker_pin_press = ROTARY_PRESS; + moduleConfig.canned_message.inputbroker_event_cw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(28); + moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(29); + moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; #endif moduleConfig.has_canned_message = true; +#if !MESHTASTIC_EXCLUDE_AUDIO + moduleConfig.has_audio = true; +#endif +#if !MESHTASTIC_EXCLUDE_PAXCOUNTER + moduleConfig.has_paxcounter = true; +#endif #if USERPREFS_MQTT_ENABLED && !MESHTASTIC_EXCLUDE_MQTT moduleConfig.mqtt.enabled = true; #endif @@ -874,12 +891,14 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.detection_sensor.detection_trigger_type = meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH; moduleConfig.detection_sensor.minimum_broadcast_secs = 45; +#if !MESHTASTIC_EXCLUDE_AMBIENTLIGHTING moduleConfig.has_ambient_lighting = true; moduleConfig.ambient_lighting.current = 10; // Default to a color based on our node number moduleConfig.ambient_lighting.red = (myNodeInfo.my_node_num & 0xFF0000) >> 16; moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8; moduleConfig.ambient_lighting.blue = myNodeInfo.my_node_num & 0x0000FF; +#endif initModuleConfigIntervals(); } @@ -1325,8 +1344,8 @@ void NodeDB::loadFromDisk() } #if ARCH_PORTDUINO // set any config overrides - if (settingsMap[has_configDisplayMode]) { - config.display.displaymode = (_meshtastic_Config_DisplayConfig_DisplayMode)settingsMap[configDisplayMode]; + if (portduino_config.has_configDisplayMode) { + config.display.displaymode = (_meshtastic_Config_DisplayConfig_DisplayMode)portduino_config.configDisplayMode; } #endif @@ -1419,15 +1438,25 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) moduleConfig.has_canned_message = true; moduleConfig.has_external_notification = true; moduleConfig.has_mqtt = true; +#if !MESHTASTIC_EXCLUDE_RANGETEST moduleConfig.has_range_test = true; +#endif moduleConfig.has_serial = true; +#if !MESHTASTIC_EXCLUDE_STOREFORWARD moduleConfig.has_store_forward = true; +#endif moduleConfig.has_telemetry = true; moduleConfig.has_neighbor_info = true; moduleConfig.has_detection_sensor = true; +#if !MESHTASTIC_EXCLUDE_AMBIENTLIGHTING moduleConfig.has_ambient_lighting = true; +#endif +#if !MESHTASTIC_EXCLUDE_AUDIO moduleConfig.has_audio = true; +#endif +#if !MESHTASTIC_EXCLUDE_PAXCOUNTER moduleConfig.has_paxcounter = true; +#endif success &= saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig); @@ -1702,10 +1731,10 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde /// we updateGUI and updateGUIforNode if we think our this change is big enough for a redraw void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) { - // if (mp.from == getNodeNum()) { - // LOG_DEBUG("Ignore update from self"); - // return; - // } + if (mp.from == getNodeNum()) { + LOG_DEBUG("Ignore update from self"); + return; + } if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) { LOG_DEBUG("Update DB node 0x%x, rx_time=%u", mp.from, mp.rx_time); diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 3902c1057..735386d79 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -294,7 +294,7 @@ void PacketHistory::insert(const PacketRecord &r) /* Check if a certain node was a relayer of a packet in the history given an ID and sender * @return true if node was indeed a relayer, false if not */ -bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender) +bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender, bool *wasSole) { if (!initOk()) { LOG_ERROR("PacketHistory - wasRelayer: NOT INITIALIZED!"); @@ -322,27 +322,42 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N found->sender, found->id, found->next_hop, millis() - found->rxTimeMsec, found->relayed_by[0], found->relayed_by[1], found->relayed_by[2], relayer); #endif - return wasRelayer(relayer, *found); + return wasRelayer(relayer, *found, wasSole); } /* Check if a certain node was a relayer of a packet in the history given iterator * @return true if node was indeed a relayer, false if not */ -bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r) +bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole) { - for (uint8_t i = 0; i < NUM_RELAYERS; i++) { + bool found = false; + bool other_present = false; + + for (uint8_t i = 0; i < NUM_RELAYERS; ++i) { if (r.relayed_by[i] == relayer) { -#if VERBOSE_PACKET_HISTORY - LOG_DEBUG("Packet History - was rel.PR.: s=%08x id=%08x rls=%02x %02x %02x / rl=%02x? YES", r.sender, r.id, - r.relayed_by[0], r.relayed_by[1], r.relayed_by[2], relayer); -#endif - return true; + found = true; + } else if (r.relayed_by[i] != 0) { + other_present = true; } } + + if (wasSole) { + *wasSole = (found && !other_present); + } + #if VERBOSE_PACKET_HISTORY LOG_DEBUG("Packet History - was rel.PR.: s=%08x id=%08x rls=%02x %02x %02x / rl=%02x? NO", r.sender, r.id, r.relayed_by[0], r.relayed_by[1], r.relayed_by[2], relayer); #endif - return false; + + return found; +} + +// Check if a certain node was the *only* relayer of a packet in the history given an ID and sender +bool PacketHistory::wasSoleRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender) +{ + bool wasSole = false; + wasRelayer(relayer, id, sender, &wasSole); + return wasSole; } // Remove a relayer from the list of relayers of a packet in the history given an ID and sender diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h index 9f14a4cf0..4b53c8f6a 100644 --- a/src/mesh/PacketHistory.h +++ b/src/mesh/PacketHistory.h @@ -34,8 +34,9 @@ class PacketHistory void insert(const PacketRecord &r); // Insert or replace a packet record in the history /* Check if a certain node was a relayer of a packet in the history given iterator + * If wasSole is not nullptr, it will be set to true if the relayer was the only relayer of that packet * @return true if node was indeed a relayer, false if not */ - bool wasRelayer(const uint8_t relayer, const PacketRecord &r); + bool wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole = nullptr); PacketHistory(const PacketHistory &); // non construction-copyable PacketHistory &operator=(const PacketHistory &); // non copyable @@ -54,8 +55,12 @@ class PacketHistory bool *weWereNextHop = nullptr); /* Check if a certain node was a relayer of a packet in the history given an ID and sender + * If wasSole is not nullptr, it will be set to true if the relayer was the only relayer of that packet * @return true if node was indeed a relayer, false if not */ - bool wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender); + bool wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender, bool *wasSole = nullptr); + + // Check if a certain node was the *only* relayer of a packet in the history given an ID and sender + bool wasSoleRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender); // Remove a relayer from the list of relayers of a packet in the history given an ID and sender void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender); diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index a3a8a2087..d11eff9e7 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -34,6 +34,21 @@ // Flag to indicate a heartbeat was received and we should send queue status bool heartbeatReceived = false; +// Helper function to skip excluded module configs and advance state +size_t PhoneAPI::skipExcludedModuleConfig(uint8_t *buf) +{ + config_state++; + if (config_state > (_meshtastic_AdminMessage_ModuleConfigType_MAX + 1)) { + if (config_nonce == SPECIAL_NONCE_ONLY_CONFIG) { + state = STATE_SEND_FILEMANIFEST; + } else { + state = STATE_SEND_OTHER_NODEINFOS; + } + config_state = 0; + } + return getFromRadio(buf); +} + PhoneAPI::PhoneAPI() { lastContactMsec = millis(); @@ -354,20 +369,35 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.payload_variant.serial = moduleConfig.serial; break; case meshtastic_ModuleConfig_external_notification_tag: +#if !(NO_EXT_GPIO || MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION) LOG_DEBUG("Send module config: ext notification"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag; fromRadioScratch.moduleConfig.payload_variant.external_notification = moduleConfig.external_notification; break; +#else + LOG_DEBUG("External Notification module excluded from build, skipping"); + return skipExcludedModuleConfig(buf); +#endif case meshtastic_ModuleConfig_store_forward_tag: +#if !MESHTASTIC_EXCLUDE_STOREFORWARD LOG_DEBUG("Send module config: store forward"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag; fromRadioScratch.moduleConfig.payload_variant.store_forward = moduleConfig.store_forward; break; +#else + LOG_DEBUG("Store & Forward module excluded from build, skipping"); + return skipExcludedModuleConfig(buf); +#endif case meshtastic_ModuleConfig_range_test_tag: +#if !MESHTASTIC_EXCLUDE_RANGETEST LOG_DEBUG("Send module config: range test"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_range_test_tag; fromRadioScratch.moduleConfig.payload_variant.range_test = moduleConfig.range_test; break; +#else + LOG_DEBUG("Range Test module excluded from build, skipping"); + return skipExcludedModuleConfig(buf); +#endif case meshtastic_ModuleConfig_telemetry_tag: LOG_DEBUG("Send module config: telemetry"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag; @@ -379,10 +409,15 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.payload_variant.canned_message = moduleConfig.canned_message; break; case meshtastic_ModuleConfig_audio_tag: +#if !MESHTASTIC_EXCLUDE_AUDIO LOG_DEBUG("Send module config: audio"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_audio_tag; fromRadioScratch.moduleConfig.payload_variant.audio = moduleConfig.audio; break; +#else + LOG_DEBUG("Audio module excluded from build, skipping"); + return skipExcludedModuleConfig(buf); +#endif case meshtastic_ModuleConfig_remote_hardware_tag: LOG_DEBUG("Send module config: remote hardware"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; @@ -399,15 +434,25 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.payload_variant.detection_sensor = moduleConfig.detection_sensor; break; case meshtastic_ModuleConfig_ambient_lighting_tag: +#if !MESHTASTIC_EXCLUDE_AMBIENTLIGHTING LOG_DEBUG("Send module config: ambient lighting"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag; fromRadioScratch.moduleConfig.payload_variant.ambient_lighting = moduleConfig.ambient_lighting; break; +#else + LOG_DEBUG("Ambient Lighting module excluded from build, skipping"); + return skipExcludedModuleConfig(buf); +#endif case meshtastic_ModuleConfig_paxcounter_tag: +#if !MESHTASTIC_EXCLUDE_PAXCOUNTER LOG_DEBUG("Send module config: paxcounter"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter; break; +#else + LOG_DEBUG("Paxcounter module excluded from build, skipping"); + return skipExcludedModuleConfig(buf); +#endif default: LOG_ERROR("Unknown module config type %d", config_state); } diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index 0d7772d17..6b4bb6fc1 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -172,4 +172,7 @@ class PhoneAPI /// If the mesh service tells us fromNum has changed, tell the phone virtual int onNotify(uint32_t newValue) override; + + /// Helper function to skip excluded module configs and advance state + size_t skipExcludedModuleConfig(uint8_t *buf); }; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 97f21fc34..0f32f3427 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -10,7 +10,7 @@ #endif #if ARCH_PORTDUINO -#define RF95_MAX_POWER settingsMap[rf95_max_power] +#define RF95_MAX_POWER portduino_config.rf95_max_power #endif #ifndef RF95_MAX_POWER #define RF95_MAX_POWER 20 @@ -94,16 +94,16 @@ void RF95Interface::setTransmitEnable(bool txon) #ifdef RF95_TXEN digitalWrite(RF95_TXEN, txon ? 1 : 0); #elif ARCH_PORTDUINO - if (settingsMap[txen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[txen_pin], txon ? 1 : 0); + if (portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_txen_pin.pin, txon ? 1 : 0); } #endif #ifdef RF95_RXEN digitalWrite(RF95_RXEN, txon ? 0 : 1); #elif ARCH_PORTDUINO - if (settingsMap[rxen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[rxen_pin], txon ? 0 : 1); + if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_rxen_pin.pin, txon ? 0 : 1); } #endif } @@ -164,13 +164,13 @@ bool RF95Interface::init() digitalWrite(RF95_RXEN, 1); #endif #if ARCH_PORTDUINO - if (settingsMap[txen_pin] != RADIOLIB_NC) { - pinMode(settingsMap[txen_pin], OUTPUT); - digitalWrite(settingsMap[txen_pin], 0); + if (portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + pinMode(portduino_config.lora_txen_pin.pin, OUTPUT); + digitalWrite(portduino_config.lora_txen_pin.pin, 0); } - if (settingsMap[rxen_pin] != RADIOLIB_NC) { - pinMode(settingsMap[rxen_pin], OUTPUT); - digitalWrite(settingsMap[rxen_pin], 0); + if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { + pinMode(portduino_config.lora_rxen_pin.pin, OUTPUT); + digitalWrite(portduino_config.lora_rxen_pin.pin, 0); } #endif setTransmitEnable(false); diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index c210d5d48..20a0bdbd1 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -32,9 +32,12 @@ const RegionInfo regions[] = { RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false), /* - https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf + EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] + + https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf + FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 0, 12, true, false, false), + RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ @@ -586,7 +589,8 @@ void RadioInterface::applyModemConfig() // Check if we use the default frequency slot RadioInterface::uses_default_frequency_slot = - channel_num == hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false)) % numChannels; + channel_num == + hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset)) % numChannels; // Old frequency selection formula // float freq = myRegion->freqStart + ((((myRegion->freqEnd - myRegion->freqStart) / numChannels) / 2) * channel_num); diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index e3ef58f14..c18612101 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -417,12 +417,13 @@ void RadioLibInterface::handleReceiveInterrupt() int state = iface->readData((uint8_t *)&radioBuffer, length); #if ARCH_PORTDUINO - if (settingsMap[logoutputlevel] == level_trace) { + if (portduino_config.logoutputlevel == level_trace) { printBytes("Raw incoming packet: ", (uint8_t *)&radioBuffer, length); } #endif if (state != RADIOLIB_ERR_NONE) { - LOG_ERROR("Ignore received packet due to error=%d", state); + LOG_ERROR("Ignore received packet due to error=%d (maybe to=0x%08x, from=0x%08x, flags=0x%02x)", state, + radioBuffer.header.to, radioBuffer.header.from, radioBuffer.header.flags); rxBad++; airTime->logAirtime(RX_ALL_LOG, xmitMsec); diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 6e5c6231b..e9ceeaef1 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -58,7 +58,10 @@ bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) // marked as wantAck sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); - stopRetransmission(key); + // Only stop retransmissions if the rebroadcast came via LoRa + if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { + stopRetransmission(key); + } } else { LOG_DEBUG("Didn't find pending packet"); } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index cceacfe9e..221e0275b 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -446,7 +446,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) #if ENABLE_JSON_LOGGING LOG_TRACE("%s", MeshPacketSerializer::JsonSerialize(p, false).c_str()); #elif ARCH_PORTDUINO - if (settingsStrings[traceFilename] != "" || settingsMap[logoutputlevel] == level_trace) { + if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) { LOG_TRACE("%s", MeshPacketSerializer::JsonSerialize(p, false).c_str()); } #endif @@ -529,8 +529,9 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) #endif // Don't use PKC with Ham mode !owner.is_licensed && - // Don't use PKC if it's not explicitly requested and a non-primary channel is requested - !(p->pki_encrypted != true && p->channel > 0) && + // Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested + !(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 || + strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) && // Check for valid keys and single node destination config.security.private_key.size == 32 && !isBroadcast(p->to) && node != nullptr && // Check for a known public key for the destination @@ -561,7 +562,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // Now that we are encrypting the packet channel should be the hash (no longer the index) p->channel = hash; if (hash < 0) { - // No suitable channel could be found for sending + // No suitable channel could be found for return meshtastic_Routing_Error_NO_CHANNEL; } crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); @@ -577,7 +578,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // Now that we are encrypting the packet channel should be the hash (no longer the index) p->channel = hash; if (hash < 0) { - // No suitable channel could be found for sending + // No suitable channel could be found for return meshtastic_Routing_Error_NO_CHANNEL; } crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); @@ -670,7 +671,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) mqtt->onSend(*p_encrypted, *p, p->channel); #endif } else if (p->from == nodeDB->getNodeNum() && !skipHandle) { - MeshModule::callModules(*p, src, ROUTING_MODULE); + MeshModule::callModules(*p, src); } packetPool.release(p_encrypted); // Release the encrypted packet @@ -684,7 +685,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) LOG_TRACE("%s", MeshPacketSerializer::JsonSerializeEncrypted(p).c_str()); #elif ARCH_PORTDUINO // Even ignored packets get logged in the trace - if (settingsStrings[traceFilename] != "" || settingsMap[logoutputlevel] == level_trace) { + if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) { p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone LOG_TRACE("%s", MeshPacketSerializer::JsonSerializeEncrypted(p).c_str()); } diff --git a/src/mesh/STM32WLE5JCInterface.cpp b/src/mesh/STM32WLE5JCInterface.cpp index d7bc37466..f6e4b3512 100644 --- a/src/mesh/STM32WLE5JCInterface.cpp +++ b/src/mesh/STM32WLE5JCInterface.cpp @@ -1,13 +1,13 @@ -#include "STM32WLE5JCInterface.h" #include "configuration.h" + +#ifdef ARCH_STM32WL +#include "STM32WLE5JCInterface.h" #include "error.h" #ifndef STM32WLx_MAX_POWER #define STM32WLx_MAX_POWER 22 #endif -#ifdef ARCH_STM32WL - STM32WLE5JCInterface::STM32WLE5JCInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, RADIOLIB_PIN_TYPE busy) : SX126xInterface(hal, cs, irq, rst, busy) diff --git a/src/mesh/STM32WLE5JCInterface.h b/src/mesh/STM32WLE5JCInterface.h index 0c8140290..ee935375e 100644 --- a/src/mesh/STM32WLE5JCInterface.h +++ b/src/mesh/STM32WLE5JCInterface.h @@ -1,8 +1,8 @@ #pragma once -#include "SX126xInterface.h" - #ifdef ARCH_STM32WL +#include "SX126xInterface.h" +#include "rfswitch.h" /** * Our adapter for STM32WLE5JC radios @@ -16,13 +16,4 @@ class STM32WLE5JCInterface : public SX126xInterface virtual bool init() override; }; -/* https://wiki.seeedstudio.com/LoRa-E5_STM32WLE5JC_Module/ - * Wio-E5 module ONLY transmits through RFO_HP - * Receive: PA4=1, PA5=0 - * Transmit(high output power, SMPS mode): PA4=0, PA5=1 */ -static const RADIOLIB_PIN_TYPE rfswitch_pins[5] = {PA4, PA5, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC}; - -static const Module::RfSwitchMode_t rfswitch_table[4] = { - {STM32WLx::MODE_IDLE, {LOW, LOW}}, {STM32WLx::MODE_RX, {HIGH, LOW}}, {STM32WLx::MODE_TX_HP, {LOW, HIGH}}, END_OF_MODE_TABLE}; - #endif // ARCH_STM32WL \ No newline at end of file diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 729c1abc6..49dc562d4 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -12,7 +12,7 @@ // Particular boards might define a different max power based on what their hardware can do, default to max power output if not // specified (may be dangerous if using external PA and SX126x power config forgotten) #if ARCH_PORTDUINO -#define SX126X_MAX_POWER settingsMap[sx126x_max_power] +#define SX126X_MAX_POWER portduino_config.sx126x_max_power #endif #ifndef SX126X_MAX_POWER #define SX126X_MAX_POWER 22 @@ -53,10 +53,10 @@ template bool SX126xInterface::init() #endif #if ARCH_PORTDUINO - tcxoVoltage = (float)settingsMap[dio3_tcxo_voltage] / 1000; - if (settingsMap[sx126x_ant_sw_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[sx126x_ant_sw_pin], HIGH); - pinMode(settingsMap[sx126x_ant_sw_pin], OUTPUT); + tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000; + if (portduino_config.lora_sx126x_ant_sw_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_sx126x_ant_sw_pin.pin, HIGH); + pinMode(portduino_config.lora_sx126x_ant_sw_pin.pin, OUTPUT); } #endif if (tcxoVoltage == 0.0) @@ -98,7 +98,7 @@ template bool SX126xInterface::init() bool dio2AsRfSwitch = true; #elif defined(ARCH_PORTDUINO) bool dio2AsRfSwitch = false; - if (settingsMap[dio2_as_rf_switch]) { + if (portduino_config.dio2_as_rf_switch) { dio2AsRfSwitch = true; } #else @@ -112,9 +112,9 @@ template bool SX126xInterface::init() // no effect #if ARCH_PORTDUINO if (res == RADIOLIB_ERR_NONE) { - LOG_DEBUG("Use MCU pin %i as RXEN and pin %i as TXEN to control RF switching", settingsMap[rxen_pin], - settingsMap[txen_pin]); - lora.setRfSwitchPins(settingsMap[rxen_pin], settingsMap[txen_pin]); + LOG_DEBUG("Use MCU pin %i as RXEN and pin %i as TXEN to control RF switching", portduino_config.lora_rxen_pin.pin, + portduino_config.lora_txen_pin.pin); + lora.setRfSwitchPins(portduino_config.lora_rxen_pin.pin, portduino_config.lora_txen_pin.pin); } #else #ifndef SX126X_RXEN diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 866426872..cbc98eeb1 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -11,7 +11,7 @@ // Particular boards might define a different max power based on what their hardware can do #if ARCH_PORTDUINO -#define SX128X_MAX_POWER settingsMap[sx128x_max_power] +#define SX128X_MAX_POWER portduino_config.sx128x_max_power #endif #ifndef SX128X_MAX_POWER #define SX128X_MAX_POWER 13 @@ -41,13 +41,13 @@ template bool SX128xInterface::init() #endif #if ARCH_PORTDUINO - if (settingsMap[rxen_pin] != RADIOLIB_NC) { - pinMode(settingsMap[rxen_pin], OUTPUT); - digitalWrite(settingsMap[rxen_pin], LOW); // Set low before becoming an output + if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { + pinMode(portduino_config.lora_rxen_pin.pin, OUTPUT); + digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); // Set low before becoming an output } - if (settingsMap[txen_pin] != RADIOLIB_NC) { - pinMode(settingsMap[txen_pin], OUTPUT); - digitalWrite(settingsMap[txen_pin], LOW); // Set low before becoming an output + if (portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + pinMode(portduino_config.lora_txen_pin.pin, OUTPUT); + digitalWrite(portduino_config.lora_txen_pin.pin, LOW); // Set low before becoming an output } #else #if defined(SX128X_RXEN) && (SX128X_RXEN != RADIOLIB_NC) // set not rx or tx mode @@ -93,8 +93,9 @@ template bool SX128xInterface::init() lora.setRfSwitchPins(SX128X_RXEN, SX128X_TXEN); } #elif ARCH_PORTDUINO - if (res == RADIOLIB_ERR_NONE && settingsMap[rxen_pin] != RADIOLIB_NC && settingsMap[txen_pin] != RADIOLIB_NC) { - lora.setRfSwitchPins(settingsMap[rxen_pin], settingsMap[txen_pin]); + if (res == RADIOLIB_ERR_NONE && portduino_config.lora_rxen_pin.pin != RADIOLIB_NC && + portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + lora.setRfSwitchPins(portduino_config.lora_rxen_pin.pin, portduino_config.lora_txen_pin.pin); } #endif @@ -174,11 +175,11 @@ template void SX128xInterface::setStandby() LOG_ERROR("SX128x standby %s%d", radioLibErr, err); assert(err == RADIOLIB_ERR_NONE); #if ARCH_PORTDUINO - if (settingsMap[rxen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[rxen_pin], LOW); + if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); } - if (settingsMap[txen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[txen_pin], LOW); + if (portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_txen_pin.pin, LOW); } #else #if defined(SX128X_RXEN) && (SX128X_RXEN != RADIOLIB_NC) // we have RXEN/TXEN control - turn off RX and TX power @@ -210,11 +211,11 @@ template void SX128xInterface::addReceiveMetadata(meshtastic_Mes template void SX128xInterface::configHardwareForSend() { #if ARCH_PORTDUINO - if (settingsMap[txen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[txen_pin], HIGH); + if (portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_txen_pin.pin, HIGH); } - if (settingsMap[rxen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[rxen_pin], LOW); + if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); } #else @@ -241,11 +242,11 @@ template void SX128xInterface::startReceive() setStandby(); #if ARCH_PORTDUINO - if (settingsMap[rxen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[rxen_pin], HIGH); + if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_rxen_pin.pin, HIGH); } - if (settingsMap[txen_pin] != RADIOLIB_NC) { - digitalWrite(settingsMap[txen_pin], LOW); + if (portduino_config.lora_txen_pin.pin != RADIOLIB_NC) { + digitalWrite(portduino_config.lora_txen_pin.pin, LOW); } #else diff --git a/src/mesh/StaticPointerQueue.h b/src/mesh/StaticPointerQueue.h new file mode 100644 index 000000000..398ee450c --- /dev/null +++ b/src/mesh/StaticPointerQueue.h @@ -0,0 +1,77 @@ +#pragma once + +#include "concurrency/OSThread.h" +#include "freertosinc.h" +#include + +/** + * A static circular buffer queue for pointers. + * This provides the same interface as PointerQueue but uses a statically allocated + * buffer instead of dynamic allocation. + */ +template class StaticPointerQueue +{ + static_assert(MaxElements > 0, "MaxElements must be greater than 0"); + + T *buffer[MaxElements]; + int head = 0; + int tail = 0; + int count = 0; + concurrency::OSThread *reader = nullptr; + + public: + StaticPointerQueue() + { + // Initialize all buffer elements to nullptr to silence warnings and ensure clean state + for (int i = 0; i < MaxElements; i++) { + buffer[i] = nullptr; + } + } + + int numFree() const { return MaxElements - count; } + + bool isEmpty() const { return count == 0; } + + int numUsed() const { return count; } + + bool enqueue(T *x, TickType_t maxWait = portMAX_DELAY) + { + if (count >= MaxElements) { + return false; // Queue is full + } + + if (reader) { + reader->setInterval(0); + concurrency::mainDelay.interrupt(); + } + + buffer[tail] = x; + tail = (tail + 1) % MaxElements; + count++; + return true; + } + + bool dequeue(T **p, TickType_t maxWait = portMAX_DELAY) + { + if (count == 0) { + return false; // Queue is empty + } + + *p = buffer[head]; + head = (head + 1) % MaxElements; + count--; + return true; + } + + // returns a ptr or null if the queue was empty + T *dequeuePtr(TickType_t maxWait = portMAX_DELAY) + { + T *p; + return dequeue(&p, maxWait) ? p : nullptr; + } + + void setReader(concurrency::OSThread *t) { reader = t; } + + // For compatibility with PointerQueue interface + int getMaxLen() const { return MaxElements; } +}; diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 4a42e5197..20026767e 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -16,6 +16,95 @@ int32_t StreamAPI::runOncePart() return result; } +int32_t StreamAPI::runOncePart(char *buf, uint16_t bufLen) +{ + auto result = readStream(buf, bufLen); + writeStream(); + checkConnectionTimeout(); + return result; +} + +/** + * Read any rx chars from the link and call handleRecStream + */ +int32_t StreamAPI::readStream(char *buf, uint16_t bufLen) +{ + if (bufLen < 1) { + // Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time + bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000); + return recentRx ? 5 : 250; + } else { + handleRecStream(buf, bufLen); + // we had bytes available this time, so assume we might have them next time also + lastRxMsec = millis(); + return 0; + } +} + +/** + * call getFromRadio() and deliver encapsulated packets to the Stream + */ +void StreamAPI::writeStream() +{ + if (canWrite) { + uint32_t len; + do { + // Send every packet we can + len = getFromRadio(txBuf + HEADER_LEN); + emitTxBuffer(len); + } while (len); + } +} + +int32_t StreamAPI::handleRecStream(char *buf, uint16_t bufLen) +{ + uint16_t index = 0; + while (bufLen > index) { // Currently we never want to block + int cInt = buf[index++]; + if (cInt < 0) + break; // We ran out of characters (even though available said otherwise) - this can happen on rf52 adafruit + // arduino + + uint8_t c = (uint8_t)cInt; + + // Use the read pointer for a little state machine, first look for framing, then length bytes, then payload + size_t ptr = rxPtr; + + rxPtr++; // assume we will probably advance the rxPtr + rxBuf[ptr] = c; // store all bytes (including framing) + + // console->printf("rxPtr %d ptr=%d c=0x%x\n", rxPtr, ptr, c); + + if (ptr == 0) { // looking for START1 + if (c != START1) + rxPtr = 0; // failed to find framing + } else if (ptr == 1) { // looking for START2 + if (c != START2) + rxPtr = 0; // failed to find framing + } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing + uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing + + // console->printf("len %d\n", len); + + if (ptr == HEADER_LEN - 1) { + // we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid + // protobuf also) + if (len > MAX_TO_FROM_RADIO_SIZE) + rxPtr = 0; // length is bogus, restart search for framing + } + + if (rxPtr != 0) // Is packet still considered 'good'? + if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload? + rxPtr = 0; // start over again on the next packet + + // If we didn't just fail the packet and we now have the right # of bytes, parse it + handleToRadio(rxBuf + HEADER_LEN, len); + } + } + } + return 0; +} + /** * Read any rx chars from the link and call handleToRadio */ @@ -76,21 +165,6 @@ int32_t StreamAPI::readStream() } } -/** - * call getFromRadio() and deliver encapsulated packets to the Stream - */ -void StreamAPI::writeStream() -{ - if (canWrite) { - uint32_t len; - do { - // Send every packet we can - len = getFromRadio(txBuf + HEADER_LEN); - emitTxBuffer(len); - } while (len); - } -} - /** * Send the current txBuffer over our stream */ diff --git a/src/mesh/StreamAPI.h b/src/mesh/StreamAPI.h index 6e0364bc1..547dd0175 100644 --- a/src/mesh/StreamAPI.h +++ b/src/mesh/StreamAPI.h @@ -50,12 +50,15 @@ class StreamAPI : public PhoneAPI * phone. */ virtual int32_t runOncePart(); + virtual int32_t runOncePart(char *buf,uint16_t bufLen); private: /** * Read any rx chars from the link and call handleToRadio */ int32_t readStream(); + int32_t readStream(char *buf,uint16_t bufLen); + int32_t handleRecStream(char *buf,uint16_t bufLen); /** * call getFromRadio() and deliver encapsulated packets to the Stream diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index 42ebb8417..74953d8fc 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -342,6 +342,11 @@ void handleFsBrowseStatic(HTTPRequest *req, HTTPResponse *res) res->print(value->Stringify().c_str()); delete value; + + // Clean up the fileList to prevent memory leak + for (auto *val : fileList) { + delete val; + } } void handleFsDeleteStatic(HTTPRequest *req, HTTPResponse *res) @@ -610,33 +615,38 @@ void handleReport(HTTPRequest *req, HTTPResponse *res) res->println("
");
     }
 
+    // Helper lambda to create JSON array and clean up memory properly
+    auto createJSONArrayFromLog = [](uint32_t *logArray, int count) -> JSONValue * {
+        JSONArray tempArray;
+        for (int i = 0; i < count; i++) {
+            tempArray.push_back(new JSONValue((int)logArray[i]));
+        }
+        JSONValue *result = new JSONValue(tempArray);
+        // Clean up original array to prevent memory leak
+        for (auto *val : tempArray) {
+            delete val;
+        }
+        return result;
+    };
+
     // data->airtime->tx_log
-    JSONArray txLogValues;
     uint32_t *logArray;
     logArray = airTime->airtimeReport(TX_LOG);
-    for (int i = 0; i < airTime->getPeriodsToLog(); i++) {
-        txLogValues.push_back(new JSONValue((int)logArray[i]));
-    }
+    JSONValue *txLogJsonValue = createJSONArrayFromLog(logArray, airTime->getPeriodsToLog());
 
     // data->airtime->rx_log
-    JSONArray rxLogValues;
     logArray = airTime->airtimeReport(RX_LOG);
-    for (int i = 0; i < airTime->getPeriodsToLog(); i++) {
-        rxLogValues.push_back(new JSONValue((int)logArray[i]));
-    }
+    JSONValue *rxLogJsonValue = createJSONArrayFromLog(logArray, airTime->getPeriodsToLog());
 
     // data->airtime->rx_all_log
-    JSONArray rxAllLogValues;
     logArray = airTime->airtimeReport(RX_ALL_LOG);
-    for (int i = 0; i < airTime->getPeriodsToLog(); i++) {
-        rxAllLogValues.push_back(new JSONValue((int)logArray[i]));
-    }
+    JSONValue *rxAllLogJsonValue = createJSONArrayFromLog(logArray, airTime->getPeriodsToLog());
 
     // data->airtime
     JSONObject jsonObjAirtime;
-    jsonObjAirtime["tx_log"] = new JSONValue(txLogValues);
-    jsonObjAirtime["rx_log"] = new JSONValue(rxLogValues);
-    jsonObjAirtime["rx_all_log"] = new JSONValue(rxAllLogValues);
+    jsonObjAirtime["tx_log"] = txLogJsonValue;
+    jsonObjAirtime["rx_log"] = rxLogJsonValue;
+    jsonObjAirtime["rx_all_log"] = rxAllLogJsonValue;
     jsonObjAirtime["channel_utilization"] = new JSONValue(airTime->channelUtilizationPercent());
     jsonObjAirtime["utilization_tx"] = new JSONValue(airTime->utilizationTXPercent());
     jsonObjAirtime["seconds_since_boot"] = new JSONValue(int(airTime->getSecondsSinceBoot()));
@@ -765,6 +775,11 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res)
     JSONValue *value = new JSONValue(jsonObjOuter);
     res->print(value->Stringify().c_str());
     delete value;
+
+    // Clean up the nodesArray to prevent memory leak
+    for (auto *val : nodesArray) {
+        delete val;
+    }
 }
 
 /*
@@ -955,5 +970,10 @@ void handleScanNetworks(HTTPRequest *req, HTTPResponse *res)
     JSONValue *value = new JSONValue(jsonObjOuter);
     res->print(value->Stringify().c_str());
     delete value;
+
+    // Clean up the networkObjs to prevent memory leak
+    for (auto *val : networkObjs) {
+        delete val;
+    }
 }
 #endif
\ No newline at end of file
diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h
index 08c03dc6b..224f45de2 100644
--- a/src/mesh/mesh-pb-constants.h
+++ b/src/mesh/mesh-pb-constants.h
@@ -18,6 +18,21 @@
 #define MAX_RX_TOPHONE 32
 #endif
 
+/// max number of QueueStatus packets which can be waiting for delivery to phone
+#ifndef MAX_RX_QUEUESTATUS_TOPHONE
+#define MAX_RX_QUEUESTATUS_TOPHONE 4
+#endif
+
+/// max number of MqttClientProxyMessage packets which can be waiting for delivery to phone
+#ifndef MAX_RX_MQTTPROXY_TOPHONE
+#define MAX_RX_MQTTPROXY_TOPHONE 32
+#endif
+
+/// max number of ClientNotification packets which can be waiting for delivery to phone
+#ifndef MAX_RX_NOTIFICATION_TOPHONE
+#define MAX_RX_NOTIFICATION_TOPHONE 4
+#endif
+
 /// Verify baseline assumption of node size. If it increases, we need to reevaluate
 /// the impact of its memory footprint, notably on MAX_NUM_NODES.
 static_assert(sizeof(meshtastic_NodeInfoLite) <= 200, "NodeInfoLite size increased. Reconsider impact on MAX_NUM_NODES.");
diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp
index 7d3542e83..3e9dbe8c2 100644
--- a/src/mesh/raspihttp/PiWebServer.cpp
+++ b/src/mesh/raspihttp/PiWebServer.cpp
@@ -65,8 +65,8 @@ mail:   marchammermann@googlemail.com
 #define DEFAULT_REALM "default_realm"
 #define PREFIX ""
 
-#define KEY_PATH settingsStrings[websslkeypath].c_str()
-#define CERT_PATH settingsStrings[websslcertpath].c_str()
+#define KEY_PATH portduino_config.webserver_ssl_key_path.c_str()
+#define CERT_PATH portduino_config.webserver_ssl_cert_path.c_str()
 
 struct _file_config configWeb;
 
@@ -458,8 +458,8 @@ PiWebServerThread::PiWebServerThread()
         }
     }
 
-    if (settingsMap[webserverport] != 0) {
-        webservport = settingsMap[webserverport];
+    if (portduino_config.webserverport != 0) {
+        webservport = portduino_config.webserverport;
         LOG_INFO("Use webserver port from yaml config %i ", webservport);
     } else {
         LOG_INFO("Webserver port in yaml config set to 0, defaulting to port 9443");
@@ -490,7 +490,7 @@ PiWebServerThread::PiWebServerThread()
         u_map_put(&configWeb.mime_types, ".ico", "image/x-icon");
         u_map_put(&configWeb.mime_types, ".svg", "image/svg+xml");
 
-        webrootpath = settingsStrings[webserverrootpath];
+        webrootpath = portduino_config.webserver_root_path;
 
         configWeb.files_path = (char *)webrootpath.c_str();
         configWeb.url_prefix = "";
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 5cfdd2063..f5a26197a 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -523,7 +523,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
     if (mp.decoded.want_response && !myReply) {
         myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
     }
-
+    if (mp.pki_encrypted && myReply) {
+        myReply->pki_encrypted = true;
+    }
     return handled;
 }
 
@@ -736,6 +738,13 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
             requiresReboot = false;
         }
 
+#if defined(ARCH_PORTDUINO)
+        // If running on portduino and using SimRadio, do not require reboot
+        if (SimRadio::instance) {
+            requiresReboot = false;
+        }
+#endif
+
 #ifdef RF95_FAN_EN
         // Turn PA off if disabled by config
         if (c.payload_variant.lora.pa_fan_disabled) {
@@ -952,6 +961,9 @@ void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req)
         res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag;
         setPassKey(&res);
         myReply = allocDataProtobuf(res);
+        if (req.pki_encrypted) {
+            myReply->pki_encrypted = true;
+        }
     }
 }
 
@@ -1023,6 +1035,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
         res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
         setPassKey(&res);
         myReply = allocDataProtobuf(res);
+        if (req.pki_encrypted) {
+            myReply->pki_encrypted = true;
+        }
     }
 }
 
@@ -1043,19 +1058,32 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
             res.get_module_config_response.payload_variant.serial = moduleConfig.serial;
             break;
         case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG:
+#if !MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION
             LOG_INFO("Get module config: External Notification");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag;
             res.get_module_config_response.payload_variant.external_notification = moduleConfig.external_notification;
+#else
+            LOG_DEBUG("External Notification module excluded from build, skipping config");
+#endif
             break;
         case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG:
+#if !MESHTASTIC_EXCLUDE_STOREFORWARD
             LOG_INFO("Get module config: Store & Forward");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag;
             res.get_module_config_response.payload_variant.store_forward = moduleConfig.store_forward;
+#else
+            LOG_DEBUG("Store & Forward module excluded from build, skipping config");
+#endif
             break;
         case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG:
+#if !MESHTASTIC_EXCLUDE_RANGETEST
             LOG_INFO("Get module config: Range Test");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_range_test_tag;
             res.get_module_config_response.payload_variant.range_test = moduleConfig.range_test;
+#else
+            LOG_DEBUG("Range Test module excluded from build, skipping config");
+            // Don't set payload variant - will result in empty response
+#endif
             break;
         case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG:
             LOG_INFO("Get module config: Telemetry");
@@ -1068,9 +1096,13 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
             res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message;
             break;
         case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG:
+#if !MESHTASTIC_EXCLUDE_AUDIO
             LOG_INFO("Get module config: Audio");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag;
             res.get_module_config_response.payload_variant.audio = moduleConfig.audio;
+#else
+            LOG_DEBUG("Audio module excluded from build, skipping config");
+#endif
             break;
         case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG:
             LOG_INFO("Get module config: Remote Hardware");
@@ -1083,19 +1115,31 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
             res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info;
             break;
         case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG:
+#if !(NO_EXT_GPIO || MESHTASTIC_EXCLUDE_DETECTIONSENSOR)
             LOG_INFO("Get module config: Detection Sensor");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag;
             res.get_module_config_response.payload_variant.detection_sensor = moduleConfig.detection_sensor;
+#else
+            LOG_DEBUG("Detection Sensor module excluded from build, skipping config");
+#endif
             break;
         case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG:
+#if !MESHTASTIC_EXCLUDE_AMBIENTLIGHTING
             LOG_INFO("Get module config: Ambient Lighting");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag;
             res.get_module_config_response.payload_variant.ambient_lighting = moduleConfig.ambient_lighting;
+#else
+            LOG_DEBUG("Ambient Lighting module excluded from build, skipping config");
+#endif
             break;
         case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG:
+#if !MESHTASTIC_EXCLUDE_PAXCOUNTER
             LOG_INFO("Get module config: Paxcounter");
             res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
             res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter;
+#else
+            LOG_DEBUG("Paxcounter module excluded from build, skipping config");
+#endif
             break;
         }
 
@@ -1110,6 +1154,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
         res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
         setPassKey(&res);
         myReply = allocDataProtobuf(res);
+        if (req.pki_encrypted) {
+            myReply->pki_encrypted = true;
+        }
     }
 }
 
@@ -1134,6 +1181,9 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r
     }
     setPassKey(&r);
     myReply = allocDataProtobuf(r);
+    if (req.pki_encrypted) {
+        myReply->pki_encrypted = true;
+    }
 }
 
 void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
@@ -1143,6 +1193,9 @@ void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
     r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
     setPassKey(&r);
     myReply = allocDataProtobuf(r);
+    if (req.pki_encrypted) {
+        myReply->pki_encrypted = true;
+    }
 }
 
 void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &req)
@@ -1211,6 +1264,9 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r
     r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag;
     setPassKey(&r);
     myReply = allocDataProtobuf(r);
+    if (req.pki_encrypted) {
+        myReply->pki_encrypted = true;
+    }
 }
 
 void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t channelIndex)
@@ -1222,6 +1278,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch
         r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag;
         setPassKey(&r);
         myReply = allocDataProtobuf(r);
+        if (req.pki_encrypted) {
+            myReply->pki_encrypted = true;
+        }
     }
 }
 
@@ -1231,6 +1290,9 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req)
     r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag;
     r.get_ui_config_response = uiconfig;
     myReply = allocDataProtobuf(r);
+    if (req.pki_encrypted) {
+        myReply->pki_encrypted = true;
+    }
 }
 
 void AdminModule::reboot(int32_t seconds)
diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp
index d40dcd24f..e8c85235a 100644
--- a/src/modules/CannedMessageModule.cpp
+++ b/src/modules/CannedMessageModule.cpp
@@ -13,12 +13,16 @@
 #include "detect/ScanI2C.h"
 #include "graphics/Screen.h"
 #include "graphics/SharedUIDisplay.h"
+#include "graphics/draw/NotificationRenderer.h"
 #include "graphics/emotes.h"
 #include "graphics/images.h"
 #include "main.h" // for cardkb_found
 #include "mesh/generated/meshtastic/cannedmessages.pb.h"
 #include "modules/AdminModule.h"
 #include "modules/ExternalNotificationModule.h" // for buzzer control
+#if HAS_TRACKBALL
+#include "input/TrackballInterruptImpl1.h"
+#endif
 #if !MESHTASTIC_EXCLUDE_GPS
 #include "GPS.h"
 #endif
@@ -38,6 +42,7 @@
 
 extern ScanI2C::DeviceAddress cardkb_found;
 extern bool graphics::isMuted;
+extern bool osk_found;
 
 static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto";
 static NodeNum lastDest = NODENUM_BROADCAST;
@@ -151,10 +156,13 @@ int CannedMessageModule::splitConfiguredMessages()
     int tempCount = 0;
     // Insert at position 0 (top)
     tempMessages[tempCount++] = "[Select Destination]";
-
 #if defined(USE_VIRTUAL_KEYBOARD)
-    // Add a "Free Text" entry at the top if using a keyboard
+    // Add a "Free Text" entry at the top if using a touch screen virtual keyboard
     tempMessages[tempCount++] = "[-- Free Text --]";
+#else
+    if (osk_found && screen) {
+        tempMessages[tempCount++] = "[-- Free Text --]";
+    }
 #endif
 
     // First message always starts at buffer start
@@ -341,6 +349,8 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
     case CANNED_MESSAGE_RUN_STATE_FREETEXT:
         return handleFreeTextInput(event); // All allowed input for this state
 
+    // Virtual keyboard mode: Show virtual keyboard and handle input
+
     // If sending, block all input except global/system (handled above)
     case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE:
         return 1;
@@ -627,15 +637,65 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo
             notifyObservers(&e);
             return true;
         }
+#else
+        if (strcmp(current, "[-- Free Text --]") == 0) {
+            if (osk_found && screen) {
+                char headerBuffer[64];
+                if (this->dest == NODENUM_BROADCAST) {
+                    snprintf(headerBuffer, sizeof(headerBuffer), "To: Broadcast@%s", channels.getName(this->channel));
+                } else {
+                    snprintf(headerBuffer, sizeof(headerBuffer), "To: %s", getNodeName(this->dest));
+                }
+                screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) {
+                    if (!text.empty()) {
+                        this->freetext = text.c_str();
+                        this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT;
+                        runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
+                        currentMessageIndex = -1;
+
+                        UIFrameEvent e;
+                        e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
+                        this->notifyObservers(&e);
+                        screen->forceDisplay();
+
+                        setIntervalFromNow(500);
+                        return;
+                    } else {
+                        // Don't delete virtual keyboard immediately - it might still be executing
+                        // Instead, just clear the callback and reset banner to stop input processing
+                        graphics::NotificationRenderer::textInputCallback = nullptr;
+                        graphics::NotificationRenderer::resetBanner();
+
+                        // Return to inactive state
+                        this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
+                        this->currentMessageIndex = -1;
+                        this->freetext = "";
+                        this->cursor = 0;
+
+                        // Force display update to show normal screen
+                        UIFrameEvent e;
+                        e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
+                        this->notifyObservers(&e);
+                        screen->forceDisplay();
+
+                        // Schedule cleanup for next loop iteration to ensure safe deletion
+                        setIntervalFromNow(50);
+                        return;
+                    }
+                });
+
+                return true;
+            }
+        }
 #endif
 
         // Normal canned message selection
         if (runState == CANNED_MESSAGE_RUN_STATE_INACTIVE || runState == CANNED_MESSAGE_RUN_STATE_DISABLED) {
         } else {
+#if CANNED_MESSAGE_ADD_CONFIRMATION
             // Show confirmation dialog before sending canned message
             NodeNum destNode = dest;
             ChannelIndex chan = channel;
-#if CANNED_MESSAGE_ADD_CONFIRMATION
             graphics::menuHandler::showConfirmationBanner("Send message?", [this, destNode, chan, current]() {
                 this->sendText(destNode, chan, current, false);
                 payload = runState;
@@ -943,12 +1003,54 @@ int32_t CannedMessageModule::runOnce()
 
     // Normal module disable/idle handling
     if ((this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) {
+        // Clean up virtual keyboard if needed when going inactive
+        if (graphics::NotificationRenderer::virtualKeyboard && graphics::NotificationRenderer::textInputCallback == nullptr) {
+            LOG_INFO("Performing delayed virtual keyboard cleanup");
+            delete graphics::NotificationRenderer::virtualKeyboard;
+            graphics::NotificationRenderer::virtualKeyboard = nullptr;
+        }
+
         temporaryMessage = "";
         return INT32_MAX;
     }
 
+    // Handle delayed virtual keyboard message sending
+    if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
+        // Virtual keyboard message sending case - text was not empty
+        if (this->freetext.length() > 0) {
+            LOG_INFO("Processing delayed virtual keyboard send: '%s'", this->freetext.c_str());
+            sendText(this->dest, this->channel, this->freetext.c_str(), true);
+
+            // Clean up virtual keyboard after sending
+            if (graphics::NotificationRenderer::virtualKeyboard) {
+                LOG_INFO("Cleaning up virtual keyboard after message send");
+                delete graphics::NotificationRenderer::virtualKeyboard;
+                graphics::NotificationRenderer::virtualKeyboard = nullptr;
+                graphics::NotificationRenderer::textInputCallback = nullptr;
+                graphics::NotificationRenderer::resetBanner();
+            }
+
+            // Clear payload to indicate virtual keyboard processing is complete
+            // But keep SENDING_ACTIVE state to show "Sending..." screen for 2 seconds
+            this->payload = 0;
+        } else {
+            // Empty message, just go inactive
+            LOG_INFO("Empty freetext detected in delayed processing, returning to inactive state");
+            this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
+        }
+
+        UIFrameEvent e;
+        e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
+        this->currentMessageIndex = -1;
+        this->freetext = "";
+        this->cursor = 0;
+        this->notifyObservers(&e);
+        return 2000;
+    }
+
     UIFrameEvent e;
-    if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) ||
+    if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload != 0 &&
+         this->payload != CANNED_MESSAGE_RUN_STATE_FREETEXT) ||
         (this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) ||
         (this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION)) {
         this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
@@ -958,6 +1060,18 @@ int32_t CannedMessageModule::runOnce()
         this->freetext = "";
         this->cursor = 0;
         this->notifyObservers(&e);
+    }
+    // Handle SENDING_ACTIVE state transition after virtual keyboard message
+    else if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == 0) {
+        // This happens after virtual keyboard message sending is complete
+        LOG_INFO("Virtual keyboard message sending completed, returning to inactive state");
+        this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
+        temporaryMessage = "";
+        e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
+        this->currentMessageIndex = -1;
+        this->freetext = "";
+        this->cursor = 0;
+        this->notifyObservers(&e);
     } else if (((this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT)) &&
                !Throttle::isWithinTimespanMs(this->lastTouchMillis, INACTIVATE_AFTER_MS)) {
         // Reset module on inactivity
@@ -966,9 +1080,23 @@ int32_t CannedMessageModule::runOnce()
         this->freetext = "";
         this->cursor = 0;
         this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
+
+        // Clean up virtual keyboard if it exists during timeout
+        if (graphics::NotificationRenderer::virtualKeyboard) {
+            LOG_INFO("Cleaning up virtual keyboard due to module timeout");
+            delete graphics::NotificationRenderer::virtualKeyboard;
+            graphics::NotificationRenderer::virtualKeyboard = nullptr;
+            graphics::NotificationRenderer::textInputCallback = nullptr;
+            graphics::NotificationRenderer::resetBanner();
+        }
+
         this->notifyObservers(&e);
     } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) {
-        if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
+        if (this->payload == 0) {
+            // [Exit] button pressed - return to inactive state
+            LOG_INFO("Processing [Exit] action - returning to inactive state");
+            this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
+        } else if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
             if (this->freetext.length() > 0) {
                 sendText(this->dest, this->channel, this->freetext.c_str(), true);
                 this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
@@ -991,7 +1119,6 @@ int32_t CannedMessageModule::runOnce()
                 this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
             }
         }
-        e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
         this->currentMessageIndex = -1;
         this->freetext = "";
         this->cursor = 0;
diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp
index 1f871f87e..2f2934984 100644
--- a/src/modules/ExternalNotificationModule.cpp
+++ b/src/modules/ExternalNotificationModule.cpp
@@ -364,9 +364,10 @@ ExternalNotificationModule::ExternalNotificationModule()
     // moduleConfig.external_notification.alert_message_buzzer = true;
 
     if (moduleConfig.external_notification.enabled) {
+#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER)
         if (inputBroker) // put our callback in the inputObserver list
             inputObserver.observe(inputBroker);
-
+#endif
         if (nodeDB->loadProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, sizeof(meshtastic_RTTTLConfig),
                               &meshtastic_RTTTLConfig_msg, &rtttlConfig) != LoadFileResult::LOAD_SUCCESS) {
             memset(rtttlConfig.ringtone, 0, sizeof(rtttlConfig.ringtone));
diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp
index 3528f57f5..85d183aef 100644
--- a/src/modules/Modules.cpp
+++ b/src/modules/Modules.cpp
@@ -3,6 +3,7 @@
 #include "buzz/BuzzerFeedbackThread.h"
 #include "input/ExpressLRSFiveWay.h"
 #include "input/InputBroker.h"
+#include "input/RotaryEncoderImpl.h"
 #include "input/RotaryEncoderInterruptImpl1.h"
 #include "input/SerialKeyboardImpl.h"
 #include "input/TrackballInterruptImpl1.h"
@@ -87,7 +88,7 @@
 #include "modules/StoreForwardModule.h"
 #endif
 #endif
-#if defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)
+
 #if !MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION
 #include "modules/ExternalNotificationModule.h"
 #endif
@@ -97,7 +98,6 @@
 #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_SERIAL
 #include "modules/SerialModule.h"
 #endif
-#endif
 
 #if !MESHTASTIC_EXCLUDE_DROPZONE
 #include "modules/DropzoneModule.h"
@@ -141,7 +141,10 @@ void setupModules()
         detectionSensorModule = new DetectionSensorModule();
 #endif
 #if !MESHTASTIC_EXCLUDE_ATAK
-        atakPluginModule = new AtakPluginModule();
+        if (IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TAK,
+                      meshtastic_Config_DeviceConfig_Role_TAK_TRACKER)) {
+            atakPluginModule = new AtakPluginModule();
+        }
 #endif
 #if !MESHTASTIC_EXCLUDE_PKI
         keyVerificationModule = new KeyVerificationModule();
@@ -170,11 +173,20 @@ void setupModules()
                 delete rotaryEncoderInterruptImpl1;
                 rotaryEncoderInterruptImpl1 = nullptr;
             }
+#ifdef T_LORA_PAGER
+            // use a special FSM based rotary encoder version for T-LoRa Pager
+            rotaryEncoderImpl = new RotaryEncoderImpl();
+            if (!rotaryEncoderImpl->init()) {
+                delete rotaryEncoderImpl;
+                rotaryEncoderImpl = nullptr;
+            }
+#else
             upDownInterruptImpl1 = new UpDownInterruptImpl1();
             if (!upDownInterruptImpl1->init()) {
                 delete upDownInterruptImpl1;
                 upDownInterruptImpl1 = nullptr;
             }
+#endif
             cardKbI2cImpl = new CardKbI2cImpl();
             cardKbI2cImpl->init();
 #ifdef INPUTBROKER_MATRIX_TYPE
@@ -236,8 +248,8 @@ void setupModules()
 #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_POWER_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
         new PowerTelemetryModule();
 #endif
-#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) &&               \
-    !defined(CONFIG_IDF_TARGET_ESP32C3)
+#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)) &&                             \
+    !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3)
 #if !MESHTASTIC_EXCLUDE_SERIAL
         if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
             new SerialModule();
@@ -258,13 +270,11 @@ void setupModules()
         storeForwardModule = new StoreForwardModule();
 #endif
 #endif
-#if defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)
 #if !MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION
         externalNotificationModule = new ExternalNotificationModule();
 #endif
 #if !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS
         new RangeTestModule();
-#endif
 #endif
     } else {
 #if !MESHTASTIC_EXCLUDE_ADMIN
diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp
index eebf428a4..97dc17001 100644
--- a/src/modules/NeighborInfoModule.cpp
+++ b/src/modules/NeighborInfoModule.cpp
@@ -105,14 +105,15 @@ void NeighborInfoModule::sendNeighborInfo(NodeNum dest, bool wantReplies)
 {
     meshtastic_NeighborInfo neighborInfo = meshtastic_NeighborInfo_init_zero;
     collectNeighborInfo(&neighborInfo);
-    meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo);
-    // send regardless of whether or not we have neighbors in our DB,
-    // because we want to get neighbors for the next cycle
-    p->to = dest;
-    p->decoded.want_response = wantReplies;
-    p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
-    printNeighborInfo("SENDING", &neighborInfo);
-    service->sendToMesh(p, RX_SRC_LOCAL, true);
+    // only send neighbours if we have some to send
+    if (neighborInfo.neighbors_count > 0) {
+        meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo);
+        p->to = dest;
+        p->decoded.want_response = wantReplies;
+        p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
+        printNeighborInfo("SENDING", &neighborInfo);
+        service->sendToMesh(p, RX_SRC_LOCAL, true);
+    }
 }
 
 /*
@@ -214,4 +215,4 @@ meshtastic_Neighbor *NeighborInfoModule::getOrCreateNeighbor(NodeNum originalSen
         neighbors.push_back(new_nbr);
     }
     return &neighbors.back();
-}
\ No newline at end of file
+}
diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp
index 6f3d69acf..d1d2d9ead 100644
--- a/src/modules/RangeTestModule.cpp
+++ b/src/modules/RangeTestModule.cpp
@@ -31,7 +31,7 @@ uint32_t packetSequence = 0;
 
 int32_t RangeTestModule::runOnce()
 {
-#if defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_PORTDUINO)
+#if defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_STM32WL) || defined(ARCH_PORTDUINO)
 
     /*
         Uncomment the preferences below if you want to use the module
@@ -130,7 +130,7 @@ void RangeTestModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
 
 ProcessMessage RangeTestModuleRadio::handleReceived(const meshtastic_MeshPacket &mp)
 {
-#if defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_PORTDUINO)
+#if defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_STM32WL) || defined(ARCH_PORTDUINO)
 
     if (moduleConfig.range_test.enabled) {
 
diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp
index b10413cc8..e7e92c79a 100644
--- a/src/modules/RoutingModule.cpp
+++ b/src/modules/RoutingModule.cpp
@@ -73,7 +73,7 @@ uint8_t RoutingModule::getHopLimitForResponse(uint8_t hopStart, uint8_t hopLimit
     return Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); // Use the default hop limit
 }
 
-RoutingModule::RoutingModule() : ProtobufModule(ROUTING_MODULE, meshtastic_PortNum_ROUTING_APP, &meshtastic_Routing_msg)
+RoutingModule::RoutingModule() : ProtobufModule("routing", meshtastic_PortNum_ROUTING_APP, &meshtastic_Routing_msg)
 {
     isPromiscuous = true;
 
diff --git a/src/modules/RoutingModule.h b/src/modules/RoutingModule.h
index 7b43a6e98..c047f6e29 100644
--- a/src/modules/RoutingModule.h
+++ b/src/modules/RoutingModule.h
@@ -2,8 +2,6 @@
 #include "Channels.h"
 #include "ProtobufModule.h"
 
-static const char *ROUTING_MODULE = "routing";
-
 /**
  * Routing module for router control messages
  */
diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp
index 866497ecc..7485f1c2d 100644
--- a/src/modules/SerialModule.cpp
+++ b/src/modules/SerialModule.cpp
@@ -45,9 +45,12 @@
 
 
 */
+#ifdef HELTEC_MESH_SOLAR
+#include "meshSolarApp.h"
+#endif
 
-#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) &&               \
-    !defined(CONFIG_IDF_TARGET_ESP32C3)
+#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)) &&                             \
+    !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3)
 
 #define RX_BUFFER 256
 #define TIMEOUT 250
@@ -60,11 +63,11 @@
 SerialModule *serialModule;
 SerialModuleRadio *serialModuleRadio;
 
-#if defined(TTGO_T_ECHO) || defined(T_ECHO_LITE) || defined(CANARYONE) || defined(MESHLINK) || defined(ELECROW_ThinkNode_M1) ||  \
-    defined(ELECROW_ThinkNode_M5)
+#if defined(TTGO_T_ECHO) || defined(CANARYONE) || defined(MESHLINK) || defined(ELECROW_ThinkNode_M1) ||                          \
+    defined(ELECROW_ThinkNode_M5) || defined(HELTEC_MESH_SOLAR) || defined(T_ECHO_LITE)
 SerialModule::SerialModule() : StreamAPI(&Serial), concurrency::OSThread("Serial") {}
 static Print *serialPrint = &Serial;
-#elif defined(CONFIG_IDF_TARGET_ESP32C6)
+#elif defined(CONFIG_IDF_TARGET_ESP32C6) || defined(RAK3172)
 SerialModule::SerialModule() : StreamAPI(&Serial1), concurrency::OSThread("Serial") {}
 static Print *serialPrint = &Serial1;
 #else
@@ -78,7 +81,8 @@ size_t serialPayloadSize;
 bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &config)
 {
     if (config.override_console_serial_port && !IS_ONE_OF(config.mode, meshtastic_ModuleConfig_SerialConfig_Serial_Mode_NMEA,
-                                                          meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO)) {
+                                                          meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO,
+                                                          meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MS_CONFIG)) {
         const char *warning =
             "Invalid Serial config: override console serial port is only supported in NMEA and CalTopo output-only modes.";
         LOG_ERROR(warning);
@@ -169,7 +173,18 @@ int32_t SerialModule::runOnce()
                 Serial.begin(baud);
                 Serial.setTimeout(moduleConfig.serial.timeout > 0 ? moduleConfig.serial.timeout : TIMEOUT);
             }
-
+#elif defined(ARCH_STM32WL)
+#ifndef RAK3172
+            HardwareSerial *serialInstance = &Serial2;
+#else
+            HardwareSerial *serialInstance = &Serial1;
+#endif
+            if (moduleConfig.serial.rxd && moduleConfig.serial.txd) {
+                serialInstance->setTx(moduleConfig.serial.txd);
+                serialInstance->setRx(moduleConfig.serial.rxd);
+            }
+            serialInstance->begin(baud);
+            serialInstance->setTimeout(moduleConfig.serial.timeout > 0 ? moduleConfig.serial.timeout : TIMEOUT);
 #elif defined(ARCH_ESP32)
 
             if (moduleConfig.serial.rxd && moduleConfig.serial.txd) {
@@ -241,13 +256,28 @@ int32_t SerialModule::runOnce()
             else if ((moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_WS85)) {
                 processWXSerial();
 
-            } else {
+            }
+#if defined(HELTEC_MESH_SOLAR)
+            else if ((moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MS_CONFIG)) {
+                serialPayloadSize = Serial.readBytes(serialBytes, sizeof(serialBytes) - 1);
+                // If the parsing fails, the following parsing will be performed.
+                if ((serialPayloadSize > 0) && (meshSolarCmdHandle(serialBytes) != 0)) {
+                    return runOncePart(serialBytes, serialPayloadSize);
+                }
+            }
+#endif
+            else {
 #if defined(CONFIG_IDF_TARGET_ESP32C6)
                 while (Serial1.available()) {
                     serialPayloadSize = Serial1.readBytes(serialBytes, meshtastic_Constants_DATA_PAYLOAD_LEN);
 #else
-                while (Serial2.available()) {
-                    serialPayloadSize = Serial2.readBytes(serialBytes, meshtastic_Constants_DATA_PAYLOAD_LEN);
+#ifndef RAK3172
+                HardwareSerial *serialInstance = &Serial2;
+#else
+                HardwareSerial *serialInstance = &Serial1;
+#endif
+                while (serialInstance->available()) {
+                    serialPayloadSize = serialInstance->readBytes(serialBytes, meshtastic_Constants_DATA_PAYLOAD_LEN);
 #endif
                     serialModuleRadio->sendPayload();
                 }
@@ -497,7 +527,7 @@ ParsedLine parseLine(const char *line)
 void SerialModule::processWXSerial()
 {
 #if !defined(TTGO_T_ECHO) && !defined(T_ECHO_LITE) && !defined(CANARYONE) && !defined(CONFIG_IDF_TARGET_ESP32C6) &&              \
-    !defined(MESHLINK) && !defined(ELECROW_ThinkNode_M1) && !defined(ELECROW_ThinkNode_M5)
+    !defined(MESHLINK) && !defined(ELECROW_ThinkNode_M1) && !defined(ELECROW_ThinkNode_M5) && !defined(ARCH_STM32WL)
     static unsigned int lastAveraged = 0;
     static unsigned int averageIntervalMillis = 300000; // 5 minutes hard coded.
     static double dir_sum_sin = 0;
diff --git a/src/modules/SerialModule.h b/src/modules/SerialModule.h
index 1c74c927c..dbe4f75db 100644
--- a/src/modules/SerialModule.h
+++ b/src/modules/SerialModule.h
@@ -8,8 +8,8 @@
 #include 
 #include 
 
-#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) &&               \
-    !defined(CONFIG_IDF_TARGET_ESP32C3)
+#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)) &&                             \
+    !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3)
 
 class SerialModule : public StreamAPI, private concurrency::OSThread
 {
diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp
index 8926b171c..8ac160f8b 100644
--- a/src/modules/Telemetry/EnvironmentTelemetry.cpp
+++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp
@@ -30,7 +30,8 @@
 
 namespace graphics
 {
-extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool battery_only);
+extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert,
+                             bool show_date);
 }
 #if __has_include()
 #include "Sensor/AHT10.h"
@@ -198,6 +199,13 @@ T1000xSensor t1000xSensor;
 IndicatorSensor indicatorSensor;
 #endif
 
+#if __has_include()
+#include "Sensor/TSL2561Sensor.h"
+TSL2561Sensor tsl2561Sensor;
+#else
+NullSensor tsl2561Sensor;
+#endif
+
 #define FAILED_STATE_SENSOR_READ_MULTIPLIER 10
 #define DISPLAY_RECEIVEID_MEASUREMENTS_ON_SCREEN true
 
@@ -296,6 +304,8 @@ int32_t EnvironmentTelemetryModule::runOnce()
                 result = max17048Sensor.runOnce();
             if (cgRadSens.hasSensor())
                 result = cgRadSens.runOnce();
+            if (tsl2561Sensor.hasSensor())
+                result = tsl2561Sensor.runOnce();
             if (pct2075Sensor.hasSensor())
                 result = pct2075Sensor.runOnce();
                 // this only works on the wismesh hub with the solar option. This is not an I2C sensor, so we don't need the
@@ -642,6 +652,10 @@ bool EnvironmentTelemetryModule::getEnvironmentTelemetry(meshtastic_Telemetry *m
         valid = valid && nau7802Sensor.getMetrics(m);
         hasSensor = true;
     }
+    if (tsl2561Sensor.hasSensor()) {
+        valid = valid && tsl2561Sensor.getMetrics(m);
+        hasSensor = true;
+    }
     if (aht10Sensor.hasSensor()) {
         if (!bmp280Sensor.hasSensor() && !bmp3xxSensor.hasSensor()) {
             valid = valid && aht10Sensor.getMetrics(m);
diff --git a/src/modules/Telemetry/HostMetrics.cpp b/src/modules/Telemetry/HostMetrics.cpp
index 8f10b9228..dcde495a2 100644
--- a/src/modules/Telemetry/HostMetrics.cpp
+++ b/src/modules/Telemetry/HostMetrics.cpp
@@ -9,11 +9,11 @@
 int32_t HostMetricsModule::runOnce()
 {
 #if ARCH_PORTDUINO
-    if (settingsMap[hostMetrics_interval] == 0) {
+    if (portduino_config.hostMetrics_interval == 0) {
         return disable();
     } else {
         sendMetrics();
-        return 60 * 1000 * settingsMap[hostMetrics_interval];
+        return 60 * 1000 * portduino_config.hostMetrics_interval;
     }
 #else
     return disable();
@@ -110,8 +110,8 @@ meshtastic_Telemetry HostMetricsModule::getHostMetrics()
             proc_loadavg.close();
         }
     }
-    if (settingsStrings[hostMetrics_user_command] != "") {
-        std::string userCommandResult = exec(settingsStrings[hostMetrics_user_command].c_str());
+    if (portduino_config.hostMetrics_user_command != "") {
+        std::string userCommandResult = exec(portduino_config.hostMetrics_user_command.c_str());
         if (userCommandResult.length() > 1) {
             strncpy(t.variant.host_metrics.user_string, userCommandResult.c_str(), sizeof(t.variant.host_metrics.user_string));
             t.variant.host_metrics.user_string[sizeof(t.variant.host_metrics.user_string) - 1] = '\0';
@@ -135,7 +135,7 @@ bool HostMetricsModule::sendMetrics()
     p->to = NODENUM_BROADCAST;
     p->decoded.want_response = false;
     p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
-    p->channel = settingsMap[hostMetrics_channel];
+    p->channel = portduino_config.hostMetrics_channel;
     LOG_INFO("Send packet to mesh");
     service->sendToMesh(p, RX_SRC_LOCAL, true);
     return true;
diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp
index 35409edef..479861a2e 100644
--- a/src/modules/Telemetry/PowerTelemetry.cpp
+++ b/src/modules/Telemetry/PowerTelemetry.cpp
@@ -24,7 +24,8 @@
 
 namespace graphics
 {
-extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool battery_only);
+extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert,
+                             bool show_date);
 }
 
 int32_t PowerTelemetryModule::runOnce()
diff --git a/src/modules/Telemetry/Sensor/TSL2561Sensor.cpp b/src/modules/Telemetry/Sensor/TSL2561Sensor.cpp
new file mode 100644
index 000000000..9f3b7e460
--- /dev/null
+++ b/src/modules/Telemetry/Sensor/TSL2561Sensor.cpp
@@ -0,0 +1,41 @@
+#include "configuration.h"
+
+#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include()
+
+#include "../mesh/generated/meshtastic/telemetry.pb.h"
+#include "TSL2561Sensor.h"
+#include "TelemetrySensor.h"
+#include 
+
+TSL2561Sensor::TSL2561Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_TSL2561, "TSL2561") {}
+
+int32_t TSL2561Sensor::runOnce()
+{
+    LOG_INFO("Init sensor: %s", sensorName);
+    if (!hasSensor()) {
+        return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
+    }
+
+    status = tsl.begin(nodeTelemetrySensorsMap[sensorType].second);
+
+    return initI2CSensor();
+}
+
+void TSL2561Sensor::setup()
+{
+    tsl.setGain(TSL2561_GAIN_1X);
+    tsl.setIntegrationTime(TSL2561_INTEGRATIONTIME_101MS);
+}
+
+bool TSL2561Sensor::getMetrics(meshtastic_Telemetry *measurement)
+{
+    measurement->variant.environment_metrics.has_lux = true;
+    sensors_event_t event;
+    tsl.getEvent(&event);
+    measurement->variant.environment_metrics.lux = event.light;
+    LOG_INFO("Lux: %f", measurement->variant.environment_metrics.lux);
+
+    return true;
+}
+
+#endif
diff --git a/src/modules/Telemetry/Sensor/TSL2561Sensor.h b/src/modules/Telemetry/Sensor/TSL2561Sensor.h
new file mode 100644
index 000000000..0329becd8
--- /dev/null
+++ b/src/modules/Telemetry/Sensor/TSL2561Sensor.h
@@ -0,0 +1,23 @@
+#include "configuration.h"
+
+#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include()
+
+#include "../mesh/generated/meshtastic/telemetry.pb.h"
+#include "TelemetrySensor.h"
+#include 
+
+class TSL2561Sensor : public TelemetrySensor
+{
+  private:
+    // The magic number is a sensor id, the actual value doesn't matter
+    Adafruit_TSL2561_Unified tsl = Adafruit_TSL2561_Unified(TSL2561_ADDR_LOW, 12345);
+
+  protected:
+    virtual void setup() override;
+
+  public:
+    TSL2561Sensor();
+    virtual int32_t runOnce() override;
+    virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
+};
+#endif
diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h
index d0efa5ae6..ddca5767c 100755
--- a/src/motion/BMX160Sensor.h
+++ b/src/motion/BMX160Sensor.h
@@ -7,7 +7,7 @@
 
 #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C
 
-#if defined(RAK_4631) && !defined(RAK2560) && __has_include()
+#if !defined(RAK2560) && __has_include()
 
 #include "Fusion/Fusion.h"
 #include 
diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h
index 522e862ac..f3954840d 100644
--- a/src/platform/esp32/architecture.h
+++ b/src/platform/esp32/architecture.h
@@ -45,6 +45,9 @@
 #ifndef HAS_CUSTOM_CRYPTO_ENGINE
 #define HAS_CUSTOM_CRYPTO_ENGINE 1
 #endif
+#ifndef HAS_32768HZ
+#define HAS_32768HZ 0
+#endif
 
 #if defined(HAS_AXP192) || defined(HAS_AXP2101)
 #define HAS_PMU
@@ -192,6 +195,8 @@
 #define HW_VENDOR meshtastic_HardwareModel_LINK_32
 #elif defined(T_DECK_PRO)
 #define HW_VENDOR meshtastic_HardwareModel_T_DECK_PRO
+#elif defined(T_LORA_PAGER)
+#define HW_VENDOR meshtastic_HardwareModel_T_LORA_PAGER
 #endif
 
 // -----------------------------------------------------------------------------
@@ -215,3 +220,13 @@
 #endif
 
 #define SERIAL0_RX_GPIO 3 // Always GPIO3 on ESP32 // FIXME: may be different on ESP32-S3, etc.
+
+// Setup flag, which indicates if our device supports power management
+#ifdef CONFIG_PM_ENABLE
+#define HAS_ESP32_PM_SUPPORT 1
+#endif
+
+// Setup flag, which indicates if our device supports dynamic light sleep
+#if defined(HAS_ESP32_PM_SUPPORT) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE)
+#define HAS_ESP32_DYNAMIC_LIGHT_SLEEP 1
+#endif
\ No newline at end of file
diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp
index cdea53c9a..760964119 100644
--- a/src/platform/esp32/main-esp32.cpp
+++ b/src/platform/esp32/main-esp32.cpp
@@ -64,7 +64,7 @@ void getMacAddr(uint8_t *dmac)
 #endif
 }
 
-#ifdef HAS_32768HZ
+#if HAS_32768HZ
 #define CALIBRATE_ONE(cali_clk) calibrate_one(cali_clk, #cali_clk)
 
 static uint32_t calibrate_one(rtc_cal_sel_t cal_clk, const char *name)
@@ -86,17 +86,17 @@ void enableSlowCLK()
     uint32_t cal_32k = CALIBRATE_ONE(RTC_CAL_32K_XTAL);
 
     if (cal_32k == 0) {
-        LOG_DEBUG("32K XTAL OSC has not started up");
+        LOG_DEBUG("32k XTAL OSC has not started up");
     } else {
         rtc_clk_slow_freq_set(RTC_SLOW_FREQ_32K_XTAL);
-        LOG_DEBUG("Switch RTC Source to 32.768Khz succeeded, using 32K XTAL");
+        LOG_DEBUG("Switch RTC Source to 32.768kHz succeeded, using 32k XTAL");
         CALIBRATE_ONE(RTC_CAL_RTC_MUX);
         CALIBRATE_ONE(RTC_CAL_32K_XTAL);
     }
     CALIBRATE_ONE(RTC_CAL_RTC_MUX);
     CALIBRATE_ONE(RTC_CAL_32K_XTAL);
     if (rtc_clk_slow_freq_get() != RTC_SLOW_FREQ_32K_XTAL) {
-        LOG_WARN("Failed to switch 32K XTAL RTC source to 32.768Khz !!! ");
+        LOG_WARN("Failed to switch 32K XTAL RTC source to 32.768kHz !!! ");
         return;
     }
 }
@@ -182,7 +182,7 @@ void esp32Setup()
     res = esp_task_wdt_add(NULL);
     assert(res == ESP_OK);
 
-#ifdef HAS_32768HZ
+#if HAS_32768HZ
     enableSlowCLK();
 #endif
 }
diff --git a/src/platform/extra_variants/t_lora_pager/variant.cpp b/src/platform/extra_variants/t_lora_pager/variant.cpp
new file mode 100644
index 000000000..ea5773d30
--- /dev/null
+++ b/src/platform/extra_variants/t_lora_pager/variant.cpp
@@ -0,0 +1,27 @@
+#include "configuration.h"
+
+#ifdef T_LORA_PAGER
+
+#include "AudioBoard.h"
+
+DriverPins PinsAudioBoardES8311;
+AudioBoard board(AudioDriverES8311, PinsAudioBoardES8311);
+
+// TLora Pager specific init
+void lateInitVariant()
+{
+    // AudioDriverLogger.begin(Serial, AudioDriverLogLevel::Debug);
+    // I2C: function, scl, sda
+    PinsAudioBoardES8311.addI2C(PinFunction::CODEC, Wire);
+    // I2S: function, mclk, bck, ws, data_out, data_in
+    PinsAudioBoardES8311.addI2S(PinFunction::CODEC, DAC_I2S_MCLK, DAC_I2S_BCK, DAC_I2S_WS, DAC_I2S_DOUT, DAC_I2S_DIN);
+
+    // configure codec
+    CodecConfig cfg;
+    cfg.input_device = ADC_INPUT_LINE1;
+    cfg.output_device = DAC_OUTPUT_ALL;
+    cfg.i2s.bits = BIT_LENGTH_16BITS;
+    cfg.i2s.rate = RATE_44K;
+    board.begin(cfg);
+}
+#endif
\ No newline at end of file
diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h
index 064bd8ef0..c9938062e 100644
--- a/src/platform/nrf52/architecture.h
+++ b/src/platform/nrf52/architecture.h
@@ -98,6 +98,8 @@
 #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_EINK
 #elif defined(SEEED_WIO_TRACKER_L1)
 #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1
+#elif defined(HELTEC_MESH_SOLAR)
+#define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_SOLAR
 #else
 #define HW_VENDOR meshtastic_HardwareModel_NRF52_UNKNOWN
 #endif
diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp
index 590d2f0ae..8ce74d5f7 100644
--- a/src/platform/nrf52/main-nrf52.cpp
+++ b/src/platform/nrf52/main-nrf52.cpp
@@ -323,7 +323,7 @@ void cpuDeepSleep(uint32_t msecToWake)
 #endif
 #endif
 
-#ifdef HELTEC_MESH_NODE_T114
+#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_MESH_SOLAR)
     nrf_gpio_cfg_default(PIN_GPS_PPS);
     detachInterrupt(PIN_GPS_PPS);
     detachInterrupt(PIN_BUTTON1);
diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp
index 929a45d09..b11d2547b 100644
--- a/src/platform/portduino/PortduinoGlue.cpp
+++ b/src/platform/portduino/PortduinoGlue.cpp
@@ -9,7 +9,6 @@
 #include "api/ServerAPI.h"
 #include "linux/gpio/LinuxGPIOPin.h"
 #include "meshUtils.h"
-#include "yaml-cpp/yaml.h"
 #include 
 #include 
 #include 
@@ -28,14 +27,13 @@
 
 #include "platform/portduino/USBHal.h"
 
-std::map settingsMap;
-std::map settingsStrings;
 portduino_config_struct portduino_config;
 std::ofstream traceFile;
 Ch341Hal *ch341Hal = nullptr;
 char *configPath = nullptr;
 char *optionMac = nullptr;
 bool verboseEnabled = false;
+bool yamlOnly = false;
 
 const char *argp_program_version = optstr(APP_VERSION);
 
@@ -75,6 +73,9 @@ static error_t parse_opt(int key, char *arg, struct argp_state *state)
     case 'v':
         verboseEnabled = true;
         break;
+    case 'y':
+        yamlOnly = true;
+        break;
     case ARGP_KEY_ARG:
         return 0;
     default:
@@ -90,6 +91,7 @@ void portduinoCustomInit()
                                            {"hwid", 'h', "HWID", 0, "The mac address to assign to this virtual machine"},
                                            {"sim", 's', 0, 0, "Run in Simulated radio mode"},
                                            {"verbose", 'v', 0, 0, "Set log level to full debug"},
+                                           {"output-yaml", 'y', 0, 0, "Output config yaml and exit"},
                                            {0}};
     static void *childArguments;
     static char doc[] = "Meshtastic native build.";
@@ -115,8 +117,8 @@ void getMacAddr(uint8_t *dmac)
             dmac[4] = hwId >> 8;
             dmac[5] = hwId & 0xff;
         }
-    } else if (settingsStrings[mac_address].length() > 11) {
-        MAC_from_string(settingsStrings[mac_address], dmac);
+    } else if (portduino_config.mac_address.length() > 11) {
+        MAC_from_string(portduino_config.mac_address, dmac);
         exit;
     } else {
 
@@ -148,89 +150,46 @@ void getMacAddr(uint8_t *dmac)
  */
 void portduinoSetup()
 {
-    printf("Set up Meshtastic on Portduino...\n");
     int max_GPIO = 0;
-    const configNames GPIO_lines[] = {cs_pin,
-                                      irq_pin,
-                                      busy_pin,
-                                      reset_pin,
-                                      sx126x_ant_sw_pin,
-                                      txen_pin,
-                                      rxen_pin,
-                                      displayDC,
-                                      displayCS,
-                                      displayBacklight,
-                                      displayBacklightPWMChannel,
-                                      displayReset,
-                                      touchscreenCS,
-                                      touchscreenIRQ,
-                                      userButtonPin,
-                                      tbUpPin,
-                                      tbDownPin,
-                                      tbLeftPin,
-                                      tbRightPin,
-                                      tbPressPin};
-
     std::string gpioChipName = "gpiochip";
-    settingsStrings[i2cdev] = "";
-    settingsStrings[keyboardDevice] = "";
-    settingsStrings[pointerDevice] = "";
-    settingsStrings[webserverrootpath] = "";
-    settingsStrings[spidev] = "";
-    settingsStrings[displayspidev] = "";
-    settingsMap[spiSpeed] = 2000000;
-    settingsMap[ascii_logs] = !isatty(1);
-    settingsMap[displayPanel] = no_screen;
-    settingsMap[touchscreenModule] = no_touchscreen;
-    settingsMap[tbUpPin] = RADIOLIB_NC;
-    settingsMap[tbDownPin] = RADIOLIB_NC;
-    settingsMap[tbLeftPin] = RADIOLIB_NC;
-    settingsMap[tbRightPin] = RADIOLIB_NC;
-    settingsMap[tbPressPin] = RADIOLIB_NC;
-
-    YAML::Node yamlConfig;
+    portduino_config.displayPanel = no_screen;
 
     if (portduino_config.force_simradio == true) {
-        settingsMap[use_simradio] = true;
+        portduino_config.lora_module = use_simradio;
     } else if (configPath != nullptr) {
         if (loadConfig(configPath)) {
-            std::cout << "Using " << configPath << " as config file" << std::endl;
+            if (!yamlOnly)
+                std::cout << "Using " << configPath << " as config file" << std::endl;
         } else {
             std::cout << "Unable to use " << configPath << " as config file" << std::endl;
             exit(EXIT_FAILURE);
         }
     } else if (access("config.yaml", R_OK) == 0) {
         if (loadConfig("config.yaml")) {
-            std::cout << "Using local config.yaml as config file" << std::endl;
+            if (!yamlOnly)
+                std::cout << "Using local config.yaml as config file" << std::endl;
         } else {
             std::cout << "Unable to use local config.yaml as config file" << std::endl;
             exit(EXIT_FAILURE);
         }
     } else if (access("/etc/meshtasticd/config.yaml", R_OK) == 0) {
         if (loadConfig("/etc/meshtasticd/config.yaml")) {
-            std::cout << "Using /etc/meshtasticd/config.yaml as config file" << std::endl;
+            if (!yamlOnly)
+                std::cout << "Using /etc/meshtasticd/config.yaml as config file" << std::endl;
         } else {
             std::cout << "Unable to use /etc/meshtasticd/config.yaml as config file" << std::endl;
             exit(EXIT_FAILURE);
         }
     } else {
-        std::cout << "No 'config.yaml' found..." << std::endl;
-        settingsMap[use_simradio] = true;
+        if (!yamlOnly)
+            std::cout << "No 'config.yaml' found..." << std::endl;
+        portduino_config.lora_module = use_simradio;
     }
 
-    if (settingsMap[use_simradio] == true) {
-        std::cout << "Running in simulated mode." << std::endl;
-        settingsMap[maxnodes] = 200;               // Default to 200 nodes
-        settingsMap[logoutputlevel] = level_debug; // Default to debug
-        // Set the random seed equal to TCPPort to have a different seed per instance
-        randomSeed(TCPPort);
-        return;
-    }
-
-    if (settingsStrings[config_directory] != "") {
+    if (portduino_config.config_directory != "") {
         std::string filetype = ".yaml";
         for (const std::filesystem::directory_entry &entry :
-             std::filesystem::directory_iterator{settingsStrings[config_directory]}) {
+             std::filesystem::directory_iterator{portduino_config.config_directory}) {
             if (ends_with(entry.path().string(), ".yaml")) {
                 std::cout << "Also using " << entry << " as additional config file" << std::endl;
                 loadConfig(entry.path().c_str());
@@ -238,15 +197,28 @@ void portduinoSetup()
         }
     }
 
+    if (yamlOnly) {
+        std::cout << portduino_config.emit_yaml() << std::endl;
+        exit(EXIT_SUCCESS);
+    }
+
+    if (portduino_config.lora_module == use_simradio) {
+        std::cout << "Running in simulated mode." << std::endl;
+        portduino_config.MaxNodes = 200; // Default to 200 nodes
+        // Set the random seed equal to TCPPort to have a different seed per instance
+        randomSeed(TCPPort);
+        return;
+    }
+
     // If LoRa `Module: auto` (default in config.yaml),
     // attempt to auto config based on Product Strings
-    if (settingsMap[use_autoconf] == true) {
+    if (portduino_config.lora_module == use_autoconf) {
         char autoconf_product[96] = {0};
         // Try CH341
         try {
             std::cout << "autoconf: Looking for CH341 device..." << std::endl;
-            ch341Hal =
-                new Ch341Hal(0, settingsStrings[lora_usb_serial_num], settingsMap[lora_usb_vid], settingsMap[lora_usb_pid]);
+            ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid,
+                                    portduino_config.lora_usb_pid);
             ch341Hal->getProductString(autoconf_product, 95);
             delete ch341Hal;
             std::cout << "autoconf: Found CH341 device " << autoconf_product << std::endl;
@@ -323,7 +295,7 @@ void portduinoSetup()
                         if (mac_start != nullptr) {
                             std::cout << "autoconf: Found mac data " << mac_start << std::endl;
                             if (strlen(mac_start) == 12)
-                                settingsStrings[mac_address] = std::string(mac_start);
+                                portduino_config.mac_address = std::string(mac_start);
                         }
                         if (devID_start != nullptr) {
                             std::cout << "autoconf: Found deviceid data " << devID_start << std::endl;
@@ -354,7 +326,7 @@ void portduinoSetup()
                 std::cerr << "autoconf: Unable to find config for " << autoconf_product << std::endl;
                 exit(EXIT_FAILURE);
             }
-            if (loadConfig((settingsStrings[available_directory] + product_config).c_str())) {
+            if (loadConfig((portduino_config.available_directory + product_config).c_str())) {
                 std::cout << "autoconf: Using " << product_config << " as config file for " << autoconf_product << std::endl;
             } else {
                 std::cerr << "autoconf: Unable to use " << product_config << " as config file for " << autoconf_product
@@ -363,15 +335,16 @@ void portduinoSetup()
             }
         } else {
             std::cerr << "autoconf: Could not locate any devices" << std::endl;
+            exit(EXIT_FAILURE);
         }
     }
 
     // if we're using a usermode driver, we need to initialize it here, to get a serial number back for mac address
     uint8_t dmac[6] = {0};
-    if (settingsStrings[spidev] == "ch341") {
+    if (portduino_config.lora_spi_dev == "ch341") {
         try {
-            ch341Hal =
-                new Ch341Hal(0, settingsStrings[lora_usb_serial_num], settingsMap[lora_usb_vid], settingsMap[lora_usb_pid]);
+            ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid,
+                                    portduino_config.lora_usb_pid);
         } catch (std::exception &e) {
             std::cerr << e.what() << std::endl;
             std::cerr << "Could not initialize CH341 device!" << std::endl;
@@ -383,7 +356,7 @@ void portduinoSetup()
         char product_string[96] = {0};
         ch341Hal->getProductString(product_string, 95);
         std::cout << "CH341 Product " << product_string << std::endl;
-        if (strlen(serial) == 8 && settingsStrings[mac_address].length() < 12) {
+        if (strlen(serial) == 8 && portduino_config.mac_address.length() < 12) {
             uint8_t hash[32] = {0};
             memcpy(hash, serial, 8);
             crypto->hash(hash, 8);
@@ -395,7 +368,7 @@ void portduinoSetup()
             dmac[5] = hash[5];
             char macBuf[13] = {0};
             sprintf(macBuf, "%02X%02X%02X%02X%02X%02X", dmac[0], dmac[1], dmac[2], dmac[3], dmac[4], dmac[5]);
-            settingsStrings[mac_address] = macBuf;
+            portduino_config.mac_address = macBuf;
         }
     }
 
@@ -409,100 +382,38 @@ void portduinoSetup()
     // Rather important to set this, if not running simulated.
     randomSeed(time(NULL));
 
-    std::string defaultGpioChipName = gpioChipName + std::to_string(settingsMap[default_gpiochip]);
-
-    for (configNames i : GPIO_lines) {
-        if (settingsMap.count(i) && settingsMap[i] > max_GPIO)
-            max_GPIO = settingsMap[i];
+    std::string defaultGpioChipName = gpioChipName + std::to_string(portduino_config.lora_default_gpiochip);
+    for (auto i : portduino_config.all_pins) {
+        if (i->enabled && i->pin > max_GPIO)
+            max_GPIO = i->pin;
     }
 
     gpioInit(max_GPIO + 1); // Done here so we can inform Portduino how many GPIOs we need.
 
     // Need to bind all the configured GPIO pins so they're not simulated
     // TODO: If one of these fails, we should log and terminate
-    if (settingsMap.count(userButtonPin) > 0 && settingsMap[userButtonPin] != RADIOLIB_NC) {
-        if (initGPIOPin(settingsMap[userButtonPin], defaultGpioChipName, settingsMap[userButtonPin]) != ERRNO_OK) {
-            settingsMap[userButtonPin] = RADIOLIB_NC;
-        }
-    }
-    if (settingsMap.count(tbUpPin) > 0 && settingsMap[tbUpPin] != RADIOLIB_NC) {
-        if (initGPIOPin(settingsMap[tbUpPin], defaultGpioChipName, settingsMap[tbUpPin]) != ERRNO_OK) {
-            settingsMap[tbUpPin] = RADIOLIB_NC;
-        }
-    }
-    if (settingsMap.count(tbDownPin) > 0 && settingsMap[tbDownPin] != RADIOLIB_NC) {
-        if (initGPIOPin(settingsMap[tbDownPin], defaultGpioChipName, settingsMap[tbDownPin]) != ERRNO_OK) {
-            settingsMap[tbDownPin] = RADIOLIB_NC;
-        }
-    }
-    if (settingsMap.count(tbLeftPin) > 0 && settingsMap[tbLeftPin] != RADIOLIB_NC) {
-        if (initGPIOPin(settingsMap[tbLeftPin], defaultGpioChipName, settingsMap[tbLeftPin]) != ERRNO_OK) {
-            settingsMap[tbLeftPin] = RADIOLIB_NC;
-        }
-    }
-    if (settingsMap.count(tbRightPin) > 0 && settingsMap[tbRightPin] != RADIOLIB_NC) {
-        if (initGPIOPin(settingsMap[tbRightPin], defaultGpioChipName, settingsMap[tbRightPin]) != ERRNO_OK) {
-            settingsMap[tbRightPin] = RADIOLIB_NC;
-        }
-    }
-    if (settingsMap.count(tbPressPin) > 0 && settingsMap[tbPressPin] != RADIOLIB_NC) {
-        if (initGPIOPin(settingsMap[tbPressPin], defaultGpioChipName, settingsMap[tbPressPin]) != ERRNO_OK) {
-            settingsMap[tbPressPin] = RADIOLIB_NC;
-        }
-    }
-    if (settingsMap[displayPanel] != no_screen) {
-        if (settingsMap[displayCS] > 0)
-            initGPIOPin(settingsMap[displayCS], defaultGpioChipName, settingsMap[displayCS]);
-        if (settingsMap[displayDC] > 0)
-            initGPIOPin(settingsMap[displayDC], defaultGpioChipName, settingsMap[displayDC]);
-        if (settingsMap[displayBacklight] > 0)
-            initGPIOPin(settingsMap[displayBacklight], defaultGpioChipName, settingsMap[displayBacklight]);
-        if (settingsMap[displayReset] > 0)
-            initGPIOPin(settingsMap[displayReset], defaultGpioChipName, settingsMap[displayReset]);
-    }
-    if (settingsMap[touchscreenModule] != no_touchscreen) {
-        if (settingsMap[touchscreenCS] > 0)
-            initGPIOPin(settingsMap[touchscreenCS], defaultGpioChipName, settingsMap[touchscreenCS]);
-        if (settingsMap[touchscreenIRQ] > 0)
-            initGPIOPin(settingsMap[touchscreenIRQ], defaultGpioChipName, settingsMap[touchscreenIRQ]);
+    for (auto i : portduino_config.all_pins) {
+        if (i->enabled)
+            if (initGPIOPin(i->pin, gpioChipName + std::to_string(i->gpiochip), i->line) != ERRNO_OK) {
+                printf("Error setting pin number %d. It may not exist, or may already be in use.\n", i->line);
+                exit(EXIT_FAILURE);
+            }
     }
 
     // Only initialize the radio pins when dealing with real, kernel controlled SPI hardware
-    if (settingsStrings[spidev] != "" && settingsStrings[spidev] != "ch341") {
-        const struct {
-            configNames pin;
-            configNames gpiochip;
-            configNames line;
-        } pinMappings[] = {{cs_pin, cs_gpiochip, cs_line},
-                           {irq_pin, irq_gpiochip, irq_line},
-                           {busy_pin, busy_gpiochip, busy_line},
-                           {reset_pin, reset_gpiochip, reset_line},
-                           {rxen_pin, rxen_gpiochip, rxen_line},
-                           {txen_pin, txen_gpiochip, txen_line},
-                           {sx126x_ant_sw_pin, sx126x_ant_sw_gpiochip, sx126x_ant_sw_line}};
-        for (auto &pinMap : pinMappings) {
-            auto setMapIter = settingsMap.find(pinMap.pin);
-            if (setMapIter != settingsMap.end() && setMapIter->second != RADIOLIB_NC) {
-                if (initGPIOPin(setMapIter->second, gpioChipName + std::to_string(settingsMap[pinMap.gpiochip]),
-                                settingsMap[pinMap.line]) != ERRNO_OK) {
-                    printf("Error setting pin number %d. It may not exist, or may already be in use.\n",
-                           settingsMap[pinMap.line]);
-                    exit(EXIT_FAILURE);
-                }
-            }
-        }
-        SPI.begin(settingsStrings[spidev].c_str());
+    if (portduino_config.lora_spi_dev != "" && portduino_config.lora_spi_dev != "ch341") {
+        SPI.begin(portduino_config.lora_spi_dev.c_str());
     }
-    if (settingsStrings[traceFilename] != "") {
+    if (portduino_config.traceFilename != "") {
         try {
-            traceFile.open(settingsStrings[traceFilename], std::ios::out | std::ios::app);
+            traceFile.open(portduino_config.traceFilename, std::ios::out | std::ios::app);
         } catch (std::ofstream::failure &e) {
             std::cout << "*** traceFile Exception " << e.what() << std::endl;
             exit(EXIT_FAILURE);
         }
     }
-    if (verboseEnabled && settingsMap[logoutputlevel] != level_trace) {
-        settingsMap[logoutputlevel] = level_debug;
+    if (verboseEnabled && portduino_config.logoutputlevel != level_trace) {
+        portduino_config.logoutputlevel = level_debug;
     }
 
     return;
@@ -537,99 +448,78 @@ bool loadConfig(const char *configPath)
         yamlConfig = YAML::LoadFile(configPath);
         if (yamlConfig["Logging"]) {
             if (yamlConfig["Logging"]["LogLevel"].as("info") == "trace") {
-                settingsMap[logoutputlevel] = level_trace;
+                portduino_config.logoutputlevel = level_trace;
             } else if (yamlConfig["Logging"]["LogLevel"].as("info") == "debug") {
-                settingsMap[logoutputlevel] = level_debug;
+                portduino_config.logoutputlevel = level_debug;
             } else if (yamlConfig["Logging"]["LogLevel"].as("info") == "info") {
-                settingsMap[logoutputlevel] = level_info;
+                portduino_config.logoutputlevel = level_info;
             } else if (yamlConfig["Logging"]["LogLevel"].as("info") == "warn") {
-                settingsMap[logoutputlevel] = level_warn;
+                portduino_config.logoutputlevel = level_warn;
             } else if (yamlConfig["Logging"]["LogLevel"].as("info") == "error") {
-                settingsMap[logoutputlevel] = level_error;
+                portduino_config.logoutputlevel = level_error;
             }
-            settingsStrings[traceFilename] = yamlConfig["Logging"]["TraceFile"].as("");
+            portduino_config.traceFilename = yamlConfig["Logging"]["TraceFile"].as("");
             if (yamlConfig["Logging"]["AsciiLogs"]) {
                 // Default is !isatty(1) but can be set explicitly in config.yaml
-                settingsMap[ascii_logs] = yamlConfig["Logging"]["AsciiLogs"].as();
+                portduino_config.ascii_logs = yamlConfig["Logging"]["AsciiLogs"].as();
+                portduino_config.ascii_logs_explicit = true;
             }
         }
         if (yamlConfig["Lora"]) {
-            const struct {
-                configNames cfgName;
-                std::string strName;
-            } loraModules[] = {{use_simradio, "sim"},  {use_autoconf, "auto"}, {use_rf95, "RF95"},     {use_sx1262, "sx1262"},
-                               {use_sx1268, "sx1268"}, {use_sx1280, "sx1280"}, {use_lr1110, "lr1110"}, {use_lr1120, "lr1120"},
-                               {use_lr1121, "lr1121"}, {use_llcc68, "LLCC68"}};
-            for (auto &loraModule : loraModules) {
-                settingsMap[loraModule.cfgName] = false;
-            }
+
             if (yamlConfig["Lora"]["Module"]) {
-                for (auto &loraModule : loraModules) {
-                    if (yamlConfig["Lora"]["Module"].as("") == loraModule.strName) {
-                        settingsMap[loraModule.cfgName] = true;
+                for (auto &loraModule : portduino_config.loraModules) {
+                    if (yamlConfig["Lora"]["Module"].as("") == loraModule.second) {
+                        portduino_config.lora_module = loraModule.first;
                         break;
                     }
                 }
             }
+            if (yamlConfig["Lora"]["SX126X_MAX_POWER"])
+                portduino_config.sx126x_max_power = yamlConfig["Lora"]["SX126X_MAX_POWER"].as(22);
+            if (yamlConfig["Lora"]["SX128X_MAX_POWER"])
+                portduino_config.sx128x_max_power = yamlConfig["Lora"]["SX128X_MAX_POWER"].as(13);
+            if (yamlConfig["Lora"]["LR1110_MAX_POWER"])
+                portduino_config.lr1110_max_power = yamlConfig["Lora"]["LR1110_MAX_POWER"].as(22);
+            if (yamlConfig["Lora"]["LR1120_MAX_POWER"])
+                portduino_config.lr1120_max_power = yamlConfig["Lora"]["LR1120_MAX_POWER"].as(13);
+            if (yamlConfig["Lora"]["RF95_MAX_POWER"])
+                portduino_config.rf95_max_power = yamlConfig["Lora"]["RF95_MAX_POWER"].as(20);
 
-            settingsMap[sx126x_max_power] = yamlConfig["Lora"]["SX126X_MAX_POWER"].as(22);
-            settingsMap[sx128x_max_power] = yamlConfig["Lora"]["SX128X_MAX_POWER"].as(13);
-            settingsMap[lr1110_max_power] = yamlConfig["Lora"]["LR1110_MAX_POWER"].as(22);
-            settingsMap[lr1120_max_power] = yamlConfig["Lora"]["LR1120_MAX_POWER"].as(13);
-            settingsMap[rf95_max_power] = yamlConfig["Lora"]["RF95_MAX_POWER"].as(20);
+            if (portduino_config.lora_module != use_autoconf && portduino_config.lora_module != use_simradio &&
+                !portduino_config.force_simradio) {
+                portduino_config.dio2_as_rf_switch = yamlConfig["Lora"]["DIO2_AS_RF_SWITCH"].as(false);
+                portduino_config.dio3_tcxo_voltage = yamlConfig["Lora"]["DIO3_TCXO_VOLTAGE"].as(0) * 1000;
+                if (portduino_config.dio3_tcxo_voltage == 0 && yamlConfig["Lora"]["DIO3_TCXO_VOLTAGE"].as(false)) {
+                    portduino_config.dio3_tcxo_voltage = 1800; // default millivolts for "true"
+                }
 
-            settingsMap[dio2_as_rf_switch] = yamlConfig["Lora"]["DIO2_AS_RF_SWITCH"].as(false);
-            settingsMap[dio3_tcxo_voltage] = yamlConfig["Lora"]["DIO3_TCXO_VOLTAGE"].as(0) * 1000;
-            if (settingsMap[dio3_tcxo_voltage] == 0 && yamlConfig["Lora"]["DIO3_TCXO_VOLTAGE"].as(false)) {
-                settingsMap[dio3_tcxo_voltage] = 1800; // default millivolts for "true"
-            }
-
-            // backwards API compatibility and to globally set gpiochip once
-            int defaultGpioChip = settingsMap[default_gpiochip] = yamlConfig["Lora"]["gpiochip"].as(0);
-
-            const struct {
-                configNames pin;
-                configNames gpiochip;
-                configNames line;
-                std::string strName;
-            } pinMappings[] = {
-                {cs_pin, cs_gpiochip, cs_line, "CS"},
-                {irq_pin, irq_gpiochip, irq_line, "IRQ"},
-                {busy_pin, busy_gpiochip, busy_line, "Busy"},
-                {reset_pin, reset_gpiochip, reset_line, "Reset"},
-                {txen_pin, txen_gpiochip, txen_line, "TXen"},
-                {rxen_pin, rxen_gpiochip, rxen_line, "RXen"},
-                {sx126x_ant_sw_pin, sx126x_ant_sw_gpiochip, sx126x_ant_sw_line, "SX126X_ANT_SW"},
-            };
-            for (auto &pinMap : pinMappings) {
-                if (yamlConfig["Lora"][pinMap.strName].IsMap()) {
-                    settingsMap[pinMap.pin] = yamlConfig["Lora"][pinMap.strName]["pin"].as(RADIOLIB_NC);
-                    settingsMap[pinMap.line] = yamlConfig["Lora"][pinMap.strName]["line"].as(settingsMap[pinMap.pin]);
-                    settingsMap[pinMap.gpiochip] = yamlConfig["Lora"][pinMap.strName]["gpiochip"].as(defaultGpioChip);
-                } else { // backwards API compatibility
-                    settingsMap[pinMap.pin] = yamlConfig["Lora"][pinMap.strName].as(RADIOLIB_NC);
-                    settingsMap[pinMap.line] = settingsMap[pinMap.pin];
-                    settingsMap[pinMap.gpiochip] = defaultGpioChip;
+                // backwards API compatibility and to globally set gpiochip once
+                portduino_config.lora_default_gpiochip = yamlConfig["Lora"]["gpiochip"].as(0);
+                for (auto this_pin : portduino_config.all_pins) {
+                    if (this_pin->config_section == "Lora") {
+                        readGPIOFromYaml(yamlConfig["Lora"][this_pin->config_name], *this_pin);
+                    }
                 }
             }
 
-            settingsMap[spiSpeed] = yamlConfig["Lora"]["spiSpeed"].as(2000000);
-            settingsStrings[lora_usb_serial_num] = yamlConfig["Lora"]["USB_Serialnum"].as("");
-            settingsMap[lora_usb_pid] = yamlConfig["Lora"]["USB_PID"].as(0x5512);
-            settingsMap[lora_usb_vid] = yamlConfig["Lora"]["USB_VID"].as(0x1A86);
+            portduino_config.spiSpeed = yamlConfig["Lora"]["spiSpeed"].as(2000000);
+            portduino_config.lora_usb_serial_num = yamlConfig["Lora"]["USB_Serialnum"].as("");
+            portduino_config.lora_usb_pid = yamlConfig["Lora"]["USB_PID"].as(0x5512);
+            portduino_config.lora_usb_vid = yamlConfig["Lora"]["USB_VID"].as(0x1A86);
 
-            settingsStrings[spidev] = yamlConfig["Lora"]["spidev"].as("spidev0.0");
-            if (settingsStrings[spidev] != "ch341") {
-                settingsStrings[spidev] = "/dev/" + settingsStrings[spidev];
-                if (settingsStrings[spidev].length() == 14) {
-                    int x = settingsStrings[spidev].at(11) - '0';
-                    int y = settingsStrings[spidev].at(13) - '0';
+            portduino_config.lora_spi_dev = yamlConfig["Lora"]["spidev"].as("spidev0.0");
+            if (portduino_config.lora_spi_dev != "ch341") {
+                portduino_config.lora_spi_dev = "/dev/" + portduino_config.lora_spi_dev;
+                if (portduino_config.lora_spi_dev.length() == 14) {
+                    int x = portduino_config.lora_spi_dev.at(11) - '0';
+                    int y = portduino_config.lora_spi_dev.at(13) - '0';
                     // Pretty sure this is always true
                     if (x >= 0 && x < 10 && y >= 0 && y < 10) {
                         // I believe this bit of weirdness is specifically for the new GUI
-                        settingsMap[spidev] = x + y << 4;
-                        settingsMap[displayspidev] = settingsMap[spidev];
-                        settingsMap[touchscreenspidev] = settingsMap[spidev];
+                        portduino_config.lora_spi_dev_int = x + y << 4;
+                        portduino_config.display_spi_dev_int = portduino_config.lora_spi_dev_int;
+                        portduino_config.touchscreen_spi_dev_int = portduino_config.lora_spi_dev_int;
                     }
                 }
             }
@@ -676,163 +566,152 @@ bool loadConfig(const char *configPath)
                 }
             }
         }
-        if (yamlConfig["GPIO"]) {
-            settingsMap[userButtonPin] = yamlConfig["GPIO"]["User"].as(RADIOLIB_NC);
-        }
+        readGPIOFromYaml(yamlConfig["GPIO"]["User"], portduino_config.userButtonPin);
         if (yamlConfig["GPS"]) {
             std::string serialPath = yamlConfig["GPS"]["SerialPath"].as("");
             if (serialPath != "") {
                 Serial1.setPath(serialPath);
-                settingsMap[has_gps] = 1;
+                portduino_config.has_gps = 1;
             }
         }
         if (yamlConfig["I2C"]) {
-            settingsStrings[i2cdev] = yamlConfig["I2C"]["I2CDevice"].as("");
+            portduino_config.i2cdev = yamlConfig["I2C"]["I2CDevice"].as("");
         }
         if (yamlConfig["Display"]) {
-            if (yamlConfig["Display"]["Panel"].as("") == "ST7789")
-                settingsMap[displayPanel] = st7789;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ST7735")
-                settingsMap[displayPanel] = st7735;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ST7735S")
-                settingsMap[displayPanel] = st7735s;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ST7796")
-                settingsMap[displayPanel] = st7796;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ILI9341")
-                settingsMap[displayPanel] = ili9341;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ILI9342")
-                settingsMap[displayPanel] = ili9342;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ILI9486")
-                settingsMap[displayPanel] = ili9486;
-            else if (yamlConfig["Display"]["Panel"].as("") == "ILI9488")
-                settingsMap[displayPanel] = ili9488;
-            else if (yamlConfig["Display"]["Panel"].as("") == "HX8357D")
-                settingsMap[displayPanel] = hx8357d;
-            else if (yamlConfig["Display"]["Panel"].as("") == "X11")
-                settingsMap[displayPanel] = x11;
-            else if (yamlConfig["Display"]["Panel"].as("") == "FB")
-                settingsMap[displayPanel] = fb;
-            settingsMap[displayHeight] = yamlConfig["Display"]["Height"].as(0);
-            settingsMap[displayWidth] = yamlConfig["Display"]["Width"].as(0);
-            settingsMap[displayDC] = yamlConfig["Display"]["DC"].as(-1);
-            settingsMap[displayCS] = yamlConfig["Display"]["CS"].as(-1);
-            settingsMap[displayRGBOrder] = yamlConfig["Display"]["RGBOrder"].as(false);
-            settingsMap[displayBacklight] = yamlConfig["Display"]["Backlight"].as(-1);
-            settingsMap[displayBacklightInvert] = yamlConfig["Display"]["BacklightInvert"].as(false);
-            settingsMap[displayBacklightPWMChannel] = yamlConfig["Display"]["BacklightPWMChannel"].as(-1);
-            settingsMap[displayReset] = yamlConfig["Display"]["Reset"].as(-1);
-            settingsMap[displayOffsetX] = yamlConfig["Display"]["OffsetX"].as(0);
-            settingsMap[displayOffsetY] = yamlConfig["Display"]["OffsetY"].as(0);
-            settingsMap[displayRotate] = yamlConfig["Display"]["Rotate"].as(false);
-            settingsMap[displayOffsetRotate] = yamlConfig["Display"]["OffsetRotate"].as(1);
-            settingsMap[displayInvert] = yamlConfig["Display"]["Invert"].as(false);
-            settingsMap[displayBusFrequency] = yamlConfig["Display"]["BusFrequency"].as(40000000);
+
+            for (auto &screen_name : portduino_config.screen_names) {
+                if (yamlConfig["Display"]["Panel"].as("") == screen_name.second)
+                    portduino_config.displayPanel = screen_name.first;
+            }
+            portduino_config.displayHeight = yamlConfig["Display"]["Height"].as(0);
+            portduino_config.displayWidth = yamlConfig["Display"]["Width"].as(0);
+
+            readGPIOFromYaml(yamlConfig["Display"]["DC"], portduino_config.displayDC, -1);
+            readGPIOFromYaml(yamlConfig["Display"]["CS"], portduino_config.displayCS, -1);
+            readGPIOFromYaml(yamlConfig["Display"]["Backlight"], portduino_config.displayBacklight, -1);
+            readGPIOFromYaml(yamlConfig["Display"]["BacklightPWMChannel"], portduino_config.displayBacklightPWMChannel, -1);
+            readGPIOFromYaml(yamlConfig["Display"]["Reset"], portduino_config.displayReset, -1);
+
+            portduino_config.displayBacklightInvert = yamlConfig["Display"]["BacklightInvert"].as(false);
+            portduino_config.displayRGBOrder = yamlConfig["Display"]["RGBOrder"].as(false);
+            portduino_config.displayOffsetX = yamlConfig["Display"]["OffsetX"].as(0);
+            portduino_config.displayOffsetY = yamlConfig["Display"]["OffsetY"].as(0);
+            portduino_config.displayRotate = yamlConfig["Display"]["Rotate"].as(false);
+            portduino_config.displayOffsetRotate = yamlConfig["Display"]["OffsetRotate"].as(1);
+            portduino_config.displayInvert = yamlConfig["Display"]["Invert"].as(false);
+            portduino_config.displayBusFrequency = yamlConfig["Display"]["BusFrequency"].as(40000000);
             if (yamlConfig["Display"]["spidev"]) {
-                settingsStrings[displayspidev] = "/dev/" + yamlConfig["Display"]["spidev"].as("spidev0.1");
-                if (settingsStrings[displayspidev].length() == 14) {
-                    int x = settingsStrings[displayspidev].at(11) - '0';
-                    int y = settingsStrings[displayspidev].at(13) - '0';
+                portduino_config.display_spi_dev = "/dev/" + yamlConfig["Display"]["spidev"].as("spidev0.1");
+                if (portduino_config.display_spi_dev.length() == 14) {
+                    int x = portduino_config.display_spi_dev.at(11) - '0';
+                    int y = portduino_config.display_spi_dev.at(13) - '0';
                     if (x >= 0 && x < 10 && y >= 0 && y < 10) {
-                        settingsMap[displayspidev] = x + y << 4;
-                        settingsMap[touchscreenspidev] = settingsMap[displayspidev];
+                        portduino_config.display_spi_dev_int = x + y << 4;
+                        portduino_config.touchscreen_spi_dev_int = portduino_config.display_spi_dev_int;
                     }
                 }
             }
         }
         if (yamlConfig["Touchscreen"]) {
             if (yamlConfig["Touchscreen"]["Module"].as("") == "XPT2046")
-                settingsMap[touchscreenModule] = xpt2046;
+                portduino_config.touchscreenModule = xpt2046;
             else if (yamlConfig["Touchscreen"]["Module"].as("") == "STMPE610")
-                settingsMap[touchscreenModule] = stmpe610;
+                portduino_config.touchscreenModule = stmpe610;
             else if (yamlConfig["Touchscreen"]["Module"].as("") == "GT911")
-                settingsMap[touchscreenModule] = gt911;
+                portduino_config.touchscreenModule = gt911;
             else if (yamlConfig["Touchscreen"]["Module"].as("") == "FT5x06")
-                settingsMap[touchscreenModule] = ft5x06;
-            settingsMap[touchscreenCS] = yamlConfig["Touchscreen"]["CS"].as(-1);
-            settingsMap[touchscreenIRQ] = yamlConfig["Touchscreen"]["IRQ"].as(-1);
-            settingsMap[touchscreenBusFrequency] = yamlConfig["Touchscreen"]["BusFrequency"].as(1000000);
-            settingsMap[touchscreenRotate] = yamlConfig["Touchscreen"]["Rotate"].as(-1);
-            settingsMap[touchscreenI2CAddr] = yamlConfig["Touchscreen"]["I2CAddr"].as(-1);
+                portduino_config.touchscreenModule = ft5x06;
+
+            readGPIOFromYaml(yamlConfig["Touchscreen"]["CS"], portduino_config.touchscreenCS, -1);
+            readGPIOFromYaml(yamlConfig["Touchscreen"]["IRQ"], portduino_config.touchscreenIRQ, -1);
+
+            portduino_config.touchscreenBusFrequency = yamlConfig["Touchscreen"]["BusFrequency"].as(1000000);
+            portduino_config.touchscreenRotate = yamlConfig["Touchscreen"]["Rotate"].as(-1);
+            portduino_config.touchscreenI2CAddr = yamlConfig["Touchscreen"]["I2CAddr"].as(-1);
             if (yamlConfig["Touchscreen"]["spidev"]) {
-                settingsStrings[touchscreenspidev] = "/dev/" + yamlConfig["Touchscreen"]["spidev"].as("");
-                if (settingsStrings[touchscreenspidev].length() == 14) {
-                    int x = settingsStrings[touchscreenspidev].at(11) - '0';
-                    int y = settingsStrings[touchscreenspidev].at(13) - '0';
+                portduino_config.touchscreen_spi_dev = "/dev/" + yamlConfig["Touchscreen"]["spidev"].as("");
+                if (portduino_config.touchscreen_spi_dev.length() == 14) {
+                    int x = portduino_config.touchscreen_spi_dev.at(11) - '0';
+                    int y = portduino_config.touchscreen_spi_dev.at(13) - '0';
                     if (x >= 0 && x < 10 && y >= 0 && y < 10) {
-                        settingsMap[touchscreenspidev] = x + y << 4;
+                        portduino_config.touchscreen_spi_dev_int = x + y << 4;
                     }
                 }
             }
         }
         if (yamlConfig["Input"]) {
-            settingsStrings[keyboardDevice] = (yamlConfig["Input"]["KeyboardDevice"]).as("");
-            settingsStrings[pointerDevice] = (yamlConfig["Input"]["PointerDevice"]).as("");
-            settingsMap[userButtonPin] = yamlConfig["Input"]["User"].as(RADIOLIB_NC);
-            settingsMap[tbUpPin] = yamlConfig["Input"]["TrackballUp"].as(RADIOLIB_NC);
-            settingsMap[tbDownPin] = yamlConfig["Input"]["TrackballDown"].as(RADIOLIB_NC);
-            settingsMap[tbLeftPin] = yamlConfig["Input"]["TrackballLeft"].as(RADIOLIB_NC);
-            settingsMap[tbRightPin] = yamlConfig["Input"]["TrackballRight"].as(RADIOLIB_NC);
-            settingsMap[tbPressPin] = yamlConfig["Input"]["TrackballPress"].as(RADIOLIB_NC);
+            portduino_config.keyboardDevice = (yamlConfig["Input"]["KeyboardDevice"]).as("");
+            portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as("");
+
+            readGPIOFromYaml(yamlConfig["Input"]["User"], portduino_config.userButtonPin);
+            readGPIOFromYaml(yamlConfig["Input"]["TrackballUp"], portduino_config.tbUpPin);
+            readGPIOFromYaml(yamlConfig["Input"]["TrackballDown"], portduino_config.tbDownPin);
+            readGPIOFromYaml(yamlConfig["Input"]["TrackballLeft"], portduino_config.tbLeftPin);
+            readGPIOFromYaml(yamlConfig["Input"]["TrackballRight"], portduino_config.tbRightPin);
+            readGPIOFromYaml(yamlConfig["Input"]["TrackballPress"], portduino_config.tbPressPin);
+
             if (yamlConfig["Input"]["TrackballDirection"].as("RISING") == "RISING") {
-                settingsMap[tbDirection] = 4;
+                portduino_config.tbDirection = 4;
             } else if (yamlConfig["Input"]["TrackballDirection"].as("RISING") == "FALLING") {
-                settingsMap[tbDirection] = 3;
+                portduino_config.tbDirection = 3;
             }
         }
 
         if (yamlConfig["Webserver"]) {
-            settingsMap[webserverport] = (yamlConfig["Webserver"]["Port"]).as(-1);
-            settingsStrings[webserverrootpath] =
+            portduino_config.webserverport = (yamlConfig["Webserver"]["Port"]).as(-1);
+            portduino_config.webserver_root_path =
                 (yamlConfig["Webserver"]["RootPath"]).as("/usr/share/meshtasticd/web");
-            settingsStrings[websslkeypath] =
+            portduino_config.webserver_ssl_key_path =
                 (yamlConfig["Webserver"]["SSLKey"]).as("/etc/meshtasticd/ssl/private_key.pem");
-            settingsStrings[websslcertpath] =
+            portduino_config.webserver_ssl_cert_path =
                 (yamlConfig["Webserver"]["SSLCert"]).as("/etc/meshtasticd/ssl/certificate.pem");
         }
 
         if (yamlConfig["HostMetrics"]) {
-            settingsMap[hostMetrics_channel] = (yamlConfig["HostMetrics"]["Channel"]).as(0);
-            settingsMap[hostMetrics_interval] = (yamlConfig["HostMetrics"]["ReportInterval"]).as(0);
-            settingsStrings[hostMetrics_user_command] = (yamlConfig["HostMetrics"]["UserStringCommand"]).as("");
+            portduino_config.hostMetrics_channel = (yamlConfig["HostMetrics"]["Channel"]).as(0);
+            portduino_config.hostMetrics_interval = (yamlConfig["HostMetrics"]["ReportInterval"]).as(0);
+            portduino_config.hostMetrics_user_command = (yamlConfig["HostMetrics"]["UserStringCommand"]).as("");
         }
 
         if (yamlConfig["Config"]) {
             if (yamlConfig["Config"]["DisplayMode"]) {
-                settingsMap[has_configDisplayMode] = true;
+                portduino_config.has_configDisplayMode = true;
                 if ((yamlConfig["Config"]["DisplayMode"]).as("") == "TWOCOLOR") {
-                    settingsMap[configDisplayMode] = meshtastic_Config_DisplayConfig_DisplayMode_TWOCOLOR;
+                    portduino_config.configDisplayMode = meshtastic_Config_DisplayConfig_DisplayMode_TWOCOLOR;
                 } else if ((yamlConfig["Config"]["DisplayMode"]).as("") == "INVERTED") {
-                    settingsMap[configDisplayMode] = meshtastic_Config_DisplayConfig_DisplayMode_INVERTED;
+                    portduino_config.configDisplayMode = meshtastic_Config_DisplayConfig_DisplayMode_INVERTED;
                 } else if ((yamlConfig["Config"]["DisplayMode"]).as("") == "COLOR") {
-                    settingsMap[configDisplayMode] = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
+                    portduino_config.configDisplayMode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
                 } else {
-                    settingsMap[configDisplayMode] = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
+                    portduino_config.configDisplayMode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
                 }
             }
         }
 
         if (yamlConfig["General"]) {
-            settingsMap[maxnodes] = (yamlConfig["General"]["MaxNodes"]).as(200);
-            settingsMap[maxtophone] = (yamlConfig["General"]["MaxMessageQueue"]).as(100);
-            settingsStrings[config_directory] = (yamlConfig["General"]["ConfigDirectory"]).as("");
-            settingsStrings[available_directory] =
+            portduino_config.MaxNodes = (yamlConfig["General"]["MaxNodes"]).as(200);
+            portduino_config.maxtophone = (yamlConfig["General"]["MaxMessageQueue"]).as(100);
+            portduino_config.config_directory = (yamlConfig["General"]["ConfigDirectory"]).as("");
+            portduino_config.available_directory =
                 (yamlConfig["General"]["AvailableDirectory"]).as("/etc/meshtasticd/available.d/");
             if ((yamlConfig["General"]["MACAddress"]).as("") != "" &&
                 (yamlConfig["General"]["MACAddressSource"]).as("") != "") {
                 std::cout << "Cannot set both MACAddress and MACAddressSource!" << std::endl;
                 exit(EXIT_FAILURE);
             }
-            settingsStrings[mac_address] = (yamlConfig["General"]["MACAddress"]).as("");
-            if ((yamlConfig["General"]["MACAddressSource"]).as("") != "") {
-                std::ifstream infile("/sys/class/net/" + (yamlConfig["General"]["MACAddressSource"]).as("") +
-                                     "/address");
-                std::getline(infile, settingsStrings[mac_address]);
+            portduino_config.mac_address = (yamlConfig["General"]["MACAddress"]).as("");
+            if (portduino_config.mac_address != "") {
+                portduino_config.mac_address_explicit = true;
+            } else if ((yamlConfig["General"]["MACAddressSource"]).as("") != "") {
+                portduino_config.mac_address_source = (yamlConfig["General"]["MACAddressSource"]).as("");
+                std::ifstream infile("/sys/class/net/" + portduino_config.mac_address_source + "/address");
+                std::getline(infile, portduino_config.mac_address);
             }
 
             // https://stackoverflow.com/a/20326454
-            settingsStrings[mac_address].erase(
-                std::remove(settingsStrings[mac_address].begin(), settingsStrings[mac_address].end(), ':'),
-                settingsStrings[mac_address].end());
+            portduino_config.mac_address.erase(
+                std::remove(portduino_config.mac_address.begin(), portduino_config.mac_address.end(), ':'),
+                portduino_config.mac_address.end());
         }
     } catch (YAML::Exception &e) {
         std::cout << "*** Exception " << e.what() << std::endl;
@@ -851,12 +730,12 @@ bool MAC_from_string(std::string mac_str, uint8_t *dmac)
 {
     mac_str.erase(std::remove(mac_str.begin(), mac_str.end(), ':'), mac_str.end());
     if (mac_str.length() == 12) {
-        dmac[0] = std::stoi(settingsStrings[mac_address].substr(0, 2), nullptr, 16);
-        dmac[1] = std::stoi(settingsStrings[mac_address].substr(2, 2), nullptr, 16);
-        dmac[2] = std::stoi(settingsStrings[mac_address].substr(4, 2), nullptr, 16);
-        dmac[3] = std::stoi(settingsStrings[mac_address].substr(6, 2), nullptr, 16);
-        dmac[4] = std::stoi(settingsStrings[mac_address].substr(8, 2), nullptr, 16);
-        dmac[5] = std::stoi(settingsStrings[mac_address].substr(10, 2), nullptr, 16);
+        dmac[0] = std::stoi(portduino_config.mac_address.substr(0, 2), nullptr, 16);
+        dmac[1] = std::stoi(portduino_config.mac_address.substr(2, 2), nullptr, 16);
+        dmac[2] = std::stoi(portduino_config.mac_address.substr(4, 2), nullptr, 16);
+        dmac[3] = std::stoi(portduino_config.mac_address.substr(6, 2), nullptr, 16);
+        dmac[4] = std::stoi(portduino_config.mac_address.substr(8, 2), nullptr, 16);
+        dmac[5] = std::stoi(portduino_config.mac_address.substr(10, 2), nullptr, 16);
         return true;
     } else {
         return false;
@@ -875,4 +754,19 @@ std::string exec(const char *cmd)
         result += buffer.data();
     }
     return result;
+}
+
+void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault)
+{
+    if (sourceNode.IsMap()) {
+        destPin.enabled = true;
+        destPin.pin = sourceNode["pin"].as(pinDefault);
+        destPin.line = sourceNode["line"].as(destPin.pin);
+        destPin.gpiochip = sourceNode["gpiochip"].as(portduino_config.lora_default_gpiochip);
+    } else if (sourceNode) { // backwards API compatibility
+        destPin.enabled = true;
+        destPin.pin = sourceNode.as(pinDefault);
+        destPin.line = destPin.pin;
+        destPin.gpiochip = portduino_config.lora_default_gpiochip;
+    }
 }
\ No newline at end of file
diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h
index 8c36a1180..106900c48 100644
--- a/src/platform/portduino/PortduinoGlue.h
+++ b/src/platform/portduino/PortduinoGlue.h
@@ -6,6 +6,7 @@
 #include "LR11x0Interface.h"
 #include "Module.h"
 #include "platform/portduino/USBHal.h"
+#include "yaml-cpp/yaml.h"
 
 // Product strings for auto-configuration
 // {"PRODUCT_STRING", "CONFIG.YAML"}
@@ -19,36 +20,10 @@ inline const std::unordered_map configProducts = {
     {"RAK6421-13300-S1", "lora-RAK6421-13300-slot1.yaml"},
     {"RAK6421-13300-S2", "lora-RAK6421-13300-slot2.yaml"}};
 
-enum configNames {
-    default_gpiochip,
-    cs_pin,
-    cs_line,
-    cs_gpiochip,
-    irq_pin,
-    irq_line,
-    irq_gpiochip,
-    busy_pin,
-    busy_line,
-    busy_gpiochip,
-    reset_pin,
-    reset_line,
-    reset_gpiochip,
-    txen_pin,
-    txen_line,
-    txen_gpiochip,
-    rxen_pin,
-    rxen_line,
-    rxen_gpiochip,
-    sx126x_ant_sw_pin,
-    sx126x_ant_sw_line,
-    sx126x_ant_sw_gpiochip,
-    sx126x_max_power,
-    sx128x_max_power,
-    lr1110_max_power,
-    lr1120_max_power,
-    rf95_max_power,
-    dio2_as_rf_switch,
-    dio3_tcxo_voltage,
+enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d };
+enum touchscreen_modules { no_touchscreen, xpt2046, stmpe610, gt911, ft5x06 };
+enum portduino_log_level { level_error, level_warn, level_info, level_debug, level_trace };
+enum lora_module_enum {
     use_simradio,
     use_autoconf,
     use_rf95,
@@ -58,72 +33,18 @@ enum configNames {
     use_lr1110,
     use_lr1120,
     use_lr1121,
-    use_llcc68,
-    lora_usb_serial_num,
-    lora_usb_pid,
-    lora_usb_vid,
-    userButtonPin,
-    tbUpPin,
-    tbDownPin,
-    tbLeftPin,
-    tbRightPin,
-    tbPressPin,
-    tbDirection,
-    spidev,
-    spiSpeed,
-    i2cdev,
-    has_gps,
-    touchscreenModule,
-    touchscreenCS,
-    touchscreenIRQ,
-    touchscreenI2CAddr,
-    touchscreenBusFrequency,
-    touchscreenRotate,
-    touchscreenspidev,
-    displayspidev,
-    displayBusFrequency,
-    displayPanel,
-    displayWidth,
-    displayHeight,
-    displayCS,
-    displayDC,
-    displayRGBOrder,
-    displayBacklight,
-    displayBacklightPWMChannel,
-    displayBacklightInvert,
-    displayReset,
-    displayRotate,
-    displayOffsetRotate,
-    displayOffsetX,
-    displayOffsetY,
-    displayInvert,
-    keyboardDevice,
-    pointerDevice,
-    logoutputlevel,
-    traceFilename,
-    webserver,
-    webserverport,
-    webserverrootpath,
-    websslkeypath,
-    websslcertpath,
-    maxtophone,
-    maxnodes,
-    ascii_logs,
-    config_directory,
-    available_directory,
-    mac_address,
-    hostMetrics_interval,
-    hostMetrics_channel,
-    hostMetrics_user_command,
-    configDisplayMode,
-    has_configDisplayMode
+    use_llcc68
+};
+
+struct pinMapping {
+    std::string config_section;
+    std::string config_name;
+    int pin = RADIOLIB_NC;
+    int gpiochip;
+    int line;
+    bool enabled = false;
 };
-enum { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d };
-enum { no_touchscreen, xpt2046, stmpe610, gt911, ft5x06 };
-enum { level_error, level_warn, level_info, level_debug, level_trace };
 
-extern std::map settingsMap;
-extern std::map settingsStrings;
 extern std::ofstream traceFile;
 extern Ch341Hal *ch341Hal;
 int initGPIOPin(int pinNum, std::string gpioChipname, int line);
@@ -131,13 +52,422 @@ bool loadConfig(const char *configPath);
 static bool ends_with(std::string_view str, std::string_view suffix);
 void getMacAddr(uint8_t *dmac);
 bool MAC_from_string(std::string mac_str, uint8_t *dmac);
+void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault = RADIOLIB_NC);
 std::string exec(const char *cmd);
 
 extern struct portduino_config_struct {
+    // Lora
+    std::map loraModules = {
+        {use_simradio, "sim"},  {use_autoconf, "auto"}, {use_rf95, "RF95"},     {use_sx1262, "sx1262"}, {use_sx1268, "sx1268"},
+        {use_sx1280, "sx1280"}, {use_lr1110, "lr1110"}, {use_lr1120, "lr1120"}, {use_lr1121, "lr1121"}, {use_llcc68, "LLCC68"}};
+
+    std::map screen_names = {{x11, "X11"},         {fb, "FB"},           {st7789, "ST7789"},
+                                                          {st7735, "ST7735"},   {st7735s, "ST7735S"}, {st7796, "ST7796"},
+                                                          {ili9341, "ILI9341"}, {ili9342, "ILI9342"}, {ili9486, "ILI9486"},
+                                                          {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}};
+
+    lora_module_enum lora_module;
     bool has_rfswitch_table = false;
     uint32_t rfswitch_dio_pins[5] = {RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
     Module::RfSwitchMode_t rfswitch_table[8];
     bool force_simradio = false;
     bool has_device_id = false;
     uint8_t device_id[16] = {0};
+    std::string lora_spi_dev = "";
+    std::string lora_usb_serial_num = "";
+    int lora_spi_dev_int = 0;
+    int lora_default_gpiochip = 0;
+    int sx126x_max_power = 22;
+    int sx128x_max_power = 13;
+    int lr1110_max_power = 22;
+    int lr1120_max_power = 13;
+    int rf95_max_power = 20;
+    bool dio2_as_rf_switch = false;
+    int dio3_tcxo_voltage = 0;
+    int lora_usb_pid = 0x5512;
+    int lora_usb_vid = 0x1A86;
+    int spiSpeed = 2000000;
+    pinMapping lora_cs_pin = {"Lora", "CS"};
+    pinMapping lora_irq_pin = {"Lora", "IRQ"};
+    pinMapping lora_busy_pin = {"Lora", "Busy"};
+    pinMapping lora_reset_pin = {"Lora", "Reset"};
+    pinMapping lora_txen_pin = {"Lora", "TXen"};
+    pinMapping lora_rxen_pin = {"Lora", "RXen"};
+    pinMapping lora_sx126x_ant_sw_pin = {"Lora", "SX126X_ANT_SW"};
+
+    // GPS
+    bool has_gps = false;
+
+    // I2C
+    std::string i2cdev = "";
+
+    // Display
+    std::string display_spi_dev = "";
+    int display_spi_dev_int = 0;
+    int displayBusFrequency = 40000000;
+    screen_modules displayPanel = no_screen;
+    int displayWidth = 0;
+    int displayHeight = 0;
+    bool displayRGBOrder = false;
+    bool displayBacklightInvert = false;
+    bool displayRotate = false;
+    int displayOffsetRotate = 1;
+    bool displayInvert = false;
+    int displayOffsetX = 0;
+    int displayOffsetY = 0;
+    pinMapping displayDC = {"Display", "DC"};
+    pinMapping displayCS = {"Display", "CS"};
+    pinMapping displayBacklight = {"Display", "Backlight"};
+    pinMapping displayBacklightPWMChannel = {"Display", "BacklightPWMChannel"};
+    pinMapping displayReset = {"Display", "Reset"};
+
+    // Touchscreen
+    std::string touchscreen_spi_dev = "";
+    int touchscreen_spi_dev_int = 0;
+    touchscreen_modules touchscreenModule = no_touchscreen;
+    int touchscreenI2CAddr = -1;
+    int touchscreenBusFrequency = 1000000;
+    int touchscreenRotate = -1;
+    pinMapping touchscreenCS = {"Touchscreen", "CS"};
+    pinMapping touchscreenIRQ = {"Touchscreen", "IRQ"};
+
+    // Input
+    std::string keyboardDevice = "";
+    std::string pointerDevice = "";
+    int tbDirection;
+    pinMapping userButtonPin = {"Input", "User"};
+    pinMapping tbUpPin = {"Input", "TrackballUp"};
+    pinMapping tbDownPin = {"Input", "TrackballDown"};
+    pinMapping tbLeftPin = {"Input", "TrackballLwft"};
+    pinMapping tbRightPin = {"Input", "TrackballRight"};
+    pinMapping tbPressPin = {"Input", "TrackballPress"};
+
+    // Logging
+    portduino_log_level logoutputlevel = level_debug;
+    std::string traceFilename;
+    bool ascii_logs = !isatty(1);
+    bool ascii_logs_explicit = false;
+
+    // Webserver
+    std::string webserver_root_path = "";
+    std::string webserver_ssl_key_path = "/etc/meshtasticd/ssl/private_key.pem";
+    std::string webserver_ssl_cert_path = "/etc/meshtasticd/ssl/certificate.pem";
+    int webserverport = -1;
+
+    // HostMetrics
+    std::string hostMetrics_user_command = "";
+    int hostMetrics_interval = 0;
+    int hostMetrics_channel = 0;
+
+    // config
+    int configDisplayMode = 0;
+    bool has_configDisplayMode = false;
+
+    // General
+    std::string mac_address = "";
+    bool mac_address_explicit = false;
+    std::string mac_address_source = "";
+    std::string config_directory = "";
+    std::string available_directory = "/etc/meshtasticd/available.d/";
+    int maxtophone = 100;
+    int MaxNodes = 200;
+
+    pinMapping *all_pins[20] = {&lora_cs_pin,
+                                &lora_irq_pin,
+                                &lora_busy_pin,
+                                &lora_reset_pin,
+                                &lora_txen_pin,
+                                &lora_rxen_pin,
+                                &lora_sx126x_ant_sw_pin,
+                                &displayDC,
+                                &displayCS,
+                                &displayBacklight,
+                                &displayBacklightPWMChannel,
+                                &displayReset,
+                                &touchscreenCS,
+                                &touchscreenIRQ,
+                                &userButtonPin,
+                                &tbUpPin,
+                                &tbDownPin,
+                                &tbLeftPin,
+                                &tbRightPin,
+                                &tbPressPin};
+
+    std::string emit_yaml()
+    {
+        YAML::Emitter out;
+        out << YAML::BeginMap;
+
+        // Lora
+        out << YAML::Key << "Lora" << YAML::Value << YAML::BeginMap;
+        out << YAML::Key << "Module" << YAML::Value << loraModules[lora_module];
+
+        for (auto lora_pin : all_pins) {
+            if (lora_pin->config_section == "Lora" && lora_pin->enabled) {
+                out << YAML::Key << lora_pin->config_name << YAML::Value << YAML::BeginMap;
+                out << YAML::Key << "pin" << YAML::Value << lora_pin->pin;
+                out << YAML::Key << "line" << YAML::Value << lora_pin->line;
+                out << YAML::Key << "gpiochip" << YAML::Value << lora_pin->gpiochip;
+                out << YAML::EndMap; // User
+            }
+        }
+
+        if (sx126x_max_power != 22)
+            out << YAML::Key << "SX126X_MAX_POWER" << YAML::Value << sx126x_max_power;
+        if (sx128x_max_power != 13)
+            out << YAML::Key << "SX128X_MAX_POWER" << YAML::Value << sx128x_max_power;
+        if (lr1110_max_power != 22)
+            out << YAML::Key << "LR1110_MAX_POWER" << YAML::Value << lr1110_max_power;
+        if (lr1120_max_power != 13)
+            out << YAML::Key << "LR1120_MAX_POWER" << YAML::Value << lr1120_max_power;
+        if (rf95_max_power != 20)
+            out << YAML::Key << "RF95_MAX_POWER" << YAML::Value << rf95_max_power;
+        out << YAML::Key << "DIO2_AS_RF_SWITCH" << YAML::Value << dio2_as_rf_switch;
+        if (dio3_tcxo_voltage != 0)
+            out << YAML::Key << "DIO3_TCXO_VOLTAGE" << YAML::Value << dio3_tcxo_voltage;
+        if (lora_usb_pid != 0x5512)
+            out << YAML::Key << "USB_PID" << YAML::Value << YAML::Hex << lora_usb_pid;
+        if (lora_usb_vid != 0x1A86)
+            out << YAML::Key << "USB_VID" << YAML::Value << YAML::Hex << lora_usb_vid;
+        if (lora_spi_dev != "")
+            out << YAML::Key << "spidev" << YAML::Value << lora_spi_dev;
+        if (lora_usb_serial_num != "")
+            out << YAML::Key << "USB_Serialnum" << YAML::Value << lora_usb_serial_num;
+        out << YAML::Key << "spiSpeed" << YAML::Value << spiSpeed;
+        if (rfswitch_dio_pins[0] != RADIOLIB_NC) {
+            out << YAML::Key << "rfswitch_table" << YAML::Value << YAML::BeginMap;
+
+            out << YAML::Key << "pins";
+            out << YAML::Value << YAML::Flow << YAML::BeginSeq;
+
+            for (int i = 0; i < 5; i++) {
+                // set up the pin array first
+                if (rfswitch_dio_pins[i] == RADIOLIB_LR11X0_DIO5)
+                    out << "DIO5";
+                if (rfswitch_dio_pins[i] == RADIOLIB_LR11X0_DIO6)
+                    out << "DIO6";
+                if (rfswitch_dio_pins[i] == RADIOLIB_LR11X0_DIO7)
+                    out << "DIO7";
+                if (rfswitch_dio_pins[i] == RADIOLIB_LR11X0_DIO8)
+                    out << "DIO8";
+                if (rfswitch_dio_pins[i] == RADIOLIB_LR11X0_DIO10)
+                    out << "DIO10";
+            }
+            out << YAML::EndSeq;
+
+            for (int i = 0; i < 7; i++) {
+                switch (i) {
+                case 0:
+                    out << YAML::Key << "MODE_STBY";
+                    break;
+                case 1:
+                    out << YAML::Key << "MODE_RX";
+                    break;
+                case 2:
+                    out << YAML::Key << "MODE_TX";
+                    break;
+                case 3:
+                    out << YAML::Key << "MODE_TX_HP";
+                    break;
+                case 4:
+                    out << YAML::Key << "MODE_TX_HF";
+                    break;
+                case 5:
+                    out << YAML::Key << "MODE_GNSS";
+                    break;
+                case 6:
+                    out << YAML::Key << "MODE_WIFI";
+                    break;
+                }
+
+                out << YAML::Value << YAML::Flow << YAML::BeginSeq;
+                for (int j = 0; j < 5; j++) {
+                    if (rfswitch_table[i].values[j] == HIGH) {
+                        out << "HIGH";
+                    } else {
+                        out << "LOW";
+                    }
+                }
+                out << YAML::EndSeq;
+            }
+            out << YAML::EndMap; // rfswitch_table
+        }
+        out << YAML::EndMap; // Lora
+
+        if (i2cdev != "") {
+            out << YAML::Key << "I2C" << YAML::Value << YAML::BeginMap;
+            out << YAML::Key << "I2CDevice" << YAML::Value << i2cdev;
+            out << YAML::EndMap; // I2C
+        }
+
+        // Display
+        if (displayPanel != no_screen) {
+            out << YAML::Key << "Display" << YAML::Value << YAML::BeginMap;
+            for (auto &screen_name : screen_names) {
+                if (displayPanel == screen_name.first)
+                    out << YAML::Key << "Module" << YAML::Value << screen_name.second;
+            }
+            for (auto display_pin : all_pins) {
+                if (display_pin->config_section == "Display" && display_pin->enabled) {
+                    out << YAML::Key << display_pin->config_name << YAML::Value << YAML::BeginMap;
+                    out << YAML::Key << "pin" << YAML::Value << display_pin->pin;
+                    out << YAML::Key << "line" << YAML::Value << display_pin->line;
+                    out << YAML::Key << "gpiochip" << YAML::Value << display_pin->gpiochip;
+                    out << YAML::EndMap;
+                }
+            }
+            out << YAML::Key << "spidev" << YAML::Value << display_spi_dev;
+            out << YAML::Key << "BusFrequency" << YAML::Value << displayBusFrequency;
+            if (displayWidth)
+                out << YAML::Key << "Width" << YAML::Value << displayWidth;
+            if (displayHeight)
+                out << YAML::Key << "Height" << YAML::Value << displayHeight;
+            if (displayRGBOrder)
+                out << YAML::Key << "RGBOrder" << YAML::Value << true;
+            if (displayBacklightInvert)
+                out << YAML::Key << "BacklightInvert" << YAML::Value << true;
+            if (displayRotate)
+                out << YAML::Key << "Rotate" << YAML::Value << true;
+            if (displayInvert)
+                out << YAML::Key << "Invert" << YAML::Value << true;
+            if (displayOffsetX)
+                out << YAML::Key << "OffsetX" << YAML::Value << displayOffsetX;
+            if (displayOffsetY)
+                out << YAML::Key << "OffsetY" << YAML::Value << displayOffsetY;
+
+            out << YAML::Key << "OffsetRotate" << YAML::Value << displayOffsetRotate;
+
+            out << YAML::EndMap; // Display
+        }
+
+        // Touchscreen
+        if (touchscreen_spi_dev != "") {
+            out << YAML::Key << "Touchscreen" << YAML::Value << YAML::BeginMap;
+            out << YAML::Key << "spidev" << YAML::Value << touchscreen_spi_dev;
+            out << YAML::Key << "BusFrequency" << YAML::Value << touchscreenBusFrequency;
+            switch (touchscreenModule) {
+            case xpt2046:
+                out << YAML::Key << "Module" << YAML::Value << "XPT2046";
+            case stmpe610:
+                out << YAML::Key << "Module" << YAML::Value << "STMPE610";
+            case gt911:
+                out << YAML::Key << "Module" << YAML::Value << "GT911";
+            case ft5x06:
+                out << YAML::Key << "Module" << YAML::Value << "FT5x06";
+            }
+            for (auto touchscreen_pin : all_pins) {
+                if (touchscreen_pin->config_section == "Touchscreen" && touchscreen_pin->enabled) {
+                    out << YAML::Key << touchscreen_pin->config_name << YAML::Value << YAML::BeginMap;
+                    out << YAML::Key << "pin" << YAML::Value << touchscreen_pin->pin;
+                    out << YAML::Key << "line" << YAML::Value << touchscreen_pin->line;
+                    out << YAML::Key << "gpiochip" << YAML::Value << touchscreen_pin->gpiochip;
+                    out << YAML::EndMap;
+                }
+            }
+            if (touchscreenRotate != -1)
+                out << YAML::Key << "Rotate" << YAML::Value << touchscreenRotate;
+            if (touchscreenI2CAddr != -1)
+                out << YAML::Key << "I2CAddr" << YAML::Value << touchscreenI2CAddr;
+            out << YAML::EndMap; // Touchscreen
+        }
+
+        // Input
+        out << YAML::Key << "Input" << YAML::Value << YAML::BeginMap;
+        if (keyboardDevice != "")
+            out << YAML::Key << "KeyboardDevice" << YAML::Value << keyboardDevice;
+        if (pointerDevice != "")
+            out << YAML::Key << "PointerDevice" << YAML::Value << pointerDevice;
+
+        for (auto input_pin : all_pins) {
+            if (input_pin->config_section == "Input" && input_pin->enabled) {
+                out << YAML::Key << input_pin->config_name << YAML::Value << YAML::BeginMap;
+                out << YAML::Key << "pin" << YAML::Value << input_pin->pin;
+                out << YAML::Key << "line" << YAML::Value << input_pin->line;
+                out << YAML::Key << "gpiochip" << YAML::Value << input_pin->gpiochip;
+                out << YAML::EndMap;
+            }
+        }
+        if (tbDirection == 3)
+            out << YAML::Key << "TrackballDirection" << YAML::Value << "FALLING";
+
+        out << YAML::EndMap; // Input
+
+        out << YAML::Key << "Logging" << YAML::Value << YAML::BeginMap;
+        out << YAML::Key << "LogLevel" << YAML::Value;
+        switch (logoutputlevel) {
+        case level_error:
+            out << "error";
+            break;
+        case level_warn:
+            out << "warn";
+            break;
+        case level_info:
+            out << "info";
+            break;
+        case level_debug:
+            out << "debug";
+            break;
+        case level_trace:
+            out << "trace";
+            break;
+        }
+        if (traceFilename != "")
+            out << YAML::Key << "TraceFile" << YAML::Value << traceFilename;
+        if (ascii_logs_explicit) {
+            out << YAML::Key << "AsciiLogs" << YAML::Value << ascii_logs;
+        }
+        out << YAML::EndMap; // Logging
+
+        // Webserver
+        if (webserver_root_path != "") {
+            out << YAML::Key << "Webserver" << YAML::Value << YAML::BeginMap;
+            out << YAML::Key << "RootPath" << YAML::Value << webserver_root_path;
+            out << YAML::Key << "SSLKey" << YAML::Value << webserver_ssl_key_path;
+            out << YAML::Key << "SSLCert" << YAML::Value << webserver_ssl_cert_path;
+            out << YAML::Key << "Port" << YAML::Value << webserverport;
+            out << YAML::EndMap; // Webserver
+        }
+
+        // HostMetrics
+        if (hostMetrics_user_command != "") {
+            out << YAML::Key << "HostMetrics" << YAML::Value << YAML::BeginMap;
+            out << YAML::Key << "UserStringCommand" << YAML::Value << hostMetrics_user_command;
+            out << YAML::Key << "ReportInterval" << YAML::Value << hostMetrics_interval;
+            out << YAML::Key << "Channel" << YAML::Value << hostMetrics_channel;
+
+            out << YAML::EndMap; // HostMetrics
+        }
+
+        // config
+        if (has_configDisplayMode) {
+            out << YAML::Key << "Config" << YAML::Value << YAML::BeginMap;
+            switch (configDisplayMode) {
+            case meshtastic_Config_DisplayConfig_DisplayMode_TWOCOLOR:
+                out << YAML::Key << "DisplayMode" << YAML::Value << "TWOCOLOR";
+            case meshtastic_Config_DisplayConfig_DisplayMode_INVERTED:
+                out << YAML::Key << "DisplayMode" << YAML::Value << "INVERTED";
+            case meshtastic_Config_DisplayConfig_DisplayMode_COLOR:
+                out << YAML::Key << "DisplayMode" << YAML::Value << "COLOR";
+            case meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT:
+                out << YAML::Key << "DisplayMode" << YAML::Value << "DEFAULT";
+            }
+
+            out << YAML::EndMap; // Config
+        }
+
+        // General
+        out << YAML::Key << "General" << YAML::Value << YAML::BeginMap;
+        if (config_directory != "")
+            out << YAML::Key << "ConfigDirectory" << YAML::Value << config_directory;
+        if (mac_address_explicit)
+            out << YAML::Key << "MACAddress" << YAML::Value << mac_address;
+        if (mac_address_source != "")
+            out << YAML::Key << "MACAddressSource" << YAML::Value << mac_address_source;
+        if (available_directory != "")
+            out << YAML::Key << "AvailableDirectory" << YAML::Value << available_directory;
+        out << YAML::Key << "MaxMessageQueue" << YAML::Value << maxtophone;
+        out << YAML::Key << "MaxNodes" << YAML::Value << MaxNodes;
+        out << YAML::EndMap; // General
+        return out.c_str();
+    }
 } portduino_config;
\ No newline at end of file
diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h
index 07d0aeee0..e10519d21 100644
--- a/src/platform/portduino/architecture.h
+++ b/src/platform/portduino/architecture.h
@@ -28,9 +28,9 @@
 #endif
 #ifndef HAS_TRACKBALL
 #define HAS_TRACKBALL 1
-#define TB_DOWN (uint8_t) settingsMap[tbDownPin]
-#define TB_UP (uint8_t) settingsMap[tbUpPin]
-#define TB_LEFT (uint8_t) settingsMap[tbLeftPin]
-#define TB_RIGHT (uint8_t) settingsMap[tbRightPin]
-#define TB_PRESS (uint8_t) settingsMap[tbPressPin]
+#define TB_DOWN (uint8_t) portduino_config.tbDownPin.pin
+#define TB_UP (uint8_t) portduino_config.tbUpPin.pin
+#define TB_LEFT (uint8_t) portduino_config.tbLeftPin.pin
+#define TB_RIGHT (uint8_t) portduino_config.tbRightPin.pin
+#define TB_PRESS (uint8_t) portduino_config.tbPressPin.pin
 #endif
\ No newline at end of file
diff --git a/src/power.h b/src/power.h
index 1c078c06d..e96f5b022 100644
--- a/src/power.h
+++ b/src/power.h
@@ -128,6 +128,8 @@ class Power : private concurrency::OSThread
     bool lipoInit();
     /// Setup a Lipo charger
     bool lipoChargerInit();
+    /// Setup a meshSolar battery sensor
+    bool meshSolarInit();
 
   private:
     void shutdown();
diff --git a/src/sleep.cpp b/src/sleep.cpp
index 1a5f246c5..83597e349 100644
--- a/src/sleep.cpp
+++ b/src/sleep.cpp
@@ -32,6 +32,16 @@ esp_sleep_source_t wakeCause; // the reason we booted this time
 #endif
 #include "Throttle.h"
 
+#ifdef USE_XL9555
+#include "ExtensionIOXL9555.hpp"
+extern ExtensionIOXL9555 io;
+#endif
+
+#ifdef HAS_PPM
+#include 
+extern XPowersPPM *PPM;
+#endif
+
 #ifndef INCLUDE_vTaskSuspend
 #define INCLUDE_vTaskSuspend 0
 #endif
@@ -297,6 +307,14 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN
 #endif
 #endif
 
+#ifdef HAS_PPM
+    if (PPM) {
+        LOG_INFO("PMM shutdown");
+        console->flush();
+        PPM->shutdown();
+    }
+#endif
+
 #ifdef HAS_PMU
     if (pmu_found && PMU) {
         // Obsolete comment: from back when we we used to receive lora packets while CPU was in deep sleep.
@@ -412,6 +430,7 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
     if (pmu_found)
         gpio_wakeup_enable((gpio_num_t)PMU_IRQ, GPIO_INTR_LOW_LEVEL); // pmu irq
 #endif
+
     auto res = esp_sleep_enable_gpio_wakeup();
     if (res != ESP_OK) {
         LOG_ERROR("esp_sleep_enable_gpio_wakeup result %d", res);
diff --git a/test/test_meshpacket_serializer/ports/test_encrypted.cpp b/test/test_meshpacket_serializer/ports/test_encrypted.cpp
index 557ee7a49..37cfc1626 100644
--- a/test/test_meshpacket_serializer/ports/test_encrypted.cpp
+++ b/test/test_meshpacket_serializer/ports/test_encrypted.cpp
@@ -1,20 +1,9 @@
 #include "../test_helpers.h"
 
-// Test encrypted packet serialization
-void test_encrypted_packet_serialization()
+// Helper function for all encrypted packet assertions
+void assert_encrypted_packet(const std::string &json, meshtastic_MeshPacket packet)
 {
-    meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
-    packet.from = 0x11223344;
-    packet.to = 0x55667788;
-    packet.id = 0x9999;
-    packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
-
-    // Add some dummy encrypted data
-    const char *encrypted_data = "encrypted_payload_data";
-    packet.encrypted.size = strlen(encrypted_data);
-    memcpy(packet.encrypted.bytes, encrypted_data, packet.encrypted.size);
-
-    std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet);
+    // Parse and validate JSON
     TEST_ASSERT_TRUE(json.length() > 0);
 
     JSONValue *root = JSON::Parse(json.c_str());
@@ -23,28 +12,48 @@ void test_encrypted_packet_serialization()
 
     JSONObject jsonObj = root->AsObject();
 
-    // Check basic packet fields
+    // Assert basic packet fields
     TEST_ASSERT_TRUE(jsonObj.find("from") != jsonObj.end());
-    TEST_ASSERT_EQUAL(0x11223344, (uint32_t)jsonObj["from"]->AsNumber());
+    TEST_ASSERT_EQUAL(packet.from, (uint32_t)jsonObj.at("from")->AsNumber());
 
     TEST_ASSERT_TRUE(jsonObj.find("to") != jsonObj.end());
-    TEST_ASSERT_EQUAL(0x55667788, (uint32_t)jsonObj["to"]->AsNumber());
+    TEST_ASSERT_EQUAL(packet.to, (uint32_t)jsonObj.at("to")->AsNumber());
 
     TEST_ASSERT_TRUE(jsonObj.find("id") != jsonObj.end());
-    TEST_ASSERT_EQUAL(0x9999, (uint32_t)jsonObj["id"]->AsNumber());
+    TEST_ASSERT_EQUAL(packet.id, (uint32_t)jsonObj.at("id")->AsNumber());
 
-    // Check that it has encrypted data fields (not "payload" but "bytes" and "size")
+    // Assert encrypted data fields
     TEST_ASSERT_TRUE(jsonObj.find("bytes") != jsonObj.end());
-    TEST_ASSERT_TRUE(jsonObj["bytes"]->IsString());
+    TEST_ASSERT_TRUE(jsonObj.at("bytes")->IsString());
 
     TEST_ASSERT_TRUE(jsonObj.find("size") != jsonObj.end());
-    TEST_ASSERT_EQUAL(22, (int)jsonObj["size"]->AsNumber()); // strlen("encrypted_payload_data") = 22
+    TEST_ASSERT_EQUAL(packet.encrypted.size, (int)jsonObj.at("size")->AsNumber());
 
-    // The encrypted data should be hex-encoded
+    // Assert hex encoding
     std::string encrypted_hex = jsonObj["bytes"]->AsString();
-    TEST_ASSERT_TRUE(encrypted_hex.length() > 0);
-    // Should be twice the size of the original data (hex encoding)
-    TEST_ASSERT_EQUAL(44, encrypted_hex.length()); // 22 * 2 = 44
+    TEST_ASSERT_EQUAL(packet.encrypted.size * 2, encrypted_hex.length());
 
     delete root;
 }
+
+// Test encrypted packet serialization
+void test_encrypted_packet_serialization()
+{
+    const char *data = "encrypted_payload_data";
+    meshtastic_MeshPacket packet =
+        create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(data), strlen(data),
+                           meshtastic_MeshPacket_encrypted_tag);
+    std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet);
+
+    assert_encrypted_packet(json, packet);
+}
+
+// Test empty encrypted packet
+void test_empty_encrypted_packet()
+{
+    meshtastic_MeshPacket packet =
+        create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, nullptr, 0, meshtastic_MeshPacket_encrypted_tag);
+    std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet);
+
+    assert_encrypted_packet(json, packet);
+}
diff --git a/test/test_meshpacket_serializer/ports/test_text_message.cpp b/test/test_meshpacket_serializer/ports/test_text_message.cpp
index de3f34541..0f3b0bc6d 100644
--- a/test/test_meshpacket_serializer/ports/test_text_message.cpp
+++ b/test/test_meshpacket_serializer/ports/test_text_message.cpp
@@ -1,42 +1,105 @@
 #include "../test_helpers.h"
+#include 
+
+// Helper function to test common packet fields and structure
+void verify_text_message_packet_structure(const std::string &json, const char *expected_text)
+{
+    TEST_ASSERT_TRUE(json.length() > 0);
+
+    // Use smart pointer for automatic memory management
+    std::unique_ptr root(JSON::Parse(json.c_str()));
+    TEST_ASSERT_NOT_NULL(root.get());
+    TEST_ASSERT_TRUE(root->IsObject());
+
+    JSONObject jsonObj = root->AsObject();
+
+    // Check basic packet fields - use helper function to reduce duplication
+    auto check_field = [&](const char *field, uint32_t expected_value) {
+        auto it = jsonObj.find(field);
+        TEST_ASSERT_TRUE(it != jsonObj.end());
+        TEST_ASSERT_EQUAL(expected_value, (uint32_t)it->second->AsNumber());
+    };
+
+    check_field("from", 0x11223344);
+    check_field("to", 0x55667788);
+    check_field("id", 0x9999);
+
+    // Check message type
+    auto type_it = jsonObj.find("type");
+    TEST_ASSERT_TRUE(type_it != jsonObj.end());
+    TEST_ASSERT_EQUAL_STRING("text", type_it->second->AsString().c_str());
+
+    // Check payload
+    auto payload_it = jsonObj.find("payload");
+    TEST_ASSERT_TRUE(payload_it != jsonObj.end());
+    TEST_ASSERT_TRUE(payload_it->second->IsObject());
+
+    JSONObject payload = payload_it->second->AsObject();
+    auto text_it = payload.find("text");
+    TEST_ASSERT_TRUE(text_it != payload.end());
+    TEST_ASSERT_EQUAL_STRING(expected_text, text_it->second->AsString().c_str());
+
+    // No need for manual delete with smart pointer
+}
 
 // Test TEXT_MESSAGE_APP port
 void test_text_message_serialization()
 {
     const char *test_text = "Hello Meshtastic!";
     meshtastic_MeshPacket packet =
-        create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, (const uint8_t *)test_text, strlen(test_text));
+        create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(test_text), strlen(test_text));
 
     std::string json = MeshPacketSerializer::JsonSerialize(&packet, false);
-    TEST_ASSERT_TRUE(json.length() > 0);
-
-    JSONValue *root = JSON::Parse(json.c_str());
-    TEST_ASSERT_NOT_NULL(root);
-    TEST_ASSERT_TRUE(root->IsObject());
-
-    JSONObject jsonObj = root->AsObject();
-
-    // Check basic packet fields
-    TEST_ASSERT_TRUE(jsonObj.find("from") != jsonObj.end());
-    TEST_ASSERT_EQUAL(0x11223344, (uint32_t)jsonObj["from"]->AsNumber());
-
-    TEST_ASSERT_TRUE(jsonObj.find("to") != jsonObj.end());
-    TEST_ASSERT_EQUAL(0x55667788, (uint32_t)jsonObj["to"]->AsNumber());
-
-    TEST_ASSERT_TRUE(jsonObj.find("id") != jsonObj.end());
-    TEST_ASSERT_EQUAL(0x9999, (uint32_t)jsonObj["id"]->AsNumber());
-
-    // Check message type
-    TEST_ASSERT_TRUE(jsonObj.find("type") != jsonObj.end());
-    TEST_ASSERT_EQUAL_STRING("text", jsonObj["type"]->AsString().c_str());
-
-    // Check payload
-    TEST_ASSERT_TRUE(jsonObj.find("payload") != jsonObj.end());
-    TEST_ASSERT_TRUE(jsonObj["payload"]->IsObject());
-
-    JSONObject payload = jsonObj["payload"]->AsObject();
-    TEST_ASSERT_TRUE(payload.find("text") != payload.end());
-    TEST_ASSERT_EQUAL_STRING("Hello Meshtastic!", payload["text"]->AsString().c_str());
-
-    delete root;
+    verify_text_message_packet_structure(json, test_text);
 }
+
+// Test with nullptr to check robustness
+void test_text_message_serialization_null()
+{
+    meshtastic_MeshPacket packet = create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, nullptr, 0);
+
+    std::string json = MeshPacketSerializer::JsonSerialize(&packet, false);
+    verify_text_message_packet_structure(json, "");
+}
+
+// Test TEXT_MESSAGE_APP port with very long message (boundary testing)
+void test_text_message_serialization_long_text()
+{
+    // Test with actual message size limits
+    constexpr size_t MAX_MESSAGE_SIZE = 200; // Typical LoRa payload limit
+    std::string long_text(MAX_MESSAGE_SIZE, 'A');
+
+    meshtastic_MeshPacket packet = create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP,
+                                                      reinterpret_cast(long_text.c_str()), long_text.length());
+
+    std::string json = MeshPacketSerializer::JsonSerialize(&packet, false);
+    verify_text_message_packet_structure(json, long_text.c_str());
+}
+
+// Test with message over size limit (should fail)
+void test_text_message_serialization_oversized()
+{
+    constexpr size_t OVERSIZED_MESSAGE = 250; // Over the limit
+    std::string oversized_text(OVERSIZED_MESSAGE, 'B');
+
+    meshtastic_MeshPacket packet = create_test_packet(
+        meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(oversized_text.c_str()), oversized_text.length());
+
+    // Should fail or return empty/error
+    std::string json = MeshPacketSerializer::JsonSerialize(&packet, false);
+    // Should only verify first 234 characters for oversized messages
+    std::string expected_text = oversized_text.substr(0, 234);
+    verify_text_message_packet_structure(json, expected_text.c_str());
+}
+
+// Add test for malformed UTF-8 sequences
+void test_text_message_serialization_invalid_utf8()
+{
+    const uint8_t invalid_utf8[] = {0xFF, 0xFE, 0xFD, 0x00}; // Invalid UTF-8
+    meshtastic_MeshPacket packet =
+        create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, invalid_utf8, sizeof(invalid_utf8) - 1);
+
+    // Should not crash, may produce replacement characters
+    std::string json = MeshPacketSerializer::JsonSerialize(&packet, false);
+    TEST_ASSERT_TRUE(json.length() > 0);
+}
\ No newline at end of file
diff --git a/test/test_meshpacket_serializer/test_helpers.h b/test/test_meshpacket_serializer/test_helpers.h
index 630e059bc..12245b85d 100644
--- a/test/test_meshpacket_serializer/test_helpers.h
+++ b/test/test_meshpacket_serializer/test_helpers.h
@@ -11,7 +11,8 @@
 #include 
 
 // Helper function to create a test packet with the given port and payload
-static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const uint8_t *payload, size_t payload_size)
+static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const uint8_t *payload, size_t payload_size,
+                                                int payload_variant = meshtastic_MeshPacket_decoded_tag)
 {
     meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
 
@@ -29,8 +30,12 @@ static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const u
     packet.delayed = meshtastic_MeshPacket_Delayed_NO_DELAY;
 
     // Set decoded variant
-    packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
+    packet.which_payload_variant = payload_variant;
     packet.decoded.portnum = port;
+    if (payload_variant == meshtastic_MeshPacket_encrypted_tag && payload) {
+        packet.encrypted.size = payload_size;
+        memcpy(packet.encrypted.bytes, payload, packet.encrypted.size);
+    }
     memcpy(packet.decoded.payload.bytes, payload, payload_size);
     packet.decoded.payload.size = payload_size;
     packet.decoded.want_response = false;
diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp
index d74031fa4..484db8d74 100644
--- a/test/test_meshpacket_serializer/test_serializer.cpp
+++ b/test/test_meshpacket_serializer/test_serializer.cpp
@@ -4,6 +4,10 @@
 
 // Forward declarations for test functions
 void test_text_message_serialization();
+void test_text_message_serialization_null();
+void test_text_message_serialization_long_text();
+void test_text_message_serialization_oversized();
+void test_text_message_serialization_invalid_utf8();
 void test_position_serialization();
 void test_nodeinfo_serialization();
 void test_waypoint_serialization();
@@ -14,6 +18,7 @@ void test_telemetry_environment_metrics_missing_fields();
 void test_telemetry_environment_metrics_complete_coverage();
 void test_telemetry_environment_metrics_unset_fields();
 void test_encrypted_packet_serialization();
+void test_empty_encrypted_packet();
 
 void setup()
 {
@@ -21,6 +26,10 @@ void setup()
 
     // Text message tests
     RUN_TEST(test_text_message_serialization);
+    RUN_TEST(test_text_message_serialization_null);
+    RUN_TEST(test_text_message_serialization_long_text);
+    RUN_TEST(test_text_message_serialization_oversized);
+    RUN_TEST(test_text_message_serialization_invalid_utf8);
 
     // Position tests
     RUN_TEST(test_position_serialization);
@@ -41,6 +50,7 @@ void setup()
 
     // Encrypted packet test
     RUN_TEST(test_encrypted_packet_serialization);
+    RUN_TEST(test_empty_encrypted_packet);
 
     UNITY_END();
 }
diff --git a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini
new file mode 100644
index 000000000..809599212
--- /dev/null
+++ b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini
@@ -0,0 +1,12 @@
+; 9M2IBR APRS LoRa Tracker: ESP32-WROOM-32 + EBYTE E22-400M30S
+; https://shopee.com.my/product/1095224/21692283917
+[env:9m2ibr_aprs_lora_tracker]
+extends = esp32_base
+board = esp32doit-devkit-v1
+board_level = extra
+build_flags =
+  ${esp32_base.build_flags}
+  -D PRIVATE_HW
+  -D EBYTE_E22
+  -D EBYTE_E22_900M30S ; Assume Tx power curve is identical to 900M30S as there is no documentation
+  -I variants/esp32/diy/9m2ibr_aprs_lora_tracker
diff --git a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h
new file mode 100644
index 000000000..037933140
--- /dev/null
+++ b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/variant.h
@@ -0,0 +1,74 @@
+/*
+
+  9M2IBR APRS LoRa Tracker: ESP32-WROOM-32 + EBYTE E22-400M30S
+  https://shopee.com.my/product/1095224/21692283917
+
+  Originally developed for LoRa_APRS_iGate and GPIO is similar to
+  https://github.com/richonguzman/LoRa_APRS_iGate/blob/main/variants/ESP32_DIY_1W_LoRa_Mesh_V1_2/board_pinout.h
+
+*/
+
+// OLED (may be different controllers depending on screen size)
+#define I2C_SDA 21
+#define I2C_SCL 22
+#define HAS_SCREEN 1 // Generates randomized BLE pin
+
+// GNSS: Ai-Thinker GP-02 BDS/GNSS module
+#define GPS_RX_PIN 16
+#define GPS_TX_PIN 17
+
+// Button
+#define BUTTON_PIN 15 // Right side button - if not available, set device.button_gpio to 0 from Meshtastic client
+
+// LEDs
+#define LED_PIN 13 // Tx LED
+#define USER_LED 2 // Rx LED
+
+// Buzzer
+#define PIN_BUZZER 33
+
+// Battery sense
+#define BATTERY_PIN 35
+#define ADC_MULTIPLIER 2.01 // 100k + 100k, and add 1% tolerance
+#define ADC_CHANNEL ADC1_GPIO35_CHANNEL
+#define BATTERY_SENSE_RESOLUTION_BITS ADC_RESOLUTION
+
+// SPI
+#define LORA_SCK 18
+#define LORA_MISO 19
+#define LORA_MOSI 23
+
+// LoRa
+#define LORA_CS 5
+#define LORA_DIO0 26          // a No connect on the SX1262/SX1268 module
+#define LORA_RESET 27         // RST for SX1276, and for SX1262/SX1268
+#define LORA_DIO1 12          // IRQ for SX1262/SX1268
+#define LORA_DIO2 RADIOLIB_NC // BUSY for SX1262/SX1268
+#define LORA_DIO3             // NC, but used as TCXO supply by E22 module
+#define LORA_RXEN 32          // RF switch RX (and E22 LNA) control by ESP32 GPIO
+#define LORA_TXEN 25          // RF switch TX (and E22 PA) control by ESP32 GPIO
+
+// RX/TX for RFM95/SX127x
+#define RF95_RXEN LORA_RXEN
+#define RF95_TXEN LORA_TXEN
+// #define RF95_TCXO 
+
+// common pinouts for SX126X modules
+#define SX126X_CS 5
+#define SX126X_DIO1 LORA_DIO1
+#define SX126X_BUSY LORA_DIO2
+#define SX126X_RESET LORA_RESET
+#define SX126X_RXEN LORA_RXEN
+#define SX126X_TXEN LORA_TXEN
+
+// Support alternative modules if soldered in place of E22
+#define USE_RF95 // RFM95/SX127x
+#define USE_SX1262
+#define USE_SX1268
+#define USE_LLCC68
+
+// E22 TCXO support
+#ifdef EBYTE_E22
+#define SX126X_DIO3_TCXO_VOLTAGE 1.8
+#define TCXO_OPTIONAL // make it so that the firmware can try both TCXO and XTAL
+#endif
diff --git a/variants/esp32/heltec_wireless_bridge/platformio.ini b/variants/esp32/heltec_wireless_bridge/platformio.ini
index 60e686f9e..93c3e3394 100644
--- a/variants/esp32/heltec_wireless_bridge/platformio.ini
+++ b/variants/esp32/heltec_wireless_bridge/platformio.ini
@@ -1,6 +1,7 @@
 [env:heltec-wireless-bridge]
 ;build_type = debug ; to make it possible to step through our jtag debugger 
 extends = esp32_base
+board_level = extra
 board = heltec_wifi_lora_32
 build_flags = 
   ${esp32_base.build_flags}
diff --git a/variants/esp32/trackerd/platformio.ini b/variants/esp32/trackerd/platformio.ini
index 3c2726a3c..00c14fad2 100644
--- a/variants/esp32/trackerd/platformio.ini
+++ b/variants/esp32/trackerd/platformio.ini
@@ -1,5 +1,6 @@
 [env:trackerd]
 extends = esp32_base
+board_level = extra
 board = pico32
 board_build.f_flash = 80000000L
 
diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini
index 59bc26000..065f22538 100644
--- a/variants/esp32s3/elecrow_panel/platformio.ini
+++ b/variants/esp32s3/elecrow_panel/platformio.ini
@@ -19,8 +19,6 @@ build_flags = ${esp32s3_base.build_flags} -Os
   -D MESHTASTIC_EXCLUDE_SERIAL=1
   -D MESHTASTIC_EXCLUDE_SOCKETAPI=1
   -D MESHTASTIC_EXCLUDE_SCREEN=1
-  -D MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1
-  -D HAS_TELEMETRY=0
   -D CONFIG_DISABLE_HAL_LOCKS=1
   -D USE_PIN_BUZZER
   -D HAS_SCREEN=0
diff --git a/variants/esp32s3/heltec_v3/variant.h b/variants/esp32s3/heltec_v3/variant.h
index 4f1d91db8..d760c3b7f 100644
--- a/variants/esp32s3/heltec_v3/variant.h
+++ b/variants/esp32s3/heltec_v3/variant.h
@@ -40,3 +40,5 @@
 
 #define SX126X_DIO2_AS_RF_SWITCH
 #define SX126X_DIO3_TCXO_VOLTAGE 1.8
+
+#define HAS_32768HZ 1
\ No newline at end of file
diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini
index d5847959b..4131cc30a 100644
--- a/variants/esp32s3/picomputer-s3/platformio.ini
+++ b/variants/esp32s3/picomputer-s3/platformio.ini
@@ -2,7 +2,7 @@
 extends = esp32s3_base
 board = bpi_picow_esp32_s3
 board_check = true
-board_build.partitions = default_8MB.csv
+board_build.partitions = partition-table-8MB.csv
 ;OpenOCD flash method
 ;upload_protocol = esp-builtin
 ;Normal method
@@ -26,6 +26,7 @@ extends = env:picomputer-s3
 
 build_flags =
   ${env:picomputer-s3.build_flags}
+  -D MESHTASTIC_EXCLUDE_WEBSERVER=1
   -D INPUTDRIVER_MATRIX_TYPE=1
   -D USE_PIN_BUZZER=PIN_BUZZER
   -D USE_SX127x
diff --git a/variants/esp32s3/rak_wismesh_tap_v2/pins_arduino.h b/variants/esp32s3/rak_wismesh_tap_v2/pins_arduino.h
new file mode 100644
index 000000000..15a26e991
--- /dev/null
+++ b/variants/esp32s3/rak_wismesh_tap_v2/pins_arduino.h
@@ -0,0 +1,28 @@
+#ifndef Pins_Arduino_h
+#define Pins_Arduino_h
+
+#include "variant.h"
+#include 
+
+#define USB_VID 0x303a
+#define USB_PID 0x1001
+
+// The default Wire will be mapped to PMU and RTC
+static const uint8_t SDA = 9;
+static const uint8_t SCL = 40;
+
+// Default SPI will be mapped to Radio
+static const uint8_t SS = 12;
+static const uint8_t MOSI = 11;
+static const uint8_t MISO = 10;
+static const uint8_t SCK = 13;
+
+#define SPI_MOSI (11)
+#define SPI_SCK (13)
+#define SPI_MISO (10)
+#define SPI_CS (12)
+
+// LEDs
+#define LED_BUILTIN LED_GREEN
+
+#endif /* Pins_Arduino_h */
diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini
new file mode 100644
index 000000000..de4714efa
--- /dev/null
+++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini
@@ -0,0 +1,88 @@
+; rak_wismeshtap2 rak3112
+
+[rak_wismeshtap_s3]
+extends = esp32s3_base
+board = wiscore_rak3312
+board_check = true
+upload_protocol = esptool
+board_build.partitions = default_8MB.csv
+
+build_flags = 
+  ${esp32_base.build_flags} 
+  -D RAK3312
+  -D RAK_WISMESH_TAP_V2 
+  -I variants/esp32s3/rak_wismesh_tap_v2
+
+lib_deps =
+  ${esp32s3_base.lib_deps}
+  lovyan03/LovyanGFX@^1.2.0
+
+[ft5x06]
+extends = mesh_tab_base
+build_flags = 
+  -D LGFX_TOUCH=FT5x06
+  -D LGFX_TOUCH_I2C_FREQ=100000
+  -D LGFX_TOUCH_I2C_PORT=0
+  -D LGFX_TOUCH_I2C_ADDR=0x38
+  -D LGFX_TOUCH_I2C_SDA=9
+  -D LGFX_TOUCH_I2C_SCL=40
+  -D LGFX_TOUCH_RST=-1
+  -D LGFX_TOUCH_INT=39
+
+[env:rak_wismesh_tap_v2-tft]
+extends = rak_wismeshtap_s3
+
+build_flags =
+  ${rak_wismeshtap_s3.build_flags}
+  -D CONFIG_ARDUHAL_ESP_LOG
+  -D CONFIG_ARDUHAL_LOG_COLORS=1
+  -D CONFIG_DISABLE_HAL_LOCKS=1
+  -D LV_LVGL_H_INCLUDE_SIMPLE
+  -D LV_CONF_INCLUDE_SIMPLE
+  -D LV_COMP_CONF_INCLUDE_SIMPLE
+  -D LV_USE_SYSMON=0
+  -D LV_USE_PROFILER=0
+  -D LV_USE_PERF_MONITOR=0
+  -D LV_USE_MEM_MONITOR=0
+  -D LV_USE_LOG=0
+  -D LV_BUILD_TEST=0
+  -D USE_LOG_DEBUG
+  -D LOG_DEBUG_INC=\"DebugConfiguration.h\"
+  -D RADIOLIB_SPI_PARANOID=0
+  -D INPUTDRIVER_BUTTON_TYPE=0
+  -D HAS_SDCARD
+  -D HAS_SCREEN=0
+  -D HAS_TFT=1
+  -D USE_PIN_BUZZER=PIN_BUZZER
+  -D RAM_SIZE=5120
+  -D LGFX_DRIVER_TEMPLATE
+  -D LGFX_DRIVER=LGFX_GENERIC
+  -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_GENERIC.h\"
+  -D LGFX_PIN_SCK=13
+  -D LGFX_PIN_MOSI=11
+  -D LGFX_PIN_MISO=10
+  -D LGFX_PIN_DC=42
+  -D LGFX_PIN_CS=12
+  -D LGFX_PIN_RST=-1
+  -D LGFX_PIN_BL=41
+  -D VIEW_320x240
+  -D USE_PACKET_API
+  ${ft5x06.build_flags}
+  -D LGFX_SCREEN_WIDTH=240
+  -D LGFX_SCREEN_HEIGHT=320
+  -D DISPLAY_SIZE=320x240 ; landscape mode
+  -D LGFX_PANEL=ST7789
+  -D LGFX_ROTATION=1
+  -D LGFX_TOUCH_X_MIN=0
+  -D LGFX_TOUCH_X_MAX=239
+  -D LGFX_TOUCH_Y_MIN=0
+  -D LGFX_TOUCH_Y_MAX=319
+  -D LGFX_TOUCH_ROTATION=2
+  -D LGFX_CFG_HOST=SPI3_HOST
+  -D MAP_FULL_REDRAW=1
+
+lib_deps =
+  ${rak_wismeshtap_s3.lib_deps}
+  ${device-ui_base.lib_deps}
+
+
diff --git a/variants/esp32s3/rak_wismesh_tap_v2/variant.h b/variants/esp32s3/rak_wismesh_tap_v2/variant.h
new file mode 100644
index 000000000..2fc056557
--- /dev/null
+++ b/variants/esp32s3/rak_wismesh_tap_v2/variant.h
@@ -0,0 +1,71 @@
+#ifndef _VARIANT_RAK_WISMESHTAP_V2_H
+#define _VARIANT_RAK_WISMESHTAP_V2_H
+
+#define I2C_SDA 9
+#define I2C_SCL 40
+
+#define USE_SX1262
+
+#define LORA_SCK 5
+#define LORA_MISO 3
+#define LORA_MOSI 6
+#define LORA_CS 7
+#define LORA_RESET 8
+
+#ifdef USE_SX1262
+#define SX126X_CS LORA_CS
+#define SX126X_DIO1 47
+#define SX126X_BUSY 48
+#define SX126X_RESET LORA_RESET
+#define SX126X_DIO2_AS_RF_SWITCH
+#define SX126X_DIO3_TCXO_VOLTAGE 1.8
+#endif
+
+#define SX126X_POWER_EN (4)
+
+#define PIN_POWER_EN PIN_3V3_EN
+#define PIN_3V3_EN (14)
+
+#define LED_GREEN 46
+#define LED_BLUE 45
+
+#define PIN_LED1 LED_GREEN
+#define PIN_LED2 LED_BLUE
+
+#define LED_CONN LED_BLUE
+#define LED_PIN LED_GREEN
+#define ledOff(pin) pinMode(pin, INPUT)
+
+#define LED_STATE_ON 1 // State when LED is litted
+
+#define HAS_GPS 1
+#define GPS_TX_PIN 43
+#define GPS_RX_PIN 44
+
+#define SPI_MOSI (11)
+#define SPI_SCK (13)
+#define SPI_MISO (10)
+#define SPI_CS (12)
+
+#define HAS_BUTTON 1
+#define BUTTON_PIN 0
+
+#define CANNED_MESSAGE_MODULE_ENABLE 1
+#define USE_VIRTUAL_KEYBOARD 1
+
+#define BATTERY_PIN 1
+#define ADC_CHANNEL ADC1_GPIO1_CHANNEL
+#define ADC_MULTIPLIER 1.667
+
+#define PIN_BUZZER 38
+
+#define HAS_SDCARD 1
+#define SDCARD_USE_SPI1 1
+#define SDCARD_CS 2
+
+#define SPI_FREQUENCY 40000000
+#define SPI_READ_FREQUENCY 16000000
+
+#define SD_SPI_FREQUENCY 50000000
+
+#endif
\ No newline at end of file
diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
index f408054cf..25ec3ebfc 100644
--- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
+++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
@@ -6,7 +6,7 @@ platform_packages =
 
 board = seeed-sensecap-indicator
 board_check = true
-board_build.partitions = default_8MB.csv
+board_build.partitions = partition-table-8MB.csv
 upload_protocol = esptool
 
 build_flags = ${esp32_base.build_flags}
diff --git a/variants/esp32s3/t-deck-pro/variant.h b/variants/esp32s3/t-deck-pro/variant.h
index b08d3f65f..abe0a772a 100644
--- a/variants/esp32s3/t-deck-pro/variant.h
+++ b/variants/esp32s3/t-deck-pro/variant.h
@@ -92,3 +92,12 @@
 #define SX126X_DIO3_TCXO_VOLTAGE 2.4
 // Internally the TTGO module hooks the SX1262-DIO2 in to control the TX/RX switch (which is the default for the sx1262interface
 // code)
+
+#define MODEM_POWER_EN  41
+#define MODEM_PWRKEY    40
+#define MODEM_RST  9
+#define MODEM_RI  7
+#define MODEM_DTR  8
+#define MODEM_RX  10
+#define MODEM_TX  11
+
diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h
index dabd52980..40ba0307a 100644
--- a/variants/esp32s3/tbeam-s3-core/variant.h
+++ b/variants/esp32s3/tbeam-s3-core/variant.h
@@ -62,6 +62,6 @@
 // #define PCF8563_RTC 0x51         //Putting definitions in variant. h does not compile correctly
 
 // has 32768 Hz crystal
-#define HAS_32768HZ
+#define HAS_32768HZ 1
 
-#define USE_SH1106
\ No newline at end of file
+#define USE_SH1106
diff --git a/variants/esp32s3/tlora-pager/pins_arduino.h b/variants/esp32s3/tlora-pager/pins_arduino.h
new file mode 100644
index 000000000..a6321f510
--- /dev/null
+++ b/variants/esp32s3/tlora-pager/pins_arduino.h
@@ -0,0 +1,19 @@
+#ifndef Pins_Arduino_h
+#define Pins_Arduino_h
+
+#include 
+
+#define USB_VID 0x303a
+#define USB_PID 0x1001
+
+// used for keyboard, battery gauge, charger and haptic driver
+static const uint8_t SDA = 3;
+static const uint8_t SCL = 2;
+
+// Default SPI will be mapped to Radio
+static const uint8_t SS = 36;
+static const uint8_t MOSI = 34;
+static const uint8_t MISO = 33;
+static const uint8_t SCK = 35;
+
+#endif /* Pins_Arduino_h */
diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini
new file mode 100644
index 000000000..312d46259
--- /dev/null
+++ b/variants/esp32s3/tlora-pager/platformio.ini
@@ -0,0 +1,71 @@
+; LilyGo T-Lora-Pager
+[env:tlora-pager]
+extends = esp32s3_base
+board = t-deck-pro ; same as T-Deck Pro
+board_check = true
+board_build.partitions = default_16MB.csv
+upload_protocol = esptool
+
+build_flags = ${esp32s3_base.build_flags} 
+  -I variants/esp32s3/tlora-pager
+  -D T_LORA_PAGER 
+  -D BOARD_HAS_PSRAM
+  -D GPS_POWER_TOGGLE
+  -D HAS_SDCARD
+  -D SDCARD_USE_SPI1
+  -D ENABLE_ROTARY_PULLUP
+  -D ENABLE_BUTTON_PULLUP
+  -D HALF_STEP
+
+lib_deps = ${esp32s3_base.lib_deps}
+  lovyan03/LovyanGFX@1.2.7
+  earlephilhower/ESP8266Audio@1.9.9
+  earlephilhower/ESP8266SAM@1.0.1
+  adafruit/Adafruit DRV2605 Library@1.2.4
+  lewisxhe/PCF8563_Library@1.0.1
+  lewisxhe/SensorLib@0.3.1
+  https://github.com/pschatzmann/arduino-audio-driver/archive/refs/tags/v0.1.3.zip
+  https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip
+  https://github.com/mverch67/RotaryEncoder/archive/25a59d5745a6645536f921427d80b08e78f886d4.zip
+
+[env:tlora-pager-tft]
+board_level = extra
+extends = env:tlora-pager
+build_flags =
+  ${env:tlora-pager.build_flags}
+  -D CONFIG_DISABLE_HAL_LOCKS=1
+  -D INPUTDRIVER_ROTARY_TYPE=1
+  -D INPUTDRIVER_ROTARY_UP=40
+  -D INPUTDRIVER_ROTARY_DOWN=41
+  -D INPUTDRIVER_ROTARY_BTN=7
+  -D INPUTDRIVER_BUTTON_TYPE=0
+  -D HAS_SCREEN=1
+  -D HAS_TFT=1
+  -D USE_I2S_BUZZER
+  -D RAM_SIZE=5120
+  -D LV_LVGL_H_INCLUDE_SIMPLE
+  -D LV_CONF_INCLUDE_SIMPLE
+  -D LV_COMP_CONF_INCLUDE_SIMPLE
+  -D LV_USE_SYSMON=0
+  -D LV_USE_PROFILER=0
+  -D LV_USE_PERF_MONITOR=0
+  -D LV_USE_MEM_MONITOR=0
+  -D LV_USE_LOG=0
+  -D USE_LOG_DEBUG
+  -D LOG_DEBUG_INC=\"DebugConfiguration.h\"
+  -D RADIOLIB_SPI_PARANOID=0
+  -D LGFX_SCREEN_WIDTH=222
+  -D LGFX_SCREEN_HEIGHT=480
+  -D DISPLAY_SIZE=480x222 ; landscape mode
+  -D DISPLAY_SET_RESOLUTION
+  -D LGFX_DRIVER=LGFX_TLORA_PAGER
+  -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_T_LORA_PAGER.h\"
+;  -D LVGL_DRIVER=LVGL_T_LORA_PAGER
+;  -D LV_USE_ST7796=1
+  -D VIEW_480x222
+  -D USE_PACKET_API
+  -D MAP_FULL_REDRAW
+
+lib_deps =
+  ${env:tlora-pager.lib_deps}
+  ${device-ui_base.lib_deps}
diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h
new file mode 100644
index 000000000..ee48088c8
--- /dev/null
+++ b/variants/esp32s3/tlora-pager/variant.h
@@ -0,0 +1,125 @@
+// ST7796 TFT LCD
+#define TFT_CS 38
+#define ST7796_CS TFT_CS
+#define ST7796_RS 37    // DC
+#define ST7796_SDA MOSI // MOSI
+#define ST7796_SCK SCK
+#define ST7796_RESET -1
+#define ST7796_MISO MISO
+#define ST7796_BUSY -1
+#define ST7796_BL 42
+#define ST7796_SPI_HOST SPI2_HOST
+#define TFT_BL 42
+#define SPI_FREQUENCY 75000000
+#define SPI_READ_FREQUENCY 16000000
+#define TFT_HEIGHT 480
+#define TFT_WIDTH 222
+#define TFT_OFFSET_X 49
+#define TFT_OFFSET_Y 0
+#define TFT_OFFSET_ROTATION 3
+#define SCREEN_ROTATE
+#define SCREEN_TRANSITION_FRAMERATE 5
+#define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness
+
+#define I2C_SDA SDA
+#define I2C_SCL SCL
+
+#define USE_POWERSAVE
+#define SLEEP_TIME 120
+
+// GNNS
+#define HAS_GPS 1
+#define GPS_BAUDRATE 38400
+#define GPS_RX_PIN 4
+#define GPS_TX_PIN 12
+#define PIN_GPS_PPS 13
+
+// PCF8563 RTC Module
+#if __has_include("pcf8563.h")
+#include "pcf8563.h"
+#endif
+#define PCF8563_RTC 0x51
+#define HAS_RTC 1
+
+// Rotary
+#define ROTARY_A (40)
+#define ROTARY_B (41)
+#define ROTARY_PRESS (7)
+
+#define BUTTON_PIN 0
+
+// SPI interface SD card slot
+#define SPI_MOSI MOSI
+#define SPI_SCK SCK
+#define SPI_MISO MISO
+#define SPI_CS 21
+#define SDCARD_CS SPI_CS
+#define SD_SPI_FREQUENCY 75000000U
+
+// TCA8418 keyboard
+#define I2C_NO_RESCAN
+#define KB_BL_PIN 46
+#define KB_INT 6
+#define CANNED_MESSAGE_MODULE_ENABLE 1
+
+// audio codec ES8311
+#define HAS_I2S
+#define DAC_I2S_BCK 11
+#define DAC_I2S_WS 18
+#define DAC_I2S_DOUT 45
+#define DAC_I2S_DIN 17
+#define DAC_I2S_MCLK 10
+
+// gyroscope BHI260AP
+#define HAS_BHI260AP
+
+// battery charger BQ25896
+#define HAS_PPM 1
+#define XPOWERS_CHIP_BQ25896
+
+// battery quality management BQ27220
+#define HAS_BQ27220 1
+#define BQ27220_I2C_SDA SDA
+#define BQ27220_I2C_SCL SCL
+#define BQ27220_DESIGN_CAPACITY 1500
+
+// NFC ST25R3916
+#define NFC_INT 5
+#define NFC_CS 39
+
+// External expansion chip XL9555
+#define USE_XL9555
+#define EXPANDS_DRV_EN (0)
+#define EXPANDS_AMP_EN (1)
+#define EXPANDS_KB_RST (2)
+#define EXPANDS_LORA_EN (3)
+#define EXPANDS_GPS_EN (4)
+#define EXPANDS_NFC_EN (5)
+#define EXPANDS_GPS_RST (7)
+#define EXPANDS_KB_EN (8)
+#define EXPANDS_GPIO_EN (9)
+#define EXPANDS_SD_DET (10)
+#define EXPANDS_SD_PULLEN (11)
+#define EXPANDS_SD_EN (12)
+
+// LoRa
+#define USE_SX1262
+#define USE_SX1268
+
+#define LORA_SCK 35
+#define LORA_MISO 33
+#define LORA_MOSI 34
+#define LORA_CS 36
+
+#define LORA_DIO0 -1 // a No connect on the SX1262 module
+#define LORA_RESET 47
+#define LORA_DIO1 14 // SX1262 IRQ
+#define LORA_DIO2 48 // SX1262 BUSY
+#define LORA_DIO3    // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TXCO is enabled
+
+#define SX126X_CS LORA_CS
+#define SX126X_DIO1 LORA_DIO1
+#define SX126X_BUSY LORA_DIO2
+#define SX126X_RESET LORA_RESET
+#define SX126X_DIO2_AS_RF_SWITCH
+#define SX126X_DIO3_TCXO_VOLTAGE 3.0
diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini
index ecb1cbd67..f17a27e17 100644
--- a/variants/esp32s3/unphone/platformio.ini
+++ b/variants/esp32s3/unphone/platformio.ini
@@ -3,7 +3,7 @@
 [env:unphone]
 extends = esp32s3_base
 board = unphone
-board_build.partitions = default_8MB.csv
+board_build.partitions = partition-table-8MB.csv
 upload_speed = 921600
 monitor_speed = 115200
 monitor_filters = esp32_exception_decoder
@@ -20,6 +20,7 @@ build_flags =
   -D UNPHONE_LORA=0
   -D UNPHONE_FACTORY_MODE=0
   -D USE_SX127x
+  -D SDCARD_CS=43
 
 build_src_filter =
   ${esp32s3_base.build_src_filter}
@@ -32,6 +33,7 @@ lib_deps = ${esp32s3_base.lib_deps}
 
 
 [env:unphone-tft]
+board_level = extra
 extends = env:unphone
 build_flags =
   ${env:unphone.build_flags}
@@ -40,6 +42,7 @@ build_flags =
   -D HAS_SCREEN=1
   -D HAS_TFT=1
   -D HAS_SDCARD
+  -D SDCARD_CS=43
   -D DISPLAY_SET_RESOLUTION
   -D RAM_SIZE=6144
   -D LV_CACHE_DEF_SIZE=2097152
@@ -52,8 +55,6 @@ build_flags =
   -D LV_USE_PERF_MONITOR=0
   -D LV_USE_MEM_MONITOR=0
   -D LV_USE_LOG=0
-  -D USE_LOG_DEBUG
-  -D LOG_DEBUG_INC=\"DebugConfiguration.h\"
   -D LGFX_SCREEN_WIDTH=320
   -D LGFX_SCREEN_HEIGHT=480
   -D DISPLAY_SIZE=320x480 ; portrait mode
diff --git a/variants/esp32s3/unphone/variant.h b/variants/esp32s3/unphone/variant.h
index e186b5740..366b49233 100644
--- a/variants/esp32s3/unphone/variant.h
+++ b/variants/esp32s3/unphone/variant.h
@@ -52,7 +52,6 @@
 #undef GPS_TX_PIN
 
 #define SD_SPI_FREQUENCY 25000000
-#define SDCARD_CS 43
 
 #define LED_PIN 13     // the red part of the RGB LED
 #define LED_STATE_ON 0 // State when LED is lit
diff --git a/variants/native/portduino-buildroot/variant.h b/variants/native/portduino-buildroot/variant.h
index b7b39d6e8..3e91c6820 100644
--- a/variants/native/portduino-buildroot/variant.h
+++ b/variants/native/portduino-buildroot/variant.h
@@ -1,5 +1,5 @@
 #define HAS_SCREEN 1
 #define CANNED_MESSAGE_MODULE_ENABLE 1
 #define HAS_GPS 1
-#define MAX_RX_TOPHONE settingsMap[maxtophone]
-#define MAX_NUM_NODES settingsMap[maxnodes]
\ No newline at end of file
+#define MAX_RX_TOPHONE portduino_config.maxtophone
+#define MAX_NUM_NODES portduino_config.MaxNodes
diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini
index 62942a80e..c47ab8bf1 100644
--- a/variants/native/portduino/platformio.ini
+++ b/variants/native/portduino/platformio.ini
@@ -3,7 +3,10 @@ extends = portduino_base
 build_flags = ${portduino_base.build_flags} -I variants/native/portduino
   -I /usr/include
 board = cross_platform
-lib_deps = ${portduino_base.lib_deps}
+lib_deps = 
+  ${portduino_base.lib_deps}
+  melopero/Melopero RV3028@^1.1.0
+
 build_src_filter = ${portduino_base.build_src_filter}
 
 [env:native]
diff --git a/variants/native/portduino/variant.h b/variants/native/portduino/variant.h
index ce7dbd865..af05fcf8d 100644
--- a/variants/native/portduino/variant.h
+++ b/variants/native/portduino/variant.h
@@ -3,5 +3,8 @@
 #endif
 #define CANNED_MESSAGE_MODULE_ENABLE 1
 #define HAS_GPS 1
-#define MAX_RX_TOPHONE settingsMap[maxtophone]
-#define MAX_NUM_NODES settingsMap[maxnodes]
\ No newline at end of file
+#define MAX_RX_TOPHONE portduino_config.maxtophone
+#define MAX_NUM_NODES portduino_config.MaxNodes
+
+// RAK12002 RTC Module
+#define RV3028_RTC (uint8_t)0b1010010
diff --git a/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini b/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini
index 72ac6320d..5c1047aae 100644
--- a/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini
+++ b/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini
@@ -1,6 +1,7 @@
 ; The very slick RAK wireless RAK 4631 / 4630 board - Unified firmware for 5005/19003, with or without OLED RAK 1921
 [env:gat562_mesh_trial_tracker]
 extends = nrf52840_base
+board_level = extra
 board = gat562_mesh_trial_tracker
 board_check = true
 build_flags = ${nrf52840_base.build_flags}
diff --git a/variants/nrf52840/heltec_mesh_node_t114/variant.h b/variants/nrf52840/heltec_mesh_node_t114/variant.h
index f4f0baf13..7e82733aa 100644
--- a/variants/nrf52840/heltec_mesh_node_t114/variant.h
+++ b/variants/nrf52840/heltec_mesh_node_t114/variant.h
@@ -208,7 +208,7 @@ No longer populated on PCB
 #undef AREF_VOLTAGE
 #define AREF_VOLTAGE 3.0
 #define VBAT_AR_INTERNAL AR_INTERNAL_3_0
-#define ADC_MULTIPLIER (4.90F)
+#define ADC_MULTIPLIER (4.916F)
 
 #define HAS_RTC 0
 #ifdef __cplusplus
diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini
new file mode 100644
index 000000000..65d26dc40
--- /dev/null
+++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini
@@ -0,0 +1,19 @@
+; First prototype nrf52840/sx1262 device
+[env:heltec-mesh-solar]
+extends = nrf52840_base
+board = heltec_mesh_solar
+board_level = pr
+debug_tool = jlink
+
+# add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling.
+build_flags = ${nrf52840_base.build_flags}
+  -Ivariants/nrf52840/heltec_mesh_solar
+  -DGPS_POWER_TOGGLE
+  -DHELTEC_MESH_SOLAR
+
+build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_solar>
+lib_deps = 
+  ${nrf52840_base.lib_deps}
+  https://github.com/NMIoT/meshsolar/archive/dfc5330dad443982e6cdd37a61d33fc7252f468b.zip
+  lewisxhe/PCF8563_Library@^1.0.1
+  ArduinoJson@6.21.4
diff --git a/variants/nrf52840/heltec_mesh_solar/variant.cpp b/variants/nrf52840/heltec_mesh_solar/variant.cpp
new file mode 100644
index 000000000..8236d7cf4
--- /dev/null
+++ b/variants/nrf52840/heltec_mesh_solar/variant.cpp
@@ -0,0 +1,36 @@
+/*
+  Copyright (c) 2014-2015 Arduino LLC.  All right reserved.
+  Copyright (c) 2016 Sandeep Mistry All right reserved.
+  Copyright (c) 2018, Adafruit Industries (adafruit.com)
+
+  This library is free software; you can redistribute it and/or
+  modify it under the terms of the GNU Lesser General Public
+  License as published by the Free Software Foundation; either
+  version 2.1 of the License, or (at your option) any later version.
+
+  This library 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 Lesser General Public License for more details.
+
+  You should have received a copy of the GNU Lesser General Public
+  License along with this library; if not, write to the Free Software
+  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+#include "variant.h"
+#include "nrf.h"
+#include "wiring_constants.h"
+#include "wiring_digital.h"
+
+const uint32_t g_ADigitalPinMap[] = {
+    // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled
+    0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
+
+    // P1
+    32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47};
+
+void initVariant()
+{
+  pinMode(BQ4050_EMERGENCY_SHUTDOWN_PIN, INPUT);
+}
diff --git a/variants/nrf52840/heltec_mesh_solar/variant.h b/variants/nrf52840/heltec_mesh_solar/variant.h
new file mode 100644
index 000000000..33c2b2556
--- /dev/null
+++ b/variants/nrf52840/heltec_mesh_solar/variant.h
@@ -0,0 +1,157 @@
+/*
+ Copyright (c) 2014-2015 Arduino LLC.  All right reserved.
+ Copyright (c) 2016 Sandeep Mistry All right reserved.
+ Copyright (c) 2018, Adafruit Industries (adafruit.com)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+ This library 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 Lesser General Public License for more details.
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+#ifndef _VARIANT_HELTEC_NRF_
+#define _VARIANT_HELTEC_NRF_
+/** Master clock frequency */
+#define VARIANT_MCK (64000000ul)
+
+#define USE_LFXO // Board uses 32khz crystal for LF
+
+/*----------------------------------------------------------------------------
+ *        Headers
+ *----------------------------------------------------------------------------*/
+
+#include "WVariant.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif // __cplusplus
+
+// Number of pins defined in PinDescription array
+#define PINS_COUNT (48)
+#define NUM_DIGITAL_PINS (48)
+#define NUM_ANALOG_INPUTS (1)
+#define NUM_ANALOG_OUTPUTS (0)
+
+
+#define PIN_LED1 (0 + 12) // green (confirmed on 1.0 board)
+#define LED_BLUE PIN_LED1 // fake for bluefruit library
+#define LED_GREEN PIN_LED1
+#define LED_BUILTIN LED_GREEN
+#define LED_STATE_ON 0  // State when LED is lit
+
+#define HAS_NEOPIXEL                         // Enable the use of neopixels
+#define NEOPIXEL_COUNT 1                     // How many neopixels are connected
+#define NEOPIXEL_DATA (32+15)                // gpio pin used to send data to the neopixels
+#define NEOPIXEL_TYPE (NEO_GRB + NEO_KHZ800) // type of neopixels in use
+
+/*
+ * Buttons
+ */
+#define PIN_BUTTON1 (32 + 10)
+// #define PIN_BUTTON2 (0 + 18)      // 0.18 is labeled on the board as RESET but we configure it in the bootloader as a regular
+// GPIO
+
+/*
+No longer populated on PCB
+*/
+#define PIN_SERIAL2_RX (0 + 9)
+#define PIN_SERIAL2_TX (0 + 10)
+//  #define PIN_SERIAL2_EN (0 + 17)
+
+/*
+ * I2C
+ */
+
+#define WIRE_INTERFACES_COUNT 2
+
+// I2C bus 0
+// Routed to footprint for PCF8563TS RTC
+// Not populated on T114 V1, maybe in future?
+#define PIN_WIRE_SDA (0 + 6) // P0.26
+#define PIN_WIRE_SCL (0 + 26) // P0.26
+
+// I2C bus 1
+// Available on header pins, for general use
+#define PIN_WIRE1_SDA (0 + 30) // P0.30
+#define PIN_WIRE1_SCL (0 + 5) // P0.13
+
+/*
+ * Lora radio
+ */
+
+#define USE_SX1262
+// #define USE_SX1268
+#define SX126X_CS (0 + 24) // FIXME - we really should define LORA_CS instead
+#define LORA_CS  (0 + 24)
+#define SX126X_DIO1 (0 + 20)
+// Note DIO2 is attached internally to the module to an analog switch for TX/RX switching
+// #define SX1262_DIO3 (0 + 21)
+// This is used as an *output* from the sx1262 and connected internally to power the tcxo, do not drive from the
+//    main
+// CPU?
+#define SX126X_BUSY  (0 + 17)
+#define SX126X_RESET (0 + 25)
+// Not really an E22 but TTGO seems to be trying to clone that
+#define SX126X_DIO2_AS_RF_SWITCH
+#define SX126X_DIO3_TCXO_VOLTAGE 1.8
+
+/*
+ * GPS pins
+ */
+
+#define GPS_L76K
+
+// #define PIN_GPS_RESET (32 + 6) // An output to reset L76K GPS. As per datasheet, low for > 100ms will reset the L76K
+// #define GPS_RESET_MODE LOW
+// #define PIN_GPS_EN (21)
+#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing
+#define VEXT_ON_VALUE HIGH
+// #define GPS_EN_ACTIVE HIGH
+#define PIN_GPS_STANDBY (32 + 2) // An output to wake GPS, low means allow sleep, high means force wake
+#define PIN_GPS_PPS (32 + 4)
+// Seems to be missing on this new board
+// #define PIN_GPS_PPS (32 + 4)  // Pulse per second input from the GPS
+#define GPS_TX_PIN (32 + 5) // This is for bits going TOWARDS the CPU
+#define GPS_RX_PIN (32 + 7) // This is for bits going TOWARDS the GPS
+
+#define GPS_THREAD_INTERVAL 50
+
+#define PIN_SERIAL1_RX GPS_TX_PIN
+#define PIN_SERIAL1_TX GPS_RX_PIN
+
+/*
+ * SPI Interfaces
+ */
+#define SPI_INTERFACES_COUNT 1
+
+// For LORA, spi 0
+#define PIN_SPI_MISO (0 + 23)
+#define PIN_SPI_MOSI (0 + 22)
+#define PIN_SPI_SCK  (0 + 19)
+
+// #define PIN_PWR_EN (0 + 6)
+
+// To debug via the segger JLINK console rather than the CDC-ACM serial device
+// #define USE_SEGGER
+
+#define BQ4050_SDA_PIN                      (32+1) // I2C data line pin
+#define BQ4050_SCL_PIN                      (32+0) // I2C clock line pin
+#define BQ4050_EMERGENCY_SHUTDOWN_PIN       (32+3) // Emergency shutdown pin
+
+#define HAS_RTC 0
+#ifdef __cplusplus
+}
+#endif
+
+/*----------------------------------------------------------------------------
+ *        Arduino objects - C++ only
+ *----------------------------------------------------------------------------*/
+
+#endif
diff --git a/variants/nrf52840/meshlink/platformio.ini b/variants/nrf52840/meshlink/platformio.ini
index 8216a704a..466362242 100644
--- a/variants/nrf52840/meshlink/platformio.ini
+++ b/variants/nrf52840/meshlink/platformio.ini
@@ -4,6 +4,7 @@
 [env:meshlink]
 extends = nrf52840_base
 board = meshlink
+board_level = extra
 ;board_check = true
 build_flags = ${nrf52840_base.build_flags}
   -I variants/nrf52840/meshlink
diff --git a/variants/nrf52840/meshlink_eink/platformio.ini b/variants/nrf52840/meshlink_eink/platformio.ini
index a48a9e695..af5a0040e 100644
--- a/variants/nrf52840/meshlink_eink/platformio.ini
+++ b/variants/nrf52840/meshlink_eink/platformio.ini
@@ -4,6 +4,7 @@
 [env:meshlink_eink]
 extends = nrf52840_base
 board = meshlink
+board_level = extra
 ;board_check = true
 build_flags = ${nrf52840_base.build_flags}
   -I variants/nrf52840/meshlink_eink
diff --git a/variants/nrf52840/meshtiny/platformio.ini b/variants/nrf52840/meshtiny/platformio.ini
index ef744a1c3..5f03f5cb2 100644
--- a/variants/nrf52840/meshtiny/platformio.ini
+++ b/variants/nrf52840/meshtiny/platformio.ini
@@ -4,15 +4,7 @@ extends = nrf52840_base
 board = meshtiny
 board_level = extra
 build_flags = ${nrf52840_base.build_flags} -Ivariants/nrf52840/meshtiny -D MESHTINY
-  -DGPS_POWER_TOGGLE ; comment this line to disable triple press function on the user button to turn off gps entirely.
-  -DRADIOLIB_EXCLUDE_SX128X=1
-  -DRADIOLIB_EXCLUDE_SX127X=1
-  -DRADIOLIB_EXCLUDE_LR11X0=1
-  -D INPUTDRIVER_ENCODER_TYPE=2
-  -D INPUTDRIVER_ENCODER_UP=4
-  -D INPUTDRIVER_ENCODER_DOWN=26
-  -D INPUTDRIVER_ENCODER_BTN=28
-  -D USE_PIN_BUZZER=PIN_BUZZER
+  -D USE_PIN_BUZZER
   -D MESHTASTIC_EXCLUDE_GPS=1
 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/meshtiny>
 lib_deps = 
diff --git a/variants/nrf52840/meshtiny/variant.h b/variants/nrf52840/meshtiny/variant.h
index 83ad4c5b9..d1139b3be 100644
--- a/variants/nrf52840/meshtiny/variant.h
+++ b/variants/nrf52840/meshtiny/variant.h
@@ -21,8 +21,6 @@
 
 #define MESHTINY
 
-// #define RAK4630
-
 /** Master clock frequency */
 #define VARIANT_MCK (64000000ul)
 
@@ -76,11 +74,10 @@ extern "C" {
  * Buttons
  */
 
-#define PIN_BUTTON1 9
+#define CANCEL_BUTTON_PIN 9
 #define BUTTON_NEED_PULLUP
-#define PIN_BUTTON2 12
-#define PIN_BUTTON3 24
-#define PIN_BUTTON4 25
+#define CANCEL_BUTTON_ACTIVE_LOW true
+#define CANCEL_BUTTON_ACTIVE_PULLUP false
 
 /*
  * Analog pins
diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini
index 83feaa06c..6bf5f44cb 100644
--- a/variants/nrf52840/rak4631/platformio.ini
+++ b/variants/nrf52840/rak4631/platformio.ini
@@ -22,6 +22,7 @@ lib_deps =
   https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip
   rakwireless/RAKwireless NCP5623 RGB LED library@^1.0.2
   beegee-tokyo/RAK12035_SoilMoisture@^1.0.4
+  # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main
   https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip
 
 ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm)
diff --git a/variants/nrf52840/rak4631_eth_gw/platformio.ini b/variants/nrf52840/rak4631_eth_gw/platformio.ini
index 79cdb28c7..4be8843a2 100644
--- a/variants/nrf52840/rak4631_eth_gw/platformio.ini
+++ b/variants/nrf52840/rak4631_eth_gw/platformio.ini
@@ -31,7 +31,8 @@ lib_deps =
   melopero/Melopero RV3028@^1.1.0
   https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip
   rakwireless/RAKwireless NCP5623 RGB LED library@^1.0.2
-  https://github.com/meshtastic/RAK12034-BMX160/archive/4821355fb10390ba8557dc43ca29a023bcfbb9d9.zip
+  # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main
+  https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip
   bblanchon/ArduinoJson @ 6.21.4
 ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm)
 ; Note: as of 6/2013 the serial/bootloader based programming takes approximately 30 seconds
diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h
index a32753343..7fb890303 100644
--- a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h
+++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h
@@ -18,16 +18,9 @@
 
 // Shared NicheGraphics components
 // --------------------------------
-#include "graphics/niche/Drivers/Backlight/LatchingBacklight.h"
-#include "graphics/niche/Drivers/EInk/GDEY0213B74.h"
+#include "graphics/niche/Drivers/EInk/ZJY122250_0213BAAMFGN.h"
 #include "graphics/niche/Inputs/TwoButton.h"
 
-// Special case - fix T-Echo's touch button
-// ----------------------------------------
-// On a handful of T-Echos, LoRa TX triggers the capacitive touch
-// To avoid this, we lockout the button during TX
-#include "mesh/RadioLibInterface.h"
-
 void setupNicheGraphics()
 {
     using namespace NicheGraphics;
@@ -41,7 +34,7 @@ void setupNicheGraphics()
     // E-Ink Driver
     // -----------------------------
 
-    Drivers::EInk *driver = new Drivers::GDEY0213B74;
+    Drivers::EInk *driver = new Drivers::ZJY122250_0213BAAMFGN;
     driver->begin(&SPI1, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY, PIN_EINK_RES);
 
     // InkHUD
@@ -53,8 +46,7 @@ void setupNicheGraphics()
     inkhud->setDriver(driver);
 
     // Set how many FAST updates per FULL update
-    // Set how unhealthy additional FAST updates beyond this number are
-    inkhud->setDisplayResilience(7, 1.5);
+    inkhud->setDisplayResilience(15);
 
     // Select fonts
     InkHUD::Applet::fontLarge = FREESANS_12PT_WIN1252;
@@ -62,16 +54,10 @@ void setupNicheGraphics()
     InkHUD::Applet::fontSmall = FREESANS_6PT_WIN1252;
 
     // Customize default settings
-    inkhud->persistence->settings.userTiles.maxCount = 2;              // Two applets side-by-side
-                                                                       // 270 degrees clockwise
+    inkhud->persistence->settings.rotation = 1;                        // 90 degrees clockwise
     inkhud->persistence->settings.optionalFeatures.batteryIcon = true; // Device definitely has a battery
-    inkhud->persistence->settings.optionalMenuItems.backlight = true;  // Until proves capacitive button works by touching it
-    inkhud->persistence->settings.userTiles.count = 1; // One tile only by default, keep things simple for new users
-
-    // Setup backlight controller
-    // Note: AUX button attached further down
-    Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance();
-    backlight->setPin(PIN_EINK_EN);
+    inkhud->persistence->settings.userTiles.count = 1;    // One tile only by default, keep things simple for new users
+    inkhud->persistence->settings.userTiles.maxCount = 2; // Two applets side-by-side
 
     // Pick applets
     // Note: order of applets determines priority of "auto-show" feature
@@ -83,11 +69,9 @@ void setupNicheGraphics()
     inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet);            // -
     inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0);         // Activated, no autoshow, default on tile 0
 
-    inkhud->persistence->settings.rotation = 1;
-    // inkhud->persistence->printSettings(&inkhud->persistence->settings);
     //  Start running InkHUD
     inkhud->begin();
-    // inkhud->persistence->printSettings(&inkhud->persistence->settings);
+
     //  Buttons
     //  --------------------------
 
diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini b/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini
index 52ff39d49..7f9eb0e2c 100644
--- a/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini
+++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini
@@ -1,17 +1,47 @@
 [env:seeed_wio_tracker_L1_eink]
 board = seeed_wio_tracker_L1
-extends = nrf52840_base, inkhud
+extends = nrf52840_base
 ;board_level = extra
 build_flags = ${nrf52840_base.build_flags}
-  ${inkhud.build_flags}
   -I variants/nrf52840/seeed_wio_tracker_L1_eink
   -D SEEED_WIO_TRACKER_L1_EINK
   -D SEEED_WIO_TRACKER_L1
   -I src/platform/nrf52/softdevice
   -I src/platform/nrf52/softdevice/nrf52
+  -DUSE_EINK
+  -DEINK_DISPLAY_MODEL=GxEPD2_213_B74
+  -DEINK_WIDTH=250
+  -DEINK_HEIGHT=122
+  -DUSE_EINK_DYNAMICDISPLAY            ; Enable Dynamic EInk
+  -DEINK_LIMIT_FASTREFRESH=10          ; How many consecutive fast-refreshes are permitted
+  -DEINK_LIMIT_RATE_BACKGROUND_SEC=30  ; Minimum interval between BACKGROUND updates
+  -DEINK_LIMIT_RATE_RESPONSIVE_SEC=1   ; Minimum interval between RESPONSIVE updates
+;   -D EINK_LIMIT_GHOSTING_PX=2000      ; (Optional) How much image ghosting is tolerated
+  -DEINK_BACKGROUND_USES_FAST          ; (Optional) Use FAST refresh for both BACKGROUND and RESPONSIVE, until a limit is reached.
+  -DEINK_HASQUIRK_GHOSTING             ; Display model is identified as "prone to ghosting"
+  -DEINK_HASQUIRK_WEAKFASTREFRESH      ; Pixels set with fast-refresh are easy to clear, disrupted by sunlight
 board_build.ldscript = src/platform/nrf52/nrf52840_s140_v7.ld
-build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/seeed_wio_tracker_L1_eink> ${inkhud.build_src_filter}
+build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/seeed_wio_tracker_L1_eink>
 lib_deps =
-  ${inkhud.lib_deps}
   ${nrf52840_base.lib_deps}
+  https://github.com/meshtastic/GxEPD2#b202ebfec6a4821e098cf7a625ba0f6f2400292d
 debug_tool = jlink
+
+[env:seeed_wio_tracker_L1_eink-inkhud]
+board = seeed_wio_tracker_L1
+extends = nrf52840_base, inkhud
+build_flags = 
+  ${nrf52840_base.build_flags}
+  ${inkhud.build_flags}
+  -I variants/nrf52840/seeed_wio_tracker_L1_eink
+  -D SEEED_WIO_TRACKER_L1
+  -D BUTTON_PIN=D13
+board_build.ldscript = src/platform/nrf52/nrf52840_s140_v7.ld
+build_src_filter = 
+  ${nrf52_base.build_src_filter}
+  ${inkhud.build_src_filter}
+  +<../variants/nrf52840/seeed_wio_tracker_L1_eink>
+lib_deps =
+  ${inkhud.lib_deps} ; Before base libs_deps, so we use ZinggJM/GFXRoot instead of AdafruitGFX (saves space)
+  ${nrf52840_base.lib_deps}
+debug_tool = jlink
\ No newline at end of file
diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h
index 98a7b2c39..f33d200b1 100644
--- a/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h
+++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h
@@ -33,17 +33,10 @@
 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 //  Button Configuration
 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
-#ifdef BUTTON_PIN
-#undef BUTTON_PIN
-#endif
-
-#define BUTTON_PIN D13 // This is the Program Button
+#define CANCEL_BUTTON_PIN D13 // This is the Program Button
 // #define BUTTON_NEED_PULLUP   1
-#define BUTTON_ACTIVE_LOW true
-#define BUTTON_ACTIVE_PULLUP false
-
-#define BUTTON_PIN_TOUCH 13 // Touch button
+#define CANCEL_BUTTON_ACTIVE_LOW true
+#define CANCEL_BUTTON_ACTIVE_PULLUP false
 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 //  Digital Pin Mapping (D0-D10)
 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -116,7 +109,7 @@ static const uint8_t SCL = PIN_WIRE_SCL;
 #define PIN_EINK_SCLK 31
 #define PIN_EINK_MOSI 33
 #define PIN_EINK_EN 14   // unused
-#define PIN_SPI1_MISO 15 // unused
+#define PIN_SPI1_MISO -1 // 15 unused
 #define PIN_SPI1_MOSI PIN_EINK_MOSI
 #define PIN_SPI1_SCK PIN_EINK_SCLK
 
@@ -175,7 +168,17 @@ static const uint8_t SCL = PIN_WIRE_SCL;
 //  joystick
 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 
+// trackball
+#define HAS_TRACKBALL 1
+#define TB_UP 25
+#define TB_DOWN 26
+#define TB_LEFT 27
+#define TB_RIGHT 28
+#define TB_PRESS 29
+#define TB_DIRECTION FALLING
+
 #define CANNED_MESSAGE_MODULE_ENABLE 1
+#define CANNED_MESSAGE_ADD_CONFIRMATION 1
 
 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 //  Compatibility Definitions
diff --git a/variants/nrf52840/tracker-t1000-e/variant.h b/variants/nrf52840/tracker-t1000-e/variant.h
index 81b4ef3fb..403552ec0 100644
--- a/variants/nrf52840/tracker-t1000-e/variant.h
+++ b/variants/nrf52840/tracker-t1000-e/variant.h
@@ -124,8 +124,7 @@ extern "C" {
 #define GPS_RTC_INT (0 + 15)     // P0.15, normal is LOW, wake by HIGH
 #define GPS_RESETB_OUT (32 + 14) // P1.14, always input pull_up
 
-#define GPS_FIX_HOLD_TIME 15000 // ms
-#define BATTERY_PIN 2           // P0.02/AIN0, BAT_ADC
+#define BATTERY_PIN 2 // P0.02/AIN0, BAT_ADC
 #define BATTERY_IMMUTABLE
 #define ADC_MULTIPLIER (2.0F)
 // P0.04/AIN2 is VCC_ADC, P0.05/AIN3 is CHARGER_DET, P1.03 is CHARGE_STA, P1.04 is CHARGE_DONE
diff --git a/variants/nrf52840/wio-t1000-s/variant.h b/variants/nrf52840/wio-t1000-s/variant.h
index eb6a34d6c..02f8a20b2 100644
--- a/variants/nrf52840/wio-t1000-s/variant.h
+++ b/variants/nrf52840/wio-t1000-s/variant.h
@@ -123,7 +123,6 @@ extern "C" {
 #define GPS_RESETB_OUT (32 + 14) // P1.14, awlays input pull_up
 
 // #define GPS_THREAD_INTERVAL 50
-#define GPS_FIX_HOLD_TIME 15000 // ms
 
 #define BATTERY_PIN 2
 // #define ADC_CHANNEL ADC1_GPIO2_CHANNEL
@@ -157,4 +156,4 @@ extern "C" {
  *        Arduino objects - C++ only
  *----------------------------------------------------------------------------*/
 
-#endif // _VARIANT_WIO_SDK_WM1110_
\ No newline at end of file
+#endif // _VARIANT_WIO_SDK_WM1110_
diff --git a/variants/rp2040/challenger_2040_lora/pins_arduino.h b/variants/rp2040/challenger_2040_lora/pins_arduino.h
new file mode 100644
index 000000000..ac472c07e
--- /dev/null
+++ b/variants/rp2040/challenger_2040_lora/pins_arduino.h
@@ -0,0 +1,79 @@
+#pragma once
+
+#define PINS_COUNT (25u)
+#define NUM_DIGITAL_PINS (25u)
+#define NUM_ANALOG_INPUTS (4u)
+#define NUM_ANALOG_OUTPUTS (0u)
+#define ADC_RESOLUTION (12u)
+
+// LEDs
+#define PIN_LED (24u)
+
+// Serial
+#define PIN_SERIAL1_TX (16u)
+#define PIN_SERIAL1_RX (17u)
+
+// SPI
+#define PIN_SPI0_MISO (20u)
+#define PIN_SPI0_MOSI (23u)
+#define PIN_SPI0_SCK (22u)
+#define PIN_SPI0_SS (21u)
+
+// Connected to LoRa module
+#define PIN_SPI1_MISO (12u)
+#define PIN_SPI1_MOSI (11u)
+#define PIN_SPI1_SCK (10u)
+#define PIN_SPI1_SS (9u)
+#define RFM95W_SS (9u)
+#define RFM95W_DIO0 (14u)
+#define RFM95W_DIO1 (15u)
+#define RFM95W_DIO2 (18u)
+#define RFM95W_RST (13u)
+#define RFM95W_SPI SPI1
+
+// Wire
+#define PIN_WIRE0_SDA (0u)
+#define PIN_WIRE0_SCL (1u)
+
+// Not pinned out
+#define PIN_WIRE1_SDA (31u)
+#define PIN_WIRE1_SCL (31u)
+#define PIN_SERIAL2_RX (31u)
+#define PIN_SERIAL2_TX (31u)
+
+#define SERIAL_HOWMANY (1u)
+#define SPI_HOWMANY (2u)
+#define WIRE_HOWMANY (1u)
+
+#define LED_BUILTIN PIN_LED
+
+static const uint8_t D0 = (16u);
+static const uint8_t D1 = (17u);
+static const uint8_t D2 = (20u);
+static const uint8_t D3 = (23u);
+static const uint8_t D4 = (22u);
+static const uint8_t D5 = (2u);
+static const uint8_t D6 = (3u);
+static const uint8_t D7 = (0u);
+static const uint8_t D8 = (1u);
+static const uint8_t D9 = (4u);
+static const uint8_t D10 = (5u);
+static const uint8_t D11 = (6u);
+static const uint8_t D12 = (7u);
+static const uint8_t D13 = (8u);
+static const uint8_t D14 = (13u);
+static const uint8_t D15 = (14u);
+static const uint8_t D16 = (15u);
+static const uint8_t D17 = (18u);
+static const uint8_t D18 = (24u);
+
+static const uint8_t A0 = (26u);
+static const uint8_t A1 = (27u);
+static const uint8_t A2 = (28u);
+static const uint8_t A3 = (29u);
+static const uint8_t A4 = (19u);
+static const uint8_t A5 = (21u);
+
+#ifndef SS
+#define SS PIN_SPI1_SS
+#endif
\ No newline at end of file
diff --git a/variants/rp2040/challenger_2040_lora/platformio.ini b/variants/rp2040/challenger_2040_lora/platformio.ini
new file mode 100644
index 000000000..4a709d650
--- /dev/null
+++ b/variants/rp2040/challenger_2040_lora/platformio.ini
@@ -0,0 +1,16 @@
+[env:challenger_2040_lora]
+extends = rp2040_base
+board = challenger_2040_lora
+board_level = extra
+upload_protocol = picotool
+# add our variants files to the include and src paths
+build_flags = 
+  ${rp2040_base.build_flags} 
+  -D PRIVATE_HW
+  -I variants/rp2040/challenger_2040_lora
+  -D DEBUG_RP2040_PORT=Serial
+  -D HW_SPI1_DEVICE
+lib_deps =
+  ${rp2040_base.lib_deps}
+debug_build_flags = ${rp2040_base.build_flags}
+debug_tool = cmsis-dap ; for e.g. Picotool
diff --git a/variants/rp2040/challenger_2040_lora/variant.h b/variants/rp2040/challenger_2040_lora/variant.h
new file mode 100644
index 000000000..552f90720
--- /dev/null
+++ b/variants/rp2040/challenger_2040_lora/variant.h
@@ -0,0 +1,39 @@
+// Define SS for compatibility with libraries expecting a default SPI chip select pin
+
+#define ARDUINO_ARCH_AVR
+
+#define EXT_NOTIFY_OUT 0xFFFFFFFF
+#define BUTTON_PIN 0xFFFFFFFF
+
+#define LED_PIN PIN_LED
+
+#define USE_RF95 // RFM95/SX127x
+
+#undef LORA_SCK
+#undef LORA_MISO
+#undef LORA_MOSI
+#undef LORA_CS
+
+// https://gitlab.com/invectorlabs/hw/challenger_rp2040_lora
+#define LORA_SCK 10  // Clock
+#define LORA_CS 9    // Chip Select
+#define LORA_MOSI 11 // Serial Data Out
+#define LORA_MISO 12 // Serial Data In
+
+#define LORA_RESET 13 // Reset
+
+#define LORA_DIO0 14         // DIO0
+#define LORA_DIO1 15         // DIO1
+#define LORA_DIO2 18         // DIO2
+#define LORA_DIO3 0xFFFFFFFF // Not connected
+#define LORA_DIO4 0xFFFFFFFF // Not connected
+#define LORA_DIO5 0xFFFFFFFF // Not connected
+
+#ifdef USE_SX1262
+#define SX126X_CS LORA_CS
+#define SX126X_DIO1 LORA_DIO1
+#define SX126X_BUSY LORA_DIO2
+#define SX126X_RESET LORA_RESET
+#define SX126X_DIO2_AS_RF_SWITCH
+// #define SX126X_DIO3_TCXO_VOLTAGE 1.8
+#endif
\ No newline at end of file
diff --git a/variants/rp2040/ec_catsniffer/platformio.ini b/variants/rp2040/ec_catsniffer/platformio.ini
index acf19d757..b70eff6d7 100644
--- a/variants/rp2040/ec_catsniffer/platformio.ini
+++ b/variants/rp2040/ec_catsniffer/platformio.ini
@@ -1,6 +1,7 @@
 [env:catsniffer]
 extends = rp2040_base
 board = rpipico
+board_level = extra
 upload_protocol = picotool
 build_flags = 
   ${rp2040_base.build_flags}
diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini
index c011f62c9..290982405 100644
--- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini
+++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini
@@ -12,7 +12,5 @@ build_flags =
   -DMESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1
   -DMESHTASTIC_EXCLUDE_I2C=1
   -DMESHTASTIC_EXCLUDE_GPS=1
-  ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF
-  ;-DCFG_DEBUG
 
 upload_port = stlink
\ No newline at end of file
diff --git a/variants/stm32/CDEBYTE_E77-MBL/rfswitch.h b/variants/stm32/CDEBYTE_E77-MBL/rfswitch.h
new file mode 100644
index 000000000..daf4aaaf9
--- /dev/null
+++ b/variants/stm32/CDEBYTE_E77-MBL/rfswitch.h
@@ -0,0 +1,9 @@
+// From E77-900M22S Product Specification
+// https://www.cdebyte.com/pdf-down.aspx?id=2963
+// Note 1: PA6 and PA7 pins are used as internal control RF switches of the module, PA6 = RF_TXEN, PA7 = RF_RXEN, RF_TXEN=1
+// RF_RXEN=0 is the transmit channel, and RF_TXEN=0 RF_RXEN=1 is the receiving channel
+
+static const RADIOLIB_PIN_TYPE rfswitch_pins[5] = {PA7, PA6, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
+
+static const Module::RfSwitchMode_t rfswitch_table[4] = {
+    {STM32WLx::MODE_IDLE, {LOW, LOW}}, {STM32WLx::MODE_RX, {HIGH, LOW}}, {STM32WLx::MODE_TX_HP, {LOW, HIGH}}, END_OF_MODE_TABLE};
\ No newline at end of file
diff --git a/variants/stm32/CDEBYTE_E77-MBL/variant.h b/variants/stm32/CDEBYTE_E77-MBL/variant.h
index 52801dac7..317f44489 100644
--- a/variants/stm32/CDEBYTE_E77-MBL/variant.h
+++ b/variants/stm32/CDEBYTE_E77-MBL/variant.h
@@ -18,5 +18,4 @@ Do not expect a working Meshtastic device with this target.
 #define LED_PIN PB4 // LED1
 // #define LED_PIN PB3 // LED2
 #define LED_STATE_ON 1
-
 #endif
diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini
index 4f9edbb92..7fc6c7cba 100644
--- a/variants/stm32/rak3172/platformio.ini
+++ b/variants/stm32/rak3172/platformio.ini
@@ -6,10 +6,14 @@ board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
 build_flags =
   ${stm32_base.build_flags}
   -Ivariants/stm32/rak3172
+  -DRAK3172
+  -DENABLE_HWSERIAL1
+  -DPIN_SERIAL1_RX=PB7
+  -DPIN_SERIAL1_TX=PB6
   -DPIN_WIRE_SDA=PA11
   -DPIN_WIRE_SCL=PA12
   -DMESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1
   -DMESHTASTIC_EXCLUDE_I2C=1
   -DMESHTASTIC_EXCLUDE_GPS=1
-  ;-DCFG_DEBUG
+
 upload_port = stlink
diff --git a/variants/stm32/rak3172/rfswitch.h b/variants/stm32/rak3172/rfswitch.h
new file mode 100644
index 000000000..2dced3c7c
--- /dev/null
+++ b/variants/stm32/rak3172/rfswitch.h
@@ -0,0 +1,7 @@
+// Pins from https://forum.rakwireless.com/t/rak3172-internal-schematic/4557/2
+// PB8, PC13
+
+static const RADIOLIB_PIN_TYPE rfswitch_pins[5] = {PB8, PC13, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
+
+static const Module::RfSwitchMode_t rfswitch_table[4] = {
+    {STM32WLx::MODE_IDLE, {LOW, LOW}}, {STM32WLx::MODE_RX, {HIGH, LOW}}, {STM32WLx::MODE_TX_HP, {LOW, HIGH}}, END_OF_MODE_TABLE};
\ No newline at end of file
diff --git a/variants/stm32/wio-e5/rfswitch.h b/variants/stm32/wio-e5/rfswitch.h
new file mode 100644
index 000000000..3eadd9b5c
--- /dev/null
+++ b/variants/stm32/wio-e5/rfswitch.h
@@ -0,0 +1,8 @@
+/* https://wiki.seeedstudio.com/LoRa-E5_STM32WLE5JC_Module/
+ * Wio-E5 module ONLY transmits through RFO_HP
+ * Receive: PA4=1, PA5=0
+ * Transmit(high output power, SMPS mode): PA4=0, PA5=1 */
+static const RADIOLIB_PIN_TYPE rfswitch_pins[5] = {PA4, PA5, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
+
+static const Module::RfSwitchMode_t rfswitch_table[4] = {
+    {STM32WLx::MODE_IDLE, {LOW, LOW}}, {STM32WLx::MODE_RX, {HIGH, LOW}}, {STM32WLx::MODE_TX_HP, {LOW, HIGH}}, END_OF_MODE_TABLE};
diff --git a/version.properties b/version.properties
index f9e2cb279..cbf8265d9 100644
--- a/version.properties
+++ b/version.properties
@@ -1,4 +1,4 @@
 [VERSION]  
 major = 2
 minor = 7
-build = 6
+build = 9