diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml
index 80f5c6855..8e461998a 100644
--- a/.github/actions/setup-base/action.yml
+++ b/.github/actions/setup-base/action.yml
@@ -8,8 +8,6 @@ runs:
uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Install dependencies
shell: bash
diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml
index b3744493b..d1bcd8898 100644
--- a/.github/workflows/build_debian_src.yml
+++ b/.github/workflows/build_debian_src.yml
@@ -16,8 +16,7 @@ on:
type: string
permissions:
- contents: write
- packages: write
+ contents: read
jobs:
build-debian-src:
@@ -28,8 +27,6 @@ jobs:
with:
submodules: recursive
path: meshtasticd
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Install deps
shell: bash
@@ -42,6 +39,7 @@ jobs:
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
- name: Import GPG key
+ if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.PPA_GPG_PRIVATE_KEY }}
@@ -60,7 +58,7 @@ jobs:
run: debian/ci_pack_sdeb.sh
env:
SERIES: ${{ inputs.series }}
- GPG_KEY_ID: ${{ steps.gpg.outputs.keyid }}
+ GPG_KEY_ID: ${{ steps.gpg.outputs.keyid || '' }}
PKG_VERSION: ${{ steps.version.outputs.deb }}
- name: Store binaries as an artifact
diff --git a/.github/workflows/build_firmware.yml b/.github/workflows/build_firmware.yml
index c4c7a54e0..470104688 100644
--- a/.github/workflows/build_firmware.yml
+++ b/.github/workflows/build_firmware.yml
@@ -26,8 +26,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Build ${{ inputs.platform }}
id: build
diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml
index 0a1744edb..706b9cfe7 100644
--- a/.github/workflows/build_one_target.yml
+++ b/.github/workflows/build_one_target.yml
@@ -87,7 +87,7 @@ jobs:
gather-artifacts:
permissions:
- contents: write
+ contents: read
pull-requests: write
runs-on: ubuntu-latest
needs: [version, build]
diff --git a/.github/workflows/daily_packaging.yml b/.github/workflows/daily_packaging.yml
index 7df688055..16363f562 100644
--- a/.github/workflows/daily_packaging.yml
+++ b/.github/workflows/daily_packaging.yml
@@ -5,7 +5,7 @@ on:
workflow_dispatch:
push:
branches:
- - master
+ - develop # Default branch, same as 'cron' above
paths:
- debian/**
- "*.rpkg"
@@ -16,7 +16,7 @@ on:
- .github/workflows/hook_copr.yml
permissions:
- contents: write
+ contents: read
packages: write
jobs:
diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml
index 2bde9e729..72987c01e 100644
--- a/.github/workflows/docker_build.yml
+++ b/.github/workflows/docker_build.yml
@@ -37,7 +37,7 @@ on:
value: ${{ jobs.docker-build.outputs.digest }}
permissions:
- contents: write
+ contents: read
packages: write
jobs:
@@ -50,8 +50,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Get release version string
run: |
@@ -69,7 +67,7 @@ jobs:
uses: docker/setup-qemu-action@v4
- name: Docker setup
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Sanitize platform string
id: sanitize_platform
@@ -78,7 +76,7 @@ jobs:
- name: Docker tag
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: meshtastic/meshtasticd
tags: |
@@ -86,7 +84,7 @@ jobs:
flavor: latest=false
- name: Docker build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
id: docker_variant
with:
context: .
diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml
index 37108101e..b2fd12599 100644
--- a/.github/workflows/docker_manifest.yml
+++ b/.github/workflows/docker_manifest.yml
@@ -12,7 +12,7 @@ on:
type: string
permissions:
- contents: write
+ contents: read
packages: write
jobs:
@@ -86,8 +86,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Get release version string
run: |
@@ -146,7 +144,7 @@ jobs:
- name: Docker meta (Debian)
id: meta_debian
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: meshtastic/meshtasticd
tags: |
@@ -167,7 +165,7 @@ jobs:
- name: Docker meta (Alpine)
id: meta_alpine
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: meshtastic/meshtasticd
tags: |
diff --git a/.github/workflows/hook_copr.yml b/.github/workflows/hook_copr.yml
index eb4ebc57b..c419848a8 100644
--- a/.github/workflows/hook_copr.yml
+++ b/.github/workflows/hook_copr.yml
@@ -11,8 +11,7 @@ on:
type: string
permissions:
- contents: write
- packages: write
+ contents: read
jobs:
build-copr-hook:
@@ -22,8 +21,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{ github.ref }}
- repository: ${{ github.repository }}
- name: Trigger COPR build
uses: vidplace7/copr-build@main
diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml
index 39da22ae0..1221c171f 100644
--- a/.github/workflows/main_matrix.yml
+++ b/.github/workflows/main_matrix.yml
@@ -15,8 +15,7 @@ on:
- "**.md"
- version.properties
- # Note: This is different from "pull_request". Need to specify ref when doing checkouts.
- pull_request_target:
+ pull_request:
branches:
- master
- develop
@@ -29,6 +28,8 @@ on:
workflow_dispatch:
+permissions: read-all
+
jobs:
setup:
strategy:
@@ -88,8 +89,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Check ${{ matrix.check.board }}
uses: meshtastic/gh-action-firmware@main
with:
@@ -126,9 +125,16 @@ jobs:
test-native:
if: ${{ !contains(github.ref_name, 'event/') && github.repository == 'meshtastic/firmware' }}
+ permissions: # Needed for dorny/test-reporter.
+ contents: read
+ actions: read
+ checks: write
uses: ./.github/workflows/test_native.yml
docker:
+ permissions: # Needed for pushing to GHCR.
+ contents: read
+ packages: write
strategy:
fail-fast: false
matrix:
@@ -153,9 +159,6 @@ jobs:
gather-artifacts:
# trunk-ignore(checkov/CKV2_GHA_1)
if: github.repository == 'meshtastic/firmware'
- permissions:
- contents: write
- pull-requests: write
strategy:
fail-fast: false
matrix:
@@ -173,9 +176,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- with:
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- uses: actions/download-artifact@v8
with:
@@ -231,13 +231,6 @@ jobs:
path: ./*.elf
retention-days: 30
- - uses: scruplelesswizard/comment-artifact@main
- if: ${{ github.event_name == 'pull_request' }}
- with:
- name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
- description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation"
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
shame:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
@@ -245,7 +238,7 @@ jobs:
needs: [build]
steps:
- uses: actions/checkout@v6
- if: github.event_name == 'pull_request_target'
+ if: github.event_name == 'pull_request'
with:
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
fetch-depth: 0
@@ -263,24 +256,26 @@ jobs:
overwrite: true
path: manifests-new/*.mt.json
- name: Find the merge base
- if: github.event_name == 'pull_request_target'
+ if: github.event_name == 'pull_request'
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
env:
base: ${{ github.base_ref }}
head: ${{ github.sha }}
# Currently broken (for-loop through EVERY artifact -- rate limiting)
# - name: Download the old manifests
- # if: github.event_name == 'pull_request_target'
+ # if: github.event_name == 'pull_request'
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
# env:
# GH_TOKEN: ${{ github.token }}
# merge_base: ${{ env.MERGE_BASE }}
# repo: ${{ github.repository }}
# - name: Do scan and post comment
- # if: github.event_name == 'pull_request_target'
+ # if: github.event_name == 'pull_request'
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
release-artifacts:
+ permissions: # Needed for 'gh release upload'.
+ contents: write
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
outputs:
@@ -372,6 +367,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-firmware:
+ permissions: # Needed for 'gh release upload'.
+ contents: write
strategy:
fail-fast: false
matrix:
diff --git a/.github/workflows/merge_queue.yml b/.github/workflows/merge_queue.yml
deleted file mode 100644
index ad8534984..000000000
--- a/.github/workflows/merge_queue.yml
+++ /dev/null
@@ -1,371 +0,0 @@
-name: Merge Queue
-# Not sure how concurrency works in merge_queue, removing for now.
-# concurrency:
-# group: merge-queue-${{ github.head_ref || github.run_id }}
-# cancel-in-progress: true
-on:
- # Merge group is a special trigger that is used to trigger the workflow when a merge group is created.
- merge_group:
-
-jobs:
- setup:
- strategy:
- fail-fast: true
- matrix:
- arch:
- - all
- - check
- runs-on: ubuntu-24.04
- steps:
- - uses: actions/checkout@v6
- - uses: actions/setup-python@v6
- with:
- python-version: 3.x
- cache: pip
- - run: pip install -U platformio
- - name: Generate matrix
- id: jsonStep
- run: |
- if [[ "$GITHUB_HEAD_REF" == "" ]]; then
- TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
- else
- TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr)
- fi
- echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF"
- echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT
- outputs:
- all: ${{ steps.jsonStep.outputs.all }}
- check: ${{ steps.jsonStep.outputs.check }}
-
- version:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - name: Get release version string
- run: |
- echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
- echo "deb=$(./bin/buildinfo.py deb)" >> $GITHUB_OUTPUT
- id: version
- env:
- BUILD_LOCATION: local
- outputs:
- long: ${{ steps.version.outputs.long }}
- deb: ${{ steps.version.outputs.deb }}
-
- check:
- needs: setup
- strategy:
- fail-fast: true
- matrix:
- check: ${{ fromJson(needs.setup.outputs.check) }}
-
- runs-on: ubuntu-latest
- if: ${{ github.event_name != 'workflow_dispatch' }}
- steps:
- - uses: actions/checkout@v6
- - name: Build base
- id: base
- uses: ./.github/actions/setup-base
- - name: Check ${{ matrix.check.board }}
- run: bin/check-all.sh ${{ matrix.check.board }}
-
- build:
- needs: [setup, version]
- strategy:
- matrix:
- build: ${{ fromJson(needs.setup.outputs.all) }}
- uses: ./.github/workflows/build_firmware.yml
- with:
- version: ${{ needs.version.outputs.long }}
- pio_env: ${{ matrix.build.board }}
- platform: ${{ matrix.build.platform }}
-
- build-debian-src:
- if: github.repository == 'meshtastic/firmware'
- uses: ./.github/workflows/build_debian_src.yml
- with:
- series: UNRELEASED
- build_location: local
- secrets: inherit
-
- package-pio-deps-native-tft:
- if: ${{ github.event_name == 'workflow_dispatch' }}
- uses: ./.github/workflows/package_pio_deps.yml
- with:
- pio_env: native-tft
- secrets: inherit
-
- test-native:
- if: ${{ !contains(github.ref_name, 'event/') }}
- uses: ./.github/workflows/test_native.yml
-
- docker:
- strategy:
- fail-fast: false
- matrix:
- distro: [debian, alpine]
- platform: [linux/amd64, linux/arm64, linux/arm/v7]
- pio_env: [native, native-tft]
- exclude:
- - distro: alpine
- platform: linux/arm/v7
- - pio_env: native-tft
- platform: linux/arm64
- - pio_env: native-tft
- platform: linux/arm/v7
- uses: ./.github/workflows/docker_build.yml
- with:
- distro: ${{ matrix.distro }}
- platform: ${{ matrix.platform }}
- runs-on: ${{ contains(matrix.platform, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
- pio_env: ${{ matrix.pio_env }}
- push: false
-
- gather-artifacts:
- # trunk-ignore(checkov/CKV2_GHA_1)
- permissions:
- contents: write
- pull-requests: write
- strategy:
- fail-fast: false
- matrix:
- arch:
- - esp32
- - esp32s3
- - esp32c3
- - esp32c6
- - nrf52840
- - rp2040
- - rp2350
- - stm32
- runs-on: ubuntu-latest
- needs: [version, build]
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
- with:
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
-
- - uses: actions/download-artifact@v8
- with:
- path: ./
- pattern: firmware-${{matrix.arch}}-*
- merge-multiple: true
-
- - name: Display structure of downloaded files
- run: ls -R
-
- - name: Move files up
- run: mv -b -t ./ ./bin/device-*.sh ./bin/device-*.bat
-
- - name: Repackage in single firmware zip
- uses: actions/upload-artifact@v7
- with:
- name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
- overwrite: true
- path: |
- ./firmware-*.bin
- ./firmware-*.uf2
- ./firmware-*.hex
- ./firmware-*.zip
- ./device-*.sh
- ./device-*.bat
- ./littlefs-*.bin
- ./bleota*bin
- ./Meshtastic_nRF52_factory_erase*.uf2
- retention-days: 30
-
- - uses: actions/download-artifact@v8
- with:
- name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
- merge-multiple: true
- path: ./output
-
- # For diagnostics
- - name: Show artifacts
- run: ls -lR
-
- - name: Device scripts permissions
- run: |
- chmod +x ./output/device-install.sh || true
- chmod +x ./output/device-update.sh || true
-
- - name: Zip firmware
- run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output
-
- - name: Repackage in single elfs zip
- uses: actions/upload-artifact@v7
- with:
- name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}
- overwrite: true
- path: ./*.elf
- retention-days: 30
-
- - uses: scruplelesswizard/comment-artifact@main
- if: ${{ github.event_name == 'pull_request' }}
- with:
- name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
- description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation"
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
- release-artifacts:
- runs-on: ubuntu-latest
- if: ${{ github.event_name == 'workflow_dispatch' }}
- outputs:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- needs:
- - version
- - gather-artifacts
- - build-debian-src
- - package-pio-deps-native-tft
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Create release
- uses: softprops/action-gh-release@v2
- id: create_release
- with:
- draft: true
- prerelease: true
- name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
- tag_name: v${{ needs.version.outputs.long }}
- body: |
- Autogenerated by github action, developer should edit as required before publishing...
-
- - name: Download source deb
- uses: actions/download-artifact@v8
- with:
- pattern: firmware-debian-${{ needs.version.outputs.deb }}~UNRELEASED-src
- merge-multiple: true
- path: ./output/debian-src
-
- - name: Download `native-tft` pio deps
- uses: actions/download-artifact@v8
- with:
- pattern: platformio-deps-native-tft-${{ needs.version.outputs.long }}
- merge-multiple: true
- path: ./output/pio-deps-native-tft
-
- - name: Zip Linux sources
- working-directory: output
- run: |
- zip -j -9 -r ./meshtasticd-${{ needs.version.outputs.deb }}-src.zip ./debian-src
- zip -9 -r ./platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip ./pio-deps-native-tft
-
- # For diagnostics
- - name: Display structure of downloaded files
- run: ls -lR
-
- - name: Add Linux sources to GtiHub Release
- # Only run when targeting master branch with workflow_dispatch
- if: ${{ github.ref_name == 'master' }}
- run: |
- gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip
- gh release upload v${{ needs.version.outputs.long }} ./output/platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- release-firmware:
- strategy:
- fail-fast: false
- matrix:
- arch:
- - esp32
- - esp32s3
- - esp32c3
- - esp32c6
- - nrf52840
- - rp2040
- - rp2350
- - stm32
- runs-on: ubuntu-latest
- if: ${{ github.event_name == 'workflow_dispatch' }}
- needs: [release-artifacts, version]
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: 3.x
-
- - uses: actions/download-artifact@v8
- with:
- pattern: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
- merge-multiple: true
- path: ./output
-
- - name: Display structure of downloaded files
- run: ls -lR
-
- - name: Device scripts permissions
- run: |
- chmod +x ./output/device-install.sh || true
- chmod +x ./output/device-update.sh || true
-
- - name: Zip firmware
- run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output
-
- - uses: actions/download-artifact@v8
- with:
- name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}
- merge-multiple: true
- path: ./elfs
-
- - name: Zip debug elfs
- run: zip -j -9 -r ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./elfs
-
- # For diagnostics
- - name: Display structure of downloaded files
- run: ls -lR
-
- - name: Add bins and debug elfs to GitHub Release
- # Only run when targeting master branch with workflow_dispatch
- if: ${{ github.ref_name == 'master' }}
- run: |
- gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip
- gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- publish-firmware:
- runs-on: ubuntu-24.04
- if: ${{ github.event_name == 'workflow_dispatch' }}
- needs: [release-firmware, version]
- env:
- targets: |-
- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: 3.x
-
- - uses: actions/download-artifact@v8
- with:
- pattern: firmware-{${{ env.targets }}}-${{ needs.version.outputs.long }}
- merge-multiple: true
- path: ./publish
-
- - name: Publish firmware to meshtastic.github.io
- uses: peaceiris/actions-gh-pages@v4
- env:
- # On event/* branches, use the event name as the destination prefix
- DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }}
- with:
- deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }}
- external_repository: meshtastic/meshtastic.github.io
- publish_branch: master
- publish_dir: ./publish
- destination_dir: ${{ env.DEST_PREFIX }}firmware-${{ needs.version.outputs.long }}
- keep_files: true
- user_name: github-actions[bot]
- user_email: github-actions[bot]@users.noreply.github.com
- commit_message: ${{ needs.version.outputs.long }}
- enable_jekyll: true
diff --git a/.github/workflows/package_obs.yml b/.github/workflows/package_obs.yml
index 395b721a5..b491f0062 100644
--- a/.github/workflows/package_obs.yml
+++ b/.github/workflows/package_obs.yml
@@ -18,8 +18,7 @@ on:
type: string
permissions:
- contents: write
- packages: write
+ contents: read
jobs:
build-debian-src:
diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml
index 8fe675cb1..6bd256f52 100644
--- a/.github/workflows/package_pio_deps.yml
+++ b/.github/workflows/package_pio_deps.yml
@@ -16,8 +16,7 @@ on:
type: string
permissions:
- contents: write
- packages: write
+ contents: read
jobs:
pkg-pio-libdeps:
@@ -27,8 +26,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Setup Python
uses: actions/setup-python@v6
diff --git a/.github/workflows/package_ppa.yml b/.github/workflows/package_ppa.yml
index 86e655809..334a7016d 100644
--- a/.github/workflows/package_ppa.yml
+++ b/.github/workflows/package_ppa.yml
@@ -16,8 +16,7 @@ on:
type: string
permissions:
- contents: write
- packages: write
+ contents: read
jobs:
build-debian-src:
@@ -36,8 +35,6 @@ jobs:
with:
submodules: recursive
path: meshtasticd
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Install deps
shell: bash
@@ -70,5 +67,6 @@ jobs:
- name: Publish with dput
if: ${{ github.event_name != 'pull_request_target' && github.event_name != 'pull_request' }}
+ timeout-minutes: 15 # dput is terrible, sometimes runs 'forever'
run: |
dput ${{ inputs.ppa_repo }} meshtasticd_${{ steps.version.outputs.deb }}~${{ inputs.series }}_source.changes
diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml
index 9d1b475a0..d0179bba8 100644
--- a/.github/workflows/test_native.yml
+++ b/.github/workflows/test_native.yml
@@ -16,8 +16,6 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
submodules: recursive
- name: Setup native build
@@ -72,8 +70,6 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
submodules: recursive
- name: Setup native build
@@ -128,9 +124,6 @@ jobs:
if: always()
steps:
- uses: actions/checkout@v6
- with:
- ref: ${{github.event.pull_request.head.ref}}
- repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Get release version string
run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 241f2cd10..daa18e6af 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -52,7 +52,7 @@ jobs:
node-version: 24
- name: Setup pnpm
- uses: pnpm/action-setup@v4
+ uses: pnpm/action-setup@v5
with:
version: latest
diff --git a/.github/workflows/update_protobufs.yml b/.github/workflows/update_protobufs.yml
index 35565d1e4..e9380467e 100644
--- a/.github/workflows/update_protobufs.yml
+++ b/.github/workflows/update_protobufs.yml
@@ -6,7 +6,7 @@ permissions: read-all
jobs:
update-protobufs:
runs-on: ubuntu-latest
- permissions:
+ permissions: # Needed for peter-evans/create-pull-request.
contents: write
pull-requests: write
steps:
diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml
index 201b129e7..0385ffd6a 100644
--- a/.trunk/trunk.yaml
+++ b/.trunk/trunk.yaml
@@ -4,29 +4,29 @@ cli:
plugins:
sources:
- id: trunk
- ref: v1.7.5
+ ref: v1.7.6
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- - checkov@3.2.506
- - renovate@43.52.0
+ - checkov@3.2.510
+ - renovate@43.84.0
- prettier@3.8.1
- - trufflehog@3.93.6
+ - trufflehog@3.93.8
- yamllint@1.38.0
- bandit@1.9.4
- trivy@0.69.3
- taplo@0.10.0
- - ruff@0.15.4
+ - ruff@0.15.7
- isort@8.0.1
- markdownlint@0.48.0
- oxipng@10.1.0
- - svgo@4.0.0
+ - svgo@4.0.1
- actionlint@1.7.11
- flake8@7.3.0
- hadolint@2.14.0
- shfmt@3.6.0
- shellcheck@0.11.0
- - black@26.1.0
+ - black@26.3.1
- git-diff-check
- gitleaks@8.30.0
- clang-format@16.0.3
diff --git a/bin/config.d/lora-RAK6421-13302-slot1.yaml b/bin/config.d/lora-RAK6421-13302-slot1.yaml
index 7922f5182..13747d4e7 100644
--- a/bin/config.d/lora-RAK6421-13302-slot1.yaml
+++ b/bin/config.d/lora-RAK6421-13302-slot1.yaml
@@ -13,4 +13,4 @@ Lora:
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
# CS: 8
- TX_GAIN_LORA: [8]
\ No newline at end of file
+ TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
\ No newline at end of file
diff --git a/bin/config.d/lora-RAK6421-13302-slot2.yaml b/bin/config.d/lora-RAK6421-13302-slot2.yaml
index 362df93a6..194172774 100644
--- a/bin/config.d/lora-RAK6421-13302-slot2.yaml
+++ b/bin/config.d/lora-RAK6421-13302-slot2.yaml
@@ -9,4 +9,4 @@ Lora:
- 23
spidev: spidev0.1
# CS: 7
- TX_GAIN_LORA: [8]
\ No newline at end of file
+ TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
\ No newline at end of file
diff --git a/bin/config.d/lora-hat-rak-6421-pi-hat.yaml b/bin/config.d/lora-hat-rak-6421-pi-hat.yaml
index 10b72d9d2..cf25caf07 100644
--- a/bin/config.d/lora-hat-rak-6421-pi-hat.yaml
+++ b/bin/config.d/lora-hat-rak-6421-pi-hat.yaml
@@ -12,4 +12,4 @@ Lora:
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
# GPIO_DETECT_PA: 13
- TX_GAIN_LORA: [8]
\ No newline at end of file
+ TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
\ No newline at end of file
diff --git a/boards/heltec_mesh_node_t096.json b/boards/heltec_mesh_node_t096.json
new file mode 100644
index 000000000..1e417c5b4
--- /dev/null
+++ b/boards/heltec_mesh_node_t096.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"],
+ ["0x2886", "0x1667"]
+ ],
+ "usb_product": "HT-n5262G",
+ "mcu": "nrf52840",
+ "variant": "heltec_mesh_node_t096",
+ "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/",
+ "vendor": "Heltec"
+}
diff --git a/debian/ci_pack_sdeb.sh b/debian/ci_pack_sdeb.sh
index 30a775295..7b2418ff6 100755
--- a/debian/ci_pack_sdeb.sh
+++ b/debian/ci_pack_sdeb.sh
@@ -10,6 +10,10 @@ export PLATFORMIO_SETTING_CHECK_PRUNE_SYSTEM_THRESHOLD=10240
# Download libraries to `pio`
platformio pkg install -e native-tft
platformio pkg install -e native-tft -t platformio/tool-scons@4.40502.0
+# Mangle PlatformIO cache to prevent internet access at build-time
+# Simply adds 1 to all expiry (epoch) timestamps, adding ~500 years to expiry date
+cp pio/core/.cache/downloads/usage.db pio/core/.cache/downloads/usage.db.bak
+jq -c 'with_entries(.value |= (. | tostring + "1" | tonumber))' pio/core/.cache/downloads/usage.db.bak >pio/core/.cache/downloads/usage.db
# Compress `pio` directory to prevent dh_clean from sanitizing it
tar -cf pio.tar pio/
rm -rf pio
@@ -23,5 +27,10 @@ rm -rf debian/changelog
dch --create --distribution "$SERIES" --package "$package" --newversion "$PKG_VERSION~$SERIES" \
"GitHub Actions Automatic packaging for $PKG_VERSION~$SERIES"
-# Build the source deb
-debuild -S -nc -k"$GPG_KEY_ID"
+if [[ -n $GPG_KEY_ID ]]; then
+ # Build and sign the source deb
+ debuild -S -nc -k"$GPG_KEY_ID"
+else
+ # Build the source deb without signing (forks)
+ debuild -S -nc
+fi
diff --git a/platformio.ini b/platformio.ini
index 9a11b1174..53d2632db 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -97,7 +97,11 @@ lib_deps =
${env.lib_deps}
# renovate: datasource=custom.pio depName=NonBlockingRTTTL packageName=end2endzone/library/NonBlockingRTTTL
end2endzone/NonBlockingRTTTL@1.4.0
+build_unflags =
+ -std=c++11
+ -std=gnu++11
build_flags = ${env.build_flags} -Os
+ -std=gnu++17
build_src_filter = ${env.build_src_filter} - -
; Common libs for communicating over TCP/IP networks such as MQTT
diff --git a/protobufs b/protobufs
index a229208f2..cb1f89372 160000
--- a/protobufs
+++ b/protobufs
@@ -1 +1 @@
-Subproject commit a229208f29a59cf1d8cfa24cbb7567a08f2d1771
+Subproject commit cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0
diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp
index d88f9fc9f..fdcf840dc 100644
--- a/src/DisplayFormatters.cpp
+++ b/src/DisplayFormatters.cpp
@@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
bool usePreset)
{
- // If use_preset is false, always return "Custom"
+ // If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
+ // rely on this being a stable literal for channel-name hashing and default-channel detection.
if (!usePreset) {
return "Custom";
}
diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp
index 672c8334c..6ff7bbb18 100644
--- a/src/RedirectablePrint.cpp
+++ b/src/RedirectablePrint.cpp
@@ -345,7 +345,7 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...)
return;
}
-void RedirectablePrint::hexDump(const char *logLevel, unsigned char *buf, uint16_t len)
+void RedirectablePrint::hexDump(const char *logLevel, const unsigned char *buf, uint16_t len)
{
const char alphabet[17] = "0123456789abcdef";
log(logLevel, " +------------------------------------------------+ +----------------+");
diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h
index 45b62b7af..c66226171 100644
--- a/src/RedirectablePrint.h
+++ b/src/RedirectablePrint.h
@@ -44,7 +44,7 @@ class RedirectablePrint : public Print
/** like printf but va_list based */
size_t vprintf(const char *logLevel, const char *format, va_list arg);
- void hexDump(const char *logLevel, unsigned char *buf, uint16_t len);
+ void hexDump(const char *logLevel, const unsigned char *buf, uint16_t len);
std::string mt_sprintf(const std::string fmt_str, ...);
diff --git a/src/configuration.h b/src/configuration.h
index cc623455f..11be86007 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -78,6 +78,11 @@ along with this program. If not, see .
// Configuration
// -----------------------------------------------------------------------------
+// Pre-hop drop handling (compile-time flag).
+#ifndef MESHTASTIC_PREHOP_DROP
+#define MESHTASTIC_PREHOP_DROP 0
+#endif
+
/// Convert a preprocessor name into a quoted string
#define xstr(s) ystr(s)
#define ystr(s) #s
@@ -151,8 +156,19 @@ along with this program. If not, see .
#ifdef USE_KCT8103L_PA
// Power Amps are often non-linear, so we can use an array of values for the power curve
+#if defined(HELTEC_WIRELESS_TRACKER_V2)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 12, 11, 10, 9, 8, 7
+#elif defined(HELTEC_MESH_NODE_T096)
+#define NUM_PA_POINTS 22
+#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7
+#else
+// If a board enables USE_KCT8103L_PA but does not match a known variant and has
+// not already provided a PA curve, fail at compile time to avoid unsafe defaults.
+#if !defined(NUM_PA_POINTS) || !defined(TX_GAIN_LORA)
+#error "USE_KCT8103L_PA is defined, but no PA gain curve (NUM_PA_POINTS / TX_GAIN_LORA) is configured for this board."
+#endif
+#endif
#endif
#ifdef RAK13302
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index 06862a2c4..2e00c11ce 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -341,7 +341,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
type = DPS310;
break;
}
- break;
+ if (type == DPS310) {
+ break;
+ }
default:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // GET_ID
switch (registerValue) {
diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp
index 1260d8b15..cf5511a33 100644
--- a/src/gps/GPS.cpp
+++ b/src/gps/GPS.cpp
@@ -103,6 +103,14 @@ static int32_t gpsSwitch()
if (gps) {
int currentState = digitalRead(PIN_GPS_SWITCH);
+ // Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS.
+ if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
+ gps->disable();
+ lastState = currentState;
+ firstrun = false;
+ return 1000;
+ }
+
// if the switch is set to zero, disable the GPS Thread
if (firstrun)
if (currentState == LOW)
diff --git a/src/graphics/EmoteRenderer.cpp b/src/graphics/EmoteRenderer.cpp
new file mode 100644
index 000000000..6fa0adb4c
--- /dev/null
+++ b/src/graphics/EmoteRenderer.cpp
@@ -0,0 +1,434 @@
+#include "configuration.h"
+#if HAS_SCREEN
+
+#include "graphics/EmoteRenderer.h"
+#include
+#include
+
+namespace graphics
+{
+namespace EmoteRenderer
+{
+
+static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len)
+{
+#if defined(OLED_UA) || defined(OLED_RU)
+ return display->getStringWidth(text, len, true);
+#else
+ (void)len;
+ return display->getStringWidth(text);
+#endif
+}
+
+size_t utf8CharLen(uint8_t c)
+{
+ if ((c & 0xE0) == 0xC0)
+ return 2;
+ if ((c & 0xF0) == 0xE0)
+ return 3;
+ if ((c & 0xF8) == 0xF0)
+ return 4;
+ return 1;
+}
+
+static inline bool isPossibleEmoteLead(uint8_t c)
+{
+ // All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges.
+ return c == 0xE2 || c == 0xF0;
+}
+
+static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len)
+{
+ char chunk[5] = {0, 0, 0, 0, 0};
+ if (len > 4)
+ len = 4;
+ memcpy(chunk, text, len);
+ return getStringWidth(display, chunk, len);
+}
+
+static inline bool isFE0FAt(const char *s, size_t pos, size_t len)
+{
+ return pos + 2 < len && static_cast(s[pos]) == 0xEF && static_cast(s[pos + 1]) == 0xB8 &&
+ static_cast(s[pos + 2]) == 0x8F;
+}
+
+static inline bool isSkinToneAt(const char *s, size_t pos, size_t len)
+{
+ return pos + 3 < len && static_cast(s[pos]) == 0xF0 && static_cast(s[pos + 1]) == 0x9F &&
+ static_cast(s[pos + 2]) == 0x8F &&
+ (static_cast(s[pos + 3]) >= 0xBB && static_cast(s[pos + 3]) <= 0xBF);
+}
+
+static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len)
+{
+ // Skip modifiers that do not change which bitmap we render.
+ if (isFE0FAt(s, pos, len))
+ return 3;
+ if (isSkinToneAt(s, pos, len))
+ return 4;
+ return 0;
+}
+
+const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount)
+{
+ if (!label || !*label)
+ return nullptr;
+
+ for (int i = 0; i < emoteCount; ++i) {
+ if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0)
+ return &emoteSet[i];
+ }
+
+ return nullptr;
+}
+
+static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed,
+ size_t &matchScore)
+{
+ // Treat FE0F and skin-tone modifiers as optional while matching.
+ textConsumed = 0;
+ matchScore = 0;
+ if (!label || !*label || pos >= textLen)
+ return false;
+
+ const size_t labelLen = strlen(label);
+ size_t ti = pos;
+ size_t li = 0;
+
+ while (true) {
+ while (ti < textLen) {
+ const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
+ if (!skipLen)
+ break;
+ ti += skipLen;
+ }
+ while (li < labelLen) {
+ const size_t skipLen = ignorableModifierLenAt(label, li, labelLen);
+ if (!skipLen)
+ break;
+ li += skipLen;
+ }
+
+ if (li >= labelLen) {
+ while (ti < textLen) {
+ const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
+ if (!skipLen)
+ break;
+ ti += skipLen;
+ }
+ textConsumed = ti - pos;
+ return textConsumed > 0;
+ }
+
+ if (ti >= textLen)
+ return false;
+
+ const uint8_t tc = static_cast(text[ti]);
+ const uint8_t lc = static_cast(label[li]);
+ const size_t tlen = utf8CharLen(tc);
+ const size_t llen = utf8CharLen(lc);
+
+ if (tlen != llen || ti + tlen > textLen || li + llen > labelLen)
+ return false;
+ if (memcmp(text + ti, label + li, tlen) != 0)
+ return false;
+
+ ti += tlen;
+ li += llen;
+ matchScore += llen;
+ }
+}
+
+const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount)
+{
+ // Prefer the longest matching label at this byte offset.
+ const Emote *matched = nullptr;
+ matchLen = 0;
+ size_t bestScore = 0;
+ if (!text || pos >= textLen)
+ return nullptr;
+
+ if (!isPossibleEmoteLead(static_cast(text[pos])))
+ return nullptr;
+
+ for (int i = 0; i < emoteCount; ++i) {
+ const char *label = emoteSet[i].label;
+ if (!label || !*label)
+ continue;
+ if (static_cast(label[0]) != static_cast(text[pos]))
+ continue;
+
+ const size_t labelLen = strlen(label);
+ if (labelLen == 0)
+ continue;
+
+ size_t candidateLen = 0;
+ size_t candidateScore = 0;
+ if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) {
+ candidateLen = labelLen;
+ candidateScore = labelLen;
+ } else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) {
+ // Matched with FE0F/skin tone modifiers treated as optional.
+ } else {
+ continue;
+ }
+
+ if (candidateScore > bestScore) {
+ matched = &emoteSet[i];
+ matchLen = candidateLen;
+ bestScore = candidateScore;
+ }
+ }
+
+ return matched;
+}
+
+static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight,
+ const Emote *emoteSet, int emoteCount, int emoteSpacing)
+{
+ // Scan once to collect width and tallest emote for this line.
+ LineMetrics metrics{0, fallbackHeight, false};
+ if (!line)
+ return metrics;
+
+ for (size_t i = 0; i < lineLen;) {
+ size_t matchLen = 0;
+ const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
+ if (matched) {
+ metrics.hasEmote = true;
+ metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height);
+ if (display)
+ metrics.width += matched->width + emoteSpacing;
+ i += matchLen;
+ continue;
+ }
+
+ const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
+ if (skipLen) {
+ i += skipLen;
+ continue;
+ }
+
+ const size_t charLen = utf8CharLen(static_cast(line[i]));
+ if (display)
+ metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
+ i += charLen;
+ }
+
+ return metrics;
+}
+
+LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount,
+ int emoteSpacing)
+{
+ return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing);
+}
+
+int maxEmoteHeight(const Emote *emoteSet, int emoteCount)
+{
+ int tallest = 0;
+ for (int i = 0; i < emoteCount; ++i) {
+ if (emoteSet[i].label && *emoteSet[i].label)
+ tallest = std::max(tallest, emoteSet[i].height);
+ }
+ return tallest;
+}
+
+int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing)
+{
+ if (!display)
+ return 0;
+
+ if (!line || !*line)
+ return 0;
+
+ return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width;
+}
+
+static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw,
+ bool fauxBold)
+{
+ // Draw plain-text runs in chunks so UTF-8 stays intact.
+ if (!text || len == 0)
+ return cursorX;
+
+ char chunk[33];
+ size_t pos = 0;
+ while (pos < len) {
+ size_t chunkLen = 0;
+ while (pos + chunkLen < len) {
+ const size_t charLen = utf8CharLen(static_cast(text[pos + chunkLen]));
+ if (chunkLen + charLen >= sizeof(chunk))
+ break;
+ chunkLen += charLen;
+ }
+
+ if (chunkLen == 0) {
+ chunkLen = std::min(len - pos, sizeof(chunk) - 1);
+ }
+
+ memcpy(chunk, text + pos, chunkLen);
+ chunk[chunkLen] = '\0';
+ if (draw) {
+ if (fauxBold)
+ display->drawString(cursorX + 1, fontY, chunk);
+ display->drawString(cursorX, fontY, chunk);
+ }
+ cursorX += getStringWidth(display, chunk, chunkLen);
+ pos += chunkLen;
+ }
+
+ return cursorX;
+}
+
+size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis,
+ const Emote *emoteSet, int emoteCount, int emoteSpacing)
+{
+ if (!out || outSize == 0)
+ return 0;
+
+ out[0] = '\0';
+ if (!display || !line || maxWidth <= 0)
+ return 0;
+
+ const size_t lineLen = strlen(line);
+ const int suffixWidth =
+ (ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0;
+ const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : "";
+ const size_t suffixLen = strlen(suffix);
+ const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0);
+
+ if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) {
+ strncpy(out, line, outSize - 1);
+ out[outSize - 1] = '\0';
+ return strlen(out);
+ }
+
+ int used = 0;
+ size_t cut = 0;
+ for (size_t i = 0; i < lineLen;) {
+ // Keep whole emotes together when deciding where to cut.
+ int tokenWidth = 0;
+ size_t advance = 0;
+
+ if (isPossibleEmoteLead(static_cast(line[i]))) {
+ size_t matchLen = 0;
+ const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
+ if (matched) {
+ tokenWidth = matched->width + emoteSpacing;
+ advance = matchLen;
+ }
+ }
+
+ if (advance == 0) {
+ const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
+ if (skipLen) {
+ i += skipLen;
+ cut = i;
+ continue;
+ }
+
+ const size_t charLen = utf8CharLen(static_cast(line[i]));
+ tokenWidth = getUtf8ChunkWidth(display, line + i, charLen);
+ advance = charLen;
+ }
+
+ if (used + tokenWidth > availableWidth)
+ break;
+
+ used += tokenWidth;
+ i += advance;
+ cut = i;
+ }
+
+ if (cut == 0) {
+ strncpy(out, suffix, outSize - 1);
+ out[outSize - 1] = '\0';
+ return strlen(out);
+ }
+
+ size_t copyLen = cut;
+ if (copyLen > outSize - 1)
+ copyLen = outSize - 1;
+ if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) {
+ copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0;
+ }
+
+ memcpy(out, line, copyLen);
+ size_t totalLen = copyLen;
+ if (suffixLen > 0 && totalLen < outSize - 1) {
+ memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen));
+ totalLen += std::min(suffixLen, outSize - 1 - totalLen);
+ }
+ out[totalLen] = '\0';
+ return totalLen;
+}
+
+void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet,
+ int emoteCount, int emoteSpacing, bool fauxBold)
+{
+ if (!line)
+ return;
+
+ const size_t lineLen = strlen(line);
+ // Center text vertically when any emote is taller than the font.
+ const int maxIconHeight =
+ analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight;
+ const int lineHeight = std::max(fontHeight, maxIconHeight);
+ const int fontY = y + (lineHeight - fontHeight) / 2;
+
+ int cursorX = x;
+ bool inBold = false;
+
+ for (size_t i = 0; i < lineLen;) {
+ // Toggle faux bold.
+ if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') {
+ inBold = !inBold;
+ i += 2;
+ continue;
+ }
+
+ const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
+ if (skipLen) {
+ i += skipLen;
+ continue;
+ }
+
+ size_t matchLen = 0;
+ const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
+ if (matched) {
+ const int iconY = y + (lineHeight - matched->height) / 2;
+ display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap);
+ cursorX += matched->width + emoteSpacing;
+ i += matchLen;
+ continue;
+ }
+
+ size_t next = i;
+ while (next < lineLen) {
+ // Stop the text run before the next emote or bold marker.
+ if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*')
+ break;
+
+ if (ignorableModifierLenAt(line, next, lineLen))
+ break;
+
+ size_t nextMatchLen = 0;
+ if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr)
+ break;
+
+ next += utf8CharLen(static_cast(line[next]));
+ }
+
+ if (next == i)
+ next += utf8CharLen(static_cast(line[i]));
+
+ cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
+ i = next;
+ }
+}
+
+} // namespace EmoteRenderer
+} // namespace graphics
+
+#endif // HAS_SCREEN
diff --git a/src/graphics/EmoteRenderer.h b/src/graphics/EmoteRenderer.h
new file mode 100644
index 000000000..93cee4b25
--- /dev/null
+++ b/src/graphics/EmoteRenderer.h
@@ -0,0 +1,79 @@
+#pragma once
+#include "configuration.h"
+
+#if HAS_SCREEN
+#include "graphics/emotes.h"
+#include
+#include
+#include
+#include
+
+namespace graphics
+{
+namespace EmoteRenderer
+{
+
+struct LineMetrics {
+ int width;
+ int tallestHeight;
+ bool hasEmote;
+};
+
+size_t utf8CharLen(uint8_t c);
+
+const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet = emotes, int emoteCount = numEmotes);
+const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
+ int emoteCount = numEmotes);
+inline const Emote *findEmoteAt(const std::string &text, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
+ int emoteCount = numEmotes)
+{
+ return findEmoteAt(text.c_str(), text.length(), pos, matchLen, emoteSet, emoteCount);
+}
+
+LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight = 0, const Emote *emoteSet = emotes,
+ int emoteCount = numEmotes, int emoteSpacing = 1);
+inline LineMetrics analyzeLine(OLEDDisplay *display, const std::string &line, int fallbackHeight = 0,
+ const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1)
+{
+ return analyzeLine(display, line.c_str(), fallbackHeight, emoteSet, emoteCount, emoteSpacing);
+}
+int maxEmoteHeight(const Emote *emoteSet = emotes, int emoteCount = numEmotes);
+
+int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet = emotes, int emoteCount = numEmotes,
+ int emoteSpacing = 1);
+inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, const Emote *emoteSet = emotes,
+ int emoteCount = numEmotes, int emoteSpacing = 1)
+{
+ return measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing);
+}
+size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
+ const char *ellipsis = "...", const Emote *emoteSet = emotes, int emoteCount = numEmotes,
+ int emoteSpacing = 1);
+inline std::string truncateToWidth(OLEDDisplay *display, const std::string &line, int maxWidth,
+ const std::string &ellipsis = "...", const Emote *emoteSet = emotes,
+ int emoteCount = numEmotes, int emoteSpacing = 1)
+{
+ if (!display || maxWidth <= 0)
+ return "";
+ if (measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing) <= maxWidth)
+ return line;
+
+ std::vector out(line.length() + ellipsis.length() + 1, '\0');
+ truncateToWidth(display, line.c_str(), out.data(), out.size(), maxWidth, ellipsis.c_str(), emoteSet, emoteCount,
+ emoteSpacing);
+ return std::string(out.data());
+}
+
+void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet = emotes,
+ int emoteCount = numEmotes, int emoteSpacing = 1, bool fauxBold = true);
+inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
+ const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1,
+ bool fauxBold = true)
+{
+ drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSet, emoteCount, emoteSpacing, fauxBold);
+}
+
+} // namespace EmoteRenderer
+} // namespace graphics
+
+#endif // HAS_SCREEN
diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp
index b86f3e32c..ec50654ae 100644
--- a/src/graphics/SharedUIDisplay.cpp
+++ b/src/graphics/SharedUIDisplay.cpp
@@ -121,11 +121,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
}
// === Screen Title ===
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->drawString(SCREEN_WIDTH / 2, y, titleStr);
- if (config.display.heading_bold) {
- display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr);
- }
+ const char *headerTitle = titleStr ? titleStr : "";
+ const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
+ const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
+ UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
}
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -515,4 +514,4 @@ std::string sanitizeString(const std::string &input)
}
} // namespace graphics
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp
index dfff66604..ea93cb7ff 100644
--- a/src/graphics/TFTDisplay.cpp
+++ b/src/graphics/TFTDisplay.cpp
@@ -1348,7 +1348,7 @@ void TFTDisplay::sendCommand(uint8_t com)
digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON);
#elif defined(HACKADAY_COMMUNICATOR)
tft->displayOn();
-#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE)
+#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
tft->wakeup();
tft->powerSaveOff();
#endif
@@ -1359,7 +1359,7 @@ void TFTDisplay::sendCommand(uint8_t com)
#ifdef UNPHONE
unphone.backlight(true); // using unPhone library
#endif
-#ifdef RAK14014
+#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
#elif !defined(M5STACK) && !defined(ST7789_CS) && \
!defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function
tft->setBrightness(172);
@@ -1375,7 +1375,7 @@ void TFTDisplay::sendCommand(uint8_t com)
digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON);
#elif defined(HACKADAY_COMMUNICATOR)
tft->displayOff();
-#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE)
+#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
tft->sleep();
tft->powerSaveOn();
#endif
@@ -1386,7 +1386,7 @@ void TFTDisplay::sendCommand(uint8_t com)
#ifdef UNPHONE
unphone.backlight(false); // using unPhone library
#endif
-#ifdef RAK14014
+#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
tft->setBrightness(0);
#endif
@@ -1401,7 +1401,7 @@ void TFTDisplay::sendCommand(uint8_t com)
void TFTDisplay::setDisplayBrightness(uint8_t _brightness)
{
-#ifdef RAK14014
+#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
// todo
#elif !defined(HACKADAY_COMMUNICATOR)
tft->setBrightness(_brightness);
@@ -1421,7 +1421,7 @@ bool TFTDisplay::hasTouch(void)
{
#ifdef RAK14014
return true;
-#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
+#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
return tft->touch() != nullptr;
#else
return false;
@@ -1440,7 +1440,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
} else {
return false;
}
-#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
+#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
return tft->getTouch(x, y);
#else
return false;
@@ -1457,7 +1457,7 @@ bool TFTDisplay::connect()
{
concurrency::LockGuard g(spiLock);
LOG_INFO("Do TFT init");
-#ifdef RAK14014
+#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
tft = new TFT_eSPI;
#elif defined(HACKADAY_COMMUNICATOR)
bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */);
@@ -1494,7 +1494,7 @@ bool TFTDisplay::connect()
ft6336u.begin();
pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SCREEN_TOUCH_INT), rak14014_tpIntHandle, FALLING);
-#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2)
+#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) || defined(HELTEC_MESH_NODE_T096)
tft->setRotation(1); // T-Deck has the TFT in landscape
#elif defined(T_WATCH_S3) || defined(T_DISPLAY_S3_PRO)
tft->setRotation(2); // T-Watch S3 left-handed orientation
diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp
index 2069c71ec..27a50d3f9 100644
--- a/src/graphics/draw/DebugRenderer.cpp
+++ b/src/graphics/draw/DebugRenderer.cpp
@@ -408,7 +408,16 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
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);
+ // For custom modem settings show the actual parameters; for presets use the preset name.
+ char modeStr[16];
+ if (!config.lora.use_preset) {
+ snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast(config.lora.bandwidth),
+ static_cast(config.lora.spread_factor), static_cast(config.lora.coding_rate));
+ } else {
+ strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true),
+ sizeof(modeStr) - 1);
+ modeStr[sizeof(modeStr) - 1] = '\0';
+ }
char regionradiopreset[25];
const char *region = myRegion ? myRegion->name : NULL;
@@ -416,7 +425,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
if (currentResolution == ScreenResolution::UltraLow) {
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
} else {
- snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
+ snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr);
}
}
textWidth = display->getStringWidth(regionradiopreset);
diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp
index f57c39512..daefff8ee 100644
--- a/src/graphics/draw/MenuHandler.cpp
+++ b/src/graphics/draw/MenuHandler.cpp
@@ -18,6 +18,7 @@
#include "main.h"
#include "mesh/Default.h"
#include "mesh/MeshTypes.h"
+#include "mesh/RadioLibInterface.h"
#include "modules/AdminModule.h"
#include "modules/CannedMessageModule.h"
#include "modules/ExternalNotificationModule.h"
@@ -25,6 +26,7 @@
#include "modules/TraceRouteModule.h"
#include
#include
+#include
#include
#include
@@ -159,31 +161,20 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return;
}
+ // Guard: without a reboot, reconfigure() applies the region directly.
+ // Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
+ if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
+ !(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
+ LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring LORA_24 selection");
+ return;
+ }
+
config.lora.region = selectedRegion;
auto changes = SEGMENT_CONFIG;
- // FIXME: This should be a method consolidated with the same logic in the admin message as well
- // This is needed as we wait til picking the LoRa region to generate keys for the first time.
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
- if (!owner.is_licensed) {
- bool keygenSuccess = false;
- if (config.security.private_key.size == 32) {
- // public key is derived from private, so this will always have the same result.
- if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
- keygenSuccess = true;
- }
-
- } else {
- LOG_INFO("Generate new PKI keys");
- crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
- keygenSuccess = true;
- }
- if (keygenSuccess) {
- config.security.public_key.size = 32;
- config.security.private_key.size = 32;
- owner.public_key.size = 32;
- memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
- }
+ if (crypto) {
+ crypto->ensurePkiKeys(config.security, owner);
}
#endif
config.lora.tx_enabled = true;
@@ -199,7 +190,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
}
service->reloadConfig(changes);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
});
bannerOptions.durationMs = duration;
@@ -265,13 +255,24 @@ void menuHandler::FrequencySlotPicker()
optionsEnumArray[options++] = 0;
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
+
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
: bwCodeToKHz(loraConfig.bandwidth);
uint32_t numChannels = 0;
if (myRegion) {
- numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0)));
+ // Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
+ const double spacing = myRegion->profile->spacing;
+ const double padding = myRegion->profile->padding;
+ const double channelBandwidthMHz = bw / 1000.0;
+ const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
+ const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
+ if (denominator > 0.0) {
+ numChannels = static_cast(round(numerator / denominator));
+ } else {
+ LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
+ }
} else {
LOG_WARN("Region not set, cannot calculate number of channels");
return;
@@ -307,7 +308,6 @@ void menuHandler::FrequencySlotPicker()
config.lora.channel_num = selected;
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
};
screen->showOverlayBanner(bannerOptions);
@@ -346,7 +346,6 @@ void menuHandler::radioPresetPicker()
config.lora.channel_num = 0; // Reset to default channel for the preset
config.lora.override_frequency = 0; // Clear any custom frequency
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
});
screen->showOverlayBanner(bannerOptions);
diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp
index 79d8b1ccd..501a7ae2c 100644
--- a/src/graphics/draw/MessageRenderer.cpp
+++ b/src/graphics/draw/MessageRenderer.cpp
@@ -7,6 +7,7 @@
#include "NodeDB.h"
#include "UIRenderer.h"
#include "gps/RTC.h"
+#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
@@ -34,44 +35,6 @@ static std::vector cachedLines;
static std::vector cachedHeights;
static bool manualScrolling = false;
-// UTF-8 skip helper
-static inline size_t utf8CharLen(uint8_t c)
-{
- if ((c & 0xE0) == 0xC0)
- return 2;
- if ((c & 0xF0) == 0xE0)
- return 3;
- if ((c & 0xF8) == 0xF0)
- return 4;
- return 1;
-}
-
-// Remove variation selectors (FE0F) and skin tone modifiers from emoji so they match your labels
-static std::string normalizeEmoji(const std::string &s)
-{
- std::string out;
- for (size_t i = 0; i < s.size();) {
- uint8_t c = static_cast(s[i]);
- size_t len = utf8CharLen(c);
-
- if (c == 0xEF && i + 2 < s.size() && (uint8_t)s[i + 1] == 0xB8 && (uint8_t)s[i + 2] == 0x8F) {
- i += 3;
- continue;
- }
-
- // Skip skin tone modifiers
- if (c == 0xF0 && i + 3 < s.size() && (uint8_t)s[i + 1] == 0x9F && (uint8_t)s[i + 2] == 0x8F &&
- ((uint8_t)s[i + 3] >= 0xBB && (uint8_t)s[i + 3] <= 0xBF)) {
- i += 4;
- continue;
- }
-
- out.append(s, i, len);
- i += len;
- }
- return out;
-}
-
// Scroll state (file scope so we can reset on new message)
float scrollY = 0.0f;
uint32_t lastTime = 0;
@@ -110,102 +73,7 @@ void scrollDown()
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
- int cursorX = x;
- const int fontHeight = FONT_HEIGHT_SMALL;
-
- // Step 1: Find tallest emote in the line
- int maxIconHeight = fontHeight;
- for (size_t i = 0; i < line.length();) {
- bool matched = false;
- for (int e = 0; e < emoteCount; ++e) {
- size_t emojiLen = strlen(emotes[e].label);
- if (line.compare(i, emojiLen, emotes[e].label) == 0) {
- if (emotes[e].height > maxIconHeight)
- maxIconHeight = emotes[e].height;
- i += emojiLen;
- matched = true;
- break;
- }
- }
- if (!matched) {
- i += utf8CharLen(static_cast(line[i]));
- }
- }
-
- // Step 2: Baseline alignment
- int lineHeight = std::max(fontHeight, maxIconHeight);
- int baselineOffset = (lineHeight - fontHeight) / 2;
- int fontY = y + baselineOffset;
-
- // Step 3: Render line in segments
- size_t i = 0;
- bool inBold = false;
-
- while (i < line.length()) {
- // Check for ** start/end for faux bold
- if (line.compare(i, 2, "**") == 0) {
- inBold = !inBold;
- i += 2;
- continue;
- }
-
- // Look ahead for the next emote match
- size_t nextEmotePos = std::string::npos;
- const Emote *matchedEmote = nullptr;
- size_t emojiLen = 0;
-
- for (int e = 0; e < emoteCount; ++e) {
- size_t pos = line.find(emotes[e].label, i);
- if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) {
- nextEmotePos = pos;
- matchedEmote = &emotes[e];
- emojiLen = strlen(emotes[e].label);
- }
- }
-
- // Render normal text segment up to the emote or bold toggle
- size_t nextControl = std::min(nextEmotePos, line.find("**", i));
- if (nextControl == std::string::npos)
- nextControl = line.length();
-
- if (nextControl > i) {
- std::string textChunk = line.substr(i, nextControl - i);
- if (inBold) {
- // Faux bold: draw twice, offset by 1px
- display->drawString(cursorX + 1, fontY, textChunk.c_str());
- }
- display->drawString(cursorX, fontY, textChunk.c_str());
-#if defined(OLED_UA) || defined(OLED_RU)
- cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true);
-#else
- cursorX += display->getStringWidth(textChunk.c_str());
-#endif
- i = nextControl;
- continue;
- }
-
- // Render the emote (if found)
- if (matchedEmote && i == nextEmotePos) {
- int iconY = y + (lineHeight - matchedEmote->height) / 2;
- display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap);
- cursorX += matchedEmote->width + 1;
- i += emojiLen;
- continue;
- } else {
- // No more emotes — render the rest of the line
- std::string remaining = line.substr(i);
- if (inBold) {
- display->drawString(cursorX + 1, fontY, remaining.c_str());
- }
- display->drawString(cursorX, fontY, remaining.c_str());
-#if defined(OLED_UA) || defined(OLED_RU)
- cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true);
-#else
- cursorX += display->getStringWidth(remaining.c_str());
-#endif
- break;
- }
- }
+ graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
}
// Reset scroll state when new messages arrive
@@ -377,32 +245,7 @@ static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8)
static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount)
{
- std::string normalized = normalizeEmoji(line);
- int totalWidth = 0;
-
- size_t i = 0;
- while (i < normalized.length()) {
- bool matched = false;
- for (int e = 0; e < emoteCount; ++e) {
- size_t emojiLen = strlen(emotes[e].label);
- if (normalized.compare(i, emojiLen, emotes[e].label) == 0) {
- totalWidth += emotes[e].width + 1; // +1 spacing
- i += emojiLen;
- matched = true;
- break;
- }
- }
- if (!matched) {
- size_t charLen = utf8CharLen(static_cast(normalized[i]));
-#if defined(OLED_UA) || defined(OLED_RU)
- totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str(), charLen, true);
-#else
- totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str());
-#endif
- i += charLen;
- }
- }
- return totalWidth;
+ return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width;
}
struct MessageBlock {
@@ -417,13 +260,7 @@ static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool i
return lineTopY + (FONT_HEIGHT_SMALL - 1);
}
- int tallest = FONT_HEIGHT_SMALL;
- for (int e = 0; e < numEmotes; ++e) {
- if (line.find(emotes[e].label) != std::string::npos) {
- if (emotes[e].height > tallest)
- tallest = emotes[e].height;
- }
- }
+ const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight;
const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest);
const int iconTop = lineTopY + (lineHeight - tallest) / 2;
@@ -536,30 +373,28 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH;
// Title string depending on mode
- static char titleBuf[32];
- const char *titleStr = "Messages";
+ char titleStr[48];
+ snprintf(titleStr, sizeof(titleStr), "Messages");
switch (currentMode) {
case ThreadMode::ALL:
- titleStr = "Messages";
+ snprintf(titleStr, sizeof(titleStr), "Messages");
break;
case ThreadMode::CHANNEL: {
const char *cname = channels.getName(currentChannel);
if (cname && cname[0]) {
- snprintf(titleBuf, sizeof(titleBuf), "#%s", cname);
+ snprintf(titleStr, sizeof(titleStr), "#%s", cname);
} else {
- snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel);
+ snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel);
}
- titleStr = titleBuf;
break;
}
case ThreadMode::DIRECT: {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
- if (node && node->has_user) {
- snprintf(titleBuf, sizeof(titleBuf), "@%s", node->user.short_name);
+ if (node && node->has_user && node->user.short_name[0]) {
+ snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name);
} else {
- snprintf(titleBuf, sizeof(titleBuf), "@%08x", currentPeer);
+ snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
}
- titleStr = titleBuf;
break;
}
}
@@ -666,44 +501,50 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
- char senderBuf[48] = "";
+ char senderName[64] = "";
if (node && node->has_user) {
- // Use long name if present
- strncpy(senderBuf, node->user.long_name, sizeof(senderBuf) - 1);
- senderBuf[sizeof(senderBuf) - 1] = '\0';
- } else {
- // No long/short name → show NodeID in parentheses
- snprintf(senderBuf, sizeof(senderBuf), "(%08x)", m.sender);
+ if (node->user.long_name[0]) {
+ strncpy(senderName, node->user.long_name, sizeof(senderName) - 1);
+ } else if (node->user.short_name[0]) {
+ strncpy(senderName, node->user.short_name, sizeof(senderName) - 1);
+ }
+ senderName[sizeof(senderName) - 1] = '\0';
+ }
+ if (!senderName[0]) {
+ snprintf(senderName, sizeof(senderName), "(%08x)", m.sender);
}
- // If this is *our own* message, override senderBuf to who the recipient was
+ // If this is *our own* message, override senderName to who the recipient was
bool mine = (m.sender == nodeDB->getNodeNum());
if (mine && node_recipient && node_recipient->has_user) {
- strcpy(senderBuf, node_recipient->user.long_name);
+ if (node_recipient->user.long_name[0]) {
+ strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1);
+ senderName[sizeof(senderName) - 1] = '\0';
+ } else if (node_recipient->user.short_name[0]) {
+ strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1);
+ senderName[sizeof(senderName) - 1] = '\0';
+ }
+ }
+ // If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
+ if (mine && (!node_recipient || !node_recipient->has_user ||
+ (!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) {
+ snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
}
// Shrink Sender name if needed
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
- display->getStringWidth(chanType) - display->getStringWidth(" @...");
+ display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @...");
if (availWidth < 0)
availWidth = 0;
-
- size_t origLen = strlen(senderBuf);
- while (senderBuf[0] && display->getStringWidth(senderBuf) > availWidth) {
- senderBuf[strlen(senderBuf) - 1] = '\0';
- }
-
- // If we actually truncated, append "..."
- if (strlen(senderBuf) < origLen) {
- strcat(senderBuf, "...");
- }
+ char truncatedSender[64];
+ graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
// Final header line
- char headerStr[96];
+ char headerStr[128];
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
- snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, senderBuf);
+ snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
} else {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
}
@@ -711,11 +552,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
}
} else {
- snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, senderBuf, chanType);
+ snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType);
}
// Push header line
- allLines.push_back(std::string(headerStr));
+ allLines.push_back(headerStr);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
@@ -816,13 +657,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
topY = visualTop - BUBBLE_PAD_TOP_HEADER;
} else {
// Body start
- bool thisLineHasEmote = false;
- for (int e = 0; e < numEmotes; ++e) {
- if (cachedLines[b.start].find(emotes[e].label) != std::string::npos) {
- thisLineHasEmote = true;
- break;
- }
- }
+ const bool thisLineHasEmote =
+ graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote;
if (thisLineHasEmote) {
constexpr int EMOTE_PADDING_ABOVE = 4;
visualTop -= EMOTE_PADDING_ABOVE;
@@ -851,7 +687,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
for (size_t i = b.start; i <= b.end; ++i) {
int w = 0;
if (isHeader[i]) {
- w = display->getStringWidth(cachedLines[i].c_str());
+ w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
if (b.mine)
w += 12; // room for ACK/NACK/relay mark
} else {
@@ -907,7 +743,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
if (isHeader[i]) {
- int w = display->getStringWidth(cachedLines[i].c_str());
+ int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
int headerX;
if (isMine[i]) {
// push header left to avoid overlap with scrollbar
@@ -917,7 +753,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
} else {
headerX = x + textIndent;
}
- display->drawString(headerX, lineY, cachedLines[i].c_str());
+ graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
+ false);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
@@ -1005,11 +842,7 @@ std::vector generateLines(OLEDDisplay *display, const char *headerS
} else {
word += ch;
std::string test = line + word;
-#if defined(OLED_UA) || defined(OLED_RU)
- uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true);
-#else
- uint16_t strWidth = display->getStringWidth(test.c_str());
-#endif
+ uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
if (strWidth > textWidth) {
if (!line.empty())
lines.push_back(line);
@@ -1038,31 +871,20 @@ std::vector calculateLineHeights(const std::vector &lines, con
std::vector rowHeights;
rowHeights.reserve(lines.size());
+ std::vector lineMetrics;
+ lineMetrics.reserve(lines.size());
+
+ for (const auto &line : lines) {
+ lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes));
+ }
for (size_t idx = 0; idx < lines.size(); ++idx) {
- const auto &line = lines[idx];
const int baseHeight = FONT_HEIGHT_SMALL;
int lineHeight = baseHeight;
- // Detect if THIS line or NEXT line contains an emote
- bool hasEmote = false;
- int tallestEmote = baseHeight;
- for (int i = 0; i < numEmotes; ++i) {
- if (line.find(emotes[i].label) != std::string::npos) {
- hasEmote = true;
- tallestEmote = std::max(tallestEmote, emotes[i].height);
- }
- }
-
- bool nextHasEmote = false;
- if (idx + 1 < lines.size()) {
- for (int i = 0; i < numEmotes; ++i) {
- if (lines[idx + 1].find(emotes[i].label) != std::string::npos) {
- nextHasEmote = true;
- break;
- }
- }
- }
+ const int tallestEmote = lineMetrics[idx].tallestHeight;
+ const bool hasEmote = lineMetrics[idx].hasEmote;
+ const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote;
if (isHeaderVec[idx]) {
// Header line spacing
@@ -1112,22 +934,22 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
// Banner logic
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
- char longName[48] = "?";
- if (node && node->user.long_name) {
- strncpy(longName, node->user.long_name, sizeof(longName) - 1);
- longName[sizeof(longName) - 1] = '\0';
+ char longName[64] = "?";
+ if (node && node->has_user) {
+ if (node->user.long_name[0]) {
+ strncpy(longName, node->user.long_name, sizeof(longName) - 1);
+ longName[sizeof(longName) - 1] = '\0';
+ } else if (node->user.short_name[0]) {
+ strncpy(longName, node->user.short_name, sizeof(longName) - 1);
+ longName[sizeof(longName) - 1] = '\0';
+ }
}
int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20);
if (availWidth < 0)
availWidth = 0;
-
- size_t origLen = strlen(longName);
- while (longName[0] && display->getStringWidth(longName) > availWidth) {
- longName[strlen(longName) - 1] = '\0';
- }
- if (strlen(longName) < origLen) {
- strcat(longName, "...");
- }
+ char truncatedLongName[64];
+ graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName),
+ availWidth);
const char *msgRaw = reinterpret_cast(packet.decoded.payload.bytes);
char banner[256];
@@ -1145,8 +967,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
}
if (isAlert) {
- if (longName && longName[0])
- snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
+ if (truncatedLongName[0])
+ snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName);
else
strcpy(banner, "Alert Received");
} else {
@@ -1154,11 +976,11 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
if (isChannelMuted)
return;
- if (longName && longName[0]) {
+ if (truncatedLongName[0]) {
if (currentResolution == ScreenResolution::UltraLow) {
strcpy(banner, "New Message");
} else {
- snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
+ snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName);
}
} else
strcpy(banner, "New Message");
@@ -1221,4 +1043,4 @@ void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet)
} // namespace MessageRenderer
} // namespace graphics
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp
index b36a5057c..98644ee3b 100644
--- a/src/graphics/draw/NodeListRenderer.cpp
+++ b/src/graphics/draw/NodeListRenderer.cpp
@@ -79,13 +79,15 @@ void scrollDown()
// Utility Functions
// =============================
-const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
+std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
{
- static char nodeName[25]; // single static buffer we return
- nodeName[0] = '\0';
+ (void)display;
+ (void)columnWidth;
- auto writeFallbackId = [&] {
- std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0));
+ auto fallbackId = [&] {
+ char id[12];
+ std::snprintf(id, sizeof(id), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0));
+ return std::string(id);
};
// 1) Choose target candidate (long vs short) only if present
@@ -94,42 +96,10 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
}
- // 2) Sanitize (empty if raw is null/empty)
- std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{};
-
- // 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed)
- if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) {
- writeFallbackId();
- } else {
- // %.*s ensures null-termination and safe truncation to buffer size - 1
- std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast(sizeof(nodeName) - 1), s.c_str());
- }
-
- // 4) Width-based truncation + ellipsis (long-name mode only)
- if (config.display.use_long_node_name && display) {
- int availWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
- if (availWidth < 0)
- availWidth = 0;
-
- const size_t beforeLen = std::strlen(nodeName);
-
- // Trim from the end until it fits or is empty
- size_t len = beforeLen;
- while (len && display->getStringWidth(nodeName) > availWidth) {
- nodeName[--len] = '\0';
- }
-
- // If truncated, append "..." (respect buffer size)
- if (len < beforeLen) {
- // Make sure there's room for "..." and '\0'
- const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0'
- const size_t needed = 3; // "..."
- if (len > capForText - needed) {
- len = capForText - needed;
- nodeName[len] = '\0';
- }
- std::strcat(nodeName, "...");
- }
+ // 2) Preserve UTF-8 names so emotes can be detected and rendered.
+ std::string nodeName = (raw && *raw) ? std::string(raw) : std::string{};
+ if (nodeName.empty()) {
+ nodeName = fallbackId();
}
return nodeName;
@@ -163,6 +133,15 @@ const char *getCurrentModeTitle_Location(int screenWidth)
}
}
+static int getNodeNameMaxWidth(int columnWidth, int baseWidth)
+{
+ if (!config.display.use_long_node_name)
+ return baseWidth;
+
+ const int legacyLongNameWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
+ return std::max(0, std::min(baseWidth, legacyLongNameWidth));
+}
+
// Use dynamic timing based on mode
unsigned long getModeCycleIntervalMs()
{
@@ -205,10 +184,13 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
- int nameMaxWidth = columnWidth - 25;
+ int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
int timeOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
- const char *nodeName = getSafeNodeName(display, node, columnWidth);
+ const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
+ char nodeName[96];
+ UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
+ nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char timeStr[10];
@@ -228,7 +210,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
- display->drawString(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nodeName);
+ UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -255,19 +237,22 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
- int nameMaxWidth = columnWidth - 25;
+ int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
int barsXOffset = columnWidth - barsOffset;
- const char *nodeName = getSafeNodeName(display, node, columnWidth);
+ const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
+ char nodeName[96];
+ UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
+ nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
- display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
+ UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -312,9 +297,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth =
- columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
+ getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
+ : (isLeftCol ? 20 : 22)));
- const char *nodeName = getSafeNodeName(display, node, columnWidth);
+ const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
+ char nodeName[96];
+ UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
+ nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char distStr[10] = "";
@@ -368,7 +357,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
- display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
+ UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -414,14 +403,18 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
// Adjust max text width depending on column and screen width
int nameMaxWidth =
- columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
+ getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
+ : (isLeftCol ? 20 : 22)));
- const char *nodeName = getSafeNodeName(display, node, columnWidth);
+ const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
+ char nodeName[96];
+ UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
+ nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
- display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
+ UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -828,4 +821,4 @@ void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields
} // namespace NodeListRenderer
} // namespace graphics
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h
index e212c031b..be80a7d80 100644
--- a/src/graphics/draw/NodeListRenderer.h
+++ b/src/graphics/draw/NodeListRenderer.h
@@ -4,6 +4,7 @@
#include "mesh/generated/meshtastic/mesh.pb.h"
#include
#include
+#include
namespace graphics
{
@@ -56,7 +57,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
// Utility functions
const char *getCurrentModeTitle_Nodes(int screenWidth);
const char *getCurrentModeTitle_Location(int screenWidth);
-const char *getSafeNodeName(meshtastic_NodeInfoLite *node, int columnWidth);
+std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth);
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
// Scrolling controls
diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp
index 04c841884..31eb2c3c8 100644
--- a/src/graphics/draw/NotificationRenderer.cpp
+++ b/src/graphics/draw/NotificationRenderer.cpp
@@ -4,6 +4,7 @@
#include "DisplayFormatters.h"
#include "NodeDB.h"
#include "NotificationRenderer.h"
+#include "UIRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/images.h"
@@ -299,7 +300,7 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
for (int i = 0; i < lineCount; i++) {
linePointers[i] = lineStarts[i];
}
- char scratchLineBuffer[visibleTotalLines - lineCount][40];
+ char scratchLineBuffer[visibleTotalLines - lineCount][64];
uint8_t firstOptionToShow = 0;
if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) {
@@ -312,28 +313,47 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
}
int scratchLineNum = 0;
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
- char temp_name[16] = {0};
- 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);
+ char tempName[48] = {0};
+ meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
+ if (node && node->has_user) {
+ const char *rawName = nullptr;
+ if (node->user.long_name[0]) {
+ rawName = node->user.long_name;
+ } else if (node->user.short_name[0]) {
+ rawName = node->user.short_name;
+ }
+ if (rawName) {
+ const int arrowWidth = (currentResolution == ScreenResolution::High)
+ ? UIRenderer::measureStringWithEmotes(display, "> <")
+ : UIRenderer::measureStringWithEmotes(display, "><");
+ const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth);
+ UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth);
+ }
} else {
- snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF));
+ snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
+ }
+ if (!tempName[0]) {
+ snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
}
if (i == curSelected) {
- selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num;
+ selectedNodenum = node ? node->num : 0;
if (currentResolution == ScreenResolution::High) {
strncpy(scratchLineBuffer[scratchLineNum], "> ", 3);
- strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36);
- strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3);
+ strncpy(scratchLineBuffer[scratchLineNum] + 2, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 3);
+ scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
+ const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
+ strncpy(scratchLineBuffer[scratchLineNum] + used, " <", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
} else {
strncpy(scratchLineBuffer[scratchLineNum], ">", 2);
- strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37);
- strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2);
+ strncpy(scratchLineBuffer[scratchLineNum] + 1, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 2);
+ scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
+ const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
+ strncpy(scratchLineBuffer[scratchLineNum] + used, "<", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
}
- scratchLineBuffer[scratchLineNum][39] = '\0';
+ scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
} else {
- strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39);
- scratchLineBuffer[scratchLineNum][39] = '\0';
+ strncpy(scratchLineBuffer[scratchLineNum], tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 1);
+ scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
}
linePointers[linesShown] = scratchLineBuffer[scratchLineNum++];
}
@@ -501,7 +521,13 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
else // if the newline wasn't found, then pull string length from strlen
lineLengths[lineCount] = strlen(lines[lineCount]);
- lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
+ if (current_notification_type == notificationTypeEnum::node_picker) {
+ char measureBuffer[64] = {0};
+ strncpy(measureBuffer, lines[lineCount], std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1));
+ lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer);
+ } else {
+ lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
+ }
// Consider extra width for signal bars on lines that contain "Signal:"
uint16_t potentialWidth = lineWidths[lineCount];
@@ -607,7 +633,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
display->setColor(BLACK);
int yOffset = 3;
- display->drawString(textX, lineY - yOffset, lineBuffer);
+ if (current_notification_type == notificationTypeEnum::node_picker) {
+ UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
+ } else {
+ display->drawString(textX, lineY - yOffset, lineBuffer);
+ }
display->setColor(WHITE);
lineY += (effectiveLineHeight - 2 - background_yOffset);
} else {
@@ -626,7 +656,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
int totalWidth = textWidth + barsWidth;
int groupStartX = boxLeft + (boxWidth - totalWidth) / 2;
- display->drawString(groupStartX, lineY, lineBuffer);
+ if (current_notification_type == notificationTypeEnum::node_picker) {
+ UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
+ } else {
+ display->drawString(groupStartX, lineY, lineBuffer);
+ }
int baseX = groupStartX + textWidth + gap;
int baseY = lineY + effectiveLineHeight - 1;
@@ -642,7 +676,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
}
} else {
- display->drawString(textX, lineY, lineBuffer);
+ if (current_notification_type == notificationTypeEnum::node_picker) {
+ UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
+ } else {
+ display->drawString(textX, lineY, lineBuffer);
+ }
}
lineY += (effectiveLineHeight);
}
diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp
index 25a70f16d..e3a4d13a2 100644
--- a/src/graphics/draw/UIRenderer.cpp
+++ b/src/graphics/draw/UIRenderer.cpp
@@ -8,6 +8,7 @@
#include "UIRenderer.h"
#include "airtime.h"
#include "gps/GeoCoord.h"
+#include "graphics/EmoteRenderer.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TimeFormatters.h"
#include "graphics/images.h"
@@ -313,8 +314,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
#endif
currentFavoriteNodeNum = node->num;
// === Create the shortName and title string ===
- const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
- char titlestr[32] = {0};
+ const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node";
+ char titlestr[40];
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
// === Draw battery/time/mail header (common across screens) ===
@@ -328,7 +329,6 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
// List of available macro Y positions in order, from top to bottom.
int line = 1; // which slot to use next
- std::string usernameStr;
// === 1. Long Name (always try to show first) ===
const char *username;
if (currentResolution == ScreenResolution::UltraLow) {
@@ -338,9 +338,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
}
if (username) {
- usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case
// Print node's long name (e.g. "Backpack Node")
- display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str());
+ UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
}
// === 2. Signal and Hops (combined on one line, if available) ===
@@ -802,14 +801,12 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
// === Node Identity ===
int textWidth = 0;
int nameX = 0;
- char shortnameble[35];
- snprintf(shortnameble, sizeof(shortnameble), "%s",
- graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
+ const char *shortName = owner.short_name ? owner.short_name : "";
// === ShortName Centered ===
- textWidth = display->getStringWidth(shortnameble);
+ textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
- display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
+ UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
#else
if (powerStatus->getHasBattery()) {
char batStr[20];
@@ -904,36 +901,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
int textWidth = 0;
int nameX = 0;
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
- std::string longNameStr;
-
- if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
- longNameStr = sanitizeString(ourNode->user.long_name);
+ const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : "";
+ const char *shortName = owner.short_name ? owner.short_name : "";
+ char combinedName[96];
+ if (longName[0] && shortName[0]) {
+ snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName);
+ } else if (longName[0]) {
+ strncpy(combinedName, longName, sizeof(combinedName) - 1);
+ combinedName[sizeof(combinedName) - 1] = '\0';
+ } else {
+ strncpy(combinedName, shortName, sizeof(combinedName) - 1);
+ combinedName[sizeof(combinedName) - 1] = '\0';
}
- char shortnameble[35];
- snprintf(shortnameble, sizeof(shortnameble), "%s",
- graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
-
- char combinedName[50];
- snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble);
- if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) {
- size_t len = strlen(combinedName);
- if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) {
- combinedName[len - 3] = '\0'; // Remove the last three characters
- }
- textWidth = display->getStringWidth(combinedName);
+ if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
+ textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
- display->drawString(
- nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName);
+ UIRenderer::drawStringWithEmotes(
+ display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset,
+ combinedName, FONT_HEIGHT_SMALL, 1, false);
} else {
// === LongName Centered ===
- textWidth = display->getStringWidth(longNameStr.c_str());
+ textWidth = UIRenderer::measureStringWithEmotes(display, longName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
- display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str());
+ UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1,
+ false);
// === ShortName Centered ===
- textWidth = display->getStringWidth(shortnameble);
+ textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
- display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
+ UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
+ false);
}
#endif
graphics::drawCommonFooter(display, x, y);
@@ -1045,12 +1042,12 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
display->setTextAlignment(TEXT_ALIGN_LEFT);
const char *pauseText = "Screen Paused";
const char *idText = owner.short_name;
- const bool useId = haveGlyphs(idText);
+ const bool useId = (idText && idText[0]);
constexpr uint8_t padding = 2;
constexpr uint8_t dividerGap = 1;
// Text widths
- const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true);
+ const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0;
const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding;
const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2);
@@ -1075,7 +1072,7 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
// Draw: text
if (useId)
- display->drawString(idTextLeft, idTextTop, idText);
+ UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false);
display->drawString(pauseTextLeft, pauseTextTop, pauseText);
display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold
@@ -1108,11 +1105,16 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(msgX, msgY, upperMsg);
}
// Draw version and short name in bottom middle
- char buf[25];
- snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT),
- graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
-
- display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf);
+ char footer[64];
+ if (owner.short_name && owner.short_name[0]) {
+ snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name);
+ } else {
+ snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT));
+ }
+ int footerW = UIRenderer::measureStringWithEmotes(display, footer);
+ int footerX = x + ((SCREEN_WIDTH - footerW) / 2);
+ UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1,
+ false);
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1130,12 +1132,15 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(x + 0, y + 0, upperMsg);
// Draw version and short name in upper right
- char buf[25];
- snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT),
- graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
-
- display->setTextAlignment(TEXT_ALIGN_RIGHT);
- display->drawString(x + SCREEN_WIDTH, y + 0, buf);
+ const char *version = xstr(APP_VERSION_SHORT);
+ int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
+ display->drawString(versionX, y + 0, version);
+ if (owner.short_name && owner.short_name[0]) {
+ const char *shortName = owner.short_name;
+ int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
+ int shortNameX = x + SCREEN_WIDTH - shortNameW;
+ UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
+ }
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1365,11 +1370,15 @@ void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, O
display->drawString(x + 0, y + 0, upperMsg);
// Draw version and shortname in upper right
- char buf[25];
- snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : "");
-
- display->setTextAlignment(TEXT_ALIGN_RIGHT);
- display->drawString(x + SCREEN_WIDTH, y + 0, buf);
+ const char *version = xstr(APP_VERSION_SHORT);
+ int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
+ display->drawString(versionX, y + 0, version);
+ if (owner.short_name && owner.short_name[0]) {
+ const char *shortName = owner.short_name;
+ int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
+ int shortNameX = x + SCREEN_WIDTH - shortNameW;
+ UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
+ }
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1558,6 +1567,25 @@ std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t mi
return uptime;
}
+int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing)
+{
+ return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing);
+}
+
+size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
+ const char *ellipsis, int emoteSpacing)
+{
+ return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes,
+ graphics::numEmotes, emoteSpacing);
+}
+
+void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing,
+ bool fauxBold)
+{
+ graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes,
+ emoteSpacing, fauxBold);
+}
+
} // namespace graphics
#endif // HAS_SCREEN
diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h
index 8f0d07881..a0bb0d849 100644
--- a/src/graphics/draw/UIRenderer.h
+++ b/src/graphics/draw/UIRenderer.h
@@ -1,6 +1,7 @@
#pragma once
#include "NodeDB.h"
+#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/emotes.h"
#include
@@ -80,6 +81,28 @@ class UIRenderer
static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime);
+ // Shared BaseUI emote helpers.
+ static int measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing = 1);
+ static inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, int emoteSpacing = 1)
+ {
+ return measureStringWithEmotes(display, line.c_str(), emoteSpacing);
+ }
+ static size_t truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
+ const char *ellipsis = "...", int emoteSpacing = 1);
+ static inline std::string truncateStringWithEmotes(OLEDDisplay *display, const std::string &line, int maxWidth,
+ const std::string &ellipsis = "...", int emoteSpacing = 1)
+ {
+ return graphics::EmoteRenderer::truncateToWidth(display, line, maxWidth, ellipsis, graphics::emotes, graphics::numEmotes,
+ emoteSpacing);
+ }
+ static void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing = 1,
+ bool fauxBold = true);
+ static inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
+ int emoteSpacing = 1, bool fauxBold = true)
+ {
+ drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold);
+ }
+
// Check if the display can render a string (detect special chars; emoji)
static bool haveGlyphs(const char *str);
}; // namespace UIRenderer
diff --git a/src/graphics/fonts/OLEDDisplayFontsRU.cpp b/src/graphics/fonts/OLEDDisplayFontsRU.cpp
index 3a1159511..9766d36b2 100644
--- a/src/graphics/fonts/OLEDDisplayFontsRU.cpp
+++ b/src/graphics/fonts/OLEDDisplayFontsRU.cpp
@@ -10,7 +10,7 @@ const uint8_t ArialMT_Plain_10_RU[] PROGMEM = {
0xE0, // Number of chars: 224
// Jump Table:
- 0xFF, 0xFF, 0x00, 0x0A, // 32
+ 0xFF, 0xFF, 0x00, 0x03, // 32
0x00, 0x00, 0x04, 0x03, // 33
0x00, 0x04, 0x05, 0x04, // 34
0x00, 0x09, 0x09, 0x06, // 35
@@ -1766,4 +1766,4 @@ const uint8_t ArialMT_Plain_24_RU[] PROGMEM = {
0x3F, // 255
};
-#endif // OLED_RU
\ No newline at end of file
+#endif // OLED_RU
diff --git a/src/graphics/fonts/OLEDDisplayFontsUA.cpp b/src/graphics/fonts/OLEDDisplayFontsUA.cpp
index 8bc56ea94..deafa77aa 100644
--- a/src/graphics/fonts/OLEDDisplayFontsUA.cpp
+++ b/src/graphics/fonts/OLEDDisplayFontsUA.cpp
@@ -9,7 +9,7 @@ const uint8_t ArialMT_Plain_10_UA[] PROGMEM = {
0x20, // First char: 32
0xE0, // Number of chars: 224
// Jump Table:
- 0xFF, 0xFF, 0x00, 0x0A, // 32
+ 0xFF, 0xFF, 0x00, 0x03, // 32
0x00, 0x00, 0x04, 0x03, // 33
0x00, 0x04, 0x05, 0x04, // 34
0x00, 0x09, 0x09, 0x06, // 35
@@ -1924,4 +1924,4 @@ const uint8_t ArialMT_Plain_24_UA[] PROGMEM = {
0xFF, // 1103
};
-#endif // OLED_UA
\ No newline at end of file
+#endif // OLED_UA
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
index b2ef1f714..d489d21ee 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
@@ -177,24 +177,8 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
auto changes = SEGMENT_CONFIG;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
- if (!owner.is_licensed) {
- bool keygenSuccess = false;
-
- if (config.security.private_key.size == 32) {
- if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
- keygenSuccess = true;
- }
- } else {
- crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
- keygenSuccess = true;
- }
-
- if (keygenSuccess) {
- config.security.public_key.size = 32;
- config.security.private_key.size = 32;
- owner.public_key.size = 32;
- memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
- }
+ if (crypto) {
+ crypto->ensurePkiKeys(config.security, owner);
}
#endif
diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp
index acf79f149..e3125ca12 100644
--- a/src/input/InputBroker.cpp
+++ b/src/input/InputBroker.cpp
@@ -34,7 +34,7 @@
#if defined(BUTTON_PIN_TOUCH)
ButtonThread *TouchButtonThread = nullptr;
-#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN)
+#if defined(PIN_EINK_EN)
static bool touchBacklightWasOn = false;
static bool touchBacklightActive = false;
#endif
@@ -220,8 +220,8 @@ void InputBroker::Init()
};
touchConfig.singlePress = INPUT_BROKER_NONE;
touchConfig.longPress = INPUT_BROKER_BACK;
-#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN)
- // On T-Echo Plus the touch pad should only drive the backlight, not UI navigation/sounds
+#if defined(PIN_EINK_EN)
+ // Touch pad drives the backlight on devices with e-ink backlight pin
touchConfig.longPress = INPUT_BROKER_NONE;
touchConfig.suppressLeadUpSound = true;
touchConfig.onPress = []() {
diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp
index 72216a63c..4a613b644 100644
--- a/src/mesh/CryptoEngine.cpp
+++ b/src/mesh/CryptoEngine.cpp
@@ -61,6 +61,33 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
}
return true;
}
+
+bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
+{
+ if (user.is_licensed) {
+ return false;
+ }
+
+ bool keygenSuccess = false;
+ if (security.private_key.size == 32) {
+ if (regeneratePublicKey(security.public_key.bytes, security.private_key.bytes)) {
+ keygenSuccess = true;
+ }
+ } else {
+ LOG_INFO("Generate new PKI keys");
+ generateKeyPair(security.public_key.bytes, security.private_key.bytes);
+ keygenSuccess = true;
+ }
+
+ if (keygenSuccess) {
+ security.public_key.size = 32;
+ security.private_key.size = 32;
+ user.public_key.size = 32;
+ memcpy(user.public_key.bytes, security.public_key.bytes, 32);
+ }
+
+ return keygenSuccess;
+}
#endif
/**
diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h
index 19d572355..f40400331 100644
--- a/src/mesh/CryptoEngine.h
+++ b/src/mesh/CryptoEngine.h
@@ -36,6 +36,7 @@ class CryptoEngine
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
+ virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
#endif
void setDHPrivateKey(uint8_t *_private_key);
diff --git a/src/mesh/Default.h b/src/mesh/Default.h
index f4633790e..069ffc0eb 100644
--- a/src/mesh/Default.h
+++ b/src/mesh/Default.h
@@ -13,12 +13,12 @@
#define TEN_SECONDS_MS 10 * 1000
#define MAX_INTERVAL INT32_MAX // FIXME: INT32_MAX to avoid overflow issues with Apple clients but should be UINT32_MAX
-#define min_default_telemetry_interval_secs 30 * 60
+#define min_default_telemetry_interval_secs IF_ROUTER(ONE_DAY / 2, 30 * 60)
#define default_gps_update_interval IF_ROUTER(ONE_DAY, 2 * 60)
#define default_telemetry_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define default_broadcast_smart_minimum_interval_secs 5 * 60
-#define min_default_broadcast_interval_secs 60 * 60
+#define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define min_default_broadcast_smart_minimum_interval_secs 5 * 60
#define default_wait_bluetooth_secs IF_ROUTER(1, 60)
#define default_sds_secs IF_ROUTER(ONE_DAY, UINT32_MAX) // Default to forever super deep sleep
diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp
index 78602a9ec..13f98299f 100644
--- a/src/mesh/FloodingRouter.cpp
+++ b/src/mesh/FloodingRouter.cpp
@@ -91,10 +91,27 @@ void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p)
{
if (nodeDB)
nodeDB->updateFrom(*p);
+
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
+ if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
+ // If we got a packet that is not decoded, try to decode it so we can check for traceroute.
+ auto decodedState = perhapsDecode(const_cast(p));
+ if (decodedState == DecodeState::DECODE_SUCCESS) {
+ // parsing was successful, print for debugging
+ printPacket("reprocessPacket(DUP)", p);
+ } else {
+ // Fatal decoding error, we can't do anything with this packet
+ LOG_WARN(
+ "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute",
+ static_cast(decodedState), p->id, getFrom(p));
+ return;
+ }
+ }
+
if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
- p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP)
+ p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
traceRouteModule->processUpgradedPacket(*p);
+ }
#endif
}
diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp
index 4fec06da4..9f808da26 100644
--- a/src/mesh/LR11x0Interface.cpp
+++ b/src/mesh/LR11x0Interface.cpp
@@ -192,6 +192,11 @@ template bool LR11x0Interface::reconfigure()
err = lora.setOutputPower(power);
assert(err == RADIOLIB_ERR_NONE);
+ // Apply RX gain mode — valid in STDBY, matches resetAGC() pattern
+ err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
+ if (err != RADIOLIB_ERR_NONE)
+ LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err);
+
startReceive(); // restart receiving
return RADIOLIB_ERR_NONE;
diff --git a/src/mesh/LoRaFEMInterface.cpp b/src/mesh/LoRaFEMInterface.cpp
index bad795c3a..b44c7539b 100644
--- a/src/mesh/LoRaFEMInterface.cpp
+++ b/src/mesh/LoRaFEMInterface.cpp
@@ -25,7 +25,7 @@ void LoRaFEMInterface::init(void)
pinMode(LORA_KCT8103L_PA_CSD, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
- digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
+ digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
setLnaCanControl(true);
} else if (digitalRead(LORA_KCT8103L_PA_CSD) == LOW) {
// FEM is GC1109
@@ -66,7 +66,7 @@ void LoRaFEMInterface::init(void)
pinMode(LORA_KCT8103L_PA_CSD, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
- digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
+ digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
setLnaCanControl(true);
#endif
}
@@ -173,14 +173,16 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
#endif
#elif defined(USE_KCT8103L_PA)
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
- rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
if (lna_enabled) {
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
} else {
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
}
+#if defined(ARCH_ESP32)
+ rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
#endif
+#endif
}
void LoRaFEMInterface::setLNAEnable(bool enabled)
diff --git a/src/mesh/LoRaFEMInterface.h b/src/mesh/LoRaFEMInterface.h
index 9024abd3a..14220c6e3 100644
--- a/src/mesh/LoRaFEMInterface.h
+++ b/src/mesh/LoRaFEMInterface.h
@@ -22,7 +22,7 @@ class LoRaFEMInterface
private:
LoRaFEMType fem_type;
- bool lna_enabled = false;
+ bool lna_enabled = true;
bool lna_can_control = false;
};
extern LoRaFEMInterface loraFEMInterface;
diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h
index 07d956878..3c3a4cf65 100644
--- a/src/mesh/MeshRadio.h
+++ b/src/mesh/MeshRadio.h
@@ -5,18 +5,52 @@
#include "PointerQueue.h"
#include "configuration.h"
+// Sentinel marking the end of a modem preset array
+static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
+ static_cast(0xFF);
+
+// Region profile: bundles the preset list with regulatory parameters shared across regions
+struct RegionProfile {
+ const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default
+ float spacing; // gaps between radio channels
+ float padding; // padding at each side of the "operating channel"
+ bool audioPermitted;
+ bool licensedOnly; // a region profile for licensed operators only
+ int8_t textThrottle; // throttle for text - future expansion
+ int8_t positionThrottle; // throttle for location data - future expansion
+ int8_t telemetryThrottle; // throttle for telemetry - future expansion
+ uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place
+};
+
+extern const RegionProfile PROFILE_STD;
+extern const RegionProfile PROFILE_EU868;
+extern const RegionProfile PROFILE_UNDEF;
+// extern const RegionProfile PROFILE_LITE;
+// extern const RegionProfile PROFILE_NARROW;
+// extern const RegionProfile PROFILE_HAM;
+
// Map from old region names to new region enums
struct RegionInfo {
meshtastic_Config_LoRaConfig_RegionCode code;
float freqStart;
float freqEnd;
- float dutyCycle;
- float spacing;
+ float dutyCycle; // modified by getEffectiveDutyCycle
uint8_t powerLimit; // Or zero for not set
- bool audioPermitted;
bool freqSwitching;
bool wideLora;
+ const RegionProfile *profile;
const char *name; // EU433 etc
+
+ // Preset accessors (delegate through profile)
+ meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; }
+ const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; }
+ size_t getNumPresets() const
+ {
+ size_t n = 0;
+ while (profile->presets[n] != MODEM_PRESET_END)
+ n++;
+ return n;
+ }
};
extern const RegionInfo regions[];
diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp
index d686e9056..13f948a7b 100644
--- a/src/mesh/NextHopRouter.cpp
+++ b/src/mesh/NextHopRouter.cpp
@@ -26,7 +26,7 @@ ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p)
p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // First set the relayer to us
wasSeenRecently(p); // FIXME, move this to a sniffSent method
- p->next_hop = getNextHop(p->to, p->relay_node); // set the next hop
+ p->next_hop = getNextHop(p->to, p->relay_node).value_or(NO_NEXT_HOP_PREFERENCE); // set the next hop
LOG_DEBUG("Setting next hop for packet with dest %x to %x", p->to, p->next_hop);
// If it's from us, ReliableRouter already handles retransmissions if want_ack is set. If a next hop is set and hop limit is
@@ -186,10 +186,10 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
* Get the next hop for a destination, given the relay node
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
*/
-uint8_t NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
+std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
{
if (isBroadcast(to))
- return NO_NEXT_HOP_PREFERENCE;
+ return std::nullopt;
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
if (node && node->next_hop) {
@@ -200,7 +200,7 @@ uint8_t NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
} else
LOG_WARN("Next hop for 0x%x is 0x%x, same as relayer; set no pref", to, node->next_hop);
}
- return NO_NEXT_HOP_PREFERENCE;
+ return std::nullopt;
}
PendingPacket *NextHopRouter::findPendingPacket(GlobalPacketId key)
diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h
index c1df3596b..42ef13cd9 100644
--- a/src/mesh/NextHopRouter.h
+++ b/src/mesh/NextHopRouter.h
@@ -1,6 +1,7 @@
#pragma once
#include "FloodingRouter.h"
+#include
#include
/**
@@ -146,7 +147,7 @@ class NextHopRouter : public FloodingRouter
* Get the next hop for a destination, given the relay node
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
*/
- uint8_t getNextHop(NodeNum to, uint8_t relay_node);
+ std::optional getNextHop(NodeNum to, uint8_t relay_node);
/** Check if we should be rebroadcasting this packet if so, do so.
* @return true if we did rebroadcast */
diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp
index 9c05dbfad..304e5d912 100644
--- a/src/mesh/NodeDB.cpp
+++ b/src/mesh/NodeDB.cpp
@@ -322,9 +322,9 @@ NodeDB::NodeDB()
// config.network.enabled_protocols = meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST;
// If we are setup to broadcast on any default channel slot (with default frequency slot semantics),
- // ensure that the telemetry intervals are coerced to the minimum value of 30 minutes or more.
+ // ensure that the telemetry intervals are coerced to the role-aware minimum value.
if (channels.hasDefaultChannel()) {
- LOG_DEBUG("Coerce telemetry to min of 30 minutes on defaults");
+ LOG_DEBUG("Coerce telemetry to role-aware minimum on defaults");
moduleConfig.telemetry.device_update_interval = Default::getConfiguredOrMinimumValue(
moduleConfig.telemetry.device_update_interval, min_default_telemetry_interval_secs);
moduleConfig.telemetry.environment_update_interval = Default::getConfiguredOrMinimumValue(
@@ -347,7 +347,7 @@ NodeDB::NodeDB()
}
}
if (positionUsesDefaultChannel) {
- LOG_DEBUG("Coerce position broadcasts to min of 1 hour and smart broadcast min of 5 minutes on defaults");
+ LOG_DEBUG("Coerce position broadcasts to role-aware minimum and smart broadcast min of 5 minutes on defaults");
config.position.position_broadcast_secs =
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, min_default_broadcast_interval_secs);
config.position.broadcast_smart_minimum_interval_secs = Default::getConfiguredOrMinimumValue(
@@ -569,7 +569,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.lora.override_duty_cycle = false;
config.lora.config_ok_to_mqtt = false;
#if HAS_LORA_FEM
- config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED;
+ config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED;
#else
config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
#endif
@@ -1299,9 +1299,13 @@ void NodeDB::loadFromDisk()
// Coerce LoRa config fields derived from presets while bootstrapping.
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
if (config.has_lora && config.lora.use_preset) {
- RadioInterface::bootstrapLoRaConfigFromPreset(config.lora);
+ RadioInterface::clampConfigLora(config.lora);
}
+#if defined(USERPREFS_LORA_TX_DISABLED) && USERPREFS_LORA_TX_DISABLED
+ config.lora.tx_enabled = false;
+#endif
+
if (backupSecurity.private_key.size > 0) {
LOG_DEBUG("Restoring backup of security config");
config.security = backupSecurity;
@@ -1646,6 +1650,25 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p)
return delta;
}
+HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p)
+{
+ // Guard against invalid values.
+ if (p.hop_start < p.hop_limit)
+ return HopStartStatus::INVALID;
+
+ if (p.hop_start == 0) {
+ // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
+ // bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware
+ // version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as
+ // the bitfield is encrypted under the channel encryption key.
+ if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield)
+ return HopStartStatus::VALID;
+ return HopStartStatus::MISSING_OR_UNKNOWN;
+ }
+
+ return HopStartStatus::VALID;
+}
+
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown)
{
// Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
@@ -1683,6 +1706,22 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
#include "MeshModule.h"
#include "Throttle.h"
+static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;
+
+void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
+{
+ static uint32_t lastLogMs = 0;
+ if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) {
+ return;
+ }
+ lastLogMs = millis();
+ const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag);
+ const bool hasBitfield = decoded && p.decoded.has_bitfield;
+ LOG_DEBUG(
+ "Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)",
+ context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield);
+}
+
/** Update position info for this node based on received position data
*/
void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src)
diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h
index adf2b42ea..f6be963c1 100644
--- a/src/mesh/NodeDB.h
+++ b/src/mesh/NodeDB.h
@@ -114,6 +114,27 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p);
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
+enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID };
+
+/// Classify hop_start validity for forwarding decisions.
+HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p);
+
+inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p)
+{
+#if !MESHTASTIC_PREHOP_DROP
+ (void)p;
+ return false;
+#else
+ if (isFromUs(&p)) {
+ return false; // local-originated packets should never be dropped by pre-hop drop policy
+ }
+ return classifyHopStart(p) != HopStartStatus::VALID;
+#endif
+}
+
+/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped.
+void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context);
+
enum LoadFileResult {
// Successfully opened the file
LOAD_SUCCESS = 1,
diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp
index 4defd00ed..9ce944002 100644
--- a/src/mesh/RadioInterface.cpp
+++ b/src/mesh/RadioInterface.cpp
@@ -21,6 +21,7 @@
#include
#include
#include
+#include
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
@@ -32,10 +33,32 @@
#include "STM32WLE5JCInterface.h"
#endif
-#define RDEF(name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, frequency_switching, wide_lora) \
+static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
+ meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END};
+
+static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
+ meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END};
+
+static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
+ MODEM_PRESET_END};
+
+// Region profiles: bundle preset list + regulatory parameters shared across regions
+// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot
+const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0};
+const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0};
+const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0};
+
+#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \
{ \
- meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, \
- frequency_switching, wide_lora, #name \
+ meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \
+ wide_lora, &profile_ptr, #name \
}
const RegionInfo regions[] = {
@@ -43,7 +66,7 @@ const RegionInfo regions[] = {
https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
*/
- RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false),
+ RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD),
/*
EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21]
@@ -51,8 +74,7 @@ const RegionInfo regions[] = {
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, 10, true, false, false),
-
+ RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD),
/*
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
@@ -67,33 +89,33 @@ const RegionInfo regions[] = {
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
*/
- RDEF(EU_868, 869.4f, 869.65f, 10, 0, 27, false, false, false),
+ RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
*/
- RDEF(CN, 470.0f, 510.0f, 100, 0, 19, true, false, false),
+ RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf
https://qiita.com/ammo0613/items/d952154f1195b64dc29f
*/
- RDEF(JP, 920.5f, 923.5f, 100, 0, 13, true, false, false),
+ RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD),
/*
https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
Also used in Brazil.
*/
- RDEF(ANZ, 915.0f, 928.0f, 100, 0, 30, true, false, false),
+ RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD),
/*
433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions
AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence
NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100
*/
- RDEF(ANZ_433, 433.05f, 434.79f, 100, 0, 14, true, false, false),
+ RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD),
/*
https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf
@@ -101,13 +123,13 @@ const RegionInfo regions[] = {
Note:
- We do LBT, so 100% is allowed.
*/
- RDEF(RU, 868.7f, 869.2f, 100, 0, 20, true, false, false),
+ RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD),
/*
https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0
https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters
*/
- RDEF(KR, 920.0f, 923.0f, 100, 0, 23, true, false, false),
+ RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD),
/*
Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor.
@@ -115,42 +137,38 @@ const RegionInfo regions[] = {
https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF
https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283
*/
- RDEF(TW, 920.0f, 925.0f, 100, 0, 27, true, false, false),
+ RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
*/
- RDEF(IN, 865.0f, 867.0f, 100, 0, 30, true, false, false),
+ RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD),
/*
https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
*/
- RDEF(NZ_865, 864.0f, 868.0f, 100, 0, 36, true, false, false),
+ RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
*/
- RDEF(TH, 920.0f, 925.0f, 100, 0, 16, true, false, false),
+ RDEF(TH, 920.0f, 925.0f, 100, 16, false, false, PROFILE_STD),
/*
433,05-434,7 Mhz 10 mW
- https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
- */
- RDEF(UA_433, 433.0f, 434.7f, 10, 0, 10, true, false, false),
-
- /*
868,0-868,6 Mhz 25 mW
https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
*/
- RDEF(UA_868, 868.0f, 868.6f, 1, 0, 14, true, false, false),
+ RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD),
+ RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD),
/*
Malaysia
433 - 435 MHz at 100mW, no restrictions.
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
*/
- RDEF(MY_433, 433.0f, 435.0f, 100, 0, 20, true, false, false),
+ RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD),
/*
Malaysia
@@ -159,14 +177,14 @@ const RegionInfo regions[] = {
Frequency hopping is used for 919 - 923 MHz.
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
*/
- RDEF(MY_919, 919.0f, 924.0f, 100, 0, 27, true, true, false),
+ RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD),
/*
Singapore
SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions.
https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf
*/
- RDEF(SG_923, 917.0f, 925.0f, 100, 0, 20, true, false, false),
+ RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD),
/*
Philippines
@@ -176,8 +194,9 @@ const RegionInfo regions[] = {
https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135
*/
- RDEF(PH_433, 433.0f, 434.7f, 100, 0, 10, true, false, false), RDEF(PH_868, 868.0f, 869.4f, 100, 0, 14, true, false, false),
- RDEF(PH_915, 915.0f, 918.0f, 100, 0, 24, true, false, false),
+ RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD),
+ RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD),
+ RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD),
/*
Kazakhstan
@@ -185,37 +204,38 @@ const RegionInfo regions[] = {
863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields
https://github.com/meshtastic/firmware/issues/7204
*/
- RDEF(KZ_433, 433.075f, 434.775f, 100, 0, 10, true, false, false),
- RDEF(KZ_863, 863.0f, 868.0f, 100, 0, 30, true, false, false),
+ RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD),
+ RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD),
/*
Nepal
865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use,
specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf
*/
- RDEF(NP_865, 865.0f, 868.0f, 100, 0, 30, true, false, false),
+ RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD),
/*
Brazil
902 - 907.5 MHz , 1W power limit, no duty cycle restrictions
https://github.com/meshtastic/firmware/issues/3741
*/
- RDEF(BR_902, 902.0f, 907.5f, 100, 0, 30, true, false, false),
+ RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD),
/*
2.4 GHZ WLAN Band equivalent. Only for SX128x chips.
*/
- RDEF(LORA_24, 2400.0f, 2483.5f, 100, 0, 10, true, false, true),
+ RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD),
/*
This needs to be last. Same as US.
*/
- RDEF(UNSET, 902.0f, 928.0f, 100, 0, 30, true, false, false)
+ RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF)
};
const RegionInfo *myRegion;
bool RadioInterface::uses_default_frequency_slot = true;
+bool RadioInterface::uses_custom_channel_name = false;
static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1];
@@ -501,45 +521,14 @@ void initRegion()
myRegion = r;
}
-void RadioInterface::bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig)
+const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
{
- if (!loraConfig.use_preset) {
- return;
- }
-
- // Find region info to determine whether "wide" LoRa is permitted (2.4 GHz uses wider bandwidth codes).
const RegionInfo *r = regions;
- for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != loraConfig.region; r++)
+ for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != code; r++)
;
-
- const bool regionWideLora = r->wideLora;
-
- float bwKHz = 0;
- uint8_t sf = 0;
- uint8_t cr = 0;
- modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr);
-
- // If selected preset requests a bandwidth larger than the region span, fall back to LONG_FAST.
- if (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && (r->freqEnd - r->freqStart) < (bwKHz / 1000.0f)) {
- loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
- modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr);
- }
-
- loraConfig.bandwidth = bwKHzToCode(bwKHz);
- loraConfig.spread_factor = sf;
+ return r;
}
-/**
- * ## LoRaWAN for North America
-
-LoRaWAN defines 64, 125 kHz channels from 902.3 to 914.9 MHz increments.
-
-The maximum output power for North America is +30 dBM.
-
-The band is from 902 to 928 MHz. It mentions channel number and its respective channel frequency. All the 13 channels are
-separated by 2.16 MHz with respect to the adjacent channels. Channel zero starts at 903.08 MHz center frequency.
-*/
-
uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received)
{
uint32_t pl = 0;
@@ -749,7 +738,7 @@ void RadioInterface::saveFreq(float freq)
}
/**
- * Save our channel for later reuse.
+ * Save our frequency slot (aka channel) for later reuse.
*/
void RadioInterface::saveChannelNum(uint32_t channel_num)
{
@@ -772,113 +761,297 @@ uint32_t RadioInterface::getChannelNum()
return savedChannelNum;
}
+/**
+ * Send an error-level client notification. Safe to call when service is null (e.g. in tests).
+ */
+static void sendErrorNotification(const char *msg)
+{
+ if (!service)
+ return;
+ meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
+ if (!cn)
+ return;
+ cn->level = meshtastic_LogRecord_Level_ERROR;
+ snprintf(cn->message, sizeof(cn->message), "%s", msg);
+ service->sendClientNotification(cn);
+}
+
+/**
+ * Checks if a region is valid for the current settings.
+ * Returns false if not compatible.
+ */
+bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
+{
+ const RegionInfo *newRegion = getRegion(loraConfig.region);
+
+ // If you are not licensed, you can't use ham regions.
+ if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
+ char err_string[160];
+ snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name);
+ LOG_ERROR("%s", err_string);
+ RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
+ sendErrorNotification(err_string);
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Internal helper: validate or clamp a LoRa config against its region.
+ * When clamp==false, returns false on first error (pure validation).
+ * When clamp==true, fixes invalid settings in-place and returns true.
+ */
+bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp)
+{
+ char err_string[160];
+ float check_bw;
+
+ const RegionInfo *newRegion = getRegion(loraConfig.region);
+
+ const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
+
+ // Check preset validity (only when use_preset is true)
+ if (loraConfig.use_preset) {
+ check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
+
+ bool preset_valid = false;
+ for (size_t i = 0; i < newRegion->getNumPresets(); i++) {
+ if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) {
+ preset_valid = true;
+ break;
+ }
+ }
+ if (!preset_valid) {
+ const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true);
+ if (clamp) {
+ snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name,
+ defaultName);
+ } else {
+ snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name);
+ }
+ LOG_ERROR("%s", err_string);
+ RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
+ sendErrorNotification(err_string);
+
+ if (clamp) {
+ loraConfig.modem_preset = newRegion->getDefaultPreset();
+ check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
+ } else {
+ return false;
+ }
+ }
+ } else {
+ check_bw = bwCodeToKHz(loraConfig.bandwidth);
+ }
+
+ // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region:
+ // spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band
+ // padding = gap at the beginning and end of the slots (0 for no padding)
+ float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz
+ uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
+
+ // Check if the region supports the requested bandwidth
+ if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) {
+ const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f;
+ snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw);
+ LOG_ERROR("%s", err_string);
+ RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
+ sendErrorNotification(err_string);
+
+ if (clamp) {
+ loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora));
+ check_bw = bwCodeToKHz(loraConfig.bandwidth);
+
+ // Recompute slot width and number of slots based on the new bandwidth
+ freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz
+ numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
+ } else {
+ return false;
+ }
+ }
+
+ const char *channelName = channels.getName(channels.getPrimaryIndex());
+ const char *presetNameDisplay =
+ DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
+ uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots;
+ uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots;
+
+ if (loraConfig.override_frequency == 0) {
+
+ // Check if we use the default frequency slot
+ uses_default_frequency_slot =
+ (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default
+ (newRegion->profile->overrideSlot != 0 &&
+ loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override
+ ((newRegion->profile->overrideSlot == 0) &&
+ ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override
+
+ // check if user setting different to preset name
+ uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0);
+
+ if (loraConfig.channel_num > numFreqSlots) {
+ snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num,
+ newRegion->name, numFreqSlots);
+ LOG_ERROR("%s", err_string);
+ RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
+ sendErrorNotification(err_string);
+
+ if (clamp) {
+ if (uses_custom_channel_name) { // clamp to channel name hash
+ loraConfig.channel_num =
+ channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
+ } else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot
+ loraConfig.channel_num =
+ newRegion->profile->overrideSlot; // use the override slot specified by the region profile
+ uses_default_frequency_slot = true;
+ } else if (loraConfig.use_preset) { // clamp to preset slot
+ loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
+ uses_default_frequency_slot = true;
+ } else { // if not using preset, and no custom channel name, just clamp to default anyway
+ uses_default_frequency_slot = true;
+ };
+ } else {
+ return false;
+ }
+ } // end of channel number check
+ } else {
+ // if we have a frequency override, we ignore the channel number and just use the override frequency
+ snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency);
+ }
+ return true;
+}
+
+bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig)
+{
+ auto copy = loraConfig;
+ return checkOrClampConfigLora(copy, false);
+}
+
+void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig)
+{
+ checkOrClampConfigLora(loraConfig, true);
+}
+
/**
* Pull our channel settings etc... from protobufs to the dumb interface settings
+ * Note: this must be given only settings which have been validated or clamped!
*/
void RadioInterface::applyModemConfig()
{
// Set up default configuration
// No Sync Words in LORA mode
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
- bool validConfig = false; // We need to check for a valid configuration
- while (!validConfig) {
- if (loraConfig.use_preset) {
- modemPresetToParams(loraConfig.modem_preset, myRegion->wideLora, bw, sf, cr);
- if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate != cr) {
- cr = loraConfig.coding_rate;
- LOG_INFO("Using custom Coding Rate %u", cr);
- }
- } else {
- sf = loraConfig.spread_factor;
+ const RegionInfo *newRegion = getRegion(loraConfig.region);
+ myRegion = newRegion;
+
+ if (loraConfig.use_preset) {
+ if (!validateConfigLora(loraConfig)) {
+ loraConfig.modem_preset = newRegion->getDefaultPreset();
+ }
+ uint8_t newcr;
+ modemPresetToParams(loraConfig.modem_preset, newRegion->wideLora, bw, sf, newcr);
+ // If custom CR is being used already, check if the new preset is higher
+ if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) {
+ cr = newcr;
+ LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr);
+ }
+ // If the custom CR is higher than the preset, use it
+ else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) {
cr = loraConfig.coding_rate;
- bw = bwCodeToKHz(loraConfig.bandwidth);
- }
-
- if ((myRegion->freqEnd - myRegion->freqStart) < bw / 1000) {
- const float regionSpanKHz = (myRegion->freqEnd - myRegion->freqStart) * 1000.0f;
- const float requestedBwKHz = bw;
- const bool isWideRequest = requestedBwKHz >= 499.5f; // treat as 500 kHz preset
- const char *presetName =
- DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
-
- char err_string[160];
- if (isWideRequest) {
- snprintf(err_string, sizeof(err_string), "%s region too narrow for 500kHz preset (%s). Falling back to LongFast.",
- myRegion->name, presetName);
- } else {
- snprintf(err_string, sizeof(err_string), "%s region span %.0fkHz < requested %.0fkHz. Falling back to LongFast.",
- myRegion->name, regionSpanKHz, requestedBwKHz);
- }
- LOG_ERROR("%s", err_string);
- RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
-
- meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
- cn->level = meshtastic_LogRecord_Level_ERROR;
- snprintf(cn->message, sizeof(cn->message), "%s", err_string);
- service->sendClientNotification(cn);
-
- // Set to default modem preset
- loraConfig.use_preset = true;
- loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
+ LOG_INFO("Using custom Coding Rate %u", cr);
} else {
- validConfig = true;
+ cr = loraConfig.coding_rate;
}
+
+ } else { // if not using preset, then just use the custom settings
+ if (validateConfigLora(loraConfig)) {
+ } else {
+ LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults",
+ newRegion->name);
+ clampConfigLora(loraConfig);
+ }
+ bw = bwCodeToKHz(loraConfig.bandwidth);
+ sf = loraConfig.spread_factor;
+ cr = loraConfig.coding_rate;
}
power = loraConfig.tx_power;
- if ((power == 0) || ((power > myRegion->powerLimit) && !devicestate.owner.is_licensed))
- power = myRegion->powerLimit;
+ if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed))
+ power = newRegion->powerLimit;
if (power == 0)
- power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of myRegion defaults
+ power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of newRegion defaults
// to 0, currently no region has an actual power limit of 0 [dBm] so we can assume regions which have this
// variable set to 0 don't have a valid power limit)
// Set final tx_power back onto config
loraConfig.tx_power = (int8_t)power; // cppcheck-suppress assignmentAddressToInteger
- // Calculate the number of channels
- uint32_t numChannels = floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000)));
+ uint32_t channel_num;
+ float freq;
- // If user has manually specified a channel num, then use that, otherwise generate one by hashing the name
- const char *channelName = channels.getName(channels.getPrimaryIndex());
- // channel_num is actually (channel_num - 1), since modulus (%) returns values from 0 to (numChannels - 1)
- uint32_t channel_num = (loraConfig.channel_num ? loraConfig.channel_num - 1 : hash(channelName)) % numChannels;
+ // Calculate number of frequency slots (aka Channels):
+ // spacing = gap between channels (0 for continuous spectrum) and at the beginning of the band
+ // padding = gap at the beginning and end of the channel (0 for no padding)
+ float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (bw / 1000); // in MHz
+ uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
- // Check if we use the default frequency slot
- RadioInterface::uses_default_frequency_slot =
- 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);
-
- // New frequency selection formula
- float freq = myRegion->freqStart + (bw / 2000) + (channel_num * (bw / 1000));
+ // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one.
+ // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to
+ // (numFreqSlots - 1).
+ uint32_t presetNameHashSlot =
+ hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots;
// override if we have a verbatim frequency
if (loraConfig.override_frequency) {
freq = loraConfig.override_frequency;
channel_num = -1;
+ uses_default_frequency_slot = false;
+ } else {
+
+ // If user has not manually specified a frequency slot, or has not specified one that is different than the default or the
+ // override for the new region, then use the default or override. If the user has not specified one, but has specified a
+ // custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually
+ // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1).
+ // NB: channel_num is also know as frequency slot but it's too late to fix now.
+ if (uses_default_frequency_slot) {
+ // if there's an override slot, use that
+ if (newRegion->profile->overrideSlot != 0) {
+ channel_num = newRegion->profile->overrideSlot - 1;
+ } else {
+ channel_num = presetNameHashSlot;
+ }
+ } else { // use the manually defined one
+ channel_num = loraConfig.channel_num - 1;
+ }
+
+ // Calculate frequency: freqStart is band edge, add half bandwidth (plus optional padding) to get middle of first channel
+ // subsequent channels are spaced by freqSlotWidth
+ freq = newRegion->freqStart + (bw / 2000) + newRegion->profile->padding + (channel_num * freqSlotWidth); // in MHz
}
saveChannelNum(channel_num);
saveFreq(freq + loraConfig.frequency_offset);
+ const char *channelName = channels.getName(channels.getPrimaryIndex());
slotTimeMsec = computeSlotTimeMsec();
preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw);
LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset);
- LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", myRegion->name, channelName, loraConfig.modem_preset,
+ LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", newRegion->name, channelName, loraConfig.modem_preset,
channel_num, power);
- LOG_INFO("myRegion->freqStart -> myRegion->freqEnd: %f -> %f (%f MHz)", myRegion->freqStart, myRegion->freqEnd,
- myRegion->freqEnd - myRegion->freqStart);
- LOG_INFO("numChannels: %d x %.3fkHz", numChannels, bw);
+ LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd,
+ newRegion->freqEnd - newRegion->freqStart);
+ LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw);
+ if (newRegion->profile->overrideSlot != 0) {
+ LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot);
+ }
LOG_INFO("channel_num: %d", channel_num + 1);
LOG_INFO("frequency: %f", getFreq());
LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec);
-}
+} // end of applyModemConfig
/** Slottime is the time to detect a transmission has started, consisting of:
- CAD duration;
@@ -992,4 +1165,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
sendingPacket = p;
return p->encrypted.size + sizeof(PacketHeader);
-}
+}
\ No newline at end of file
diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h
index 05825dce1..a7176f388 100644
--- a/src/mesh/RadioInterface.h
+++ b/src/mesh/RadioInterface.h
@@ -127,7 +127,7 @@ class RadioInterface
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
*/
- static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig);
+ // static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded?
/**
* Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving)
@@ -156,7 +156,7 @@ class RadioInterface
virtual ErrorCode send(meshtastic_MeshPacket *p) = 0;
/** Return TX queue status */
- virtual meshtastic_QueueStatus getQueueStatus()
+ [[nodiscard]] virtual meshtastic_QueueStatus getQueueStatus()
{
meshtastic_QueueStatus qs;
qs.res = qs.mesh_packet_id = qs.free = qs.maxlen = 0;
@@ -182,22 +182,22 @@ class RadioInterface
virtual bool reconfigure();
/** The delay to use for retransmitting dropped packets */
- uint32_t getRetransmissionMsec(const meshtastic_MeshPacket *p);
+ [[nodiscard]] uint32_t getRetransmissionMsec(const meshtastic_MeshPacket *p);
/** The delay to use when we want to send something */
- uint32_t getTxDelayMsec();
+ [[nodiscard]] uint32_t getTxDelayMsec();
/** The CW to use when calculating SNR_based delays */
- uint8_t getCWsize(float snr);
+ [[nodiscard]] uint8_t getCWsize(float snr);
/** The worst-case SNR_based packet delay */
- uint32_t getTxDelayMsecWeightedWorst(float snr);
+ [[nodiscard]] uint32_t getTxDelayMsecWeightedWorst(float snr);
/** Returns true if we should rebroadcast early like a ROUTER */
- bool shouldRebroadcastEarlyLikeRouter(meshtastic_MeshPacket *p);
+ [[nodiscard]] bool shouldRebroadcastEarlyLikeRouter(meshtastic_MeshPacket *p);
/** The delay to use when we want to flood a message. Use a weighted scale based on SNR */
- uint32_t getTxDelayMsecWeighted(meshtastic_MeshPacket *p);
+ [[nodiscard]] uint32_t getTxDelayMsecWeighted(meshtastic_MeshPacket *p);
/** If the packet is not already in the late rebroadcast window, move it there */
virtual void clampToLateRebroadcastWindow(NodeNum from, PacketId id) { return; }
@@ -215,18 +215,18 @@ class RadioInterface
*
* @return num msecs for the packet
*/
- uint32_t getPacketTime(const meshtastic_MeshPacket *p, bool received = false);
- virtual uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) = 0;
+ [[nodiscard]] uint32_t getPacketTime(const meshtastic_MeshPacket *p, bool received = false);
+ [[nodiscard]] virtual uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) = 0;
/**
* Get the channel we saved.
*/
- uint32_t getChannelNum();
+ [[nodiscard]] uint32_t getChannelNum();
/**
* Get the frequency we saved.
*/
- virtual float getFreq();
+ [[nodiscard]] virtual float getFreq();
/// Some boards (1st gen Pinetab Lora module) have broken IRQ wires, so we need to poll via i2c registers
virtual bool isIRQPending() { return false; }
@@ -234,6 +234,20 @@ class RadioInterface
// Whether we use the default frequency slot given our LoRa config (region and modem preset)
static bool uses_default_frequency_slot;
+ // Whether we have a custom channel name
+ static bool uses_custom_channel_name;
+
+ static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
+
+ // Check if a candidate region is compatible and valid.
+ static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig);
+
+ // Check if a candidate radio configuration is valid.
+ static bool validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig);
+
+ // Make a candidate radio configuration valid, even if it isn't.
+ static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig);
+
protected:
int8_t power = 17; // Set by applyModemConfig()
@@ -246,7 +260,7 @@ class RadioInterface
*
* Used as the first step of
*/
- size_t beginSending(meshtastic_MeshPacket *p);
+ [[nodiscard]] size_t beginSending(meshtastic_MeshPacket *p);
/**
* Some regulatory regions limit xmit power.
diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp
index db4b88413..836cd1a22 100644
--- a/src/mesh/Router.cpp
+++ b/src/mesh/Router.cpp
@@ -802,8 +802,32 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
p_encrypted->pki_encrypted = true;
// After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet
if ((decodedState == DecodeState::DECODE_SUCCESS || p_encrypted->pki_encrypted) && moduleConfig.mqtt.enabled &&
- !isFromUs(p) && mqtt)
+ !isFromUs(p) && mqtt) {
+ if (decodedState == DecodeState::DECODE_SUCCESS && p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP &&
+ moduleConfig.mqtt.encryption_enabled) {
+ // For TRACEROUTE_APP packets release the original encrypted packet and encrypt a new from the changed packet
+ // Only release the original after successful allocation to avoid losing an incomplete but valid packet
+ auto *p_encrypted_new = packetPool.allocCopy(*p);
+ if (p_encrypted_new) {
+ auto encodeResult = perhapsEncode(p_encrypted_new);
+ if (encodeResult != meshtastic_Routing_Error_NONE) {
+ // Encryption failed, release the new packet and fall back to sending the original encrypted packet to
+ // MQTT
+ LOG_WARN("Encryption of new TR packet failed, sending original TR to MQTT");
+ packetPool.release(p_encrypted_new);
+ p_encrypted_new = nullptr;
+ } else {
+ // Successfully re-encrypted, release the original encrypted packet and use the new one for MQTT
+ packetPool.release(p_encrypted);
+ p_encrypted = p_encrypted_new;
+ }
+ } else {
+ // Allocation failed, log a warning and fall back to sending the original encrypted packet to MQTT
+ LOG_WARN("Failed to allocate new encrypted packet for TR, sending original TR to MQTT");
+ }
+ }
mqtt->onSend(*p_encrypted, *p, p->channel);
+ }
}
#endif
}
@@ -851,6 +875,12 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
return;
}
+ if (shouldDropPacketForPreHop(*p)) {
+ logHopStartDrop(*p, "pre-hop drop");
+ packetPool.release(p);
+ return;
+ }
+
if (shouldFilterReceived(p)) {
LOG_DEBUG("Incoming msg was filtered from 0x%x", p->from);
packetPool.release(p);
diff --git a/src/mesh/Router.h b/src/mesh/Router.h
index dbb5e5802..0f342d57b 100644
--- a/src/mesh/Router.h
+++ b/src/mesh/Router.h
@@ -58,14 +58,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory
/** Allocate and return a meshpacket which defaults as send to broadcast from the current node.
* The returned packet is guaranteed to have a unique packet ID already assigned
*/
- meshtastic_MeshPacket *allocForSending();
+ [[nodiscard]] meshtastic_MeshPacket *allocForSending();
/** Return Underlying interface's TX queue status */
- meshtastic_QueueStatus getQueueStatus();
+ [[nodiscard]] meshtastic_QueueStatus getQueueStatus();
/**
* @return our local nodenum */
- NodeNum getNodeNum();
+ [[nodiscard]] NodeNum getNodeNum();
/** Wake up the router thread ASAP, because we just queued a message for it.
* FIXME, this is kinda a hack because we don't have a nice way yet to say 'wake us because we are 'blocked on this queue'
diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp
index 4dd90b6e6..a4e82011e 100644
--- a/src/mesh/SX126xInterface.cpp
+++ b/src/mesh/SX126xInterface.cpp
@@ -60,7 +60,7 @@ template bool SX126xInterface::init()
loraFEMInterface.init();
// Apply saved FEM LNA mode from config
if (loraFEMInterface.isLnaCanControl()) {
- loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED);
+ loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
}
#endif
@@ -253,6 +253,11 @@ template bool SX126xInterface::reconfigure()
LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err);
assert(err == RADIOLIB_ERR_NONE);
+ // Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern
+ err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
+ if (err != RADIOLIB_ERR_NONE)
+ LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err);
+
startReceive(); // restart receiving
return RADIOLIB_ERR_NONE;
diff --git a/src/mesh/TransmitHistory.cpp b/src/mesh/TransmitHistory.cpp
index 3dbabc635..b615c307a 100644
--- a/src/mesh/TransmitHistory.cpp
+++ b/src/mesh/TransmitHistory.cpp
@@ -141,12 +141,12 @@ bool TransmitHistory::saveToDisk()
file.write((uint8_t *)&header, sizeof(header));
uint8_t written = 0;
- for (auto &pair : history) {
+ for (const auto &[key, epochSeconds] : history) {
if (written >= MAX_ENTRIES)
break;
Entry entry{};
- entry.key = pair.first;
- entry.epochSeconds = pair.second;
+ entry.key = key;
+ entry.epochSeconds = epochSeconds;
file.write((uint8_t *)&entry, sizeof(entry));
written++;
}
diff --git a/src/mesh/api/ServerAPI.cpp b/src/mesh/api/ServerAPI.cpp
index 7bb1a8108..f3e7854ca 100644
--- a/src/mesh/api/ServerAPI.cpp
+++ b/src/mesh/api/ServerAPI.cpp
@@ -1,7 +1,10 @@
#include "ServerAPI.h"
+#include "Throttle.h"
#include "configuration.h"
#include
+static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL;
+
template
ServerAPI::ServerAPI(T &_client) : StreamAPI(&client), concurrency::OSThread("ServerAPI"), client(_client)
{
@@ -28,6 +31,12 @@ template bool ServerAPI::checkIsConnected()
template int32_t ServerAPI::runOnce()
{
if (client.connected()) {
+ if (lastContactMsec > 0 && !Throttle::isWithinTimespanMs(lastContactMsec, TCP_IDLE_TIMEOUT_MS)) {
+ LOG_WARN("TCP connection timeout, no data for %lu ms", (unsigned long)(millis() - lastContactMsec));
+ close();
+ enabled = false;
+ return 0;
+ }
return StreamAPI::runOncePart();
} else {
LOG_INFO("Client dropped connection, suspend API service");
@@ -57,7 +66,7 @@ template int32_t APIServerPort::runOnce()
#else
auto client = U::available();
#endif
-#elif defined(ARCH_RP2040)
+#elif defined(ARCH_RP2040) || defined(ARCH_NRF52)
auto client = U::accept();
#else
auto client = U::available();
diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp
index 10ff06df2..43ed74cf8 100644
--- a/src/mesh/api/ethServerAPI.cpp
+++ b/src/mesh/api/ethServerAPI.cpp
@@ -17,6 +17,15 @@ void initApiServer(int port)
}
}
+void deInitApiServer()
+{
+ if (apiPort) {
+ LOG_INFO("Deinit API server");
+ delete apiPort;
+ apiPort = nullptr;
+ }
+}
+
ethServerAPI::ethServerAPI(EthernetClient &_client) : ServerAPI(_client)
{
LOG_INFO("Incoming ethernet connection");
diff --git a/src/mesh/api/ethServerAPI.h b/src/mesh/api/ethServerAPI.h
index c616c87be..8f81ee6ff 100644
--- a/src/mesh/api/ethServerAPI.h
+++ b/src/mesh/api/ethServerAPI.h
@@ -24,4 +24,5 @@ class ethServerPort : public APIServerPort
};
void initApiServer(int port = SERVER_API_DEFAULT_PORT);
+void deInitApiServer();
#endif
diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp
index a811ec16c..80741810a 100644
--- a/src/mesh/eth/ethClient.cpp
+++ b/src/mesh/eth/ethClient.cpp
@@ -32,6 +32,69 @@ static Periodic *ethEvent;
static int32_t reconnectETH()
{
if (config.network.eth_enabled) {
+
+ // Detect W5100S chip reset by verifying the MAC address register.
+ // PoE power instability can brownout the W5100S while the MCU keeps running,
+ // causing all chip registers (MAC, IP, sockets) to revert to defaults.
+ uint8_t currentMac[6];
+ Ethernet.MACAddress(currentMac);
+
+ uint8_t expectedMac[6];
+ getMacAddr(expectedMac);
+ expectedMac[0] &= 0xfe;
+
+ if (memcmp(currentMac, expectedMac, 6) != 0) {
+ LOG_WARN("W5100S MAC mismatch (chip reset detected), reinitializing Ethernet");
+
+ syslog.disable();
+#if !MESHTASTIC_EXCLUDE_SOCKETAPI
+ deInitApiServer();
+#endif
+#if HAS_UDP_MULTICAST
+ if (udpHandler) {
+ udpHandler->stop();
+ }
+#endif
+
+ ethStartupComplete = false;
+#ifndef DISABLE_NTP
+ ntp_renew = 0;
+#endif
+
+#ifdef PIN_ETHERNET_RESET
+ pinMode(PIN_ETHERNET_RESET, OUTPUT);
+ digitalWrite(PIN_ETHERNET_RESET, LOW);
+ delay(100);
+ digitalWrite(PIN_ETHERNET_RESET, HIGH);
+ delay(100);
+#endif
+
+#ifdef RAK11310
+ ETH_SPI_PORT.setSCK(PIN_SPI0_SCK);
+ ETH_SPI_PORT.setTX(PIN_SPI0_MOSI);
+ ETH_SPI_PORT.setRX(PIN_SPI0_MISO);
+ ETH_SPI_PORT.begin();
+#endif
+ Ethernet.init(ETH_SPI_PORT, PIN_ETHERNET_SS);
+
+ int status = 0;
+ if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_DHCP) {
+ status = Ethernet.begin(expectedMac);
+ } else if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_STATIC) {
+ Ethernet.begin(expectedMac, config.network.ipv4_config.ip, config.network.ipv4_config.dns,
+ config.network.ipv4_config.gateway, config.network.ipv4_config.subnet);
+ status = 1;
+ }
+
+ if (status == 0) {
+ LOG_ERROR("Ethernet re-initialization failed, will retry");
+ return 5000;
+ }
+
+ LOG_INFO("Ethernet reinitialized - IP %u.%u.%u.%u", Ethernet.localIP()[0], Ethernet.localIP()[1],
+ Ethernet.localIP()[2], Ethernet.localIP()[3]);
+ }
+
Ethernet.maintain();
if (!ethStartupComplete) {
// Start web server
diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp
index d0bd09800..3dcc241d9 100644
--- a/src/mesh/generated/meshtastic/admin.pb.cpp
+++ b/src/mesh/generated/meshtastic/admin.pb.cpp
@@ -39,6 +39,9 @@ PB_BIND(meshtastic_SEN5X_config, meshtastic_SEN5X_config, AUTO)
PB_BIND(meshtastic_SCD30_config, meshtastic_SCD30_config, AUTO)
+PB_BIND(meshtastic_SHTXX_config, meshtastic_SHTXX_config, AUTO)
+
+
diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h
index d529336c8..58e0356ca 100644
--- a/src/mesh/generated/meshtastic/admin.pb.h
+++ b/src/mesh/generated/meshtastic/admin.pb.h
@@ -229,6 +229,12 @@ typedef struct _meshtastic_SCD30_config {
bool soft_reset;
} meshtastic_SCD30_config;
+typedef struct _meshtastic_SHTXX_config {
+ /* Accuracy mode (0 = low, 1 = medium, 2 = high) */
+ bool has_set_accuracy;
+ uint32_t set_accuracy;
+} meshtastic_SHTXX_config;
+
typedef struct _meshtastic_SensorConfig {
/* SCD4X CO2 Sensor configuration */
bool has_scd4x_config;
@@ -239,6 +245,9 @@ typedef struct _meshtastic_SensorConfig {
/* SCD30 CO2 Sensor configuration */
bool has_scd30_config;
meshtastic_SCD30_config scd30_config;
+ /* SHTXX temperature and relative humidity sensor configuration */
+ bool has_shtxx_config;
+ meshtastic_SHTXX_config shtxx_config;
} meshtastic_SensorConfig;
typedef PB_BYTES_ARRAY_T(8) meshtastic_AdminMessage_session_passkey_t;
@@ -427,6 +436,7 @@ extern "C" {
+
/* Initializer values for message structs */
#define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}}
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
@@ -435,10 +445,11 @@ extern "C" {
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
#define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
-#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default}
+#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default}
#define meshtastic_SCD4X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0}
#define meshtastic_SEN5X_config_init_default {false, 0, false, 0}
#define meshtastic_SCD30_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0}
+#define meshtastic_SHTXX_config_init_default {false, 0}
#define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}}
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
@@ -446,10 +457,11 @@ extern "C" {
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
#define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
-#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero}
+#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero}
#define meshtastic_SCD4X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0}
#define meshtastic_SEN5X_config_init_zero {false, 0, false, 0}
#define meshtastic_SCD30_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0}
+#define meshtastic_SHTXX_config_init_zero {false, 0}
/* Field tags (for use in manual encoding/decoding) */
#define meshtastic_AdminMessage_InputEvent_event_code_tag 1
@@ -486,9 +498,11 @@ extern "C" {
#define meshtastic_SCD30_config_set_altitude_tag 4
#define meshtastic_SCD30_config_set_measurement_interval_tag 5
#define meshtastic_SCD30_config_soft_reset_tag 6
+#define meshtastic_SHTXX_config_set_accuracy_tag 1
#define meshtastic_SensorConfig_scd4x_config_tag 1
#define meshtastic_SensorConfig_sen5x_config_tag 2
#define meshtastic_SensorConfig_scd30_config_tag 3
+#define meshtastic_SensorConfig_shtxx_config_tag 4
#define meshtastic_AdminMessage_get_channel_request_tag 1
#define meshtastic_AdminMessage_get_channel_response_tag 2
#define meshtastic_AdminMessage_get_owner_request_tag 3
@@ -679,12 +693,14 @@ X(a, STATIC, OPTIONAL, UINT32, security_number, 4)
#define meshtastic_SensorConfig_FIELDLIST(X, a) \
X(a, STATIC, OPTIONAL, MESSAGE, scd4x_config, 1) \
X(a, STATIC, OPTIONAL, MESSAGE, sen5x_config, 2) \
-X(a, STATIC, OPTIONAL, MESSAGE, scd30_config, 3)
+X(a, STATIC, OPTIONAL, MESSAGE, scd30_config, 3) \
+X(a, STATIC, OPTIONAL, MESSAGE, shtxx_config, 4)
#define meshtastic_SensorConfig_CALLBACK NULL
#define meshtastic_SensorConfig_DEFAULT NULL
#define meshtastic_SensorConfig_scd4x_config_MSGTYPE meshtastic_SCD4X_config
#define meshtastic_SensorConfig_sen5x_config_MSGTYPE meshtastic_SEN5X_config
#define meshtastic_SensorConfig_scd30_config_MSGTYPE meshtastic_SCD30_config
+#define meshtastic_SensorConfig_shtxx_config_MSGTYPE meshtastic_SHTXX_config
#define meshtastic_SCD4X_config_FIELDLIST(X, a) \
X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \
@@ -713,6 +729,11 @@ X(a, STATIC, OPTIONAL, BOOL, soft_reset, 6)
#define meshtastic_SCD30_config_CALLBACK NULL
#define meshtastic_SCD30_config_DEFAULT NULL
+#define meshtastic_SHTXX_config_FIELDLIST(X, a) \
+X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1)
+#define meshtastic_SHTXX_config_CALLBACK NULL
+#define meshtastic_SHTXX_config_DEFAULT NULL
+
extern const pb_msgdesc_t meshtastic_AdminMessage_msg;
extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg;
extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg;
@@ -724,6 +745,7 @@ extern const pb_msgdesc_t meshtastic_SensorConfig_msg;
extern const pb_msgdesc_t meshtastic_SCD4X_config_msg;
extern const pb_msgdesc_t meshtastic_SEN5X_config_msg;
extern const pb_msgdesc_t meshtastic_SCD30_config_msg;
+extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
/* Defines for backwards compatibility with code written before nanopb-0.4.0 */
#define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg
@@ -737,6 +759,7 @@ extern const pb_msgdesc_t meshtastic_SCD30_config_msg;
#define meshtastic_SCD4X_config_fields &meshtastic_SCD4X_config_msg
#define meshtastic_SEN5X_config_fields &meshtastic_SEN5X_config_msg
#define meshtastic_SCD30_config_fields &meshtastic_SCD30_config_msg
+#define meshtastic_SHTXX_config_fields &meshtastic_SHTXX_config_msg
/* Maximum encoded size of messages (where known) */
#define MESHTASTIC_MESHTASTIC_ADMIN_PB_H_MAX_SIZE meshtastic_AdminMessage_size
@@ -749,7 +772,8 @@ extern const pb_msgdesc_t meshtastic_SCD30_config_msg;
#define meshtastic_SCD30_config_size 27
#define meshtastic_SCD4X_config_size 29
#define meshtastic_SEN5X_config_size 7
-#define meshtastic_SensorConfig_size 69
+#define meshtastic_SHTXX_config_size 6
+#define meshtastic_SensorConfig_size 77
#define meshtastic_SharedContact_size 127
#ifdef __cplusplus
diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h
index 773cc8ed6..477c3b31b 100644
--- a/src/mesh/generated/meshtastic/mesh.pb.h
+++ b/src/mesh/generated/meshtastic/mesh.pb.h
@@ -304,6 +304,10 @@ typedef enum _meshtastic_HardwareModel {
meshtastic_HardwareModel_TBEAM_BPF = 124,
/* LilyGo T-Mini E-paper S3 Kit */
meshtastic_HardwareModel_MINI_EPAPER_S3 = 125,
+ /* LilyGo T-Display S3 Pro LR1121 */
+ meshtastic_HardwareModel_TDISPLAY_S3_PRO = 126,
+ /* Heltec Mesh Node T096 board features an nRF52840 CPU and a TFT screen. */
+ meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 = 127,
/* ------------------------------------------------------------------------------------------------------------------------------------------
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
------------------------------------------------------------------------------------------------------------------------------------------ */
diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h
index d31daa4b2..bd1fe48c4 100644
--- a/src/mesh/generated/meshtastic/portnums.pb.h
+++ b/src/mesh/generated/meshtastic/portnums.pb.h
@@ -140,6 +140,9 @@ typedef enum _meshtastic_PortNum {
meshtastic_PortNum_MAP_REPORT_APP = 73,
/* PowerStress based monitoring support (for automated power consumption testing) */
meshtastic_PortNum_POWERSTRESS_APP = 74,
+ /* LoraWAN Payload Transport
+ ENCODING: compact binary LoRaWAN uplink (10-byte RF metadata + PHY payload) - see LoRaWANBridgeModule */
+ meshtastic_PortNum_LORAWAN_BRIDGE = 75,
/* Reticulum Network Stack Tunnel App
ENCODING: Fragmented RNS Packet. Handled by Meshtastic RNS interface */
meshtastic_PortNum_RETICULUM_TUNNEL_APP = 76,
@@ -147,6 +150,10 @@ typedef enum _meshtastic_PortNum {
arbitrary telemetry over meshtastic that is not covered by telemetry.proto
ENCODING: CayenneLLP */
meshtastic_PortNum_CAYENNE_APP = 77,
+ /* GroupAlarm integration
+ Used for transporting GroupAlarm-related messages between Meshtastic nodes
+ and companion applications/services. */
+ meshtastic_PortNum_GROUPALARM_APP = 112,
/* Private applications should use portnums >= 256.
To simplify initial development and testing you can use "PRIVATE_APP"
in your code without needing to rebuild protobuf files (via [regen-protos.sh](https://github.com/meshtastic/firmware/blob/master/bin/regen-protos.sh)) */
diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h
index 8c34cd84c..f48d946a4 100644
--- a/src/mesh/generated/meshtastic/telemetry.pb.h
+++ b/src/mesh/generated/meshtastic/telemetry.pb.h
@@ -26,7 +26,7 @@ typedef enum _meshtastic_TelemetrySensorType {
meshtastic_TelemetrySensorType_INA219 = 5,
/* High accuracy temperature and pressure */
meshtastic_TelemetrySensorType_BMP280 = 6,
- /* High accuracy temperature and humidity */
+ /* TODO - REMOVE High accuracy temperature and humidity */
meshtastic_TelemetrySensorType_SHTC3 = 7,
/* High accuracy pressure */
meshtastic_TelemetrySensorType_LPS22 = 8,
@@ -36,7 +36,7 @@ typedef enum _meshtastic_TelemetrySensorType {
meshtastic_TelemetrySensorType_QMI8658 = 10,
/* 3-Axis magnetic sensor */
meshtastic_TelemetrySensorType_QMC5883L = 11,
- /* High accuracy temperature and humidity */
+ /* TODO - REMOVE High accuracy temperature and humidity */
meshtastic_TelemetrySensorType_SHT31 = 12,
/* PM2.5 air quality sensor */
meshtastic_TelemetrySensorType_PMSA003I = 13,
@@ -46,7 +46,7 @@ typedef enum _meshtastic_TelemetrySensorType {
meshtastic_TelemetrySensorType_BMP085 = 15,
/* RCWL-9620 Doppler Radar Distance Sensor, used for water level detection */
meshtastic_TelemetrySensorType_RCWL9620 = 16,
- /* Sensirion High accuracy temperature and humidity */
+ /* TODO - REMOVE Sensirion High accuracy temperature and humidity */
meshtastic_TelemetrySensorType_SHT4X = 17,
/* VEML7700 high accuracy ambient light(Lux) digital 16-bit resolution sensor. */
meshtastic_TelemetrySensorType_VEML7700 = 18,
@@ -106,12 +106,14 @@ typedef enum _meshtastic_TelemetrySensorType {
meshtastic_TelemetrySensorType_BH1750 = 45,
/* HDC1080 Temperature and Humidity Sensor */
meshtastic_TelemetrySensorType_HDC1080 = 46,
- /* STH21 Temperature and R. Humidity sensor */
+ /* TODO - REMOVE STH21 Temperature and R. Humidity sensor */
meshtastic_TelemetrySensorType_SHT21 = 47,
/* Sensirion STC31 CO2 sensor */
meshtastic_TelemetrySensorType_STC31 = 48,
/* SCD30 CO2, humidity, temperature sensor */
- meshtastic_TelemetrySensorType_SCD30 = 49
+ meshtastic_TelemetrySensorType_SCD30 = 49,
+ /* SHT family of sensors for temperature and humidity */
+ meshtastic_TelemetrySensorType_SHTXX = 50
} meshtastic_TelemetrySensorType;
/* Struct definitions */
@@ -489,8 +491,8 @@ extern "C" {
/* Helper constants for enums */
#define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET
-#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SCD30
-#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SCD30+1))
+#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SHTXX
+#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SHTXX+1))
diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h
index a5b0ef360..493cc5353 100644
--- a/src/mesh/udp/UdpMulticastHandler.h
+++ b/src/mesh/udp/UdpMulticastHandler.h
@@ -69,7 +69,7 @@ class UdpMulticastHandler final
// FIXME(PORTDUINO): arduino lacks IPAddress::toString()
LOG_DEBUG("UDP broadcast from: %s, len=%u", packet.remoteIP().toString().c_str(), packetLength);
#endif
- meshtastic_MeshPacket mp;
+ meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
LOG_DEBUG("Decoding MeshPacket from UDP len=%u", packetLength);
bool isPacketDecoded = pb_decode_from_bytes(packet.data(), packetLength, &meshtastic_MeshPacket_msg, &mp);
if (isPacketDecoded && router && mp.which_payload_variant == meshtastic_MeshPacket_encrypted_tag) {
@@ -79,9 +79,6 @@ class UdpMulticastHandler final
return;
}
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
- mp.pki_encrypted = false;
- mp.public_key.size = 0;
- memset(mp.public_key.bytes, 0, sizeof(mp.public_key.bytes));
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
// Unset received SNR/RSSI
p->rx_snr = 0;
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 6ffde70db..e3a501097 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -24,7 +24,9 @@
#include "Default.h"
#include "MeshRadio.h"
+#include "RadioInterface.h"
#include "TypeConversions.h"
+#include "mesh/RadioLibInterface.h"
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
@@ -197,10 +199,35 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
handleSetOwner(r->set_owner);
break;
- case meshtastic_AdminMessage_set_config_tag:
+ case meshtastic_AdminMessage_set_config_tag: {
LOG_DEBUG("Client set config");
- handleSetConfig(r->set_config);
+
+ // Non-LoRa configs need no further validation.
+ if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) {
+ LOG_DEBUG("Non-LoRa config, applying directly");
+ handleSetConfig(r->set_config, fromOthers);
+ break;
+ }
+
+ // Only LORA_24 requires hardware capability validation.
+ if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
+ LOG_DEBUG("LoRa config, region is not LORA_24, applying directly");
+ handleSetConfig(r->set_config, fromOthers);
+ break;
+ }
+
+ // Hardware supports 2.4 GHz — apply the config.
+ // Fail closed: null instance is treated as incapable.
+ if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) {
+ LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying");
+ handleSetConfig(r->set_config, fromOthers);
+ break;
+ }
+
+ LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region");
+ myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
break;
+ }
case meshtastic_AdminMessage_set_module_config_tag:
LOG_DEBUG("Client set module config");
@@ -626,7 +653,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
}
}
-void AdminModule::handleSetConfig(const meshtastic_Config &c)
+void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
{
auto changes = SEGMENT_CONFIG;
auto existingRole = config.device.role;
@@ -768,18 +795,57 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
validatedLora.spread_factor = LORA_SF_DEFAULT;
}
- // If no lora radio parameters change, don't need to reboot
- if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region &&
- oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth &&
- oldLoraConfig.spread_factor == validatedLora.spread_factor &&
- oldLoraConfig.coding_rate == validatedLora.coding_rate && oldLoraConfig.tx_power == validatedLora.tx_power &&
- oldLoraConfig.frequency_offset == validatedLora.frequency_offset &&
- oldLoraConfig.override_frequency == validatedLora.override_frequency &&
- oldLoraConfig.channel_num == validatedLora.channel_num &&
- oldLoraConfig.sx126x_rx_boosted_gain == validatedLora.sx126x_rx_boosted_gain) {
- requiresReboot = false;
+ // If we're setting a new region, check the region is valid and then init the region or discard the change
+ if (validatedLora.region != myRegion->code) {
+ // Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid
+ if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) {
+ // If we're setting region for the first time, init the region and regenerate the keys
+ if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
+#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
+ if (crypto) {
+ crypto->ensurePkiKeys(config.security, owner);
+ }
+#endif
+ // new region is valid and we're coming from an unset region, so enable tx
+ validatedLora.tx_enabled = true;
+ }
+ // If we're unsetting the region for some reason, disable tx
+ if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
+ validatedLora.tx_enabled = false;
+ }
+ // Ensure initRegion() uses the newly validated region
+ config.lora.region = validatedLora.region;
+ initRegion();
+ if (myRegion->dutyCycle < 100) {
+ validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
+ }
+ if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
+ // Default root is in use, so subscribe to the appropriate MQTT topic for this region
+ sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
+ }
+ changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
+ } else {
+ // Region validation has failed, so just copy all of the old config over the new config
+ validatedLora = oldLoraConfig;
+ }
+ } // end of new region handling
+
+ if (!RadioInterface::validateConfigLora(validatedLora)) {
+ if (fromOthers) {
+ LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
+ // modem_preset set to use the old setting if the check fails
+ validatedLora.modem_preset = oldLoraConfig.modem_preset;
+ } else {
+ LOG_WARN("Invalid LoRa config received from client, using corrected values");
+ RadioInterface::clampConfigLora(validatedLora);
+ }
+ // use_preset and bandwidth are coerced into valid values by the check.
}
+ // All LoRa radio changes apply live via configChanged observer → reconfigure().
+ // reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive.
+ requiresReboot = false;
+
#if defined(ARCH_PORTDUINO)
// If running on portduino and using SimRadio, do not require reboot
if (SimRadio::instance) {
@@ -795,63 +861,21 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
digitalWrite(RF95_FAN_EN, HIGH ^ 0);
}
#endif
- config.lora = validatedLora;
#if HAS_LORA_FEM
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
+ // Note that a rejected lora config will revert this as well.
if (loraFEMInterface.isLnaCanControl()) {
- loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED);
- } else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
+ loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
+ } else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT");
- config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
+ validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
}
#endif
- // If we're setting region for the first time, init the region and regenerate the keys
- if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
-#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
- if (!owner.is_licensed) {
- bool keygenSuccess = false;
- if (config.security.private_key.size == 32) {
- if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
- keygenSuccess = true;
- }
- } else {
- LOG_INFO("Generate new PKI keys");
- crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
- keygenSuccess = true;
- }
- if (keygenSuccess) {
- config.security.public_key.size = 32;
- config.security.private_key.size = 32;
- owner.public_key.size = 32;
- memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
- }
- }
-#endif
- config.lora.tx_enabled = true;
- initRegion();
- if (myRegion->dutyCycle < 100) {
- config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
- }
- // Compare the entire string, we are sure of the length as a topic has never been set
- if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) {
- sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
- changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
- }
- }
- if (config.lora.region != myRegion->code) {
- // Region has changed so check whether there is a regulatory one we should be using instead.
- // Additionally as a side-effect, assume a new value under myRegion
- initRegion();
- if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
- // Default root is in use, so subscribe to the appropriate MQTT topic for this region
- sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
- }
+ config.lora = validatedLora; // Finally, return the validated config back to the main config
- changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
- }
break;
}
case meshtastic_Config_bluetooth_tag:
@@ -899,10 +923,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
}
if (requiresReboot && !hasOpenEditTransaction) {
disableBluetooth();
- }
+ } // end of switch case which_payload_variant
saveChanges(changes, requiresReboot);
-}
+} // end of handleSetConfig
bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
{
@@ -1127,83 +1151,85 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
if (req.decoded.want_response) {
+ const char *configName = "?";
switch (configType) {
case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG:
- LOG_INFO("Get module config: MQTT");
+ configName = "MQTT";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
break;
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
- LOG_INFO("Get module config: Serial");
+ configName = "Serial";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_serial_tag;
res.get_module_config_response.payload_variant.serial = moduleConfig.serial;
break;
case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG:
- LOG_INFO("Get module config: External Notification");
+ configName = "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;
break;
case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG:
- LOG_INFO("Get module config: Store & Forward");
+ configName = "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;
break;
case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG:
- LOG_INFO("Get module config: Range Test");
+ configName = "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;
break;
case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG:
- LOG_INFO("Get module config: Telemetry");
+ configName = "Telemetry";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag;
res.get_module_config_response.payload_variant.telemetry = moduleConfig.telemetry;
break;
case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG:
- LOG_INFO("Get module config: Canned Message");
+ configName = "Canned Message";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag;
res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message;
break;
case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG:
- LOG_INFO("Get module config: Audio");
+ configName = "Audio";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag;
res.get_module_config_response.payload_variant.audio = moduleConfig.audio;
break;
case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG:
- LOG_INFO("Get module config: Remote Hardware");
+ configName = "Remote Hardware";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag;
res.get_module_config_response.payload_variant.remote_hardware = moduleConfig.remote_hardware;
break;
case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG:
- LOG_INFO("Get module config: Neighbor Info");
+ configName = "Neighbor Info";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag;
res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info;
break;
case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG:
- LOG_INFO("Get module config: Detection Sensor");
+ configName = "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;
break;
case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG:
- LOG_INFO("Get module config: Ambient Lighting");
+ configName = "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;
break;
case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG:
- LOG_INFO("Get module config: Paxcounter");
+ configName = "Paxcounter";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter;
break;
case meshtastic_AdminMessage_ModuleConfigType_STATUSMESSAGE_CONFIG:
- LOG_INFO("Get module config: StatusMessage");
+ configName = "StatusMessage";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag;
res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage;
break;
case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG:
- LOG_INFO("Get module config: Traffic Management");
+ configName = "Traffic Management";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
break;
}
+ LOG_INFO("Get module config: %s", configName);
// NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior.
// So even if we internally use 0 to represent 'use default' we still need to send the value we are
@@ -1386,23 +1412,17 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic
void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
{
// Validate ham parameters before setting since this would bypass validation in the owner struct
- if (*p.call_sign) {
- const char *start = p.call_sign;
- // Skip all whitespace
- while (*start && isspace((unsigned char)*start))
- start++;
- if (*start == '\0') {
- LOG_WARN("Rejected ham call_sign: must contain at least 1 non-whitespace character");
- return;
- }
- }
- if (*p.short_name) {
- const char *start = p.short_name;
- while (*start && isspace((unsigned char)*start))
- start++;
- if (*start == '\0') {
- LOG_WARN("Rejected ham short_name: must contain at least 1 non-whitespace character");
- return;
+ const char *fieldsToCheck[] = {p.call_sign, p.short_name};
+ const char *fieldNames[] = {"call_sign", "short_name"};
+ for (int i = 0; i < 2; i++) {
+ if (*fieldsToCheck[i]) {
+ const char *start = fieldsToCheck[i];
+ while (*start && isspace((unsigned char)*start))
+ start++;
+ if (*start == '\0') {
+ LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]);
+ return;
+ }
}
}
diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h
index c446887b3..5c690abbd 100644
--- a/src/modules/AdminModule.h
+++ b/src/modules/AdminModule.h
@@ -60,7 +60,11 @@ class AdminModule : public ProtobufModule, public Obser
*/
void handleSetOwner(const meshtastic_User &o);
void handleSetChannel(const meshtastic_Channel &cc);
- void handleSetConfig(const meshtastic_Config &c);
+
+ protected:
+ void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
+
+ private:
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
void handleSetChannel();
void handleSetHamMode(const meshtastic_HamParameters &req);
diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp
index ae25de0cb..65e903134 100644
--- a/src/modules/CannedMessageModule.cpp
+++ b/src/modules/CannedMessageModule.cpp
@@ -13,10 +13,12 @@
#include "buzz.h"
#include "detect/ScanI2C.h"
#include "gps/RTC.h"
+#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/draw/MessageRenderer.h"
#include "graphics/draw/NotificationRenderer.h"
+#include "graphics/draw/UIRenderer.h"
#include "graphics/emotes.h"
#include "graphics/images.h"
#include "input/SerialKeyboard.h"
@@ -45,71 +47,6 @@ extern MessageStore messageStore;
// Remove Canned message screen if no action is taken for some milliseconds
#define INACTIVATE_AFTER_MS 20000
-// Tokenize a message string into emote/text segments
-static std::vector> tokenizeMessageWithEmotes(const char *msg)
-{
- std::vector> tokens;
- int msgLen = strlen(msg);
- int pos = 0;
- while (pos < msgLen) {
- const graphics::Emote *foundEmote = nullptr;
- int foundLen = 0;
- for (int j = 0; j < graphics::numEmotes; j++) {
- const char *label = graphics::emotes[j].label;
- int labelLen = strlen(label);
- if (labelLen == 0)
- continue;
- if (strncmp(msg + pos, label, labelLen) == 0) {
- if (!foundEmote || labelLen > foundLen) {
- foundEmote = &graphics::emotes[j];
- foundLen = labelLen;
- }
- }
- }
- if (foundEmote) {
- tokens.emplace_back(true, String(foundEmote->label));
- pos += foundLen;
- } else {
- // Find next emote
- int nextEmote = msgLen;
- for (int j = 0; j < graphics::numEmotes; j++) {
- const char *label = graphics::emotes[j].label;
- if (!label || !*label)
- continue;
- const char *found = strstr(msg + pos, label);
- if (found && (found - msg) < nextEmote) {
- nextEmote = found - msg;
- }
- }
- int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos);
- if (textLen > 0) {
- tokens.emplace_back(false, String(msg + pos).substring(0, textLen));
- pos += textLen;
- } else {
- break;
- }
- }
- }
- return tokens;
-}
-
-// Render a single emote token centered vertically on a row
-static void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label)
-{
- const graphics::Emote *emote = nullptr;
- for (int j = 0; j < graphics::numEmotes; j++) {
- if (label == graphics::emotes[j].label) {
- emote = &graphics::emotes[j];
- break;
- }
- }
- if (emote) {
- int emoteYOffset = (rowHeight - emote->height) / 2; // vertically center the emote
- display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap);
- nextX += emote->width + 2; // spacing between tokens
- }
-}
-
namespace graphics
{
extern int bannerSignalBars;
@@ -264,19 +201,20 @@ int CannedMessageModule::splitConfiguredMessages()
}
void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer)
{
- if (graphics::currentResolution == graphics::ScreenResolution::High) {
- if (this->dest == NODENUM_BROADCAST) {
- display->drawStringf(x, y, buffer, "To: #%s", channels.getName(this->channel));
- } else {
- display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest));
- }
+ (void)buffer;
+
+ char header[96];
+ if (this->dest == NODENUM_BROADCAST) {
+ const char *channelName = channels.getName(this->channel);
+ snprintf(header, sizeof(header), "To: #%s", channelName ? channelName : "?");
} else {
- if (this->dest == NODENUM_BROADCAST) {
- display->drawStringf(x, y, buffer, "To: #%.20s", channels.getName(this->channel));
- } else {
- display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest));
- }
+ snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest));
}
+
+ const int maxWidth = std::max(0, display->getWidth() - x);
+ char truncatedHeader[96];
+ graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth);
+ graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false);
}
void CannedMessageModule::resetSearch()
@@ -370,6 +308,92 @@ bool CannedMessageModule::isCharInputAllowed() const
{
return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
}
+
+static int getRowHeightForEmoteText(const char *text, int minimumHeight, int emoteSpacing = 2)
+{
+ // Grow the row only when an emote is taller than the font.
+ const auto metrics =
+ graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", 0, graphics::emotes, graphics::numEmotes, emoteSpacing);
+ return std::max(minimumHeight, metrics.tallestHeight + 2);
+}
+
+static void drawCenteredEmoteText(OLEDDisplay *display, int x, int y, int rowHeight, const char *text, int emoteSpacing = 2)
+{
+ // Center mixed text and emotes inside the row height.
+ const auto metrics = graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes,
+ graphics::numEmotes, emoteSpacing);
+ const int contentHeight = std::max(FONT_HEIGHT_SMALL, metrics.tallestHeight);
+ const int drawY = y + ((rowHeight - contentHeight) / 2);
+ graphics::EmoteRenderer::drawStringWithEmotes(display, x, drawY, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes,
+ graphics::numEmotes, emoteSpacing, false);
+}
+
+static size_t firstWrappedTokenLen(const char *text)
+{
+ // Fall back to one full emote or one UTF-8 glyph when width is tiny.
+ if (!text || !*text)
+ return 0;
+
+ const size_t textLen = strlen(text);
+ size_t matchLen = 0;
+ if (graphics::EmoteRenderer::findEmoteAt(text, textLen, 0, matchLen, graphics::emotes, graphics::numEmotes))
+ return matchLen;
+
+ return graphics::EmoteRenderer::utf8CharLen(static_cast(text[0]));
+}
+
+static void drawWrappedEmoteText(OLEDDisplay *display, int x, int y, const char *text, int maxWidth, int minimumRowHeight,
+ int emoteSpacing = 2)
+{
+ // Wrap onto multiple rows without splitting emotes.
+ if (!display || !text || maxWidth <= 0)
+ return;
+
+ constexpr size_t kLineBufferSize = 256;
+ char lineBuffer[kLineBufferSize];
+ const size_t textLen = strlen(text);
+ size_t offset = 0;
+ int yCursor = y;
+
+ while (offset < textLen) {
+ size_t copied = graphics::EmoteRenderer::truncateToWidth(display, text + offset, lineBuffer, sizeof(lineBuffer), maxWidth,
+ "", graphics::emotes, graphics::numEmotes, emoteSpacing);
+ size_t consumed = copied;
+
+ if (copied == 0) {
+ consumed = firstWrappedTokenLen(text + offset);
+ if (consumed == 0)
+ break;
+
+ const size_t fallbackLen = std::min(consumed, sizeof(lineBuffer) - 1);
+ memcpy(lineBuffer, text + offset, fallbackLen);
+ lineBuffer[fallbackLen] = '\0';
+ consumed = fallbackLen;
+ } else if (text[offset + copied] != '\0') {
+ // Prefer wrapping at the last space when a full line does not fit.
+ size_t lastSpace = copied;
+ while (lastSpace > 0 && lineBuffer[lastSpace - 1] != ' ')
+ --lastSpace;
+
+ if (lastSpace > 0) {
+ consumed = lastSpace;
+ while (consumed > 0 && lineBuffer[consumed - 1] == ' ')
+ --consumed;
+ lineBuffer[consumed] = '\0';
+ }
+ }
+
+ if (lineBuffer[0]) {
+ const int rowHeight = getRowHeightForEmoteText(lineBuffer, minimumRowHeight, emoteSpacing);
+ drawCenteredEmoteText(display, x, yCursor, rowHeight, lineBuffer, emoteSpacing);
+ yCursor += rowHeight;
+ }
+
+ offset += std::max(consumed, 1);
+ while (offset < textLen && text[offset] == ' ')
+ ++offset;
+ }
+}
/**
* Main input event dispatcher for CannedMessageModule.
* Routes keyboard/button/touch input to the correct handler based on the current runState.
@@ -491,18 +515,16 @@ bool CannedMessageModule::handleTabSwitch(const InputEvent *event)
if (event->kbchar != 0x09)
return false;
- updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
- : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION);
+ const cannedMessageModuleRunState targetState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
+ ? CANNED_MESSAGE_RUN_STATE_FREETEXT
+ : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
destIndex = 0;
scrollIndex = 0;
- // RESTORE THIS!
- if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
+ if (targetState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
updateDestinationSelectionList();
- updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
- : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION,
- true);
+ updateState(targetState, true);
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
@@ -1686,55 +1708,51 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
int xOffset = 0;
int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset;
- char entryText[64] = "";
+ std::string entryText;
// Draw Channels First
if (itemIndex < numActiveChannels) {
uint8_t channelIndex = this->activeChannelIndices[itemIndex];
- snprintf(entryText, sizeof(entryText), "#%s", channels.getName(channelIndex));
+ const char *channelName = channels.getName(channelIndex);
+ entryText = std::string("#") + (channelName ? channelName : "?");
}
// Then Draw Nodes
else {
int nodeIndex = itemIndex - numActiveChannels;
if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) {
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
- if (node && node->user.long_name) {
- strncpy(entryText, node->user.long_name, sizeof(entryText) - 1);
- entryText[sizeof(entryText) - 1] = '\0';
+ if (node) {
+ if (display->getWidth() <= 64) {
+ entryText = node->user.short_name;
+ } else if (node->user.long_name[0]) {
+ entryText = node->user.long_name;
+ } else {
+ entryText = node->user.short_name;
+ }
}
+
int availWidth = display->getWidth() -
((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) -
((node && node->is_favorite) ? 10 : 0);
if (availWidth < 0)
availWidth = 0;
-
- size_t origLen = strlen(entryText);
- while (entryText[0] && display->getStringWidth(entryText) > availWidth) {
- entryText[strlen(entryText) - 1] = '\0';
- }
- if (strlen(entryText) < origLen) {
- strcat(entryText, "...");
- }
+ char truncatedEntry[96];
+ graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
+ availWidth);
+ entryText = truncatedEntry;
// Prepend "* " if this is a favorite
if (node && node->is_favorite) {
- size_t len = strlen(entryText);
- if (len + 2 < sizeof(entryText)) {
- memmove(entryText + 2, entryText, len + 1);
- entryText[0] = '*';
- entryText[1] = ' ';
- }
- }
- if (node) {
- if (display->getWidth() <= 64) {
- snprintf(entryText, sizeof(entryText), "%s", node->user.short_name);
- }
+ entryText = "* " + entryText;
}
+ graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
+ availWidth);
+ entryText = truncatedEntry;
}
}
- if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0)
- strcpy(entryText, "?");
+ if (entryText.empty() || entryText == "Unknown")
+ entryText = "?";
// Highlight background (if selected)
if (itemIndex == destIndex) {
@@ -1744,7 +1762,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
}
// Draw entry text
- display->drawString(xOffset + 2, yOffset, entryText);
+ graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false);
display->setColor(WHITE);
// Draw key icon (after highlight)
@@ -1785,15 +1803,10 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
{
const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height
const int headerMargin = 2; // Extra pixels below header
- const int labelGap = 6;
+ const int labelGap = 4;
const int bitmapGapX = 4;
- // Find max emote height (assume all same, or precalculated)
- int maxEmoteHeight = 0;
- for (int i = 0; i < graphics::numEmotes; ++i)
- if (graphics::emotes[i].height > maxEmoteHeight)
- maxEmoteHeight = graphics::emotes[i].height;
-
+ const int maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight();
const int rowHeight = maxEmoteHeight + 2;
// Place header at top, then compute start of emote list
@@ -1840,14 +1853,16 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
display->setColor(BLACK);
}
- // Emote bitmap (left), 1px margin from highlight bar top
- int emoteY = rowY + 1;
- display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap);
+ // Emote bitmap (left), centered inside the row
+ int labelStartX = x + bitmapGapX;
+ const int emoteY = rowY + ((rowHeight - emote.height) / 2);
+ display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap);
+ labelStartX += emote.width;
// Emote label (right of bitmap)
display->setFont(FONT_MEDIUM);
int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2);
- display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label);
+ display->drawString(labelStartX + labelGap, labelY, emote.label);
if (emoteIdx == emotePickerIndex)
display->setColor(WHITE);
@@ -2007,91 +2022,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
{
int inputY = 0 + y + FONT_HEIGHT_SMALL;
String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor);
-
- // Tokenize input into (isEmote, token) pairs
- const char *msg = msgWithCursor.c_str();
- std::vector> tokens = tokenizeMessageWithEmotes(msg);
-
- // Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed)
- std::vector>> lines;
- std::vector> currentLine;
- int lineWidth = 0;
- int maxWidth = display->getWidth();
- for (auto &token : tokens) {
- if (token.first) {
- // Emote
- int tokenWidth = 0;
- for (int j = 0; j < graphics::numEmotes; j++) {
- if (token.second == graphics::emotes[j].label) {
- tokenWidth = graphics::emotes[j].width + 2;
- break;
- }
- }
- if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) {
- lines.push_back(currentLine);
- currentLine.clear();
- lineWidth = 0;
- }
- currentLine.push_back(token);
- lineWidth += tokenWidth;
- } else {
- // Text: split by words and wrap inside word if needed
- String text = token.second;
- int pos = 0;
- while (pos < static_cast(text.length())) {
- // Find next space (or end)
- int spacePos = text.indexOf(' ', pos);
- int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space
- String word = text.substring(pos, endPos);
- int wordWidth = display->getStringWidth(word);
-
- if (lineWidth + wordWidth > maxWidth && lineWidth > 0) {
- lines.push_back(currentLine);
- currentLine.clear();
- lineWidth = 0;
- }
- // If word itself too big, split by character
- if (wordWidth > maxWidth) {
- uint16_t charPos = 0;
- while (charPos < word.length()) {
- String oneChar = word.substring(charPos, charPos + 1);
- int charWidth = display->getStringWidth(oneChar);
- if (lineWidth + charWidth > maxWidth && lineWidth > 0) {
- lines.push_back(currentLine);
- currentLine.clear();
- lineWidth = 0;
- }
- currentLine.push_back({false, oneChar});
- lineWidth += charWidth;
- charPos++;
- }
- } else {
- currentLine.push_back({false, word});
- lineWidth += wordWidth;
- }
- pos = endPos;
- }
- }
- }
- if (!currentLine.empty())
- lines.push_back(currentLine);
-
- // Draw lines with emotes
- int rowHeight = FONT_HEIGHT_SMALL;
- int yLine = inputY;
- for (const auto &line : lines) {
- int nextX = x;
- for (const auto &token : line) {
- if (token.first) {
- // Emote rendering centralized in helper
- renderEmote(display, nextX, yLine, rowHeight, token.second);
- } else {
- display->drawString(nextX, yLine, token.second);
- nextX += display->getStringWidth(token.second);
- }
- }
- yLine += rowHeight;
- }
+ drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL);
}
#endif
return;
@@ -2106,7 +2037,6 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
const int baseRowSpacing = FONT_HEIGHT_SMALL - 4;
int topMsg;
- std::vector rowHeights;
int _visibleRows;
// Draw header (To: ...)
@@ -2122,36 +2052,15 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
: 0;
int countRows = std::min(messagesCount, _visibleRows);
- // Build per-row max height based on all emotes in line
- for (int i = 0; i < countRows; i++) {
- const char *msg = getMessageByIndex(topMsg + i);
- int maxEmoteHeight = 0;
- for (int j = 0; j < graphics::numEmotes; j++) {
- const char *label = graphics::emotes[j].label;
- if (!label || !*label)
- continue;
- const char *search = msg;
- while ((search = strstr(search, label))) {
- if (graphics::emotes[j].height > maxEmoteHeight)
- maxEmoteHeight = graphics::emotes[j].height;
- search += strlen(label); // Advance past this emote
- }
- }
- rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2));
- }
-
// Draw all message rows with multi-emote support
int yCursor = listYOffset;
for (int vis = 0; vis < countRows; vis++) {
int msgIdx = topMsg + vis;
int lineY = yCursor;
const char *msg = getMessageByIndex(msgIdx);
- int rowHeight = rowHeights[vis];
+ int rowHeight = getRowHeightForEmoteText(msg, baseRowSpacing);
bool _highlight = (msgIdx == currentMessageIndex);
- // Multi-emote tokenization
- std::vector> tokens = tokenizeMessageWithEmotes(msg);
-
// Vertically center based on rowHeight
int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2;
@@ -2168,17 +2077,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
int nextX = x + (_highlight ? 2 : 0);
#endif
- // Draw all tokens left to right
- for (const auto &token : tokens) {
- if (token.first) {
- // Emote rendering centralized in helper
- renderEmote(display, nextX, lineY, rowHeight, token.second);
- } else {
- // Text
- display->drawString(nextX, lineY + textYOffset, token.second);
- nextX += display->getStringWidth(token.second);
- }
- }
+ if (msg && *msg)
+ drawCenteredEmoteText(display, nextX, lineY, rowHeight, msg);
#ifndef USE_EINK
if (_highlight)
display->setColor(WHITE);
diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp
index 79bda557c..f41fafdee 100644
--- a/src/modules/NodeInfoModule.cpp
+++ b/src/modules/NodeInfoModule.cpp
@@ -30,7 +30,8 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
auto p = *pptr;
- if (mp.decoded.want_response) {
+ // Suppress replies to senders we've replied to recently (12H window)
+ if (mp.decoded.want_response && !isFromUs(&mp)) {
const NodeNum sender = getFrom(&mp);
const uint32_t now = mp.rx_time ? mp.rx_time : getTime();
auto it = lastNodeInfoSeen.find(sender);
@@ -119,7 +120,13 @@ void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha
meshtastic_MeshPacket *NodeInfoModule::allocReply()
{
- if (suppressReplyForCurrentRequest) {
+ // Only apply suppression when actually replying to someone else's request, not for periodic broadcasts.
+ const bool isReplyingToExternalRequest = currentRequest &&
+ currentRequest->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
+ currentRequest->decoded.portnum == meshtastic_PortNum_NODEINFO_APP &&
+ currentRequest->decoded.want_response && !isFromUs(currentRequest);
+
+ if (suppressReplyForCurrentRequest && isReplyingToExternalRequest) {
LOG_DEBUG("Skip send NodeInfo since we heard the requester <12h ago");
ignoreRequest = true;
suppressReplyForCurrentRequest = false;
diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp
index e9a21a22b..0378d01e7 100644
--- a/src/modules/PositionModule.cpp
+++ b/src/modules/PositionModule.cpp
@@ -467,7 +467,11 @@ int32_t PositionModule::runOnce()
if (smartPosition.hasTraveledOverThreshold &&
Throttle::execute(
&lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); },
- []() { LOG_DEBUG("Skip send smart broadcast due to time throttling"); })) {
+ []() {
+#ifdef GPS_DEBUG
+ LOG_DEBUG("Skip send smart broadcast due to time throttling");
+#endif
+ })) {
LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, "
"minTimeInterval=%ims)",
@@ -559,7 +563,11 @@ void PositionModule::handleNewPosition()
if (smartPosition.hasTraveledOverThreshold &&
Throttle::execute(
&lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); },
- []() { LOG_DEBUG("Skip send smart broadcast due to time throttling"); })) {
+ []() {
+#ifdef GPS_DEBUG
+ LOG_DEBUG("Skip send smart broadcast due to time throttling");
+#endif
+ })) {
LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, "
"minTimeInterval=%ims)",
localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend,
diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp
index d87cf3a44..85e7f8c06 100644
--- a/src/modules/RoutingModule.cpp
+++ b/src/modules/RoutingModule.cpp
@@ -20,10 +20,11 @@ bool RoutingModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mesh
if ((nodeDB->getMeshNode(mp.from) == NULL || !nodeDB->getMeshNode(mp.from)->has_user) &&
(nodeDB->getMeshNode(mp.to) == NULL || !nodeDB->getMeshNode(mp.to)->has_user))
return false;
- } else if (owner.is_licensed && nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) {
- // Don't let licensed users to rebroadcast packets from unlicensed users
+ } else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) ||
+ (nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) {
+ // Don't let licensed users to rebroadcast packets to or from unlicensed users
// If we know they are in-fact unlicensed
- LOG_DEBUG("Packet from unlicensed user, ignoring packet");
+ LOG_DEBUG("Packet to or from unlicensed user, ignoring packet");
return false;
}
diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp
index 77cc94359..37e3e9184 100644
--- a/src/modules/esp32/AudioModule.cpp
+++ b/src/modules/esp32/AudioModule.cpp
@@ -100,7 +100,7 @@ AudioModule::AudioModule() : SinglePortModule("Audio", meshtastic_PortNum_AUDIO_
// moduleConfig.audio.i2s_sck = 14;
// moduleConfig.audio.ptt_pin = 39;
- if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) {
+ if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
LOG_INFO("Set up codec2 in mode %u", (moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1);
codec2 = codec2_create((moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1);
memcpy(tx_header.magic, c2_magic, sizeof(c2_magic));
@@ -143,7 +143,7 @@ void AudioModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
int32_t AudioModule::runOnce()
{
- if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) {
+ if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
esp_err_t res;
if (firstTime) {
// Set up I2S Processor configuration. This will produce 16bit samples at 8 kHz instead of 12 from the ADC
@@ -270,7 +270,7 @@ void AudioModule::sendPayload(NodeNum dest, bool wantReplies)
ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp)
{
- if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) {
+ if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
auto &p = mp.decoded;
if (!isFromUs(&mp)) {
memcpy(rx_encode_frame, p.payload.bytes, p.payload.size);
diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp
index 32b637e81..ac022a1ab 100644
--- a/src/mqtt/MQTT.cpp
+++ b/src/mqtt/MQTT.cpp
@@ -300,7 +300,9 @@ struct PubSubConfig {
if (config.tls_enabled) {
serverPort = 8883;
}
- std::tie(serverAddr, serverPort) = parseHostAndPort(serverAddr.c_str(), serverPort);
+ auto [parsedServerAddr, parsedServerPort] = parseHostAndPort(serverAddr.c_str(), serverPort);
+ serverAddr = std::move(parsedServerAddr);
+ serverPort = parsedServerPort;
}
// Defaults
@@ -441,7 +443,8 @@ MQTT::MQTT() : concurrency::OSThread("mqtt"), mqttQueue(MAX_MQTT_QUEUE)
moduleConfig.mqtt.map_report_settings.publish_interval_secs, default_map_publish_interval_secs);
}
- String host = parseHostAndPort(moduleConfig.mqtt.address).first;
+ auto [host, parsedPort] = parseHostAndPort(moduleConfig.mqtt.address);
+ (void)parsedPort;
isConfiguredForDefaultServer = isDefaultServer(host);
IPAddress ip;
isMqttServerAddressPrivate = ip.fromString(host.c_str()) && isPrivateIpAddress(ip);
@@ -648,22 +651,34 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
if (config.enabled && !config.proxy_to_client_enabled) {
#if HAS_NETWORKING
- std::unique_ptr clientConnection;
if (config.tls_enabled) {
-#if MQTT_SUPPORTS_TLS
- MQTTClientTLS *tlsClient = new MQTTClientTLS;
- clientConnection.reset(tlsClient);
- tlsClient->setInsecure();
-#else
+#if !MQTT_SUPPORTS_TLS
LOG_ERROR("Invalid MQTT config: tls_enabled is not supported on this node");
return false;
#endif
- } else {
- clientConnection.reset(new MQTTClient);
}
- std::unique_ptr pubSub(new PubSubClient);
+ // Perform a lightweight TCP connectivity check without using connectPubSub(),
+ // which mutates the module's isConnected state. This only checks if the server
+ // is reachable — it does not establish an MQTT session.
+ // Settings are always saved regardless of the result.
if (isConnectedToNetwork()) {
- return connectPubSub(parsed, *pubSub, (client != nullptr) ? *client : *clientConnection);
+ MQTTClient testClient;
+ if (!testClient.connect(parsed.serverAddr.c_str(), parsed.serverPort)) {
+ const char *warning = "Could not reach the MQTT server. Settings will be saved, but please verify the server "
+ "address and credentials.";
+ LOG_WARN(warning);
+#if !IS_RUNNING_TESTS
+ meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
+ if (cn) {
+ cn->level = meshtastic_LogRecord_Level_WARNING;
+ cn->time = getValidTime(RTCQualityFromNet);
+ strncpy(cn->message, warning, sizeof(cn->message) - 1);
+ cn->message[sizeof(cn->message) - 1] = '\0';
+ service->sendClientNotification(cn);
+ }
+#endif
+ }
+ testClient.stop();
}
#else
const char *warning = "Invalid MQTT config: proxy_to_client_enabled must be enabled on nodes that do not have a network";
diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h
index f73a5b896..eafd799fc 100644
--- a/src/platform/nrf52/architecture.h
+++ b/src/platform/nrf52/architecture.h
@@ -157,7 +157,7 @@
#endif
-#ifdef PIN_LED1
+#if defined(PIN_LED1) && !defined(LED_POWER)
#define LED_POWER PIN_LED1 // LED1 on nrf52840-DK
#endif
diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp
new file mode 100644
index 000000000..9906bb94c
--- /dev/null
+++ b/test/test_admin_radio/test_main.cpp
@@ -0,0 +1,814 @@
+/**
+ * Tests for the radio configuration validation and clamping functions
+ * introduced in the radio_interface_cherrypick branch.
+ *
+ * Targets:
+ * 1. getRegion()
+ * 2. RadioInterface::validateConfigRegion()
+ * 3. RadioInterface::validateConfigLora()
+ * 4. RadioInterface::clampConfigLora()
+ * 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF)
+ * 6. Channel spacing calculation (placeholder for future protobuf changes)
+ */
+
+#include "MeshRadio.h"
+#include "MeshService.h"
+#include "NodeDB.h"
+#include "RadioInterface.h"
+#include "TestUtil.h"
+#include "modules/AdminModule.h"
+#include
+
+#include "meshtastic/config.pb.h"
+
+class MockMeshService : public MeshService
+{
+ public:
+ void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
+};
+
+static MockMeshService *mockMeshService;
+
+// -----------------------------------------------------------------------
+// getRegion() tests
+// -----------------------------------------------------------------------
+extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
+
+static void test_getRegion_returnsCorrectRegion_US()
+{
+ const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ TEST_ASSERT_NOT_NULL(r);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code);
+ TEST_ASSERT_EQUAL_STRING("US", r->name);
+}
+
+static void test_getRegion_returnsCorrectRegion_EU868()
+{
+ const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_NOT_NULL(r);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code);
+ TEST_ASSERT_EQUAL_STRING("EU_868", r->name);
+}
+
+static void test_getRegion_returnsCorrectRegion_LORA24()
+{
+ const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
+ TEST_ASSERT_NOT_NULL(r);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code);
+ TEST_ASSERT_TRUE(r->wideLora);
+}
+
+static void test_getRegion_unsetCodeReturnsUnsetEntry()
+{
+ const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
+ TEST_ASSERT_NOT_NULL(r);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
+ TEST_ASSERT_EQUAL_STRING("UNSET", r->name);
+}
+
+static void test_getRegion_unknownCodeFallsToUnset()
+{
+ // A code not in the table should iterate to the UNSET sentinel
+ const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255);
+ TEST_ASSERT_NOT_NULL(r);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
+}
+
+// -----------------------------------------------------------------------
+// validateConfigRegion() tests
+// -----------------------------------------------------------------------
+
+static void test_validateConfigRegion_validRegionReturnsTrue()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+
+ // Ensure owner is not licensed (should not matter for non-licensed-only regions)
+ devicestate.owner.is_licensed = false;
+
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
+}
+
+static void test_validateConfigRegion_unsetRegionReturnsTrue()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
+
+ devicestate.owner.is_licensed = false;
+
+ // UNSET region has licensedOnly=false, so should pass
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
+}
+
+// -----------------------------------------------------------------------
+// Shadow tables for testing (preset lists → profiles → regions → lookup)
+// -----------------------------------------------------------------------
+
+// A minimal preset list with only one entry
+static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
+ MODEM_PRESET_END,
+};
+
+// A preset list that includes all turbo variants only
+static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
+ MODEM_PRESET_END,
+};
+
+// A restricted list simulating a hypothetical tight-regulation region
+static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
+ MODEM_PRESET_END,
+};
+
+// Mirrors PROFILE_STD but with non-zero spacing/padding for testing
+static const RegionProfile TEST_PROFILE_SPACED = {
+ TEST_PRESETS_SINGLE,
+ /* spacing */ 0.025f,
+ /* padding */ 0.010f,
+ /* audioPermitted */ true,
+ /* licensedOnly */ false,
+ /* textThrottle */ 0,
+ /* positionThrottle */ 0,
+ /* telemetryThrottle */ 0,
+ /* overrideSlot */ 0,
+};
+
+// A licensed-only profile for testing access control
+static const RegionProfile TEST_PROFILE_LICENSED = {
+ TEST_PRESETS_RESTRICTED,
+ /* spacing */ 0.0f,
+ /* padding */ 0.0f,
+ /* audioPermitted */ false,
+ /* licensedOnly */ true,
+ /* textThrottle */ 5,
+ /* positionThrottle */ 10,
+ /* telemetryThrottle */ 10,
+ /* overrideSlot */ 3,
+};
+
+// Turbo-only profile
+static const RegionProfile TEST_PROFILE_TURBO = {
+ TEST_PRESETS_TURBO_ONLY,
+ /* spacing */ 0.0f,
+ /* padding */ 0.0f,
+ /* audioPermitted */ true,
+ /* licensedOnly */ false,
+ /* textThrottle */ 0,
+ /* positionThrottle */ 0,
+ /* telemetryThrottle */ 0,
+ /* overrideSlot */ 0,
+};
+
+static const RegionInfo testRegions[] = {
+ // A wide US-like region with spacing + padding
+ {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"},
+
+ // A narrow band simulating tight EU regulation
+ {meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED,
+ "TEST_EU_LICENSED"},
+
+ // A wide-LoRa region with turbo-only presets
+ {meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO,
+ "TEST_LORA24_TURBO"},
+
+ // Sentinel — must be last
+ {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"},
+};
+
+static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code)
+{
+ const RegionInfo *r = testRegions;
+ while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
+ if (r->code == code)
+ return r;
+ r++;
+ }
+ return r; // Returns the UNSET sentinel
+}
+
+// -----------------------------------------------------------------------
+// Shadow table tests
+// -----------------------------------------------------------------------
+
+static void test_shadowTable_spacedProfileHasNonZeroSpacing()
+{
+ const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name);
+ TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing);
+ TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding);
+}
+
+static void test_shadowTable_licensedProfileFlagsCorrect()
+{
+ const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_TRUE(r->profile->licensedOnly);
+ TEST_ASSERT_FALSE(r->profile->audioPermitted);
+ TEST_ASSERT_EQUAL(3, r->profile->overrideSlot);
+}
+
+static void test_shadowTable_presetCountMatchesExpected()
+{
+ const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ TEST_ASSERT_EQUAL(1, spaced->getNumPresets());
+
+ const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_EQUAL(2, licensed->getNumPresets());
+
+ const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
+ TEST_ASSERT_EQUAL(2, turbo->getNumPresets());
+}
+
+static void test_shadowTable_defaultPresetIsFirstInList()
+{
+ const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset());
+
+ const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset());
+
+ const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset());
+}
+
+static void test_shadowTable_channelSpacingWithPadding()
+{
+ // Verify channel count when spacing + padding are non-zero
+ const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
+ float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f);
+
+ // spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250
+ // channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz
+ TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing);
+
+ uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f);
+ // (928 - 902 + 0.025) / 0.295 = 88.2 → 88
+ TEST_ASSERT_EQUAL_UINT32(88, numChannels);
+}
+
+static void test_shadowTable_turboOnlyOnWideLora()
+{
+ const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
+ TEST_ASSERT_TRUE(r->wideLora);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset());
+
+ // Verify wide-LoRa bandwidth for SHORT_TURBO
+ float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
+ TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode
+}
+
+static void test_shadowTable_unknownCodeFallsToSentinel()
+{
+ const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200);
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
+ TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name);
+}
+
+// -----------------------------------------------------------------------
+// validateConfigLora() tests
+// -----------------------------------------------------------------------
+
+static void test_validateConfigLora_validPresetForUS()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.use_preset = true;
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
+
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
+}
+
+static void test_validateConfigLora_allStdPresetsValidForUS()
+{
+ meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
+ meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
+ };
+
+ for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.use_preset = true;
+ cfg.modem_preset = stdPresets[i];
+ TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US");
+ }
+}
+
+static void test_validateConfigLora_turboPresetsInvalidForEU868()
+{
+ // EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.use_preset = true;
+
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
+ TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868");
+
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
+ TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868");
+}
+
+static void test_validateConfigLora_validPresetsForEU868()
+{
+ meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
+ meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
+ };
+
+ for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) {
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.use_preset = true;
+ cfg.modem_preset = eu868Presets[i];
+ TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868");
+ }
+}
+
+static void test_validateConfigLora_customBandwidthTooWideForEU868()
+{
+ // EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz
+ // A 500 kHz custom BW should be rejected
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.use_preset = false;
+ cfg.bandwidth = 500;
+ cfg.spread_factor = 11;
+ cfg.coding_rate = 5;
+
+ TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
+}
+
+static void test_validateConfigLora_customBandwidthFitsUS()
+{
+ // US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.use_preset = false;
+ cfg.bandwidth = 250;
+ cfg.spread_factor = 11;
+ cfg.coding_rate = 5;
+
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
+}
+
+static void test_validateConfigLora_customBandwidthFitsEU868()
+{
+ // EU_868 spans 250 kHz, 125 kHz BW should fit
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.use_preset = false;
+ cfg.bandwidth = 125;
+ cfg.spread_factor = 12;
+ cfg.coding_rate = 8;
+
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
+}
+
+static void test_validateConfigLora_bogusPresetRejected()
+{
+ // A fabricated preset value not in any list should be rejected
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.use_preset = true;
+ cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
+
+ TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
+}
+
+static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast()
+{
+ // UNSET uses PROFILE_UNDEF which has only LONG_FAST
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
+ cfg.use_preset = true;
+
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
+ TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET");
+
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
+ TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET");
+
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
+ TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET");
+}
+
+static void test_validateConfigLora_allPresetsValidForLORA24()
+{
+ // LORA_24 uses PROFILE_STD (9 presets) with wideLora=true
+ meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
+ meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
+ meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
+ };
+
+ for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
+ cfg.use_preset = true;
+ cfg.modem_preset = stdPresets[i];
+ TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24");
+ }
+}
+
+// -----------------------------------------------------------------------
+// clampConfigLora() tests
+// -----------------------------------------------------------------------
+
+static void test_clampConfigLora_invalidPresetClampedToDefault()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.use_preset = true;
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list
+
+ RadioInterface::clampConfigLora(cfg);
+
+ const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset);
+}
+
+static void test_clampConfigLora_validPresetUnchanged()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.use_preset = true;
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
+}
+
+static void test_clampConfigLora_customBwTooWideClampedToDefaultBw()
+{
+ // EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW.
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.use_preset = false;
+ cfg.bandwidth = 500;
+ cfg.spread_factor = 11;
+ cfg.coding_rate = 5;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora);
+ TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth);
+}
+
+static void test_clampConfigLora_customBwValidLeftUnchanged()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.use_preset = false;
+ cfg.bandwidth = 125;
+ cfg.spread_factor = 12;
+ cfg.coding_rate = 8;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth);
+}
+
+static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast()
+{
+ // UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
+ cfg.use_preset = true;
+ cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
+}
+
+static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault()
+{
+ // LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD)
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
+ cfg.use_preset = true;
+ cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
+ TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset);
+}
+
+// -----------------------------------------------------------------------
+// RegionInfo preset list integrity tests
+// -----------------------------------------------------------------------
+
+static void test_presetsStd_hasNineEntries()
+{
+ // PROFILE_STD should have exactly 9 presets
+ const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ TEST_ASSERT_EQUAL(9, us->getNumPresets());
+ TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets());
+}
+
+static void test_presetsEU868_hasSevenEntries()
+{
+ const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_EQUAL(7, eu->getNumPresets());
+ TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets());
+}
+
+static void test_presetsUndef_hasOneEntry()
+{
+ const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
+ TEST_ASSERT_EQUAL(1, unset->getNumPresets());
+ TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets());
+}
+
+static void test_defaultPresetIsInAvailablePresets()
+{
+ // For every region, the defaultPreset must appear in its own availablePresets list
+ const RegionInfo *r = regions;
+ while (true) {
+ bool found = false;
+ for (size_t i = 0; i < r->getNumPresets(); i++) {
+ if (r->getAvailablePresets()[i] == r->getDefaultPreset()) {
+ found = true;
+ break;
+ }
+ }
+ char msg[80];
+ snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name);
+ TEST_ASSERT_TRUE_MESSAGE(found, msg);
+
+ if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
+ break; // UNSET is the sentinel, stop after it
+ r++;
+ }
+}
+
+static void test_regionFieldsAreSane()
+{
+ // Basic sanity check: all regions have freqEnd > freqStart and a non-null name
+ const RegionInfo *r = regions;
+ while (true) {
+ char msg[80];
+ snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name);
+ TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg);
+ TEST_ASSERT_NOT_NULL(r->name);
+ TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0");
+ TEST_ASSERT_NOT_NULL(r->getAvailablePresets());
+
+ if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
+ break;
+ r++;
+ }
+}
+
+static void test_onlyLORA24HasWideLora()
+{
+ // Verify that LORA_24 is the only region with wideLora=true
+ const RegionInfo *r = regions;
+ while (true) {
+ char msg[80];
+ if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
+ snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name);
+ TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg);
+ } else {
+ snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name);
+ TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg);
+ }
+
+ if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
+ break;
+ r++;
+ }
+}
+
+// -----------------------------------------------------------------------
+// Channel spacing calculation (placeholder for future protobuf updates)
+// -----------------------------------------------------------------------
+
+static void test_channelSpacingCalculation_US_LONG_FAST()
+{
+ // Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000)
+ // US: spacing=0, padding=0
+ // LONG_FAST on non-wide region: bw=250 kHz
+ // channelSpacing = 0 + 0 + 0.250 = 0.250 MHz
+ // numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104
+ const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
+ float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
+ float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f);
+ uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f);
+
+ TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
+ TEST_ASSERT_EQUAL_UINT32(104, numChannels);
+}
+
+static void test_channelSpacingCalculation_EU868_LONG_FAST()
+{
+ // EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0
+ // LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz
+ // numChannels = round((0.25 + 0) / 0.250) = 1
+ const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora);
+ float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f);
+ uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f);
+
+ TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
+ TEST_ASSERT_EQUAL_UINT32(1, numChannels);
+}
+
+// Placeholder: when protobuf region definitions include non-zero padding/spacing,
+// add tests here to verify the channel count and frequency calculations.
+static void test_channelSpacingCalculation_placeholder()
+{
+ // TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values,
+ // verify:
+ // - Channel count matches expected value for each (region, preset) pair
+ // - First channel frequency = freqStart + (bw/2000) + padding
+ // - Nth channel frequency = first + (n * channelSpacing)
+ // - overrideSlot, when non-zero, forces the channel_num
+ TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields");
+}
+
+// -----------------------------------------------------------------------
+// handleSetConfig fromOthers dispatch tests
+// -----------------------------------------------------------------------
+
+class AdminModuleTestShim : public AdminModule
+{
+ public:
+ using AdminModule::handleSetConfig;
+};
+
+static AdminModuleTestShim *testAdmin;
+
+static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
+ meshtastic_Config_LoRaConfig_ModemPreset preset)
+{
+ meshtastic_Config c = meshtastic_Config_init_zero;
+ c.which_payload_variant = meshtastic_Config_lora_tag;
+ c.payload_variant.lora.region = region;
+ c.payload_variant.lora.use_preset = usePreset;
+ c.payload_variant.lora.modem_preset = preset;
+ return c;
+}
+
+static void test_handleSetConfig_fromOthers_invalidPresetRejected()
+{
+ // Set up a known-good baseline in the global config
+ config.lora = meshtastic_Config_LoRaConfig_init_zero;
+ config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ config.lora.use_preset = true;
+ config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
+ initRegion();
+
+ // Build an admin set_config with an invalid preset for EU_868
+ meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
+
+ testAdmin->handleSetConfig(c, true); // fromOthers = true
+
+ // fromOthers=true: invalid preset should be rejected, old preset preserved
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
+}
+
+static void test_handleSetConfig_fromLocal_invalidPresetClamped()
+{
+ // Set up a known-good baseline
+ config.lora = meshtastic_Config_LoRaConfig_init_zero;
+ config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ config.lora.use_preset = true;
+ config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
+ initRegion();
+
+ // Build an admin set_config with an invalid preset for EU_868
+ meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
+ meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
+
+ testAdmin->handleSetConfig(c, false); // fromOthers = false (local client)
+
+ // fromOthers=false: invalid preset should be clamped to the region's default
+ const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
+ TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset);
+}
+
+static void test_handleSetConfig_fromOthers_validPresetAccepted()
+{
+ // Set up baseline
+ config.lora = meshtastic_Config_LoRaConfig_init_zero;
+ config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ config.lora.use_preset = true;
+ config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
+ initRegion();
+
+ // Build an admin set_config with a valid preset for EU_868
+ meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
+ meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
+
+ testAdmin->handleSetConfig(c, true); // fromOthers = true
+
+ // Valid preset should be accepted regardless of fromOthers
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
+}
+
+// -----------------------------------------------------------------------
+// Test runner
+// -----------------------------------------------------------------------
+
+void setUp(void)
+{
+ mockMeshService = new MockMeshService();
+ service = mockMeshService;
+ testAdmin = new AdminModuleTestShim();
+}
+void tearDown(void)
+{
+ service = nullptr;
+ delete mockMeshService;
+ mockMeshService = nullptr;
+ delete testAdmin;
+ testAdmin = nullptr;
+}
+
+void setup()
+{
+ delay(10);
+ delay(2000);
+
+ initializeTestEnvironment();
+
+ UNITY_BEGIN();
+
+ // getRegion()
+ RUN_TEST(test_getRegion_returnsCorrectRegion_US);
+ RUN_TEST(test_getRegion_returnsCorrectRegion_EU868);
+ RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
+ RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry);
+ RUN_TEST(test_getRegion_unknownCodeFallsToUnset);
+
+ // validateConfigRegion()
+ RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue);
+ RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue);
+
+ // Shadow table tests
+ RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing);
+ RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect);
+ RUN_TEST(test_shadowTable_presetCountMatchesExpected);
+ RUN_TEST(test_shadowTable_defaultPresetIsFirstInList);
+ RUN_TEST(test_shadowTable_channelSpacingWithPadding);
+ RUN_TEST(test_shadowTable_turboOnlyOnWideLora);
+ RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel);
+
+ // validateConfigLora()
+ RUN_TEST(test_validateConfigLora_validPresetForUS);
+ RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS);
+ RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868);
+ RUN_TEST(test_validateConfigLora_validPresetsForEU868);
+ RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868);
+ RUN_TEST(test_validateConfigLora_customBandwidthFitsUS);
+ RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868);
+ RUN_TEST(test_validateConfigLora_bogusPresetRejected);
+ RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast);
+ RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24);
+
+ // clampConfigLora()
+ RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
+ RUN_TEST(test_clampConfigLora_validPresetUnchanged);
+ RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw);
+ RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged);
+ RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast);
+ RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault);
+
+ // RegionInfo preset list integrity
+ RUN_TEST(test_presetsStd_hasNineEntries);
+ RUN_TEST(test_presetsEU868_hasSevenEntries);
+ RUN_TEST(test_presetsUndef_hasOneEntry);
+ RUN_TEST(test_defaultPresetIsInAvailablePresets);
+ RUN_TEST(test_regionFieldsAreSane);
+ RUN_TEST(test_onlyLORA24HasWideLora);
+
+ // Channel spacing (current + placeholder)
+ RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST);
+ RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST);
+ RUN_TEST(test_channelSpacingCalculation_placeholder);
+
+ // handleSetConfig fromOthers dispatch
+ RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected);
+ RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
+ RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
+
+ exit(UNITY_END());
+}
+
+void loop() {}
diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp
index d832fc809..9da367897 100644
--- a/test/test_default/test_main.cpp
+++ b/test/test_default/test_main.cpp
@@ -10,8 +10,9 @@ static uint32_t computeExpectedMs(uint32_t defaultSeconds, uint32_t numOnlineNod
{
uint32_t baseMs = Default::getConfiguredOrDefaultMs(0, defaultSeconds);
- // Routers don't scale
- if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER) {
+ // Routers (including ROUTER_LATE) don't scale
+ if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER ||
+ config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) {
return baseMs;
}
@@ -93,6 +94,39 @@ void test_client_medium_fast_preset_scaling()
TEST_ASSERT_INT_WITHIN(1, expected, res);
}
+void test_router_uses_router_minimums()
+{
+ config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
+
+ uint32_t telemetry = Default::getConfiguredOrMinimumValue(60, min_default_telemetry_interval_secs);
+ uint32_t position = Default::getConfiguredOrMinimumValue(60, min_default_broadcast_interval_secs);
+
+ TEST_ASSERT_EQUAL_UINT32(ONE_DAY / 2, telemetry);
+ TEST_ASSERT_EQUAL_UINT32(ONE_DAY / 2, position);
+}
+
+void test_router_late_uses_router_minimums()
+{
+ config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER_LATE;
+
+ uint32_t telemetry = Default::getConfiguredOrMinimumValue(60, min_default_telemetry_interval_secs);
+ uint32_t position = Default::getConfiguredOrMinimumValue(60, min_default_broadcast_interval_secs);
+
+ TEST_ASSERT_EQUAL_UINT32(ONE_DAY / 2, telemetry);
+ TEST_ASSERT_EQUAL_UINT32(ONE_DAY / 2, position);
+}
+
+void test_client_uses_public_channel_minimums()
+{
+ config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
+
+ uint32_t telemetry = Default::getConfiguredOrMinimumValue(60, min_default_telemetry_interval_secs);
+ uint32_t position = Default::getConfiguredOrMinimumValue(60, min_default_broadcast_interval_secs);
+
+ TEST_ASSERT_EQUAL_UINT32(30 * 60, telemetry);
+ TEST_ASSERT_EQUAL_UINT32(60 * 60, position);
+}
+
void setup()
{
// Small delay to match other test mains
@@ -103,6 +137,9 @@ void setup()
RUN_TEST(test_client_below_threshold);
RUN_TEST(test_client_default_preset_scaling);
RUN_TEST(test_client_medium_fast_preset_scaling);
+ RUN_TEST(test_router_uses_router_minimums);
+ RUN_TEST(test_router_late_uses_router_minimums);
+ RUN_TEST(test_client_uses_public_channel_minimums);
exit(UNITY_END());
}
diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp
index 4a2eed87d..edf9a3983 100644
--- a/test/test_mqtt/MQTT.cpp
+++ b/test/test_mqtt/MQTT.cpp
@@ -818,16 +818,13 @@ void test_configEmptyIsValid(void)
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
-// Empty 'enabled' configuration is valid.
+// Empty 'enabled' configuration is valid. A lightweight TCP check may be performed
+// but does not affect the result.
void test_configEnabledEmptyIsValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true};
- MockPubSubServer client;
- TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
- TEST_ASSERT_TRUE(client.connected_);
- TEST_ASSERT_EQUAL_STRING(default_mqtt_address, client.host_.c_str());
- TEST_ASSERT_EQUAL(1883, client.port_);
+ TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// Configuration with the default server is valid.
@@ -846,38 +843,32 @@ void test_configWithDefaultServerAndInvalidPort(void)
TEST_ASSERT_FALSE(MQTT::isValidConfig(config));
}
-// isValidConfig connects to a custom host and port.
+// Custom host and port is valid. TCP reachability is checked but does not block saving.
void test_configCustomHostAndPort(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server:1234"};
- MockPubSubServer client;
- TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
- TEST_ASSERT_TRUE(client.connected_);
- TEST_ASSERT_EQUAL_STRING("server", client.host_.c_str());
- TEST_ASSERT_EQUAL(1234, client.port_);
+ TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
-// isValidConfig returns false if a connection cannot be established.
-void test_configWithConnectionFailure(void)
+// An unreachable server is still a valid config — settings always save.
+// A warning notification is sent in non-test builds, but isValidConfig returns true.
+void test_configWithUnreachableServerIsStillValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server"};
- MockPubSubServer client;
- client.refuseConnection_ = true;
- TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client));
+ TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// isValidConfig returns true when tls_enabled is supported, or false otherwise.
void test_configWithTLSEnabled(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server", .tls_enabled = true};
- MockPubSubServer client;
#if MQTT_SUPPORTS_TLS
- TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
+ TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
#else
- TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client));
+ TEST_ASSERT_FALSE(MQTT::isValidConfig(config));
#endif
}
@@ -927,7 +918,7 @@ void setup()
RUN_TEST(test_configWithDefaultServer);
RUN_TEST(test_configWithDefaultServerAndInvalidPort);
RUN_TEST(test_configCustomHostAndPort);
- RUN_TEST(test_configWithConnectionFailure);
+ RUN_TEST(test_configWithUnreachableServerIsStillValid);
RUN_TEST(test_configWithTLSEnabled);
exit(UNITY_END());
}
diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp
index fbe2b1b13..49eaabe5b 100644
--- a/test/test_radio/test_main.cpp
+++ b/test/test_radio/test_main.cpp
@@ -1,10 +1,19 @@
#include "MeshRadio.h"
+#include "MeshService.h"
#include "RadioInterface.h"
#include "TestUtil.h"
#include
#include "meshtastic/config.pb.h"
+class MockMeshService : public MeshService
+{
+ public:
+ void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
+};
+
+static MockMeshService *mockMeshService;
+
static void test_bwCodeToKHz_specialMappings()
{
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31));
@@ -21,7 +30,7 @@ static void test_bwCodeToKHz_passthrough()
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250));
}
-static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse()
+static void test_validateConfigLora_noopWhenUsePresetFalse()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = false;
@@ -30,55 +39,78 @@ static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse()
cfg.bandwidth = 123;
cfg.spread_factor = 8;
- RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
+ RadioInterface::validateConfigLora(cfg);
TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
-static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion()
+static void test_validateConfigLora_validPreset_nonWideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
- RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
-
- TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth);
- TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor);
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
-static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion()
+static void test_validateConfigLora_validPreset_wideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
- RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
-
- TEST_ASSERT_EQUAL_UINT16(800, cfg.bandwidth);
- TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor);
+ TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
-static void test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan()
+static void test_validateConfigLora_rejectsInvalidPresetForRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
- RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
-
- TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
- TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth);
- TEST_ASSERT_EQUAL_UINT32(11, cfg.spread_factor);
+ TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
-void setUp(void) {}
-void tearDown(void) {}
+static void test_clampConfigLora_invalidPresetClampedToDefault()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.use_preset = true;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
+}
+
+static void test_clampConfigLora_validPresetUnchanged()
+{
+ meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
+ cfg.use_preset = true;
+ cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
+ cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
+
+ RadioInterface::clampConfigLora(cfg);
+
+ TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
+}
+
+void setUp(void)
+{
+ mockMeshService = new MockMeshService();
+ service = mockMeshService;
+}
+void tearDown(void)
+{
+ service = nullptr;
+ delete mockMeshService;
+ mockMeshService = nullptr;
+}
void setup()
{
@@ -90,10 +122,12 @@ void setup()
UNITY_BEGIN();
RUN_TEST(test_bwCodeToKHz_specialMappings);
RUN_TEST(test_bwCodeToKHz_passthrough);
- RUN_TEST(test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse);
- RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion);
- RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion);
- RUN_TEST(test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan);
+ RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse);
+ RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion);
+ RUN_TEST(test_validateConfigLora_validPreset_wideRegion);
+ RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion);
+ RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
+ RUN_TEST(test_clampConfigLora_validPresetUnchanged);
exit(UNITY_END());
}
diff --git a/userPrefs.jsonc b/userPrefs.jsonc
index 9e916aae2..b81f09362 100644
--- a/userPrefs.jsonc
+++ b/userPrefs.jsonc
@@ -18,6 +18,7 @@
// "USERPREFS_CHANNEL_2_UPLINK_ENABLED": "false",
// "USERPREFS_CONFIG_GPS_MODE": "meshtastic_Config_PositionConfig_GpsMode_ENABLED",
// "USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true",
+ // "USERPREFS_LORA_TX_DISABLED": "1", // If set, forces config.lora.tx_enabled=false during lora bootstrap
// "USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US",
// "USERPREFS_CONFIG_OWNER_LONG_NAME": "My Long Name",
// "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN",
diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini
index f6c00bf88..701183280 100644
--- a/variants/esp32/esp32-common.ini
+++ b/variants/esp32/esp32-common.ini
@@ -27,14 +27,20 @@ board_build.filesystem = littlefs
# Remove -DMYNEWT_VAL_BLE_HS_LOG_LVL=LOG_LEVEL_CRITICAL for low level BLE logging.
# See library directory for BLE logging possible values: .pio/libdeps/tbeam/NimBLE-Arduino/src/log_common/log_common.h
# This overrides the BLE logging default of LOG_LEVEL_INFO (1) from: .pio/libdeps/tbeam/NimBLE-Arduino/src/esp_nimble_cfg.h
-build_unflags = -fno-lto
+build_unflags =
+ -fno-lto
+ # Keep explicit std unflags on ESP32; base-level unflags are not sufficient
+ # to prevent framework-injected C++11 fallback on this platform.
+ -std=c++11
+ -std=gnu++11
build_flags =
${arduino_base.build_flags}
-flto
-Wall
-Wextra
-Isrc/platform/esp32
- -std=c++11
+ -include mbedtls/error.h
+ -std=gnu++17
-DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG
-DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG
-DMYNEWT_VAL_BLE_HS_LOG_LVL=LOG_LEVEL_CRITICAL
@@ -62,7 +68,7 @@ lib_deps =
${environmental_extra.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-esp32_https_server packageName=https://github.com/meshtastic/esp32_https_server gitBranch=master
- https://github.com/meshtastic/esp32_https_server/archive/b0f3960b3e8444563280656d88e22b5899481884.zip
+ https://github.com/meshtastic/esp32_https_server/archive/b78f12c86ea65c3ca08968840ff554ff7ed69b60.zip
# renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino
h2zero/NimBLE-Arduino@^1.4.3
# renovate: datasource=git-refs depName=libpax packageName=https://github.com/dbinfrago/libpax gitBranch=master
diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini
index 20ce38fae..e0c05896d 100644
--- a/variants/esp32/esp32.ini
+++ b/variants/esp32/esp32.ini
@@ -17,7 +17,7 @@ lib_deps =
${environmental_extra_no_bsec.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-esp32_https_server packageName=https://github.com/meshtastic/esp32_https_server gitBranch=master
- https://github.com/meshtastic/esp32_https_server/archive/b0f3960b3e8444563280656d88e22b5899481884.zip
+ https://github.com/meshtastic/esp32_https_server/archive/b78f12c86ea65c3ca08968840ff554ff7ed69b60.zip
# renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino
h2zero/NimBLE-Arduino@^1.4.3
# renovate: datasource=git-refs depName=libpax packageName=https://github.com/dbinfrago/libpax gitBranch=master
diff --git a/variants/esp32/m5stack_coreink/platformio.ini b/variants/esp32/m5stack_coreink/platformio.ini
index 7393c291f..e107bd893 100644
--- a/variants/esp32/m5stack_coreink/platformio.ini
+++ b/variants/esp32/m5stack_coreink/platformio.ini
@@ -19,7 +19,7 @@ build_flags =
lib_deps =
${esp32_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
# renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib
lewisxhe/SensorLib@0.3.4
lib_ignore =
diff --git a/variants/esp32/rak11200/platformio.ini b/variants/esp32/rak11200/platformio.ini
index 63821a092..b48d638fb 100644
--- a/variants/esp32/rak11200/platformio.ini
+++ b/variants/esp32/rak11200/platformio.ini
@@ -16,4 +16,8 @@ build_flags =
${esp32_base.build_flags}
-D RAK_11200
-I variants/esp32/rak11200
+ -DMESHTASTIC_EXCLUDE_WEBSERVER=1
+ -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1
+ -DMESHTASTIC_EXCLUDE_RANGETEST=1
+ -DMESHTASTIC_EXCLUDE_MQTT=1
upload_speed = 115200
diff --git a/variants/esp32/tlora_v1/platformio.ini b/variants/esp32/tlora_v1/platformio.ini
index c45cc2ce9..5f72d634e 100644
--- a/variants/esp32/tlora_v1/platformio.ini
+++ b/variants/esp32/tlora_v1/platformio.ini
@@ -13,4 +13,5 @@ build_flags =
${esp32_base.build_flags}
-D TLORA_V1
-I variants/esp32/tlora_v1
+ -ULED_BUILTIN
upload_speed = 115200
diff --git a/variants/esp32c6/esp32c6.ini b/variants/esp32c6/esp32c6.ini
index 9ee8591be..9ab185d02 100644
--- a/variants/esp32c6/esp32c6.ini
+++ b/variants/esp32c6/esp32c6.ini
@@ -8,7 +8,6 @@ build_flags =
-Wall
-Wextra
-Isrc/platform/esp32
- -std=c++11
-DESP_OPENSSL_SUPPRESS_LEGACY_WARNING
-DSERIAL_BUFFER_SIZE=4096
-DLIBPAX_ARDUINO
diff --git a/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h
index 8768fd70e..c8e56426f 100644
--- a/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h
+++ b/variants/esp32s3/ELECROW-ThinkNode-M2/variant.h
@@ -1,6 +1,3 @@
-// Status
-#define LED_POWER 1
-
#define PIN_BUTTON1 47 // 功能键
#define PIN_BUTTON2 4 // 电源键
#define ALT_BUTTON_PIN PIN_BUTTON2
diff --git a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini
index 9230e08c5..c2805425b 100644
--- a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini
+++ b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini
@@ -11,7 +11,7 @@ upload_speed = 921600
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
# renovate: datasource=custom.pio depName=NeoPixel packageName=adafruit/library/Adafruit NeoPixel
adafruit/Adafruit NeoPixel@1.15.4
build_unflags =
diff --git a/variants/esp32s3/dreamcatcher/platformio.ini b/variants/esp32s3/dreamcatcher/platformio.ini
index c830346e0..64d1b993d 100644
--- a/variants/esp32s3/dreamcatcher/platformio.ini
+++ b/variants/esp32s3/dreamcatcher/platformio.ini
@@ -12,8 +12,8 @@ build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps = ${esp32s3_base.lib_deps}
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini
index e0f6f0760..c357d41a5 100644
--- a/variants/esp32s3/elecrow_panel/platformio.ini
+++ b/variants/esp32s3/elecrow_panel/platformio.ini
@@ -41,8 +41,8 @@ build_flags = ${esp32s3_base.build_flags} -Os
lib_deps = ${esp32s3_base.lib_deps}
${device-ui_base.lib_deps}
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534
diff --git a/variants/esp32s3/esp32-s3-pico/platformio.ini b/variants/esp32s3/esp32-s3-pico/platformio.ini
index aad29247b..07ca069f3 100644
--- a/variants/esp32s3/esp32-s3-pico/platformio.ini
+++ b/variants/esp32s3/esp32-s3-pico/platformio.ini
@@ -23,6 +23,6 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
# renovate: datasource=custom.pio depName=NeoPixel packageName=adafruit/library/Adafruit NeoPixel
adafruit/Adafruit NeoPixel@1.15.4
diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini
index 8d9921d6a..9acf30c21 100644
--- a/variants/esp32s3/heltec_v4/platformio.ini
+++ b/variants/esp32s3/heltec_v4/platformio.ini
@@ -68,7 +68,7 @@ build_flags =
-D INPUTDRIVER_BUTTON_TYPE=0
-D HAS_SCREEN=1
-D HAS_TFT=1
- -D RAM_SIZE=1560
+ -D RAM_SIZE=1860
-D LV_LVGL_H_INCLUDE_SIMPLE
-D LV_CONF_INCLUDE_SIMPLE
-D LV_COMP_CONF_INCLUDE_SIMPLE
@@ -83,9 +83,9 @@ build_flags =
-D USE_PACKET_API
-D LGFX_DRIVER=LGFX_HELTEC_V4_TFT
-D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_HELTEC_V4_TFT.h\"
- -D VIEW_320x240
- -D MAP_FULL_REDRAW
- -D DISPLAY_SIZE=320x240 ; landscape mode
+ -D VIEW_240x320
+ -D DISPLAY_SET_RESOLUTION
+ -D DISPLAY_SIZE=240x320 ; portrait mode
-D LGFX_PIN_SCK=17
-D LGFX_PIN_MOSI=33
-D LGFX_PIN_DC=16
diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini
index 7563da082..91d2e568a 100644
--- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini
+++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini
@@ -17,9 +17,9 @@ lib_deps =
https://github.com/meshtastic/st7789/archive/9ee76d6b18b9a8f45a2c5cae06b1134a587691eb.zip
# # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver
# https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=custom.pio depName=NeoPixel packageName=adafruit/library/Adafruit NeoPixel
- adafruit/Adafruit NeoPixel@1.15.2
+ adafruit/Adafruit NeoPixel@1.15.4
diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini
index 8423bb4df..96b1a067b 100644
--- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini
+++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini
@@ -1,6 +1,26 @@
; rak_wismeshtap2 rak3112
+[ft5x06]
+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]
+custom_meshtastic_hw_model = 116
+custom_meshtastic_hw_model_slug = WISMESH_TAP_V2
+custom_meshtastic_architecture = esp32-s3
+custom_meshtastic_actively_supported = true
+custom_meshtastic_support_level = 1
+custom_meshtastic_display_name = RAK WisMesh Tap V2
+custom_meshtastic_images = rak-wismesh-tap-v2.svg
+custom_meshtastic_tags = RAK
+custom_meshtastic_partition_scheme = 8MB
-[rak_wismeshtap_s3]
extends = esp32s3_base
board = wiscore_rak3312
board_check = true
@@ -18,23 +38,11 @@ lib_deps =
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.19
-[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
+extends = env:rak_wismesh_tap_v2
build_flags =
- ${rak_wismeshtap_s3.build_flags}
+ ${env:rak_wismesh_tap_v2.build_flags}
-D CONFIG_ARDUHAL_ESP_LOG
-D CONFIG_ARDUHAL_LOG_COLORS=1
-D CONFIG_DISABLE_HAL_LOCKS=1
@@ -69,9 +77,9 @@ build_flags =
-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_SCREEN_WIDTH=240 ; native panel width (portrait)
+ -D LGFX_SCREEN_HEIGHT=320 ; native panel height (portrait)
+ -D DISPLAY_SIZE=320x240 ; UI runs in landscape mode
-D LGFX_PANEL=ST7789
-D LGFX_ROTATION=1
-D LGFX_TOUCH_X_MIN=0
@@ -83,7 +91,7 @@ build_flags =
-D MAP_FULL_REDRAW=1
lib_deps =
- ${rak_wismeshtap_s3.lib_deps}
+ ${env:rak_wismesh_tap_v2.lib_deps}
${device-ui_base.lib_deps}
diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
index 70a10e0d4..a4650f826 100644
--- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
+++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
@@ -37,8 +37,8 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
; TODO switch back to official LovyanGFX
https://github.com/mverch67/LovyanGFX/archive/4c76238c1344162a234ae917b27651af146d6fb2.zip
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
diff --git a/variants/esp32s3/seeed_xiao_s3/variant.h b/variants/esp32s3/seeed_xiao_s3/variant.h
index 11bf48521..cbdbf8eb8 100644
--- a/variants/esp32s3/seeed_xiao_s3/variant.h
+++ b/variants/esp32s3/seeed_xiao_s3/variant.h
@@ -50,7 +50,6 @@ L76K GPS Module Information : https://www.seeedstudio.com/L76K-GNSS-Module-for-S
#define GPS_RX_PIN 44
#define GPS_TX_PIN 43
#define HAS_GPS 1
-#define GPS_BAUDRATE 9600
#define GPS_THREAD_INTERVAL 50
#define PIN_SERIAL1_RX PIN_GPS_TX
#define PIN_SERIAL1_TX PIN_GPS_RX
diff --git a/variants/esp32s3/t-deck-pro/platformio.ini b/variants/esp32s3/t-deck-pro/platformio.ini
index 29960f280..c5411da13 100644
--- a/variants/esp32s3/t-deck-pro/platformio.ini
+++ b/variants/esp32s3/t-deck-pro/platformio.ini
@@ -33,7 +33,7 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
# renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main
https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip
# renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328
diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini
index c216ec595..7d79e934c 100644
--- a/variants/esp32s3/t-deck/platformio.ini
+++ b/variants/esp32s3/t-deck/platformio.ini
@@ -29,8 +29,8 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.19
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini
index 8c79ca097..352396818 100644
--- a/variants/esp32s3/t-watch-s3/platformio.ini
+++ b/variants/esp32s3/t-watch-s3/platformio.ini
@@ -27,7 +27,7 @@ lib_deps = ${esp32s3_base.lib_deps}
lewisxhe/SensorLib@0.3.4
# renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
adafruit/Adafruit DRV2605 Library@1.2.4
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini
index b5c9fd579..dc96113b0 100644
--- a/variants/esp32s3/tlora-pager/platformio.ini
+++ b/variants/esp32s3/tlora-pager/platformio.ini
@@ -26,7 +26,6 @@ build_flags = ${esp32s3_base.build_flags}
-D T_LORA_PAGER
-D BOARD_HAS_PSRAM
-D HAS_SDCARD
- -D SDCARD_USE_SPI1
-D ENABLE_ROTARY_PULLUP
-D ENABLE_BUTTON_PULLUP
-D ROTARY_BUXTRONICS
@@ -34,8 +33,8 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.19
- # renovate: datasource=custom.pio depName=ESP8266Audio packageName=earlephilhower/library/ESP8266Audio
- earlephilhower/ESP8266Audio@1.9.9
+ # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
+ https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini
index 17ec59442..17828f6f6 100644
--- a/variants/native/portduino.ini
+++ b/variants/native/portduino.ini
@@ -55,8 +55,7 @@ build_flags =
-li2c
-luv
-std=gnu17
- -std=c++17
-
+ -std=gnu++17
lib_ignore =
Adafruit NeoPixel
Adafruit ST7735 and ST7789 Library
diff --git a/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini b/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini
index e202715b0..fd159a6d2 100644
--- a/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini
+++ b/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini
@@ -12,5 +12,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/Dongle_
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
debug_tool = jlink
diff --git a/variants/nrf52840/ELECROW-ThinkNode-M1/variant.cpp b/variants/nrf52840/ELECROW-ThinkNode-M1/variant.cpp
index 04f86e2d4..216b62dcb 100644
--- a/variants/nrf52840/ELECROW-ThinkNode-M1/variant.cpp
+++ b/variants/nrf52840/ELECROW-ThinkNode-M1/variant.cpp
@@ -32,8 +32,8 @@ const uint32_t g_ADigitalPinMap[] = {
void initVariant()
{
- pinMode(PIN_LED1, OUTPUT);
- ledOff(PIN_LED1);
+ // pinMode(PIN_LED1, OUTPUT);
+ // ledOff(PIN_LED1);
}
void variant_shutdown()
diff --git a/variants/nrf52840/ELECROW-ThinkNode-M1/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M1/variant.h
index e00e56785..4ae462758 100644
--- a/variants/nrf52840/ELECROW-ThinkNode-M1/variant.h
+++ b/variants/nrf52840/ELECROW-ThinkNode-M1/variant.h
@@ -42,15 +42,17 @@ extern "C" {
#define NUM_ANALOG_OUTPUTS (0)
// LED
-#define PIN_LED1 (32 + 6) // red
-#define LED_POWER (32 + 4)
+// #define PIN_LED1 (32 + 6)
+#define LED_POWER (32 + 4) // red
#define LED_NOTIFICATION (0 + 13) // green
+#define POWER_LED_HARDWARE_BLINKS_WHILE_CHARGING
+
// USB_CHECK
#define EXT_PWR_DETECT (32 + 3)
#define ADC_V (0 + 8)
-#define LED_BLUE PIN_LED1
-#define LED_STATE_ON 0 // State when LED is lit // LED灯亮时的状态
+// #define LED_BLUE PIN_LED1
+#define LED_STATE_ON 1 // State when LED is lit // LED灯亮时的状态
#define PIN_BUZZER (0 + 6)
/*
* Buttons
diff --git a/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini b/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini
index afc8e4b2a..39b5dfbd4 100644
--- a/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini
+++ b/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini
@@ -16,7 +16,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ME25LS0
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm)
upload_protocol = nrfutil
;upload_port = /dev/ttyACM1
diff --git a/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini b/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini
index 3410859b1..ebea1ce97 100644
--- a/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini
+++ b/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini
@@ -15,6 +15,6 @@ lib_deps =
# renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master
https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
debug_tool = jlink
;upload_port = /dev/ttyACM4
\ No newline at end of file
diff --git a/variants/nrf52840/TWC_mesh_v4/platformio.ini b/variants/nrf52840/TWC_mesh_v4/platformio.ini
index 5e5b4b665..c529caa0b 100644
--- a/variants/nrf52840/TWC_mesh_v4/platformio.ini
+++ b/variants/nrf52840/TWC_mesh_v4/platformio.ini
@@ -9,5 +9,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/TWC_mes
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
debug_tool = jlink
diff --git a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini
new file mode 100644
index 000000000..e1bdd529d
--- /dev/null
+++ b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini
@@ -0,0 +1,34 @@
+; First prototype nrf52840/sx1262 device
+[env:heltec-mesh-node-t096]
+custom_meshtastic_hw_model = 127
+custom_meshtastic_hw_model_slug = HELTEC_MESH_NODE_T096
+custom_meshtastic_architecture = nrf52840
+custom_meshtastic_actively_supported = true
+custom_meshtastic_support_level = 1
+custom_meshtastic_display_name = Heltec Mesh Node 096
+custom_meshtastic_images = heltec-mesh-node-t096.svg, heltec-mesh-node-t096-case.svg
+custom_meshtastic_tags = Heltec
+
+extends = nrf52840_base
+board = heltec_mesh_node_t096
+board_level = pr
+debug_tool = jlink
+
+build_flags = ${nrf52840_base.build_flags}
+ -Ivariants/nrf52840/heltec_mesh_node_t096
+ -D HAS_LORA_FEM=1
+ -D HELTEC_MESH_NODE_T096
+ -D USE_TFTDISPLAY=1
+ -D USER_SETUP_LOADED
+ -D ST7735_DRIVER
+ -D ST7735_REDTAB160x80
+ -D TFT_SPI_PORT=SPI1
+ -D TFT_CS=ST7735_CS ; Chip select control
+ -D TFT_DC=ST7735_RS ; Data Command control pin
+ -D TFT_RST=ST7735_RESET ; Reset pin
+ -D TFT_BL=ST7735_BL ; LED back-light
+ -D TFT_BACKLIGHT_ON=LOW
+build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_node_t096>
+lib_deps =
+ ${nrf52840_base.lib_deps}
+ bodmer/TFT_eSPI@2.5.43 ; renovate: datasource=platformio-registry depName=bodmer/TFT_eSPI
diff --git a/variants/nrf52840/heltec_mesh_node_t096/variant.cpp b/variants/nrf52840/heltec_mesh_node_t096/variant.cpp
new file mode 100644
index 000000000..29158e8ba
--- /dev/null
+++ b/variants/nrf52840/heltec_mesh_node_t096/variant.cpp
@@ -0,0 +1,76 @@
+/*
+ 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()
+{
+ // LED1
+ pinMode(PIN_LED1, OUTPUT);
+ ledOff(PIN_LED1);
+}
+
+void variant_shutdown()
+{
+ nrf_gpio_cfg_default(VEXT_ENABLE);
+ nrf_gpio_cfg_default(ST7735_CS);
+ nrf_gpio_cfg_default(ST7735_RS);
+ nrf_gpio_cfg_default(ST7735_SDA);
+ nrf_gpio_cfg_default(ST7735_SCK);
+ nrf_gpio_cfg_default(ST7735_RESET);
+ nrf_gpio_cfg_default(ST7735_BL);
+
+ nrf_gpio_cfg_default(PIN_LED1);
+
+ // nrf_gpio_cfg_default(LORA_PA_POWER);
+ pinMode(LORA_PA_POWER, OUTPUT);
+ digitalWrite(LORA_PA_POWER, LOW);
+
+ nrf_gpio_cfg_default(LORA_KCT8103L_PA_CSD);
+ nrf_gpio_cfg_default(LORA_KCT8103L_PA_CTX);
+
+ pinMode(ADC_CTRL, OUTPUT);
+ digitalWrite(ADC_CTRL, LOW);
+
+ nrf_gpio_cfg_default(SX126X_CS);
+ nrf_gpio_cfg_default(SX126X_DIO1);
+ nrf_gpio_cfg_default(SX126X_BUSY);
+ nrf_gpio_cfg_default(SX126X_RESET);
+
+ nrf_gpio_cfg_default(PIN_SPI_MISO);
+ nrf_gpio_cfg_default(PIN_SPI_MOSI);
+ nrf_gpio_cfg_default(PIN_SPI_SCK);
+
+ nrf_gpio_cfg_default(PIN_GPS_PPS);
+ nrf_gpio_cfg_default(PIN_GPS_RESET);
+ nrf_gpio_cfg_default(PIN_GPS_EN);
+ nrf_gpio_cfg_default(GPS_TX_PIN);
+ nrf_gpio_cfg_default(GPS_RX_PIN);
+}
\ No newline at end of file
diff --git a/variants/nrf52840/heltec_mesh_node_t096/variant.h b/variants/nrf52840/heltec_mesh_node_t096/variant.h
new file mode 100644
index 000000000..04e22af26
--- /dev/null
+++ b/variants/nrf52840/heltec_mesh_node_t096/variant.h
@@ -0,0 +1,202 @@
+/*
+ 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
+
+#define VEXT_ENABLE (0 + 26)
+#define VEXT_ON_VALUE HIGH
+
+// ST7735S TFT LCD
+#define ST7735_CS (0 + 22)
+#define ST7735_RS (0 + 15) // DC
+#define ST7735_SDA (0 + 17) // MOSI
+#define ST7735_SCK (0 + 20)
+#define ST7735_RESET (0 + 13)
+#define ST7735_MISO -1
+#define ST7735_BUSY -1
+#define ST7735_BL (32 + 12)
+#define SPI_FREQUENCY 40000000
+#define SPI_READ_FREQUENCY 16000000
+#define SCREEN_ROTATE
+#define TFT_HEIGHT 160
+#define TFT_WIDTH 80
+#define TFT_OFFSET_X 24
+#define TFT_OFFSET_Y 0
+#define TFT_INVERT false
+#define SCREEN_TRANSITION_FRAMERATE 3 // fps
+#define DISPLAY_FORCE_SMALL_FONTS
+
+// 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)
+
+// LEDs
+#define PIN_LED1 (0 + 28) // green (confirmed on 1.0 board)
+#define LED_BLUE PIN_LED1 // fake for bluefruit library
+#define LED_GREEN PIN_LED1
+#define LED_STATE_ON 1 // State when LED is lit
+
+// #define HAS_NEOPIXEL // Enable the use of neopixels
+// #define NEOPIXEL_COUNT 2 // How many neopixels are connected
+// #define NEOPIXEL_DATA 14 // 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)
+
+/*
+ * I2C
+ */
+
+#define WIRE_INTERFACES_COUNT 2
+
+// I2C bus 0
+#define PIN_WIRE_SDA (0 + 7) // SDA
+#define PIN_WIRE_SCL (0 + 8) // SCL
+
+// I2C bus 1
+#define PIN_WIRE1_SDA (0 + 4) // SDA (secondary bus)
+#define PIN_WIRE1_SCL (0 + 27) // SCL (secondary bus)
+
+/*
+ * Lora radio
+ */
+
+#define USE_SX1262
+#define SX126X_CS (0 + 5) // FIXME - we really should define LORA_CS instead
+#define LORA_CS (0 + 5)
+#define SX126X_DIO1 (0 + 21)
+#define SX126X_BUSY (0 + 19)
+#define SX126X_RESET (0 + 16)
+#define SX126X_DIO2_AS_RF_SWITCH
+#define SX126X_DIO3_TCXO_VOLTAGE 1.8
+
+// ---- KCT8103L RF FRONT END CONFIGURATION ----
+// The heltec_wireless_tracker_v2 uses a KCT8103L FEM chip with integrated PA and LNA
+// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna
+// Control logic (from KCT8103L datasheet):
+// Transmit PA: CSD=1, CTX=1, CPS=1
+// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF)
+// Receive bypass: CSD=1, CTX=1, CPS=0
+// Shutdown: CSD=0, CTX=X, CPS=X
+// Pin mapping:
+// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH)
+// CSD (pin 4) -> GPIO12: Chip enable (HIGH=on, LOW=shutdown)
+// CTX (pin 6) -> GPIO41: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA)
+// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO30
+// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH)
+
+#define USE_KCT8103L_PA
+#define LORA_PA_POWER (0 + 30) // VFEM_Ctrl - KCT8103L LDO power enable
+#define LORA_KCT8103L_PA_CSD (0 + 12) // CSD - KCT8103L chip enable (HIGH=on)
+#define LORA_KCT8103L_PA_CTX \
+ (32 + 9) // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA)
+
+/*
+ * SPI Interfaces
+ */
+#define SPI_INTERFACES_COUNT 2
+
+// For LORA, spi 0
+#define PIN_SPI_MISO (0 + 14)
+#define PIN_SPI_MOSI (0 + 11)
+#define PIN_SPI_SCK (32 + 8)
+
+#define PIN_SPI1_MISO \
+ ST7735_MISO // FIXME not really needed, but for now the SPI code requires something to be defined, pick an used GPIO
+#define PIN_SPI1_MOSI ST7735_SDA
+#define PIN_SPI1_SCK ST7735_SCK
+
+/*
+ * GPS pins
+ */
+#define GPS_UC6580
+#define GPS_BAUDRATE 115200
+#define PIN_GPS_RESET (32 + 14) // An output to reset UC6580 GPS. As per datasheet, low for > 100ms will reset the UC6580
+#define GPS_RESET_MODE LOW
+#define PIN_GPS_EN (0 + 6)
+#define GPS_EN_ACTIVE LOW
+#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing
+#define PIN_GPS_PPS (32 + 11)
+#define GPS_TX_PIN (0 + 25) // This is for bits going TOWARDS the CPU
+#define GPS_RX_PIN (0 + 23) // This is for bits going TOWARDS the GPS
+
+#define GPS_THREAD_INTERVAL 50
+
+#define PIN_SERIAL1_RX GPS_RX_PIN
+#define PIN_SERIAL1_TX GPS_TX_PIN
+
+#define ADC_CTRL (32 + 15)
+#define ADC_CTRL_ENABLED HIGH
+#define BATTERY_PIN (0 + 3)
+#define ADC_RESOLUTION 14
+
+#define BATTERY_SENSE_RESOLUTION_BITS 12
+#define BATTERY_SENSE_RESOLUTION 4096.0
+#undef AREF_VOLTAGE
+#define AREF_VOLTAGE 3.0
+#define VBAT_AR_INTERNAL AR_INTERNAL_3_0
+#define ADC_MULTIPLIER (4.916F)
+
+// rf52840 AIN1 = Pin 3
+#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_1
+
+// We have AIN1 with a VBAT divider so AIN1 = VBAT * (100/490)
+// We have the device going deep sleep under 3.1V, which is AIN1 = 0.63V
+// So we can wake up when VBAT>=VDD is restored to 3.3V, where AIN2 = 0.67V
+// Ratio 0.67/3.3 = 0.20, so we can pick a bit higher, 2/8 VDD, which means
+// VBAT=4.04V
+#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_2_8
+
+#define HAS_RTC 0
+#ifdef __cplusplus
+}
+#endif
+
+/*----------------------------------------------------------------------------
+ * Arduino objects - C++ only
+ *----------------------------------------------------------------------------*/
+
+#endif
diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini
index aeda68a2e..7df05f9f5 100644
--- a/variants/nrf52840/nrf52.ini
+++ b/variants/nrf52840/nrf52.ini
@@ -5,9 +5,9 @@ platform =
platformio/nordicnrf52@10.11.0
extends = arduino_base
platform_packages =
- ; our custom Git version until they merge our PR
+ ; our custom Git version with C++17 support in platform.txt
# TODO renovate
- platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#74096746e5f167a2ff22e483d8e79bb1aef00591
+ platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#cpp17-platform
; Don't renovate toolchain-gccarmnoneeabi
platformio/toolchain-gccarmnoneeabi@~1.90301.0
@@ -35,6 +35,8 @@ build_unflags =
-g
-g1
-g0
+ -std=c++11
+ -std=gnu++11
build_src_filter =
${arduino_base.build_src_filter} - - - - - - - - - - -
diff --git a/variants/nrf52840/rak4631_epaper/platformio.ini b/variants/nrf52840/rak4631_epaper/platformio.ini
index ba48cf2a2..caa6ea328 100644
--- a/variants/nrf52840/rak4631_epaper/platformio.ini
+++ b/variants/nrf52840/rak4631_epaper/platformio.ini
@@ -15,7 +15,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
# renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028
melopero/Melopero RV3028@1.2.0
# renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library
diff --git a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini
index 714737c9d..84a582fd9 100644
--- a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini
+++ b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini
@@ -17,7 +17,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.7
+ zinggjm/GxEPD2@1.6.8
# renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028
melopero/Melopero RV3028@1.2.0
# renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library