Merge branch 'develop' into t-display-s3-pro

This commit is contained in:
Manuel
2026-03-24 21:22:46 +01:00
committed by GitHub
121 changed files with 3314 additions and 1563 deletions
-2
View File
@@ -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
+3 -5
View File
@@ -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
-2
View File
@@ -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
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
gather-artifacts:
permissions:
contents: write
contents: read
pull-requests: write
runs-on: ubuntu-latest
needs: [version, build]
+2 -2
View File
@@ -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:
+4 -6
View File
@@ -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: .
+3 -5
View File
@@ -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: |
+1 -4
View File
@@ -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
+18 -21
View File
@@ -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:
-371
View File
@@ -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
+1 -2
View File
@@ -18,8 +18,7 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
jobs:
build-debian-src:
+1 -4
View File
@@ -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
+2 -4
View File
@@ -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
-7
View File
@@ -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
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
version: latest
+1 -1
View File
@@ -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:
+7 -7
View File
@@ -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
+1 -1
View File
@@ -13,4 +13,4 @@ Lora:
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
# CS: 8
TX_GAIN_LORA: [8]
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]
+1 -1
View File
@@ -9,4 +9,4 @@ Lora:
- 23
spidev: spidev0.1
# CS: 7
TX_GAIN_LORA: [8]
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]
+1 -1
View File
@@ -12,4 +12,4 @@ Lora:
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
# GPIO_DETECT_PA: 13
TX_GAIN_LORA: [8]
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]
+54
View File
@@ -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"
}
+11 -2
View File
@@ -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
+4
View File
@@ -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} -<platform/portduino/> -<graphics/niche/>
; Common libs for communicating over TCP/IP networks such as MQTT
+2 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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, " +------------------------------------------------+ +----------------+");
+1 -1
View File
@@ -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, ...);
+16
View File
@@ -78,6 +78,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// 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 <http://www.gnu.org/licenses/>.
#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
+3 -1
View File
@@ -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) {
+8
View File
@@ -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)
+434
View File
@@ -0,0 +1,434 @@
#include "configuration.h"
#if HAS_SCREEN
#include "graphics/EmoteRenderer.h"
#include <algorithm>
#include <cstring>
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<uint8_t>(s[pos]) == 0xEF && static_cast<uint8_t>(s[pos + 1]) == 0xB8 &&
static_cast<uint8_t>(s[pos + 2]) == 0x8F;
}
static inline bool isSkinToneAt(const char *s, size_t pos, size_t len)
{
return pos + 3 < len && static_cast<uint8_t>(s[pos]) == 0xF0 && static_cast<uint8_t>(s[pos + 1]) == 0x9F &&
static_cast<uint8_t>(s[pos + 2]) == 0x8F &&
(static_cast<uint8_t>(s[pos + 3]) >= 0xBB && static_cast<uint8_t>(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<uint8_t>(text[ti]);
const uint8_t lc = static_cast<uint8_t>(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<uint8_t>(text[pos])))
return nullptr;
for (int i = 0; i < emoteCount; ++i) {
const char *label = emoteSet[i].label;
if (!label || !*label)
continue;
if (static_cast<uint8_t>(label[0]) != static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(line[next]));
}
if (next == i)
next += utf8CharLen(static_cast<uint8_t>(line[i]));
cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
i = next;
}
}
} // namespace EmoteRenderer
} // namespace graphics
#endif // HAS_SCREEN
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN
#include "graphics/emotes.h"
#include <Arduino.h>
#include <OLEDDisplay.h>
#include <string>
#include <vector>
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<char> 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
+5 -6
View File
@@ -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
#endif
+9 -9
View File
@@ -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
+11 -2
View File
@@ -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<unsigned>(config.lora.bandwidth),
static_cast<unsigned>(config.lora.spread_factor), static_cast<unsigned>(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);
+24 -25
View File
@@ -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 <algorithm>
#include <array>
#include <cmath>
#include <functional>
#include <utility>
@@ -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<uint32_t>(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);
+75 -253
View File
@@ -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<std::string> cachedLines;
static std::vector<int> 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<uint8_t>(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<uint8_t>(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<uint8_t>(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<std::string> 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<int> calculateLineHeights(const std::vector<std::string> &lines, con
std::vector<int> rowHeights;
rowHeights.reserve(lines.size());
std::vector<graphics::EmoteRenderer::LineMetrics> 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<const char *>(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
#endif
+47 -54
View File
@@ -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<uint16_t>(node ? (node->num & 0xFFFF) : 0));
auto fallbackId = [&] {
char id[12];
std::snprintf(id, sizeof(id), "(%04X)", static_cast<uint16_t>(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<int>(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
#endif
+2 -1
View File
@@ -4,6 +4,7 @@
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
#include <string>
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
+56 -18
View File
@@ -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<size_t>(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);
}
+79 -51
View File
@@ -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
+23
View File
@@ -1,6 +1,7 @@
#pragma once
#include "NodeDB.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/emotes.h"
#include <OLEDDisplay.h>
@@ -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
+2 -2
View File
@@ -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
#endif // OLED_RU
+2 -2
View File
@@ -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
#endif // OLED_UA
@@ -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
+3 -3
View File
@@ -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 = []() {
+27
View File
@@ -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
/**
+1
View File
@@ -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);
+2 -2
View File
@@ -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
+18 -1
View File
@@ -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<meshtastic_MeshPacket *>(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<int>(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
}
+5
View File
@@ -192,6 +192,11 @@ template <typename T> bool LR11x0Interface<T>::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;
+5 -3
View File
@@ -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)
+1 -1
View File
@@ -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;
+37 -3
View File
@@ -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<meshtastic_Config_LoRaConfig_ModemPreset>(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[];
+4 -4
View File
@@ -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<uint8_t> 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)
+2 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "FloodingRouter.h"
#include <optional>
#include <unordered_map>
/**
@@ -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<uint8_t> 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 */
+44 -5
View File
@@ -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)
+21
View File
@@ -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,
+307 -134
View File
@@ -21,6 +21,7 @@
#include <assert.h>
#include <pb_decode.h>
#include <pb_encode.h>
#include <string.h>
#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);
}
}
+27 -13
View File
@@ -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.
+31 -1
View File
@@ -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);
+3 -3
View File
@@ -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'
+6 -1
View File
@@ -60,7 +60,7 @@ template <typename T> bool SX126xInterface<T>::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 <typename T> bool SX126xInterface<T>::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;
+3 -3
View File
@@ -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++;
}
+10 -1
View File
@@ -1,7 +1,10 @@
#include "ServerAPI.h"
#include "Throttle.h"
#include "configuration.h"
#include <Arduino.h>
static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL;
template <typename T>
ServerAPI<T>::ServerAPI(T &_client) : StreamAPI(&client), concurrency::OSThread("ServerAPI"), client(_client)
{
@@ -28,6 +31,12 @@ template <typename T> bool ServerAPI<T>::checkIsConnected()
template <class T> int32_t ServerAPI<T>::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 <class T, class U> int32_t APIServerPort<T, U>::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();
+9
View File
@@ -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");
+1
View File
@@ -24,4 +24,5 @@ class ethServerPort : public APIServerPort<ethServerAPI, EthernetServer>
};
void initApiServer(int port = SERVER_API_DEFAULT_PORT);
void deInitApiServer();
#endif
+63
View File
@@ -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
@@ -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)
+28 -4
View File
@@ -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
+4
View File
@@ -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.
------------------------------------------------------------------------------------------------------------------------------------------ */
@@ -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)) */
+9 -7
View File
@@ -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))
+1 -4
View File
@@ -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;
+114 -94
View File
@@ -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;
}
}
}
+5 -1
View File
@@ -60,7 +60,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, 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);
+140 -240
View File
@@ -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<std::pair<bool, String>> tokenizeMessageWithEmotes(const char *msg)
{
std::vector<std::pair<bool, String>> 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<uint8_t>(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<size_t>(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<int>(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<std::pair<bool, String>> tokens = tokenizeMessageWithEmotes(msg);
// Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed)
std::vector<std::vector<std::pair<bool, String>>> lines;
std::vector<std::pair<bool, String>> 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<int>(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<int> 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<std::pair<bool, String>> 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);
+9 -2
View File
@@ -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;
+10 -2
View File
@@ -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,
+4 -3
View File
@@ -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;
}
+3 -3
View File
@@ -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);
+27 -12
View File
@@ -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<MQTTClient> 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<PubSubClient> 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";
+1 -1
View File
@@ -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
+814
View File
@@ -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 <unity.h>
#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() {}
+39 -2
View File
@@ -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());
}
+12 -21
View File
@@ -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());
}
+58 -24
View File
@@ -1,10 +1,19 @@
#include "MeshRadio.h"
#include "MeshService.h"
#include "RadioInterface.h"
#include "TestUtil.h"
#include <unity.h>
#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());
}
+1
View File
@@ -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",
+9 -3
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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 =
+4
View File
@@ -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
+1
View File
@@ -13,4 +13,5 @@ build_flags =
${esp32_base.build_flags}
-D TLORA_V1
-I variants/esp32/tlora_v1
-ULED_BUILTIN
upload_speed = 115200
-1
View File
@@ -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
@@ -1,6 +1,3 @@
// Status
#define LED_POWER 1
#define PIN_BUTTON1 47 // 功能键
#define PIN_BUTTON2 4 // 电源键
#define ALT_BUTTON_PIN PIN_BUTTON2
@@ -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 =
+2 -2
View File
@@ -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
@@ -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
@@ -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
+4 -4
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More