diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 80f5c6855..8e461998a 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -8,8 +8,6 @@ runs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Install dependencies shell: bash diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index b3744493b..d1bcd8898 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -16,8 +16,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-debian-src: @@ -28,8 +27,6 @@ jobs: with: submodules: recursive path: meshtasticd - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Install deps shell: bash @@ -42,6 +39,7 @@ jobs: sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control - name: Import GPG key + if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target' uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.PPA_GPG_PRIVATE_KEY }} @@ -60,7 +58,7 @@ jobs: run: debian/ci_pack_sdeb.sh env: SERIES: ${{ inputs.series }} - GPG_KEY_ID: ${{ steps.gpg.outputs.keyid }} + GPG_KEY_ID: ${{ steps.gpg.outputs.keyid || '' }} PKG_VERSION: ${{ steps.version.outputs.deb }} - name: Store binaries as an artifact diff --git a/.github/workflows/build_firmware.yml b/.github/workflows/build_firmware.yml index c4c7a54e0..470104688 100644 --- a/.github/workflows/build_firmware.yml +++ b/.github/workflows/build_firmware.yml @@ -26,8 +26,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Build ${{ inputs.platform }} id: build diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml index 0a1744edb..706b9cfe7 100644 --- a/.github/workflows/build_one_target.yml +++ b/.github/workflows/build_one_target.yml @@ -87,7 +87,7 @@ jobs: gather-artifacts: permissions: - contents: write + contents: read pull-requests: write runs-on: ubuntu-latest needs: [version, build] diff --git a/.github/workflows/daily_packaging.yml b/.github/workflows/daily_packaging.yml index 7df688055..16363f562 100644 --- a/.github/workflows/daily_packaging.yml +++ b/.github/workflows/daily_packaging.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: push: branches: - - master + - develop # Default branch, same as 'cron' above paths: - debian/** - "*.rpkg" @@ -16,7 +16,7 @@ on: - .github/workflows/hook_copr.yml permissions: - contents: write + contents: read packages: write jobs: diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 826117586..72987c01e 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -37,7 +37,7 @@ on: value: ${{ jobs.docker-build.outputs.digest }} permissions: - contents: write + contents: read packages: write jobs: @@ -50,8 +50,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Get release version string run: | diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml index 0f209201a..b2fd12599 100644 --- a/.github/workflows/docker_manifest.yml +++ b/.github/workflows/docker_manifest.yml @@ -12,7 +12,7 @@ on: type: string permissions: - contents: write + contents: read packages: write jobs: @@ -86,8 +86,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Get release version string run: | diff --git a/.github/workflows/hook_copr.yml b/.github/workflows/hook_copr.yml index eb4ebc57b..c419848a8 100644 --- a/.github/workflows/hook_copr.yml +++ b/.github/workflows/hook_copr.yml @@ -11,8 +11,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-copr-hook: @@ -22,8 +21,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{ github.ref }} - repository: ${{ github.repository }} - name: Trigger COPR build uses: vidplace7/copr-build@main diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 39da22ae0..1221c171f 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -15,8 +15,7 @@ on: - "**.md" - version.properties - # Note: This is different from "pull_request". Need to specify ref when doing checkouts. - pull_request_target: + pull_request: branches: - master - develop @@ -29,6 +28,8 @@ on: workflow_dispatch: +permissions: read-all + jobs: setup: strategy: @@ -88,8 +89,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Check ${{ matrix.check.board }} uses: meshtastic/gh-action-firmware@main with: @@ -126,9 +125,16 @@ jobs: test-native: if: ${{ !contains(github.ref_name, 'event/') && github.repository == 'meshtastic/firmware' }} + permissions: # Needed for dorny/test-reporter. + contents: read + actions: read + checks: write uses: ./.github/workflows/test_native.yml docker: + permissions: # Needed for pushing to GHCR. + contents: read + packages: write strategy: fail-fast: false matrix: @@ -153,9 +159,6 @@ jobs: gather-artifacts: # trunk-ignore(checkov/CKV2_GHA_1) if: github.repository == 'meshtastic/firmware' - permissions: - contents: write - pull-requests: write strategy: fail-fast: false matrix: @@ -173,9 +176,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - uses: actions/download-artifact@v8 with: @@ -231,13 +231,6 @@ jobs: path: ./*.elf retention-days: 30 - - uses: scruplelesswizard/comment-artifact@main - if: ${{ github.event_name == 'pull_request' }} - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation" - github-token: ${{ secrets.GITHUB_TOKEN }} - shame: if: github.repository == 'meshtastic/firmware' continue-on-error: true @@ -245,7 +238,7 @@ jobs: needs: [build] steps: - uses: actions/checkout@v6 - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' with: filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head) fetch-depth: 0 @@ -263,24 +256,26 @@ jobs: overwrite: true path: manifests-new/*.mt.json - name: Find the merge base - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV env: base: ${{ github.base_ref }} head: ${{ github.sha }} # Currently broken (for-loop through EVERY artifact -- rate limiting) # - name: Download the old manifests - # if: github.event_name == 'pull_request_target' + # if: github.event_name == 'pull_request' # run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/ # env: # GH_TOKEN: ${{ github.token }} # merge_base: ${{ env.MERGE_BASE }} # repo: ${{ github.repository }} # - name: Do scan and post comment - # if: github.event_name == 'pull_request_target' + # if: github.event_name == 'pull_request' # run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/ release-artifacts: + permissions: # Needed for 'gh release upload'. + contents: write runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }} outputs: @@ -372,6 +367,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-firmware: + permissions: # Needed for 'gh release upload'. + contents: write strategy: fail-fast: false matrix: diff --git a/.github/workflows/merge_queue.yml b/.github/workflows/merge_queue.yml deleted file mode 100644 index ad8534984..000000000 --- a/.github/workflows/merge_queue.yml +++ /dev/null @@ -1,371 +0,0 @@ -name: Merge Queue -# Not sure how concurrency works in merge_queue, removing for now. -# concurrency: -# group: merge-queue-${{ github.head_ref || github.run_id }} -# cancel-in-progress: true -on: - # Merge group is a special trigger that is used to trigger the workflow when a merge group is created. - merge_group: - -jobs: - setup: - strategy: - fail-fast: true - matrix: - arch: - - all - - check - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: 3.x - cache: pip - - run: pip install -U platformio - - name: Generate matrix - id: jsonStep - run: | - if [[ "$GITHUB_HEAD_REF" == "" ]]; then - TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}}) - else - TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr) - fi - echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF" - echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT - outputs: - all: ${{ steps.jsonStep.outputs.all }} - check: ${{ steps.jsonStep.outputs.check }} - - version: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Get release version string - run: | - echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT - echo "deb=$(./bin/buildinfo.py deb)" >> $GITHUB_OUTPUT - id: version - env: - BUILD_LOCATION: local - outputs: - long: ${{ steps.version.outputs.long }} - deb: ${{ steps.version.outputs.deb }} - - check: - needs: setup - strategy: - fail-fast: true - matrix: - check: ${{ fromJson(needs.setup.outputs.check) }} - - runs-on: ubuntu-latest - if: ${{ github.event_name != 'workflow_dispatch' }} - steps: - - uses: actions/checkout@v6 - - name: Build base - id: base - uses: ./.github/actions/setup-base - - name: Check ${{ matrix.check.board }} - run: bin/check-all.sh ${{ matrix.check.board }} - - build: - needs: [setup, version] - strategy: - matrix: - build: ${{ fromJson(needs.setup.outputs.all) }} - uses: ./.github/workflows/build_firmware.yml - with: - version: ${{ needs.version.outputs.long }} - pio_env: ${{ matrix.build.board }} - platform: ${{ matrix.build.platform }} - - build-debian-src: - if: github.repository == 'meshtastic/firmware' - uses: ./.github/workflows/build_debian_src.yml - with: - series: UNRELEASED - build_location: local - secrets: inherit - - package-pio-deps-native-tft: - if: ${{ github.event_name == 'workflow_dispatch' }} - uses: ./.github/workflows/package_pio_deps.yml - with: - pio_env: native-tft - secrets: inherit - - test-native: - if: ${{ !contains(github.ref_name, 'event/') }} - uses: ./.github/workflows/test_native.yml - - docker: - strategy: - fail-fast: false - matrix: - distro: [debian, alpine] - platform: [linux/amd64, linux/arm64, linux/arm/v7] - pio_env: [native, native-tft] - exclude: - - distro: alpine - platform: linux/arm/v7 - - pio_env: native-tft - platform: linux/arm64 - - pio_env: native-tft - platform: linux/arm/v7 - uses: ./.github/workflows/docker_build.yml - with: - distro: ${{ matrix.distro }} - platform: ${{ matrix.platform }} - runs-on: ${{ contains(matrix.platform, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - pio_env: ${{ matrix.pio_env }} - push: false - - gather-artifacts: - # trunk-ignore(checkov/CKV2_GHA_1) - permissions: - contents: write - pull-requests: write - strategy: - fail-fast: false - matrix: - arch: - - esp32 - - esp32s3 - - esp32c3 - - esp32c6 - - nrf52840 - - rp2040 - - rp2350 - - stm32 - runs-on: ubuntu-latest - needs: [version, build] - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - - uses: actions/download-artifact@v8 - with: - path: ./ - pattern: firmware-${{matrix.arch}}-* - merge-multiple: true - - - name: Display structure of downloaded files - run: ls -R - - - name: Move files up - run: mv -b -t ./ ./bin/device-*.sh ./bin/device-*.bat - - - name: Repackage in single firmware zip - uses: actions/upload-artifact@v7 - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - overwrite: true - path: | - ./firmware-*.bin - ./firmware-*.uf2 - ./firmware-*.hex - ./firmware-*.zip - ./device-*.sh - ./device-*.bat - ./littlefs-*.bin - ./bleota*bin - ./Meshtastic_nRF52_factory_erase*.uf2 - retention-days: 30 - - - uses: actions/download-artifact@v8 - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./output - - # For diagnostics - - name: Show artifacts - run: ls -lR - - - name: Device scripts permissions - run: | - chmod +x ./output/device-install.sh || true - chmod +x ./output/device-update.sh || true - - - name: Zip firmware - run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output - - - name: Repackage in single elfs zip - uses: actions/upload-artifact@v7 - with: - name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }} - overwrite: true - path: ./*.elf - retention-days: 30 - - - uses: scruplelesswizard/comment-artifact@main - if: ${{ github.event_name == 'pull_request' }} - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation" - github-token: ${{ secrets.GITHUB_TOKEN }} - - release-artifacts: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' }} - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - needs: - - version - - gather-artifacts - - build-debian-src - - package-pio-deps-native-tft - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Create release - uses: softprops/action-gh-release@v2 - id: create_release - with: - draft: true - prerelease: true - name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha - tag_name: v${{ needs.version.outputs.long }} - body: | - Autogenerated by github action, developer should edit as required before publishing... - - - name: Download source deb - uses: actions/download-artifact@v8 - with: - pattern: firmware-debian-${{ needs.version.outputs.deb }}~UNRELEASED-src - merge-multiple: true - path: ./output/debian-src - - - name: Download `native-tft` pio deps - uses: actions/download-artifact@v8 - with: - pattern: platformio-deps-native-tft-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./output/pio-deps-native-tft - - - name: Zip Linux sources - working-directory: output - run: | - zip -j -9 -r ./meshtasticd-${{ needs.version.outputs.deb }}-src.zip ./debian-src - zip -9 -r ./platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip ./pio-deps-native-tft - - # For diagnostics - - name: Display structure of downloaded files - run: ls -lR - - - name: Add Linux sources to GtiHub Release - # Only run when targeting master branch with workflow_dispatch - if: ${{ github.ref_name == 'master' }} - run: | - gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip - gh release upload v${{ needs.version.outputs.long }} ./output/platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - release-firmware: - strategy: - fail-fast: false - matrix: - arch: - - esp32 - - esp32s3 - - esp32c3 - - esp32c6 - - nrf52840 - - rp2040 - - rp2350 - - stm32 - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' }} - needs: [release-artifacts, version] - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: 3.x - - - uses: actions/download-artifact@v8 - with: - pattern: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./output - - - name: Display structure of downloaded files - run: ls -lR - - - name: Device scripts permissions - run: | - chmod +x ./output/device-install.sh || true - chmod +x ./output/device-update.sh || true - - - name: Zip firmware - run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output - - - uses: actions/download-artifact@v8 - with: - name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./elfs - - - name: Zip debug elfs - run: zip -j -9 -r ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./elfs - - # For diagnostics - - name: Display structure of downloaded files - run: ls -lR - - - name: Add bins and debug elfs to GitHub Release - # Only run when targeting master branch with workflow_dispatch - if: ${{ github.ref_name == 'master' }} - run: | - gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip - gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-firmware: - runs-on: ubuntu-24.04 - if: ${{ github.event_name == 'workflow_dispatch' }} - needs: [release-firmware, version] - env: - targets: |- - esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: 3.x - - - uses: actions/download-artifact@v8 - with: - pattern: firmware-{${{ env.targets }}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./publish - - - name: Publish firmware to meshtastic.github.io - uses: peaceiris/actions-gh-pages@v4 - env: - # On event/* branches, use the event name as the destination prefix - DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }} - with: - deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} - external_repository: meshtastic/meshtastic.github.io - publish_branch: master - publish_dir: ./publish - destination_dir: ${{ env.DEST_PREFIX }}firmware-${{ needs.version.outputs.long }} - keep_files: true - user_name: github-actions[bot] - user_email: github-actions[bot]@users.noreply.github.com - commit_message: ${{ needs.version.outputs.long }} - enable_jekyll: true diff --git a/.github/workflows/package_obs.yml b/.github/workflows/package_obs.yml index 395b721a5..b491f0062 100644 --- a/.github/workflows/package_obs.yml +++ b/.github/workflows/package_obs.yml @@ -18,8 +18,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-debian-src: diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml index 8fe675cb1..6bd256f52 100644 --- a/.github/workflows/package_pio_deps.yml +++ b/.github/workflows/package_pio_deps.yml @@ -16,8 +16,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: pkg-pio-libdeps: @@ -27,8 +26,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/package_ppa.yml b/.github/workflows/package_ppa.yml index 86e655809..334a7016d 100644 --- a/.github/workflows/package_ppa.yml +++ b/.github/workflows/package_ppa.yml @@ -16,8 +16,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-debian-src: @@ -36,8 +35,6 @@ jobs: with: submodules: recursive path: meshtasticd - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Install deps shell: bash @@ -70,5 +67,6 @@ jobs: - name: Publish with dput if: ${{ github.event_name != 'pull_request_target' && github.event_name != 'pull_request' }} + timeout-minutes: 15 # dput is terrible, sometimes runs 'forever' run: | dput ${{ inputs.ppa_repo }} meshtasticd_${{ steps.version.outputs.deb }}~${{ inputs.series }}_source.changes diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 9d1b475a0..d0179bba8 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -16,8 +16,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} submodules: recursive - name: Setup native build @@ -72,8 +70,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} submodules: recursive - name: Setup native build @@ -128,9 +124,6 @@ jobs: if: always() steps: - uses: actions/checkout@v6 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Get release version string run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/update_protobufs.yml b/.github/workflows/update_protobufs.yml index 35565d1e4..e9380467e 100644 --- a/.github/workflows/update_protobufs.yml +++ b/.github/workflows/update_protobufs.yml @@ -6,7 +6,7 @@ permissions: read-all jobs: update-protobufs: runs-on: ubuntu-latest - permissions: + permissions: # Needed for peter-evans/create-pull-request. contents: write pull-requests: write steps: diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 797800ae0..0385ffd6a 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,15 +8,15 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.509 - - renovate@43.77.8 + - checkov@3.2.510 + - renovate@43.84.0 - prettier@3.8.1 - trufflehog@3.93.8 - yamllint@1.38.0 - bandit@1.9.4 - trivy@0.69.3 - taplo@0.10.0 - - ruff@0.15.6 + - ruff@0.15.7 - isort@8.0.1 - markdownlint@0.48.0 - oxipng@10.1.0 diff --git a/boards/heltec_mesh_node_t096.json b/boards/heltec_mesh_node_t096.json new file mode 100644 index 000000000..1e417c5b4 --- /dev/null +++ b/boards/heltec_mesh_node_t096.json @@ -0,0 +1,54 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x4405"], + ["0x239A", "0x0029"], + ["0x239A", "0x002A"], + ["0x2886", "0x1667"] + ], + "usb_product": "HT-n5262G", + "mcu": "nrf52840", + "variant": "heltec_mesh_node_t096", + "variants_dir": "variants", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "jlink_device": "nRF52840_xxAA", + "onboard_tools": ["jlink"], + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52840-mdk-rs" + }, + "frameworks": ["arduino"], + "name": "Heltec nrf (Adafruit BSP)", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/debian/ci_pack_sdeb.sh b/debian/ci_pack_sdeb.sh index ad5289f40..7b2418ff6 100755 --- a/debian/ci_pack_sdeb.sh +++ b/debian/ci_pack_sdeb.sh @@ -27,5 +27,10 @@ rm -rf debian/changelog dch --create --distribution "$SERIES" --package "$package" --newversion "$PKG_VERSION~$SERIES" \ "GitHub Actions Automatic packaging for $PKG_VERSION~$SERIES" -# Build the source deb -debuild -S -nc -k"$GPG_KEY_ID" +if [[ -n $GPG_KEY_ID ]]; then + # Build and sign the source deb + debuild -S -nc -k"$GPG_KEY_ID" +else + # Build the source deb without signing (forks) + debuild -S -nc +fi diff --git a/protobufs b/protobufs index eba2d94c8..cb1f89372 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit eba2d94c8d53e798f560e12d63d0457e1e22759e +Subproject commit cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0 diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index d88f9fc9f..fdcf840dc 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC bool usePreset) { - // If use_preset is false, always return "Custom" + // If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels + // rely on this being a stable literal for channel-name hashing and default-channel detection. if (!usePreset) { return "Custom"; } diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 672c8334c..6ff7bbb18 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -345,7 +345,7 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...) return; } -void RedirectablePrint::hexDump(const char *logLevel, unsigned char *buf, uint16_t len) +void RedirectablePrint::hexDump(const char *logLevel, const unsigned char *buf, uint16_t len) { const char alphabet[17] = "0123456789abcdef"; log(logLevel, " +------------------------------------------------+ +----------------+"); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index 45b62b7af..c66226171 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -44,7 +44,7 @@ class RedirectablePrint : public Print /** like printf but va_list based */ size_t vprintf(const char *logLevel, const char *format, va_list arg); - void hexDump(const char *logLevel, unsigned char *buf, uint16_t len); + void hexDump(const char *logLevel, const unsigned char *buf, uint16_t len); std::string mt_sprintf(const std::string fmt_str, ...); diff --git a/src/configuration.h b/src/configuration.h index a5f2cd9a9..11be86007 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -78,6 +78,11 @@ along with this program. If not, see . // Configuration // ----------------------------------------------------------------------------- +// Pre-hop drop handling (compile-time flag). +#ifndef MESHTASTIC_PREHOP_DROP +#define MESHTASTIC_PREHOP_DROP 0 +#endif + /// Convert a preprocessor name into a quoted string #define xstr(s) ystr(s) #define ystr(s) #s @@ -151,8 +156,19 @@ along with this program. If not, see . #ifdef USE_KCT8103L_PA // Power Amps are often non-linear, so we can use an array of values for the power curve +#if defined(HELTEC_WIRELESS_TRACKER_V2) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 12, 11, 10, 9, 8, 7 +#elif defined(HELTEC_MESH_NODE_T096) +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7 +#else +// If a board enables USE_KCT8103L_PA but does not match a known variant and has +// not already provided a PA curve, fail at compile time to avoid unsafe defaults. +#if !defined(NUM_PA_POINTS) || !defined(TX_GAIN_LORA) +#error "USE_KCT8103L_PA is defined, but no PA gain curve (NUM_PA_POINTS / TX_GAIN_LORA) is configured for this board." +#endif +#endif #endif #ifdef RAK13302 @@ -499,6 +515,7 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1 #define MESHTASTIC_EXCLUDE_STOREFORWARD 1 #define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1 +#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1 #define MESHTASTIC_EXCLUDE_ATAK 1 #define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1 #define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1 diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 06862a2c4..2e00c11ce 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -341,7 +341,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = DPS310; break; } - break; + if (type == DPS310) { + break; + } default: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // GET_ID switch (registerValue) { diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 1260d8b15..cf5511a33 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -103,6 +103,14 @@ static int32_t gpsSwitch() if (gps) { int currentState = digitalRead(PIN_GPS_SWITCH); + // Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS. + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + gps->disable(); + lastState = currentState; + firstrun = false; + return 1000; + } + // if the switch is set to zero, disable the GPS Thread if (firstrun) if (currentState == LOW) diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index faf72e06d..12e229da3 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -143,6 +143,10 @@ bool EInkDisplay::connect() #ifdef ELECROW_ThinkNode_M1 // ThinkNode M1 has a hardware dimmable backlight. Start enabled digitalWrite(PIN_EINK_EN, HIGH); +#elif defined(MINI_EPAPER_S3) + // T-Mini Epaper S3 requires panel power rail enabled before SPI transfer. + digitalWrite(PIN_EINK_EN, HIGH); + delay(10); #else digitalWrite(PIN_EINK_EN, LOW); #endif @@ -202,7 +206,8 @@ bool EInkDisplay::connect() } #elif defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || \ - defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) + defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \ + defined(MINI_EPAPER_S3) { // Start HSPI hspi = new SPIClass(HSPI); @@ -216,9 +221,13 @@ bool EInkDisplay::connect() // Init GxEPD2 adafruitDisplay->init(); +#if defined(MINI_EPAPER_S3) + adafruitDisplay->setRotation(3); +#else adafruitDisplay->setRotation(3); #if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) adafruitDisplay->setRotation(0); +#endif #endif } #elif defined(PCA10059) || defined(ME25LS01) @@ -259,17 +268,6 @@ bool EInkDisplay::connect() adafruitDisplay->setRotation(3); adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); } -#elif defined(MINI_EPAPER_S3) - spi1 = new SPIClass(HSPI); - spi1->begin(PIN_SPI1_SCK, PIN_SPI1_MISO, PIN_SPI1_MOSI, PIN_EINK_CS); - - // Create GxEPD2 objects - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1); - adafruitDisplay = new GxEPD2_BW(*lowLevel); - - // Init GxEPD2 - adafruitDisplay->init(); - adafruitDisplay->setRotation(1); #elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213) // Detect display model, before starting SPI diff --git a/src/graphics/EInkDisplay2.h b/src/graphics/EInkDisplay2.h index 14adeda12..7a86b0f57 100644 --- a/src/graphics/EInkDisplay2.h +++ b/src/graphics/EInkDisplay2.h @@ -89,12 +89,12 @@ class EInkDisplay : public OLEDDisplay // If display uses HSPI #if defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E213) || \ defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || defined(CROWPANEL_ESP32S3_5_EPAPER) || \ - defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) + defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) || \ + defined(MINI_EPAPER_S3) SPIClass *hspi = NULL; #endif -#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK) || \ - defined(MINI_EPAPER_S3) +#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK) SPIClass *spi1 = NULL; #endif diff --git a/src/graphics/EmoteRenderer.cpp b/src/graphics/EmoteRenderer.cpp new file mode 100644 index 000000000..6fa0adb4c --- /dev/null +++ b/src/graphics/EmoteRenderer.cpp @@ -0,0 +1,434 @@ +#include "configuration.h" +#if HAS_SCREEN + +#include "graphics/EmoteRenderer.h" +#include +#include + +namespace graphics +{ +namespace EmoteRenderer +{ + +static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len) +{ +#if defined(OLED_UA) || defined(OLED_RU) + return display->getStringWidth(text, len, true); +#else + (void)len; + return display->getStringWidth(text); +#endif +} + +size_t utf8CharLen(uint8_t c) +{ + if ((c & 0xE0) == 0xC0) + return 2; + if ((c & 0xF0) == 0xE0) + return 3; + if ((c & 0xF8) == 0xF0) + return 4; + return 1; +} + +static inline bool isPossibleEmoteLead(uint8_t c) +{ + // All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges. + return c == 0xE2 || c == 0xF0; +} + +static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len) +{ + char chunk[5] = {0, 0, 0, 0, 0}; + if (len > 4) + len = 4; + memcpy(chunk, text, len); + return getStringWidth(display, chunk, len); +} + +static inline bool isFE0FAt(const char *s, size_t pos, size_t len) +{ + return pos + 2 < len && static_cast(s[pos]) == 0xEF && static_cast(s[pos + 1]) == 0xB8 && + static_cast(s[pos + 2]) == 0x8F; +} + +static inline bool isSkinToneAt(const char *s, size_t pos, size_t len) +{ + return pos + 3 < len && static_cast(s[pos]) == 0xF0 && static_cast(s[pos + 1]) == 0x9F && + static_cast(s[pos + 2]) == 0x8F && + (static_cast(s[pos + 3]) >= 0xBB && static_cast(s[pos + 3]) <= 0xBF); +} + +static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len) +{ + // Skip modifiers that do not change which bitmap we render. + if (isFE0FAt(s, pos, len)) + return 3; + if (isSkinToneAt(s, pos, len)) + return 4; + return 0; +} + +const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount) +{ + if (!label || !*label) + return nullptr; + + for (int i = 0; i < emoteCount; ++i) { + if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0) + return &emoteSet[i]; + } + + return nullptr; +} + +static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed, + size_t &matchScore) +{ + // Treat FE0F and skin-tone modifiers as optional while matching. + textConsumed = 0; + matchScore = 0; + if (!label || !*label || pos >= textLen) + return false; + + const size_t labelLen = strlen(label); + size_t ti = pos; + size_t li = 0; + + while (true) { + while (ti < textLen) { + const size_t skipLen = ignorableModifierLenAt(text, ti, textLen); + if (!skipLen) + break; + ti += skipLen; + } + while (li < labelLen) { + const size_t skipLen = ignorableModifierLenAt(label, li, labelLen); + if (!skipLen) + break; + li += skipLen; + } + + if (li >= labelLen) { + while (ti < textLen) { + const size_t skipLen = ignorableModifierLenAt(text, ti, textLen); + if (!skipLen) + break; + ti += skipLen; + } + textConsumed = ti - pos; + return textConsumed > 0; + } + + if (ti >= textLen) + return false; + + const uint8_t tc = static_cast(text[ti]); + const uint8_t lc = static_cast(label[li]); + const size_t tlen = utf8CharLen(tc); + const size_t llen = utf8CharLen(lc); + + if (tlen != llen || ti + tlen > textLen || li + llen > labelLen) + return false; + if (memcmp(text + ti, label + li, tlen) != 0) + return false; + + ti += tlen; + li += llen; + matchScore += llen; + } +} + +const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount) +{ + // Prefer the longest matching label at this byte offset. + const Emote *matched = nullptr; + matchLen = 0; + size_t bestScore = 0; + if (!text || pos >= textLen) + return nullptr; + + if (!isPossibleEmoteLead(static_cast(text[pos]))) + return nullptr; + + for (int i = 0; i < emoteCount; ++i) { + const char *label = emoteSet[i].label; + if (!label || !*label) + continue; + if (static_cast(label[0]) != static_cast(text[pos])) + continue; + + const size_t labelLen = strlen(label); + if (labelLen == 0) + continue; + + size_t candidateLen = 0; + size_t candidateScore = 0; + if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) { + candidateLen = labelLen; + candidateScore = labelLen; + } else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) { + // Matched with FE0F/skin tone modifiers treated as optional. + } else { + continue; + } + + if (candidateScore > bestScore) { + matched = &emoteSet[i]; + matchLen = candidateLen; + bestScore = candidateScore; + } + } + + return matched; +} + +static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight, + const Emote *emoteSet, int emoteCount, int emoteSpacing) +{ + // Scan once to collect width and tallest emote for this line. + LineMetrics metrics{0, fallbackHeight, false}; + if (!line) + return metrics; + + for (size_t i = 0; i < lineLen;) { + size_t matchLen = 0; + const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); + if (matched) { + metrics.hasEmote = true; + metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height); + if (display) + metrics.width += matched->width + emoteSpacing; + i += matchLen; + continue; + } + + const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); + if (skipLen) { + i += skipLen; + continue; + } + + const size_t charLen = utf8CharLen(static_cast(line[i])); + if (display) + metrics.width += getUtf8ChunkWidth(display, line + i, charLen); + i += charLen; + } + + return metrics; +} + +LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount, + int emoteSpacing) +{ + return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing); +} + +int maxEmoteHeight(const Emote *emoteSet, int emoteCount) +{ + int tallest = 0; + for (int i = 0; i < emoteCount; ++i) { + if (emoteSet[i].label && *emoteSet[i].label) + tallest = std::max(tallest, emoteSet[i].height); + } + return tallest; +} + +int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing) +{ + if (!display) + return 0; + + if (!line || !*line) + return 0; + + return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width; +} + +static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw, + bool fauxBold) +{ + // Draw plain-text runs in chunks so UTF-8 stays intact. + if (!text || len == 0) + return cursorX; + + char chunk[33]; + size_t pos = 0; + while (pos < len) { + size_t chunkLen = 0; + while (pos + chunkLen < len) { + const size_t charLen = utf8CharLen(static_cast(text[pos + chunkLen])); + if (chunkLen + charLen >= sizeof(chunk)) + break; + chunkLen += charLen; + } + + if (chunkLen == 0) { + chunkLen = std::min(len - pos, sizeof(chunk) - 1); + } + + memcpy(chunk, text + pos, chunkLen); + chunk[chunkLen] = '\0'; + if (draw) { + if (fauxBold) + display->drawString(cursorX + 1, fontY, chunk); + display->drawString(cursorX, fontY, chunk); + } + cursorX += getStringWidth(display, chunk, chunkLen); + pos += chunkLen; + } + + return cursorX; +} + +size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis, + const Emote *emoteSet, int emoteCount, int emoteSpacing) +{ + if (!out || outSize == 0) + return 0; + + out[0] = '\0'; + if (!display || !line || maxWidth <= 0) + return 0; + + const size_t lineLen = strlen(line); + const int suffixWidth = + (ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0; + const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : ""; + const size_t suffixLen = strlen(suffix); + const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0); + + if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) { + strncpy(out, line, outSize - 1); + out[outSize - 1] = '\0'; + return strlen(out); + } + + int used = 0; + size_t cut = 0; + for (size_t i = 0; i < lineLen;) { + // Keep whole emotes together when deciding where to cut. + int tokenWidth = 0; + size_t advance = 0; + + if (isPossibleEmoteLead(static_cast(line[i]))) { + size_t matchLen = 0; + const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); + if (matched) { + tokenWidth = matched->width + emoteSpacing; + advance = matchLen; + } + } + + if (advance == 0) { + const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); + if (skipLen) { + i += skipLen; + cut = i; + continue; + } + + const size_t charLen = utf8CharLen(static_cast(line[i])); + tokenWidth = getUtf8ChunkWidth(display, line + i, charLen); + advance = charLen; + } + + if (used + tokenWidth > availableWidth) + break; + + used += tokenWidth; + i += advance; + cut = i; + } + + if (cut == 0) { + strncpy(out, suffix, outSize - 1); + out[outSize - 1] = '\0'; + return strlen(out); + } + + size_t copyLen = cut; + if (copyLen > outSize - 1) + copyLen = outSize - 1; + if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) { + copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0; + } + + memcpy(out, line, copyLen); + size_t totalLen = copyLen; + if (suffixLen > 0 && totalLen < outSize - 1) { + memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen)); + totalLen += std::min(suffixLen, outSize - 1 - totalLen); + } + out[totalLen] = '\0'; + return totalLen; +} + +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet, + int emoteCount, int emoteSpacing, bool fauxBold) +{ + if (!line) + return; + + const size_t lineLen = strlen(line); + // Center text vertically when any emote is taller than the font. + const int maxIconHeight = + analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight; + const int lineHeight = std::max(fontHeight, maxIconHeight); + const int fontY = y + (lineHeight - fontHeight) / 2; + + int cursorX = x; + bool inBold = false; + + for (size_t i = 0; i < lineLen;) { + // Toggle faux bold. + if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') { + inBold = !inBold; + i += 2; + continue; + } + + const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); + if (skipLen) { + i += skipLen; + continue; + } + + size_t matchLen = 0; + const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); + if (matched) { + const int iconY = y + (lineHeight - matched->height) / 2; + display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap); + cursorX += matched->width + emoteSpacing; + i += matchLen; + continue; + } + + size_t next = i; + while (next < lineLen) { + // Stop the text run before the next emote or bold marker. + if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*') + break; + + if (ignorableModifierLenAt(line, next, lineLen)) + break; + + size_t nextMatchLen = 0; + if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr) + break; + + next += utf8CharLen(static_cast(line[next])); + } + + if (next == i) + next += utf8CharLen(static_cast(line[i])); + + cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold); + i = next; + } +} + +} // namespace EmoteRenderer +} // namespace graphics + +#endif // HAS_SCREEN diff --git a/src/graphics/EmoteRenderer.h b/src/graphics/EmoteRenderer.h new file mode 100644 index 000000000..93cee4b25 --- /dev/null +++ b/src/graphics/EmoteRenderer.h @@ -0,0 +1,79 @@ +#pragma once +#include "configuration.h" + +#if HAS_SCREEN +#include "graphics/emotes.h" +#include +#include +#include +#include + +namespace graphics +{ +namespace EmoteRenderer +{ + +struct LineMetrics { + int width; + int tallestHeight; + bool hasEmote; +}; + +size_t utf8CharLen(uint8_t c); + +const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet = emotes, int emoteCount = numEmotes); +const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes, + int emoteCount = numEmotes); +inline const Emote *findEmoteAt(const std::string &text, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes, + int emoteCount = numEmotes) +{ + return findEmoteAt(text.c_str(), text.length(), pos, matchLen, emoteSet, emoteCount); +} + +LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight = 0, const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1); +inline LineMetrics analyzeLine(OLEDDisplay *display, const std::string &line, int fallbackHeight = 0, + const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1) +{ + return analyzeLine(display, line.c_str(), fallbackHeight, emoteSet, emoteCount, emoteSpacing); +} +int maxEmoteHeight(const Emote *emoteSet = emotes, int emoteCount = numEmotes); + +int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet = emotes, int emoteCount = numEmotes, + int emoteSpacing = 1); +inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1) +{ + return measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing); +} +size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, + const char *ellipsis = "...", const Emote *emoteSet = emotes, int emoteCount = numEmotes, + int emoteSpacing = 1); +inline std::string truncateToWidth(OLEDDisplay *display, const std::string &line, int maxWidth, + const std::string &ellipsis = "...", const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1) +{ + if (!display || maxWidth <= 0) + return ""; + if (measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing) <= maxWidth) + return line; + + std::vector out(line.length() + ellipsis.length() + 1, '\0'); + truncateToWidth(display, line.c_str(), out.data(), out.size(), maxWidth, ellipsis.c_str(), emoteSet, emoteCount, + emoteSpacing); + return std::string(out.data()); +} + +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1, bool fauxBold = true); +inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight, + const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1, + bool fauxBold = true) +{ + drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSet, emoteCount, emoteSpacing, fauxBold); +} + +} // namespace EmoteRenderer +} // namespace graphics + +#endif // HAS_SCREEN diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index b86f3e32c..ec50654ae 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -121,11 +121,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } // === Screen Title === - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(SCREEN_WIDTH / 2, y, titleStr); - if (config.display.heading_bold) { - display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr); - } + const char *headerTitle = titleStr ? titleStr : ""; + const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); + const int titleX = (SCREEN_WIDTH - titleWidth) / 2; + UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -515,4 +514,4 @@ std::string sanitizeString(const std::string &input) } } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 1c2eb72d4..005ead292 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -1348,7 +1348,7 @@ void TFTDisplay::sendCommand(uint8_t com) digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON); #elif defined(HACKADAY_COMMUNICATOR) tft->displayOn(); -#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) +#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) tft->wakeup(); tft->powerSaveOff(); #endif @@ -1359,7 +1359,7 @@ void TFTDisplay::sendCommand(uint8_t com) #ifdef UNPHONE unphone.backlight(true); // using unPhone library #endif -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) #elif !defined(M5STACK) && !defined(ST7789_CS) && \ !defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function tft->setBrightness(172); @@ -1375,7 +1375,7 @@ void TFTDisplay::sendCommand(uint8_t com) digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON); #elif defined(HACKADAY_COMMUNICATOR) tft->displayOff(); -#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) +#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) tft->sleep(); tft->powerSaveOn(); #endif @@ -1386,7 +1386,7 @@ void TFTDisplay::sendCommand(uint8_t com) #ifdef UNPHONE unphone.backlight(false); // using unPhone library #endif -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) #elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) tft->setBrightness(0); #endif @@ -1401,7 +1401,7 @@ void TFTDisplay::sendCommand(uint8_t com) void TFTDisplay::setDisplayBrightness(uint8_t _brightness) { -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) // todo #elif !defined(HACKADAY_COMMUNICATOR) tft->setBrightness(_brightness); @@ -1421,7 +1421,7 @@ bool TFTDisplay::hasTouch(void) { #ifdef RAK14014 return true; -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) +#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) return tft->touch() != nullptr; #else return false; @@ -1440,7 +1440,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y) } else { return false; } -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) +#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) return tft->getTouch(x, y); #else return false; @@ -1457,7 +1457,7 @@ bool TFTDisplay::connect() { concurrency::LockGuard g(spiLock); LOG_INFO("Do TFT init"); -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) tft = new TFT_eSPI; #elif defined(HACKADAY_COMMUNICATOR) bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */); @@ -1494,7 +1494,7 @@ bool TFTDisplay::connect() ft6336u.begin(); pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(SCREEN_TOUCH_INT), rak14014_tpIntHandle, FALLING); -#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) +#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) || defined(HELTEC_MESH_NODE_T096) tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) tft->setRotation(2); // T-Watch S3 left-handed orientation diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 2069c71ec..27a50d3f9 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -408,7 +408,16 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(nameX, getTextPositions(display)[line++], device_role); // === Third Row: Radio Preset === - auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset); + // For custom modem settings show the actual parameters; for presets use the preset name. + char modeStr[16]; + if (!config.lora.use_preset) { + snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast(config.lora.bandwidth), + static_cast(config.lora.spread_factor), static_cast(config.lora.coding_rate)); + } else { + strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), + sizeof(modeStr) - 1); + modeStr[sizeof(modeStr) - 1] = '\0'; + } char regionradiopreset[25]; const char *region = myRegion ? myRegion->name : NULL; @@ -416,7 +425,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, if (currentResolution == ScreenResolution::UltraLow) { snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region); } else { - snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); + snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr); } } textWidth = display->getStringWidth(regionradiopreset); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index f57c39512..daefff8ee 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -18,6 +18,7 @@ #include "main.h" #include "mesh/Default.h" #include "mesh/MeshTypes.h" +#include "mesh/RadioLibInterface.h" #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" @@ -25,6 +26,7 @@ #include "modules/TraceRouteModule.h" #include #include +#include #include #include @@ -159,31 +161,20 @@ void menuHandler::LoraRegionPicker(uint32_t duration) return; } + // Guard: without a reboot, reconfigure() applies the region directly. + // Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot. + if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 && + !(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) { + LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring LORA_24 selection"); + return; + } + config.lora.region = selectedRegion; auto changes = SEGMENT_CONFIG; - // FIXME: This should be a method consolidated with the same logic in the admin message as well - // This is needed as we wait til picking the LoRa region to generate keys for the first time. #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - // public key is derived from private, so this will always have the same result. - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif config.lora.tx_enabled = true; @@ -199,7 +190,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration) } service->reloadConfig(changes); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); bannerOptions.durationMs = duration; @@ -265,13 +255,24 @@ void menuHandler::FrequencySlotPicker() optionsEnumArray[options++] = 0; // Calculate number of channels (copied from RadioInterface::applyModemConfig()) + meshtastic_Config_LoRaConfig &loraConfig = config.lora; double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora) : bwCodeToKHz(loraConfig.bandwidth); uint32_t numChannels = 0; if (myRegion) { - numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0))); + // Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round() + const double spacing = myRegion->profile->spacing; + const double padding = myRegion->profile->padding; + const double channelBandwidthMHz = bw / 1000.0; + const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing; + const double denominator = spacing + (padding * 2) + channelBandwidthMHz; + if (denominator > 0.0) { + numChannels = static_cast(round(numerator / denominator)); + } else { + LOG_WARN("Invalid region configuration: non-positive channel spacing/width"); + } } else { LOG_WARN("Region not set, cannot calculate number of channels"); return; @@ -307,7 +308,6 @@ void menuHandler::FrequencySlotPicker() config.lora.channel_num = selected; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }; screen->showOverlayBanner(bannerOptions); @@ -346,7 +346,6 @@ void menuHandler::radioPresetPicker() config.lora.channel_num = 0; // Reset to default channel for the preset config.lora.override_frequency = 0; // Clear any custom frequency service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); screen->showOverlayBanner(bannerOptions); diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 79d8b1ccd..501a7ae2c 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -7,6 +7,7 @@ #include "NodeDB.h" #include "UIRenderer.h" #include "gps/RTC.h" +#include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" @@ -34,44 +35,6 @@ static std::vector cachedLines; static std::vector cachedHeights; static bool manualScrolling = false; -// UTF-8 skip helper -static inline size_t utf8CharLen(uint8_t c) -{ - if ((c & 0xE0) == 0xC0) - return 2; - if ((c & 0xF0) == 0xE0) - return 3; - if ((c & 0xF8) == 0xF0) - return 4; - return 1; -} - -// Remove variation selectors (FE0F) and skin tone modifiers from emoji so they match your labels -static std::string normalizeEmoji(const std::string &s) -{ - std::string out; - for (size_t i = 0; i < s.size();) { - uint8_t c = static_cast(s[i]); - size_t len = utf8CharLen(c); - - if (c == 0xEF && i + 2 < s.size() && (uint8_t)s[i + 1] == 0xB8 && (uint8_t)s[i + 2] == 0x8F) { - i += 3; - continue; - } - - // Skip skin tone modifiers - if (c == 0xF0 && i + 3 < s.size() && (uint8_t)s[i + 1] == 0x9F && (uint8_t)s[i + 2] == 0x8F && - ((uint8_t)s[i + 3] >= 0xBB && (uint8_t)s[i + 3] <= 0xBF)) { - i += 4; - continue; - } - - out.append(s, i, len); - i += len; - } - return out; -} - // Scroll state (file scope so we can reset on new message) float scrollY = 0.0f; uint32_t lastTime = 0; @@ -110,102 +73,7 @@ void scrollDown() void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount) { - int cursorX = x; - const int fontHeight = FONT_HEIGHT_SMALL; - - // Step 1: Find tallest emote in the line - int maxIconHeight = fontHeight; - for (size_t i = 0; i < line.length();) { - bool matched = false; - for (int e = 0; e < emoteCount; ++e) { - size_t emojiLen = strlen(emotes[e].label); - if (line.compare(i, emojiLen, emotes[e].label) == 0) { - if (emotes[e].height > maxIconHeight) - maxIconHeight = emotes[e].height; - i += emojiLen; - matched = true; - break; - } - } - if (!matched) { - i += utf8CharLen(static_cast(line[i])); - } - } - - // Step 2: Baseline alignment - int lineHeight = std::max(fontHeight, maxIconHeight); - int baselineOffset = (lineHeight - fontHeight) / 2; - int fontY = y + baselineOffset; - - // Step 3: Render line in segments - size_t i = 0; - bool inBold = false; - - while (i < line.length()) { - // Check for ** start/end for faux bold - if (line.compare(i, 2, "**") == 0) { - inBold = !inBold; - i += 2; - continue; - } - - // Look ahead for the next emote match - size_t nextEmotePos = std::string::npos; - const Emote *matchedEmote = nullptr; - size_t emojiLen = 0; - - for (int e = 0; e < emoteCount; ++e) { - size_t pos = line.find(emotes[e].label, i); - if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) { - nextEmotePos = pos; - matchedEmote = &emotes[e]; - emojiLen = strlen(emotes[e].label); - } - } - - // Render normal text segment up to the emote or bold toggle - size_t nextControl = std::min(nextEmotePos, line.find("**", i)); - if (nextControl == std::string::npos) - nextControl = line.length(); - - if (nextControl > i) { - std::string textChunk = line.substr(i, nextControl - i); - if (inBold) { - // Faux bold: draw twice, offset by 1px - display->drawString(cursorX + 1, fontY, textChunk.c_str()); - } - display->drawString(cursorX, fontY, textChunk.c_str()); -#if defined(OLED_UA) || defined(OLED_RU) - cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true); -#else - cursorX += display->getStringWidth(textChunk.c_str()); -#endif - i = nextControl; - continue; - } - - // Render the emote (if found) - if (matchedEmote && i == nextEmotePos) { - int iconY = y + (lineHeight - matchedEmote->height) / 2; - display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap); - cursorX += matchedEmote->width + 1; - i += emojiLen; - continue; - } else { - // No more emotes — render the rest of the line - std::string remaining = line.substr(i); - if (inBold) { - display->drawString(cursorX + 1, fontY, remaining.c_str()); - } - display->drawString(cursorX, fontY, remaining.c_str()); -#if defined(OLED_UA) || defined(OLED_RU) - cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true); -#else - cursorX += display->getStringWidth(remaining.c_str()); -#endif - break; - } - } + graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount); } // Reset scroll state when new messages arrive @@ -377,32 +245,7 @@ static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8) static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount) { - std::string normalized = normalizeEmoji(line); - int totalWidth = 0; - - size_t i = 0; - while (i < normalized.length()) { - bool matched = false; - for (int e = 0; e < emoteCount; ++e) { - size_t emojiLen = strlen(emotes[e].label); - if (normalized.compare(i, emojiLen, emotes[e].label) == 0) { - totalWidth += emotes[e].width + 1; // +1 spacing - i += emojiLen; - matched = true; - break; - } - } - if (!matched) { - size_t charLen = utf8CharLen(static_cast(normalized[i])); -#if defined(OLED_UA) || defined(OLED_RU) - totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str(), charLen, true); -#else - totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str()); -#endif - i += charLen; - } - } - return totalWidth; + return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width; } struct MessageBlock { @@ -417,13 +260,7 @@ static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool i return lineTopY + (FONT_HEIGHT_SMALL - 1); } - int tallest = FONT_HEIGHT_SMALL; - for (int e = 0; e < numEmotes; ++e) { - if (line.find(emotes[e].label) != std::string::npos) { - if (emotes[e].height > tallest) - tallest = emotes[e].height; - } - } + const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight; const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest); const int iconTop = lineTopY + (lineHeight - tallest) / 2; @@ -536,30 +373,28 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH; // Title string depending on mode - static char titleBuf[32]; - const char *titleStr = "Messages"; + char titleStr[48]; + snprintf(titleStr, sizeof(titleStr), "Messages"); switch (currentMode) { case ThreadMode::ALL: - titleStr = "Messages"; + snprintf(titleStr, sizeof(titleStr), "Messages"); break; case ThreadMode::CHANNEL: { const char *cname = channels.getName(currentChannel); if (cname && cname[0]) { - snprintf(titleBuf, sizeof(titleBuf), "#%s", cname); + snprintf(titleStr, sizeof(titleStr), "#%s", cname); } else { - snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel); + snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel); } - titleStr = titleBuf; break; } case ThreadMode::DIRECT: { meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer); - if (node && node->has_user) { - snprintf(titleBuf, sizeof(titleBuf), "@%s", node->user.short_name); + if (node && node->has_user && node->user.short_name[0]) { + snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name); } else { - snprintf(titleBuf, sizeof(titleBuf), "@%08x", currentPeer); + snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer); } - titleStr = titleBuf; break; } } @@ -666,44 +501,50 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender); meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest); - char senderBuf[48] = ""; + char senderName[64] = ""; if (node && node->has_user) { - // Use long name if present - strncpy(senderBuf, node->user.long_name, sizeof(senderBuf) - 1); - senderBuf[sizeof(senderBuf) - 1] = '\0'; - } else { - // No long/short name → show NodeID in parentheses - snprintf(senderBuf, sizeof(senderBuf), "(%08x)", m.sender); + if (node->user.long_name[0]) { + strncpy(senderName, node->user.long_name, sizeof(senderName) - 1); + } else if (node->user.short_name[0]) { + strncpy(senderName, node->user.short_name, sizeof(senderName) - 1); + } + senderName[sizeof(senderName) - 1] = '\0'; + } + if (!senderName[0]) { + snprintf(senderName, sizeof(senderName), "(%08x)", m.sender); } - // If this is *our own* message, override senderBuf to who the recipient was + // If this is *our own* message, override senderName to who the recipient was bool mine = (m.sender == nodeDB->getNodeNum()); if (mine && node_recipient && node_recipient->has_user) { - strcpy(senderBuf, node_recipient->user.long_name); + if (node_recipient->user.long_name[0]) { + strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1); + senderName[sizeof(senderName) - 1] = '\0'; + } else if (node_recipient->user.short_name[0]) { + strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1); + senderName[sizeof(senderName) - 1] = '\0'; + } + } + // If recipient info is missing/empty, prefer a recipient identifier for outbound messages. + if (mine && (!node_recipient || !node_recipient->has_user || + (!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) { + snprintf(senderName, sizeof(senderName), "(%08x)", m.dest); } // Shrink Sender name if needed int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) - - display->getStringWidth(chanType) - display->getStringWidth(" @..."); + display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @..."); if (availWidth < 0) availWidth = 0; - - size_t origLen = strlen(senderBuf); - while (senderBuf[0] && display->getStringWidth(senderBuf) > availWidth) { - senderBuf[strlen(senderBuf) - 1] = '\0'; - } - - // If we actually truncated, append "..." - if (strlen(senderBuf) < origLen) { - strcat(senderBuf, "..."); - } + char truncatedSender[64]; + graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth); // Final header line - char headerStr[96]; + char headerStr[128]; if (mine) { if (currentMode == ThreadMode::ALL) { if (strcmp(chanType, "(DM)") == 0) { - snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, senderBuf); + snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender); } else { snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType); } @@ -711,11 +552,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 snprintf(headerStr, sizeof(headerStr), "%s", timeBuf); } } else { - snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, senderBuf, chanType); + snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType); } // Push header line - allLines.push_back(std::string(headerStr)); + allLines.push_back(headerStr); isMine.push_back(mine); isHeader.push_back(true); ackForLine.push_back(m.ackStatus); @@ -816,13 +657,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 topY = visualTop - BUBBLE_PAD_TOP_HEADER; } else { // Body start - bool thisLineHasEmote = false; - for (int e = 0; e < numEmotes; ++e) { - if (cachedLines[b.start].find(emotes[e].label) != std::string::npos) { - thisLineHasEmote = true; - break; - } - } + const bool thisLineHasEmote = + graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote; if (thisLineHasEmote) { constexpr int EMOTE_PADDING_ABOVE = 4; visualTop -= EMOTE_PADDING_ABOVE; @@ -851,7 +687,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 for (size_t i = b.start; i <= b.end; ++i) { int w = 0; if (isHeader[i]) { - w = display->getStringWidth(cachedLines[i].c_str()); + w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str()); if (b.mine) w += 12; // room for ACK/NACK/relay mark } else { @@ -907,7 +743,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 if (lineY > -cachedHeights[i] && lineY < scrollBottom) { if (isHeader[i]) { - int w = display->getStringWidth(cachedLines[i].c_str()); + int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str()); int headerX; if (isMine[i]) { // push header left to avoid overlap with scrollbar @@ -917,7 +753,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } else { headerX = x + textIndent; } - display->drawString(headerX, lineY, cachedLines[i].c_str()); + graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1, + false); // Draw underline just under header text int underlineY = lineY + FONT_HEIGHT_SMALL; @@ -1005,11 +842,7 @@ std::vector generateLines(OLEDDisplay *display, const char *headerS } else { word += ch; std::string test = line + word; -#if defined(OLED_UA) || defined(OLED_RU) - uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true); -#else - uint16_t strWidth = display->getStringWidth(test.c_str()); -#endif + uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str()); if (strWidth > textWidth) { if (!line.empty()) lines.push_back(line); @@ -1038,31 +871,20 @@ std::vector calculateLineHeights(const std::vector &lines, con std::vector rowHeights; rowHeights.reserve(lines.size()); + std::vector lineMetrics; + lineMetrics.reserve(lines.size()); + + for (const auto &line : lines) { + lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes)); + } for (size_t idx = 0; idx < lines.size(); ++idx) { - const auto &line = lines[idx]; const int baseHeight = FONT_HEIGHT_SMALL; int lineHeight = baseHeight; - // Detect if THIS line or NEXT line contains an emote - bool hasEmote = false; - int tallestEmote = baseHeight; - for (int i = 0; i < numEmotes; ++i) { - if (line.find(emotes[i].label) != std::string::npos) { - hasEmote = true; - tallestEmote = std::max(tallestEmote, emotes[i].height); - } - } - - bool nextHasEmote = false; - if (idx + 1 < lines.size()) { - for (int i = 0; i < numEmotes; ++i) { - if (lines[idx + 1].find(emotes[i].label) != std::string::npos) { - nextHasEmote = true; - break; - } - } - } + const int tallestEmote = lineMetrics[idx].tallestHeight; + const bool hasEmote = lineMetrics[idx].hasEmote; + const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote; if (isHeaderVec[idx]) { // Header line spacing @@ -1112,22 +934,22 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht // Banner logic const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); - char longName[48] = "?"; - if (node && node->user.long_name) { - strncpy(longName, node->user.long_name, sizeof(longName) - 1); - longName[sizeof(longName) - 1] = '\0'; + char longName[64] = "?"; + if (node && node->has_user) { + if (node->user.long_name[0]) { + strncpy(longName, node->user.long_name, sizeof(longName) - 1); + longName[sizeof(longName) - 1] = '\0'; + } else if (node->user.short_name[0]) { + strncpy(longName, node->user.short_name, sizeof(longName) - 1); + longName[sizeof(longName) - 1] = '\0'; + } } int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20); if (availWidth < 0) availWidth = 0; - - size_t origLen = strlen(longName); - while (longName[0] && display->getStringWidth(longName) > availWidth) { - longName[strlen(longName) - 1] = '\0'; - } - if (strlen(longName) < origLen) { - strcat(longName, "..."); - } + char truncatedLongName[64]; + graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName), + availWidth); const char *msgRaw = reinterpret_cast(packet.decoded.payload.bytes); char banner[256]; @@ -1145,8 +967,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht } if (isAlert) { - if (longName && longName[0]) - snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); + if (truncatedLongName[0]) + snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName); else strcpy(banner, "Alert Received"); } else { @@ -1154,11 +976,11 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht if (isChannelMuted) return; - if (longName && longName[0]) { + if (truncatedLongName[0]) { if (currentResolution == ScreenResolution::UltraLow) { strcpy(banner, "New Message"); } else { - snprintf(banner, sizeof(banner), "New Message from\n%s", longName); + snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName); } } else strcpy(banner, "New Message"); @@ -1221,4 +1043,4 @@ void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet) } // namespace MessageRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index b36a5057c..98644ee3b 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -79,13 +79,15 @@ void scrollDown() // Utility Functions // ============================= -const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth) +std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth) { - static char nodeName[25]; // single static buffer we return - nodeName[0] = '\0'; + (void)display; + (void)columnWidth; - auto writeFallbackId = [&] { - std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0)); + auto fallbackId = [&] { + char id[12]; + std::snprintf(id, sizeof(id), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0)); + return std::string(id); }; // 1) Choose target candidate (long vs short) only if present @@ -94,42 +96,10 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; } - // 2) Sanitize (empty if raw is null/empty) - std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{}; - - // 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed) - if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) { - writeFallbackId(); - } else { - // %.*s ensures null-termination and safe truncation to buffer size - 1 - std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast(sizeof(nodeName) - 1), s.c_str()); - } - - // 4) Width-based truncation + ellipsis (long-name mode only) - if (config.display.use_long_node_name && display) { - int availWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38); - if (availWidth < 0) - availWidth = 0; - - const size_t beforeLen = std::strlen(nodeName); - - // Trim from the end until it fits or is empty - size_t len = beforeLen; - while (len && display->getStringWidth(nodeName) > availWidth) { - nodeName[--len] = '\0'; - } - - // If truncated, append "..." (respect buffer size) - if (len < beforeLen) { - // Make sure there's room for "..." and '\0' - const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0' - const size_t needed = 3; // "..." - if (len > capForText - needed) { - len = capForText - needed; - nodeName[len] = '\0'; - } - std::strcat(nodeName, "..."); - } + // 2) Preserve UTF-8 names so emotes can be detected and rendered. + std::string nodeName = (raw && *raw) ? std::string(raw) : std::string{}; + if (nodeName.empty()) { + nodeName = fallbackId(); } return nodeName; @@ -163,6 +133,15 @@ const char *getCurrentModeTitle_Location(int screenWidth) } } +static int getNodeNameMaxWidth(int columnWidth, int baseWidth) +{ + if (!config.display.use_long_node_name) + return baseWidth; + + const int legacyLongNameWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38); + return std::max(0, std::min(baseWidth, legacyLongNameWidth)); +} + // Use dynamic timing based on mode unsigned long getModeCycleIntervalMs() { @@ -205,10 +184,13 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) { bool isLeftCol = (x < SCREEN_WIDTH / 2); - int nameMaxWidth = columnWidth - 25; + int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25); int timeOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7); - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char timeStr[10]; @@ -228,7 +210,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawString(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -255,19 +237,22 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int { bool isLeftCol = (x < SCREEN_WIDTH / 2); - int nameMaxWidth = columnWidth - 25; + int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25); int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19); int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17); int barsXOffset = columnWidth - barsOffset; - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -312,9 +297,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 { bool isLeftCol = (x < SCREEN_WIDTH / 2); int nameMaxWidth = - columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) + : (isLeftCol ? 20 : 22))); - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char distStr[10] = ""; @@ -368,7 +357,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -414,14 +403,18 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 // Adjust max text width depending on column and screen width int nameMaxWidth = - columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) + : (isLeftCol ? 20 : 22))); - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -828,4 +821,4 @@ void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields } // namespace NodeListRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index e212c031b..be80a7d80 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -4,6 +4,7 @@ #include "mesh/generated/meshtastic/mesh.pb.h" #include #include +#include namespace graphics { @@ -56,7 +57,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, // Utility functions const char *getCurrentModeTitle_Nodes(int screenWidth); const char *getCurrentModeTitle_Location(int screenWidth); -const char *getSafeNodeName(meshtastic_NodeInfoLite *node, int columnWidth); +std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth); void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); // Scrolling controls diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 04c841884..31eb2c3c8 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -4,6 +4,7 @@ #include "DisplayFormatters.h" #include "NodeDB.h" #include "NotificationRenderer.h" +#include "UIRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" @@ -299,7 +300,7 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta for (int i = 0; i < lineCount; i++) { linePointers[i] = lineStarts[i]; } - char scratchLineBuffer[visibleTotalLines - lineCount][40]; + char scratchLineBuffer[visibleTotalLines - lineCount][64]; uint8_t firstOptionToShow = 0; if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) { @@ -312,28 +313,47 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta } int scratchLineNum = 0; for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) { - char temp_name[16] = {0}; - if (nodeDB->getMeshNodeByIndex(i + 1)->has_user) { - std::string sanitized = sanitizeString(nodeDB->getMeshNodeByIndex(i + 1)->user.long_name); - strncpy(temp_name, sanitized.c_str(), sizeof(temp_name) - 1); + char tempName[48] = {0}; + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1); + if (node && node->has_user) { + const char *rawName = nullptr; + if (node->user.long_name[0]) { + rawName = node->user.long_name; + } else if (node->user.short_name[0]) { + rawName = node->user.short_name; + } + if (rawName) { + const int arrowWidth = (currentResolution == ScreenResolution::High) + ? UIRenderer::measureStringWithEmotes(display, "> <") + : UIRenderer::measureStringWithEmotes(display, "><"); + const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth); + UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth); + } } else { - snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF)); + snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0)); + } + if (!tempName[0]) { + snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0)); } if (i == curSelected) { - selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num; + selectedNodenum = node ? node->num : 0; if (currentResolution == ScreenResolution::High) { strncpy(scratchLineBuffer[scratchLineNum], "> ", 3); - strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36); - strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3); + strncpy(scratchLineBuffer[scratchLineNum] + 2, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 3); + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; + const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1); + strncpy(scratchLineBuffer[scratchLineNum] + used, " <", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1); } else { strncpy(scratchLineBuffer[scratchLineNum], ">", 2); - strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37); - strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2); + strncpy(scratchLineBuffer[scratchLineNum] + 1, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 2); + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; + const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1); + strncpy(scratchLineBuffer[scratchLineNum] + used, "<", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1); } - scratchLineBuffer[scratchLineNum][39] = '\0'; + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; } else { - strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39); - scratchLineBuffer[scratchLineNum][39] = '\0'; + strncpy(scratchLineBuffer[scratchLineNum], tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 1); + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; } linePointers[linesShown] = scratchLineBuffer[scratchLineNum++]; } @@ -501,7 +521,13 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay else // if the newline wasn't found, then pull string length from strlen lineLengths[lineCount] = strlen(lines[lineCount]); - lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); + if (current_notification_type == notificationTypeEnum::node_picker) { + char measureBuffer[64] = {0}; + strncpy(measureBuffer, lines[lineCount], std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1)); + lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer); + } else { + lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); + } // Consider extra width for signal bars on lines that contain "Signal:" uint16_t potentialWidth = lineWidths[lineCount]; @@ -607,7 +633,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset); display->setColor(BLACK); int yOffset = 3; - display->drawString(textX, lineY - yOffset, lineBuffer); + if (current_notification_type == notificationTypeEnum::node_picker) { + UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false); + } else { + display->drawString(textX, lineY - yOffset, lineBuffer); + } display->setColor(WHITE); lineY += (effectiveLineHeight - 2 - background_yOffset); } else { @@ -626,7 +656,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay int totalWidth = textWidth + barsWidth; int groupStartX = boxLeft + (boxWidth - totalWidth) / 2; - display->drawString(groupStartX, lineY, lineBuffer); + if (current_notification_type == notificationTypeEnum::node_picker) { + UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false); + } else { + display->drawString(groupStartX, lineY, lineBuffer); + } int baseX = groupStartX + textWidth + gap; int baseY = lineY + effectiveLineHeight - 1; @@ -642,7 +676,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay } } } else { - display->drawString(textX, lineY, lineBuffer); + if (current_notification_type == notificationTypeEnum::node_picker) { + UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false); + } else { + display->drawString(textX, lineY, lineBuffer); + } } lineY += (effectiveLineHeight); } diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 25a70f16d..e3a4d13a2 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -8,6 +8,7 @@ #include "UIRenderer.h" #include "airtime.h" #include "gps/GeoCoord.h" +#include "graphics/EmoteRenderer.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TimeFormatters.h" #include "graphics/images.h" @@ -313,8 +314,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i #endif currentFavoriteNodeNum = node->num; // === Create the shortName and title string === - const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node"; - char titlestr[32] = {0}; + const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node"; + char titlestr[40]; snprintf(titlestr, sizeof(titlestr), "*%s*", shortName); // === Draw battery/time/mail header (common across screens) === @@ -328,7 +329,6 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i // List of available macro Y positions in order, from top to bottom. int line = 1; // which slot to use next - std::string usernameStr; // === 1. Long Name (always try to show first) === const char *username; if (currentResolution == ScreenResolution::UltraLow) { @@ -338,9 +338,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i } if (username) { - usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case // Print node's long name (e.g. "Backpack Node") - display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str()); + UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false); } // === 2. Signal and Hops (combined on one line, if available) === @@ -802,14 +801,12 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta // === Node Identity === int textWidth = 0; int nameX = 0; - char shortnameble[35]; - snprintf(shortnameble, sizeof(shortnameble), "%s", - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); + const char *shortName = owner.short_name ? owner.short_name : ""; // === ShortName Centered === - textWidth = display->getStringWidth(shortnameble); + textWidth = UIRenderer::measureStringWithEmotes(display, shortName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false); #else if (powerStatus->getHasBattery()) { char batStr[20]; @@ -904,36 +901,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta int textWidth = 0; int nameX = 0; int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5; - std::string longNameStr; - - if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) { - longNameStr = sanitizeString(ourNode->user.long_name); + const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : ""; + const char *shortName = owner.short_name ? owner.short_name : ""; + char combinedName[96]; + if (longName[0] && shortName[0]) { + snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName); + } else if (longName[0]) { + strncpy(combinedName, longName, sizeof(combinedName) - 1); + combinedName[sizeof(combinedName) - 1] = '\0'; + } else { + strncpy(combinedName, shortName, sizeof(combinedName) - 1); + combinedName[sizeof(combinedName) - 1] = '\0'; } - char shortnameble[35]; - snprintf(shortnameble, sizeof(shortnameble), "%s", - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); - - char combinedName[50]; - snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble); - if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) { - size_t len = strlen(combinedName); - if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) { - combinedName[len - 3] = '\0'; // Remove the last three characters - } - textWidth = display->getStringWidth(combinedName); + if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) { + textWidth = UIRenderer::measureStringWithEmotes(display, combinedName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString( - nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName); + UIRenderer::drawStringWithEmotes( + display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, + combinedName, FONT_HEIGHT_SMALL, 1, false); } else { // === LongName Centered === - textWidth = display->getStringWidth(longNameStr.c_str()); + textWidth = UIRenderer::measureStringWithEmotes(display, longName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str()); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1, + false); // === ShortName Centered === - textWidth = display->getStringWidth(shortnameble); + textWidth = UIRenderer::measureStringWithEmotes(display, shortName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, + false); } #endif graphics::drawCommonFooter(display, x, y); @@ -1045,12 +1042,12 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState display->setTextAlignment(TEXT_ALIGN_LEFT); const char *pauseText = "Screen Paused"; const char *idText = owner.short_name; - const bool useId = haveGlyphs(idText); + const bool useId = (idText && idText[0]); constexpr uint8_t padding = 2; constexpr uint8_t dividerGap = 1; // Text widths - const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); + const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0; const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding; const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2); @@ -1075,7 +1072,7 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState // Draw: text if (useId) - display->drawString(idTextLeft, idTextTop, idText); + UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false); display->drawString(pauseTextLeft, pauseTextTop, pauseText); display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold @@ -1108,11 +1105,16 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED display->drawString(msgX, msgY, upperMsg); } // Draw version and short name in bottom middle - char buf[25]; - snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT), - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf); + char footer[64]; + if (owner.short_name && owner.short_name[0]) { + snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name); + } else { + snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT)); + } + int footerW = UIRenderer::measureStringWithEmotes(display, footer); + int footerX = x + ((SCREEN_WIDTH - footerW) / 2); + UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1, + false); screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1130,12 +1132,15 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED display->drawString(x + 0, y + 0, upperMsg); // Draw version and short name in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); + const char *version = xstr(APP_VERSION_SHORT); + int versionX = x + SCREEN_WIDTH - display->getStringWidth(version); + display->drawString(versionX, y + 0, version); + if (owner.short_name && owner.short_name[0]) { + const char *shortName = owner.short_name; + int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName); + int shortNameX = x + SCREEN_WIDTH - shortNameW; + UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false); + } screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1365,11 +1370,15 @@ void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, O display->drawString(x + 0, y + 0, upperMsg); // Draw version and shortname in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); + const char *version = xstr(APP_VERSION_SHORT); + int versionX = x + SCREEN_WIDTH - display->getStringWidth(version); + display->drawString(versionX, y + 0, version); + if (owner.short_name && owner.short_name[0]) { + const char *shortName = owner.short_name; + int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName); + int shortNameX = x + SCREEN_WIDTH - shortNameW; + UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false); + } screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1558,6 +1567,25 @@ std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t mi return uptime; } +int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing) +{ + return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing); +} + +size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, + const char *ellipsis, int emoteSpacing) +{ + return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes, + graphics::numEmotes, emoteSpacing); +} + +void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing, + bool fauxBold) +{ + graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes, + emoteSpacing, fauxBold); +} + } // namespace graphics #endif // HAS_SCREEN diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index 8f0d07881..a0bb0d849 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "NodeDB.h" +#include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" #include "graphics/emotes.h" #include @@ -80,6 +81,28 @@ class UIRenderer static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds); static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime); + // Shared BaseUI emote helpers. + static int measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing = 1); + static inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, int emoteSpacing = 1) + { + return measureStringWithEmotes(display, line.c_str(), emoteSpacing); + } + static size_t truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, + const char *ellipsis = "...", int emoteSpacing = 1); + static inline std::string truncateStringWithEmotes(OLEDDisplay *display, const std::string &line, int maxWidth, + const std::string &ellipsis = "...", int emoteSpacing = 1) + { + return graphics::EmoteRenderer::truncateToWidth(display, line, maxWidth, ellipsis, graphics::emotes, graphics::numEmotes, + emoteSpacing); + } + static void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing = 1, + bool fauxBold = true); + static inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight, + int emoteSpacing = 1, bool fauxBold = true) + { + drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold); + } + // Check if the display can render a string (detect special chars; emoji) static bool haveGlyphs(const char *str); }; // namespace UIRenderer diff --git a/src/graphics/fonts/OLEDDisplayFontsRU.cpp b/src/graphics/fonts/OLEDDisplayFontsRU.cpp index 3a1159511..9766d36b2 100644 --- a/src/graphics/fonts/OLEDDisplayFontsRU.cpp +++ b/src/graphics/fonts/OLEDDisplayFontsRU.cpp @@ -10,7 +10,7 @@ const uint8_t ArialMT_Plain_10_RU[] PROGMEM = { 0xE0, // Number of chars: 224 // Jump Table: - 0xFF, 0xFF, 0x00, 0x0A, // 32 + 0xFF, 0xFF, 0x00, 0x03, // 32 0x00, 0x00, 0x04, 0x03, // 33 0x00, 0x04, 0x05, 0x04, // 34 0x00, 0x09, 0x09, 0x06, // 35 @@ -1766,4 +1766,4 @@ const uint8_t ArialMT_Plain_24_RU[] PROGMEM = { 0x3F, // 255 }; -#endif // OLED_RU \ No newline at end of file +#endif // OLED_RU diff --git a/src/graphics/fonts/OLEDDisplayFontsUA.cpp b/src/graphics/fonts/OLEDDisplayFontsUA.cpp index 8bc56ea94..deafa77aa 100644 --- a/src/graphics/fonts/OLEDDisplayFontsUA.cpp +++ b/src/graphics/fonts/OLEDDisplayFontsUA.cpp @@ -9,7 +9,7 @@ const uint8_t ArialMT_Plain_10_UA[] PROGMEM = { 0x20, // First char: 32 0xE0, // Number of chars: 224 // Jump Table: - 0xFF, 0xFF, 0x00, 0x0A, // 32 + 0xFF, 0xFF, 0x00, 0x03, // 32 0x00, 0x00, 0x04, 0x03, // 33 0x00, 0x04, 0x05, 0x04, // 34 0x00, 0x09, 0x09, 0x06, // 35 @@ -1924,4 +1924,4 @@ const uint8_t ArialMT_Plain_24_UA[] PROGMEM = { 0xFF, // 1103 }; -#endif // OLED_UA \ No newline at end of file +#endif // OLED_UA diff --git a/src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp b/src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp new file mode 100644 index 000000000..a670db0d0 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp @@ -0,0 +1,178 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "./GDEW0102T4.h" + +#include + +using namespace NicheGraphics::Drivers; + +// LUTs from GxEPD2_102.cpp (GDEW0102T4 / UC8175). +static const uint8_t LUT_W_FULL[] = { + 0x60, 0x5A, 0x5A, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +static const uint8_t LUT_B_FULL[] = { + 0x90, 0x5A, 0x5A, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +static const uint8_t LUT_W_FAST[] = { + 0x60, 0x01, 0x01, 0x00, 0x00, 0x01, // + 0x80, 0x12, 0x00, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +static const uint8_t LUT_B_FAST[] = { + 0x90, 0x01, 0x01, 0x00, 0x00, 0x01, // + 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +GDEW0102T4::GDEW0102T4() : UC8175(width, height, supported) {} + +void GDEW0102T4::setFastConfig(FastConfig cfg) +{ + // Clamp out only clearly invalid PLL settings. + if (cfg.reg30 < 0x05) + cfg.reg30 = 0x05; + fastConfig = cfg; +} + +GDEW0102T4::FastConfig GDEW0102T4::getFastConfig() const +{ + return fastConfig; +} + +void GDEW0102T4::configCommon() +{ + // Init path aligned with GxEPD2_GDEW0102T4 (UC8175 family). + sendCommand(0xD2); + sendData(0x3F); + + sendCommand(0x00); + sendData(0x6F); + + sendCommand(0x01); + sendData(0x03); + sendData(0x00); + sendData(0x2B); + sendData(0x2B); + + sendCommand(0x06); + sendData(0x3F); + + sendCommand(0x2A); + sendData(0x00); + sendData(0x00); + + sendCommand(0x30); // PLL / drive clock + sendData(0x13); + + sendCommand(0x50); // Last border/data interval; subtle but can affect artifacts + sendData(0x57); + + sendCommand(0x60); + sendData(0x22); + + sendCommand(0x61); + sendData(width); + sendData(height); + + sendCommand(0x82); // VCOM DC setting + sendData(0x12); + + sendCommand(0xE3); + sendData(0x33); +} + +void GDEW0102T4::configFull() +{ + sendCommand(0x23); + sendData(LUT_W_FULL, sizeof(LUT_W_FULL)); + sendCommand(0x24); + sendData(LUT_B_FULL, sizeof(LUT_B_FULL)); + + powerOn(); +} + +void GDEW0102T4::configFast() +{ + uint8_t lutW[sizeof(LUT_W_FAST)]; + uint8_t lutB[sizeof(LUT_B_FAST)]; + memcpy(lutW, LUT_W_FAST, sizeof(LUT_W_FAST)); + memcpy(lutB, LUT_B_FAST, sizeof(LUT_B_FAST)); + + // Second stage duration bytes are the main "darkness vs ghosting" control for this panel. + lutW[7] = fastConfig.lutW2; + lutB[7] = fastConfig.lutB2; + + sendCommand(0x30); + sendData(fastConfig.reg30); + + sendCommand(0x50); + sendData(fastConfig.reg50); + + sendCommand(0x82); + sendData(fastConfig.reg82); + + sendCommand(0x23); + sendData(lutW, sizeof(lutW)); + sendCommand(0x24); + sendData(lutB, sizeof(lutB)); + + powerOn(); +} + +void GDEW0102T4::writeOldImage() +{ + // On this panel, FULL refresh is most reliable when "old image" is all white. + if (updateType == FULL) { + sendCommand(0x10); + // Use buffered writes of 0xFF to avoid per-byte SPI transactions. + const uint16_t chunkSize = 64; + uint8_t ffBuf[chunkSize]; + memset(ffBuf, 0xFF, sizeof(ffBuf)); + + uint32_t remaining = bufferSize; + while (remaining > 0) { + uint16_t toSend = remaining > chunkSize ? chunkSize : static_cast(remaining); + sendData(ffBuf, toSend); + remaining -= toSend; + } + return; + } + + // FAST refresh uses differential data (previous frame as old image). + if (previousBuffer) { + writeImage(0x10, previousBuffer); + } else { + writeImage(0x10, buffer); + } +} + +void GDEW0102T4::finalizeUpdate() +{ + // Keep panel out of deep-sleep between updates for better reliability of repeated FAST refresh. + powerOff(); +} + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/GDEW0102T4.h b/src/graphics/niche/Drivers/EInk/GDEW0102T4.h new file mode 100644 index 000000000..02df8b4fe --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/GDEW0102T4.h @@ -0,0 +1,55 @@ +/* + +E-Ink display driver + - GDEW0102T4 + - Controller: UC8175 + - Size: 1.02 inch + - Resolution: 80px x 128px + +*/ + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./UC8175.h" + +namespace NicheGraphics::Drivers +{ + +class GDEW0102T4 : public UC8175 +{ + private: + static constexpr uint16_t width = 80; + static constexpr uint16_t height = 128; + static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST); + + public: + struct FastConfig { + uint8_t reg30; + uint8_t reg50; + uint8_t reg82; + uint8_t lutW2; + uint8_t lutB2; + }; + + GDEW0102T4(); + void setFastConfig(FastConfig cfg); + FastConfig getFastConfig() const; + + protected: + void configCommon() override; + void configFull() override; + void configFast() override; + void writeOldImage() override; + void finalizeUpdate() override; + + private: + FastConfig fastConfig = {0x13, 0xF2, 0x12, 0x0E, 0x14}; +}; + +} // namespace NicheGraphics::Drivers + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/UC8175.cpp b/src/graphics/niche/Drivers/EInk/UC8175.cpp new file mode 100644 index 000000000..576b645bd --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/UC8175.cpp @@ -0,0 +1,203 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "./UC8175.h" + +#include + +#include "SPILock.h" + +using namespace NicheGraphics::Drivers; + +UC8175::UC8175(uint16_t width, uint16_t height, UpdateTypes supported) : EInk(width, height, supported) +{ + bufferRowSize = ((width - 1) / 8) + 1; + bufferSize = bufferRowSize * height; +} + +void UC8175::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst) +{ + this->spi = spi; + this->pin_dc = pin_dc; + this->pin_cs = pin_cs; + this->pin_busy = pin_busy; + this->pin_rst = pin_rst; + + pinMode(pin_dc, OUTPUT); + pinMode(pin_cs, OUTPUT); + pinMode(pin_busy, INPUT); + + // Reset is active LOW, hold HIGH when idle. + if (pin_rst != (uint8_t)-1) { + pinMode(pin_rst, OUTPUT); + digitalWrite(pin_rst, HIGH); + } + + if (!previousBuffer) { + previousBuffer = new uint8_t[bufferSize]; + if (previousBuffer) + memset(previousBuffer, 0xFF, bufferSize); + } +} + +void UC8175::update(uint8_t *imageData, UpdateTypes type) +{ + buffer = imageData; + updateType = (type == UpdateTypes::UNSPECIFIED) ? UpdateTypes::FULL : type; + + if (updateType == FAST && hasPreviousBuffer && previousBuffer && memcmp(previousBuffer, buffer, bufferSize) == 0) + return; + + reset(); + configCommon(); + + if (updateType == FAST) + configFast(); + else + configFull(); + + writeOldImage(); + writeNewImage(); + sendCommand(0x12); // Display refresh. + + if (previousBuffer) { + memcpy(previousBuffer, buffer, bufferSize); + hasPreviousBuffer = true; + } + + detachFromUpdate(); +} + +void UC8175::wait(uint32_t timeoutMs) +{ + if (failed) + return; + + uint32_t started = millis(); + while (digitalRead(pin_busy) == BUSY_ACTIVE) { + if ((millis() - started) > timeoutMs) { + failed = true; + break; + } + yield(); + } +} + +void UC8175::reset() +{ + if (pin_rst != (uint8_t)-1) { + digitalWrite(pin_rst, LOW); + delay(20); + digitalWrite(pin_rst, HIGH); + delay(20); + } else { + sendCommand(0x12); // Software reset. + delay(10); + } + + wait(3000); +} + +void UC8175::sendCommand(uint8_t command) +{ + if (failed) + return; + + spiLock->lock(); + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, LOW); + digitalWrite(pin_cs, LOW); + spi->transfer(command); + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); + spiLock->unlock(); +} + +void UC8175::sendData(uint8_t data) +{ + sendData(&data, 1); +} + +void UC8175::sendData(const uint8_t *data, uint32_t size) +{ + if (failed) + return; + + spiLock->lock(); + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, HIGH); + digitalWrite(pin_cs, LOW); + +#if defined(ARCH_ESP32) + spi->transferBytes(data, NULL, size); +#elif defined(ARCH_NRF52) + spi->transfer(data, NULL, size); +#else + for (uint32_t i = 0; i < size; ++i) + spi->transfer(data[i]); +#endif + + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); + spiLock->unlock(); +} + +void UC8175::powerOn() +{ + sendCommand(0x04); + wait(2000); +} + +void UC8175::powerOff() +{ + sendCommand(0x02); // Power off. + wait(1500); +} + +void UC8175::writeImage(uint8_t command, const uint8_t *image) +{ + sendCommand(command); + sendData(image, bufferSize); +} + +void UC8175::writeOldImage() +{ + if (updateType == FAST && previousBuffer) + writeImage(0x10, previousBuffer); + else + writeImage(0x10, buffer); +} + +void UC8175::writeNewImage() +{ + writeImage(0x13, buffer); +} + +void UC8175::detachFromUpdate() +{ + switch (updateType) { + case FAST: + return beginPolling(50, 400); + case FULL: + default: + return beginPolling(100, 2000); + } +} + +bool UC8175::isUpdateDone() +{ + return digitalRead(pin_busy) != BUSY_ACTIVE; +} + +void UC8175::finalizeUpdate() +{ + powerOff(); + + if (pin_rst != (uint8_t)-1) { + sendCommand(0x07); // Deep sleep. + sendData(0xA5); + } +} + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/UC8175.h b/src/graphics/niche/Drivers/EInk/UC8175.h new file mode 100644 index 000000000..b248d4bea --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/UC8175.h @@ -0,0 +1,62 @@ +// E-Ink base class for displays based on UC8175 / UC8176 style controller ICs. + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./EInk.h" + +namespace NicheGraphics::Drivers +{ + +class UC8175 : public EInk +{ + public: + UC8175(uint16_t width, uint16_t height, UpdateTypes supported); + void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override; + void update(uint8_t *imageData, UpdateTypes type) override; + + protected: + virtual void wait(uint32_t timeoutMs = 1000); + virtual void reset(); + virtual void sendCommand(uint8_t command); + virtual void sendData(uint8_t data); + virtual void sendData(const uint8_t *data, uint32_t size); + + virtual void configCommon() = 0; // Always run + virtual void configFull() = 0; // Run when updateType == FULL + virtual void configFast() = 0; // Run when updateType == FAST + + virtual void powerOn(); + virtual void powerOff(); + virtual void writeOldImage(); + virtual void writeNewImage(); + virtual void writeImage(uint8_t command, const uint8_t *image); + + virtual void detachFromUpdate(); + virtual bool isUpdateDone() override; + virtual void finalizeUpdate() override; + + protected: + static constexpr uint8_t BUSY_ACTIVE = LOW; + + uint16_t bufferRowSize = 0; + uint32_t bufferSize = 0; + uint8_t *buffer = nullptr; + uint8_t *previousBuffer = nullptr; + bool hasPreviousBuffer = false; + UpdateTypes updateType = UpdateTypes::UNSPECIFIED; + + uint8_t pin_dc = (uint8_t)-1; + uint8_t pin_cs = (uint8_t)-1; + uint8_t pin_busy = (uint8_t)-1; + uint8_t pin_rst = (uint8_t)-1; + SPIClass *spi = nullptr; + SPISettings spiSettings = SPISettings(8000000, MSBFIRST, SPI_MODE0); +}; + +} // namespace NicheGraphics::Drivers + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index a07e56665..d489d21ee 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -177,24 +177,8 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) auto changes = SEGMENT_CONFIG; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif @@ -349,13 +333,13 @@ void InkHUD::MenuApplet::execute(MenuItem item) handleFreeText = true; cm.freeTextItem.rawText.erase(); // clear the previous freetext message freeTextMode = true; // render input field instead of normal menu - // Open the on-screen keyboard if the joystick is enabled - if (settings->joystick.enabled) + // Open the on-screen keyboard only for full joystick devices + if (settings->joystick.enabled && !inkhud->twoWayRocker) inkhud->openKeyboard(); break; case STORE_CANNEDMESSAGE_SELECTION: - if (!settings->joystick.enabled) + if (!settings->joystick.enabled || inkhud->twoWayRocker) cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry else cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry @@ -922,7 +906,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page) if (settings->userTiles.maxCount > 1) items.push_back(MenuItem("Layout", MenuAction::LAYOUT, MenuPage::OPTIONS)); items.push_back(MenuItem("Rotate", MenuAction::ROTATE, MenuPage::OPTIONS)); - if (settings->joystick.enabled) + if (settings->joystick.enabled && !inkhud->twoWayRocker) items.push_back(MenuItem("Align Joystick", MenuAction::ALIGN_JOYSTICK, MenuPage::EXIT)); items.push_back(MenuItem("Notifications", MenuAction::TOGGLE_NOTIFICATIONS, MenuPage::OPTIONS, &settings->optionalFeatures.notifications)); @@ -1751,7 +1735,7 @@ void InkHUD::MenuApplet::populateSendPage() items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT)); // If joystick is available, include the Free Text option - if (settings->joystick.enabled) + if (settings->joystick.enabled && !inkhud->twoWayRocker) items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND)); // One menu item for each canned message diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp index 6cac2644b..a45e8d9b5 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp @@ -152,6 +152,11 @@ void InkHUD::TipsApplet::onRender(bool full) drawBullet("User Button"); drawBullet("- short press: next"); drawBullet("- long press: select or open menu"); + } else if (inkhud->twoWayRocker) { + drawBullet("Rocker + Button"); + drawBullet("- center press: open menu or select"); + drawBullet("- left/right: applet nav"); + drawBullet("- in menu: up/down"); } else { drawBullet("Joystick"); drawBullet("- press: open menu or select"); diff --git a/src/graphics/niche/InkHUD/InkHUD.h b/src/graphics/niche/InkHUD/InkHUD.h index 0e25b0900..abd53951a 100644 --- a/src/graphics/niche/InkHUD/InkHUD.h +++ b/src/graphics/niche/InkHUD/InkHUD.h @@ -88,6 +88,9 @@ class InkHUD // Used by TipsApplet to force menu to start on Region selection bool forceRegionMenu = false; + // Input mode hint for devices that use a left/right rocker plus center button + bool twoWayRocker = false; + // Updating the display // - called by various InkHUD components @@ -130,4 +133,4 @@ class InkHUD } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/WindowManager.cpp b/src/graphics/niche/InkHUD/WindowManager.cpp index ff324943b..c4a0813d8 100644 --- a/src/graphics/niche/InkHUD/WindowManager.cpp +++ b/src/graphics/niche/InkHUD/WindowManager.cpp @@ -143,7 +143,7 @@ void InkHUD::WindowManager::openMenu() // Bring the AlignStick applet to the foreground void InkHUD::WindowManager::openAlignStick() { - if (settings->joystick.enabled) { + if (settings->joystick.enabled && !inkhud->twoWayRocker) { AlignStickApplet *alignStick = (AlignStickApplet *)inkhud->getSystemApplet("AlignStick"); alignStick->bringToForeground(); } @@ -151,6 +151,9 @@ void InkHUD::WindowManager::openAlignStick() void InkHUD::WindowManager::openKeyboard() { + if (!settings->joystick.enabled || inkhud->twoWayRocker) + return; + KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard"); if (keyboard) { @@ -162,6 +165,9 @@ void InkHUD::WindowManager::openKeyboard() void InkHUD::WindowManager::closeKeyboard() { + if (!settings->joystick.enabled || inkhud->twoWayRocker) + return; + KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard"); if (keyboard) { @@ -477,7 +483,7 @@ void InkHUD::WindowManager::createSystemApplets() addSystemApplet("Logo", new LogoApplet, new Tile); addSystemApplet("Pairing", new PairingApplet, new Tile); addSystemApplet("Tips", new TipsApplet, new Tile); - if (settings->joystick.enabled) { + if (settings->joystick.enabled && !inkhud->twoWayRocker) { addSystemApplet("AlignStick", new AlignStickApplet, new Tile); addSystemApplet("Keyboard", new KeyboardApplet, new Tile); } @@ -503,7 +509,7 @@ void InkHUD::WindowManager::placeSystemTiles() inkhud->getSystemApplet("Logo")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); inkhud->getSystemApplet("Pairing")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); inkhud->getSystemApplet("Tips")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); - if (settings->joystick.enabled) { + if (settings->joystick.enabled && !inkhud->twoWayRocker) { inkhud->getSystemApplet("AlignStick")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight(); inkhud->getSystemApplet("Keyboard") diff --git a/src/graphics/niche/Inputs/TwoButtonExtended.cpp b/src/graphics/niche/Inputs/TwoButtonExtended.cpp index 287fb943f..f979faca9 100644 --- a/src/graphics/niche/Inputs/TwoButtonExtended.cpp +++ b/src/graphics/niche/Inputs/TwoButtonExtended.cpp @@ -156,6 +156,24 @@ void TwoButtonExtended::setJoystickWiring(uint8_t uPin, uint8_t dPin, uint8_t lP pinMode(joystick[Direction::RIGHT].pin, internalPullup ? INPUT_PULLUP : INPUT); } +// Configures only left/right joystick directions for a two-way rocker +void TwoButtonExtended::setTwoWayRockerWiring(uint8_t leftPin, uint8_t rightPin, bool internalPullup) +{ + if (leftPin == rightPin) { + LOG_WARN("Attempted reuse of TwoWayRocker GPIO. Ignoring assignment"); + return; + } + + joystick[Direction::UP].pin = 0xFF; + joystick[Direction::DOWN].pin = 0xFF; + joystick[Direction::LEFT].pin = leftPin; + joystick[Direction::RIGHT].pin = rightPin; + joystickActiveLogic = LOW; + + pinMode(joystick[Direction::LEFT].pin, internalPullup ? INPUT_PULLUP : INPUT); + pinMode(joystick[Direction::RIGHT].pin, internalPullup ? INPUT_PULLUP : INPUT); +} + void TwoButtonExtended::setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs) { assert(whichButton < 2); @@ -229,6 +247,13 @@ void TwoButtonExtended::setJoystickPressHandlers(Callback uPress, Callback dPres joystick[Direction::RIGHT].onPress = rPress; } +// Set press handlers for a two-way rocker mapped to left/right directions +void TwoButtonExtended::setTwoWayRockerPressHandlers(Callback lPress, Callback rPress) +{ + joystick[Direction::LEFT].onPress = lPress; + joystick[Direction::RIGHT].onPress = rPress; +} + // Handle the start of a press to the primary button // Wakes our button thread void TwoButtonExtended::isrPrimary() diff --git a/src/graphics/niche/Inputs/TwoButtonExtended.h b/src/graphics/niche/Inputs/TwoButtonExtended.h index 23fd78a2a..eb536907d 100644 --- a/src/graphics/niche/Inputs/TwoButtonExtended.h +++ b/src/graphics/niche/Inputs/TwoButtonExtended.h @@ -45,6 +45,7 @@ class TwoButtonExtended : protected concurrency::OSThread void stop(); // Stop handling button input (disconnect ISRs for sleep) void setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup = false); void setJoystickWiring(uint8_t uPin, uint8_t dPin, uint8_t lPin, uint8_t rPin, bool internalPullup = false); + void setTwoWayRockerWiring(uint8_t leftPin, uint8_t rightPin, bool internalPullup = false); void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); void setJoystickDebounce(uint32_t debounceMs); void setHandlerDown(uint8_t whichButton, Callback onDown); @@ -54,6 +55,7 @@ class TwoButtonExtended : protected concurrency::OSThread void setJoystickDownHandlers(Callback uDown, Callback dDown, Callback ldown, Callback rDown); void setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp); void setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress); + void setTwoWayRockerPressHandlers(Callback lPress, Callback rPress); // Disconnect and reconnect interrupts for light sleep #ifdef ARCH_ESP32 diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index acf79f149..e3125ca12 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -34,7 +34,7 @@ #if defined(BUTTON_PIN_TOUCH) ButtonThread *TouchButtonThread = nullptr; -#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN) +#if defined(PIN_EINK_EN) static bool touchBacklightWasOn = false; static bool touchBacklightActive = false; #endif @@ -220,8 +220,8 @@ void InputBroker::Init() }; touchConfig.singlePress = INPUT_BROKER_NONE; touchConfig.longPress = INPUT_BROKER_BACK; -#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN) - // On T-Echo Plus the touch pad should only drive the backlight, not UI navigation/sounds +#if defined(PIN_EINK_EN) + // Touch pad drives the backlight on devices with e-ink backlight pin touchConfig.longPress = INPUT_BROKER_NONE; touchConfig.suppressLeadUpSound = true; touchConfig.onPress = []() { diff --git a/src/input/UpDownInterruptImpl1.cpp b/src/input/UpDownInterruptImpl1.cpp index 906dcd2a8..4f62fd5fa 100644 --- a/src/input/UpDownInterruptImpl1.cpp +++ b/src/input/UpDownInterruptImpl1.cpp @@ -8,6 +8,14 @@ UpDownInterruptImpl1::UpDownInterruptImpl1() : UpDownInterruptBase("upDown1") {} bool UpDownInterruptImpl1::init() { +#if defined(INPUTDRIVER_TWO_WAY_ROCKER) && defined(INPUTDRIVER_TWO_WAY_ROCKER_LEFT) && defined(INPUTDRIVER_TWO_WAY_ROCKER_RIGHT) + moduleConfig.canned_message.updown1_enabled = true; + moduleConfig.canned_message.inputbroker_pin_a = INPUTDRIVER_TWO_WAY_ROCKER_LEFT; + moduleConfig.canned_message.inputbroker_pin_b = INPUTDRIVER_TWO_WAY_ROCKER_RIGHT; +#if defined(INPUTDRIVER_TWO_WAY_ROCKER_BTN) + moduleConfig.canned_message.inputbroker_pin_press = INPUTDRIVER_TWO_WAY_ROCKER_BTN; +#endif +#endif if (!moduleConfig.canned_message.updown1_enabled) { // Input device is disabled. @@ -46,4 +54,4 @@ void UpDownInterruptImpl1::handleIntUp() void UpDownInterruptImpl1::handleIntPressed() { upDownInterruptImpl1->intPressHandler(); -} \ No newline at end of file +} diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 72216a63c..4a613b644 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -61,6 +61,33 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) } return true; } + +bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user) +{ + if (user.is_licensed) { + return false; + } + + bool keygenSuccess = false; + if (security.private_key.size == 32) { + if (regeneratePublicKey(security.public_key.bytes, security.private_key.bytes)) { + keygenSuccess = true; + } + } else { + LOG_INFO("Generate new PKI keys"); + generateKeyPair(security.public_key.bytes, security.private_key.bytes); + keygenSuccess = true; + } + + if (keygenSuccess) { + security.public_key.size = 32; + security.private_key.size = 32; + user.public_key.size = 32; + memcpy(user.public_key.bytes, security.public_key.bytes, 32); + } + + return keygenSuccess; +} #endif /** diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 19d572355..f40400331 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -36,6 +36,7 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey); virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey); + virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user); #endif void setDHPrivateKey(uint8_t *_private_key); diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 2b6a42d9c..069ffc0eb 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -30,6 +30,11 @@ #define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 + +// Traffic management defaults +#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells +#define default_traffic_mgmt_position_min_interval_secs ONE_DAY // 1 day between identical positions + #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS #else diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 78602a9ec..13f98299f 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -91,10 +91,27 @@ void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) { if (nodeDB) nodeDB->updateFrom(*p); + #if !MESHTASTIC_EXCLUDE_TRACEROUTE + if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + // If we got a packet that is not decoded, try to decode it so we can check for traceroute. + auto decodedState = perhapsDecode(const_cast(p)); + if (decodedState == DecodeState::DECODE_SUCCESS) { + // parsing was successful, print for debugging + printPacket("reprocessPacket(DUP)", p); + } else { + // Fatal decoding error, we can't do anything with this packet + LOG_WARN( + "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute", + static_cast(decodedState), p->id, getFrom(p)); + return; + } + } + if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && - p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) + p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { traceRouteModule->processUpgradedPacket(*p); + } #endif } diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 4fec06da4..9f808da26 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -192,6 +192,11 @@ template bool LR11x0Interface::reconfigure() err = lora.setOutputPower(power); assert(err == RADIOLIB_ERR_NONE); + // Apply RX gain mode — valid in STDBY, matches resetAGC() pattern + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err); + startReceive(); // restart receiving return RADIOLIB_ERR_NONE; diff --git a/src/mesh/LoRaFEMInterface.cpp b/src/mesh/LoRaFEMInterface.cpp index bad795c3a..b44c7539b 100644 --- a/src/mesh/LoRaFEMInterface.cpp +++ b/src/mesh/LoRaFEMInterface.cpp @@ -25,7 +25,7 @@ void LoRaFEMInterface::init(void) pinMode(LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(LORA_KCT8103L_PA_CSD, HIGH); pinMode(LORA_KCT8103L_PA_CTX, OUTPUT); - digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); + digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default setLnaCanControl(true); } else if (digitalRead(LORA_KCT8103L_PA_CSD) == LOW) { // FEM is GC1109 @@ -66,7 +66,7 @@ void LoRaFEMInterface::init(void) pinMode(LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(LORA_KCT8103L_PA_CSD, HIGH); pinMode(LORA_KCT8103L_PA_CTX, OUTPUT); - digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); + digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default setLnaCanControl(true); #endif } @@ -173,14 +173,16 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void) #endif #elif defined(USE_KCT8103L_PA) digitalWrite(LORA_KCT8103L_PA_CSD, HIGH); - rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD); if (lna_enabled) { digitalWrite(LORA_KCT8103L_PA_CTX, LOW); } else { digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); } +#if defined(ARCH_ESP32) + rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD); rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX); #endif +#endif } void LoRaFEMInterface::setLNAEnable(bool enabled) diff --git a/src/mesh/LoRaFEMInterface.h b/src/mesh/LoRaFEMInterface.h index 9024abd3a..14220c6e3 100644 --- a/src/mesh/LoRaFEMInterface.h +++ b/src/mesh/LoRaFEMInterface.h @@ -22,7 +22,7 @@ class LoRaFEMInterface private: LoRaFEMType fem_type; - bool lna_enabled = false; + bool lna_enabled = true; bool lna_can_control = false; }; extern LoRaFEMInterface loraFEMInterface; diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 07d956878..3c3a4cf65 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -5,18 +5,52 @@ #include "PointerQueue.h" #include "configuration.h" +// Sentinel marking the end of a modem preset array +static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = + static_cast(0xFF); + +// Region profile: bundles the preset list with regulatory parameters shared across regions +struct RegionProfile { + const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default + float spacing; // gaps between radio channels + float padding; // padding at each side of the "operating channel" + bool audioPermitted; + bool licensedOnly; // a region profile for licensed operators only + int8_t textThrottle; // throttle for text - future expansion + int8_t positionThrottle; // throttle for location data - future expansion + int8_t telemetryThrottle; // throttle for telemetry - future expansion + uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place +}; + +extern const RegionProfile PROFILE_STD; +extern const RegionProfile PROFILE_EU868; +extern const RegionProfile PROFILE_UNDEF; +// extern const RegionProfile PROFILE_LITE; +// extern const RegionProfile PROFILE_NARROW; +// extern const RegionProfile PROFILE_HAM; + // Map from old region names to new region enums struct RegionInfo { meshtastic_Config_LoRaConfig_RegionCode code; float freqStart; float freqEnd; - float dutyCycle; - float spacing; + float dutyCycle; // modified by getEffectiveDutyCycle uint8_t powerLimit; // Or zero for not set - bool audioPermitted; bool freqSwitching; bool wideLora; + const RegionProfile *profile; const char *name; // EU433 etc + + // Preset accessors (delegate through profile) + meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; } + const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; } + size_t getNumPresets() const + { + size_t n = 0; + while (profile->presets[n] != MODEM_PRESET_END) + n++; + return n; + } }; extern const RegionInfo regions[]; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 49b54d181..13f948a7b 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -4,6 +4,9 @@ #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" #endif +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "NodeDB.h" NextHopRouter::NextHopRouter() {} @@ -126,15 +129,28 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast /* Check if we should be rebroadcasting this packet if so, do so. */ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) { - if (!isToUs(p) && !isFromUs(p) && p->hop_limit > 0) { + // Check if traffic management wants to exhaust this packet's hops + bool exhaustHops = false; +#if HAS_TRAFFIC_MANAGEMENT + if (trafficManagementModule && trafficManagementModule->shouldExhaustHops(*p)) { + exhaustHops = true; + } +#endif + + // Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay) + if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) { if (p->id != 0) { if (isRebroadcaster()) { if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) { meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it LOG_INFO("Rebroadcast received message coming from %x", p->relay_node); - // Use shared logic to determine if hop_limit should be decremented - if (shouldDecrementHopLimit(p)) { + // If exhausting hops, force hop_limit = 0 regardless of other logic + if (exhaustHops) { + tosend->hop_limit = 0; + LOG_INFO("Traffic management: exhausting hops for 0x%08x, setting hop_limit=0", getFrom(p)); + } else if (shouldDecrementHopLimit(p)) { + // Use shared logic to determine if hop_limit should be decremented tosend->hop_limit--; // bump down the hop count } else { LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit"); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 90bcd4890..01be50c26 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -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,7 +1299,7 @@ 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 @@ -1650,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 @@ -1687,6 +1706,22 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly) #include "MeshModule.h" #include "Throttle.h" +static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000; + +void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) +{ + static uint32_t lastLogMs = 0; + if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) { + return; + } + lastLogMs = millis(); + const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag); + const bool hasBitfield = decoded && p.decoded.has_bitfield; + LOG_DEBUG( + "Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)", + context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield); +} + /** Update position info for this node based on received position data */ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index adf2b42ea..f6be963c1 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -114,6 +114,27 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p); /// Returns defaultIfUnknown if the number of hops couldn't be determined. int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1); +enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID }; + +/// Classify hop_start validity for forwarding decisions. +HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p); + +inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p) +{ +#if !MESHTASTIC_PREHOP_DROP + (void)p; + return false; +#else + if (isFromUs(&p)) { + return false; // local-originated packets should never be dropped by pre-hop drop policy + } + return classifyHopStart(p) != HopStartStatus::VALID; +#endif +} + +/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped. +void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context); + enum LoadFileResult { // Successfully opened the file LOAD_SUCCESS = 1, diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index a02f96ac5..7df6720a2 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -449,6 +449,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter; break; + case meshtastic_ModuleConfig_traffic_management_tag: + LOG_DEBUG("Send module config: traffic management"); + fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; + fromRadioScratch.moduleConfig.payload_variant.traffic_management = moduleConfig.traffic_management; + break; default: LOG_ERROR("Unknown module config type %d", config_state); } diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 4defd00ed..9ce944002 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" @@ -32,10 +33,32 @@ #include "STM32WLE5JCInterface.h" #endif -#define RDEF(name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, frequency_switching, wide_lora) \ +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + MODEM_PRESET_END}; + +// Region profiles: bundle preset list + regulatory parameters shared across regions +// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot +const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0}; + +#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \ { \ - meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, \ - frequency_switching, wide_lora, #name \ + meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \ + wide_lora, &profile_ptr, #name \ } const RegionInfo regions[] = { @@ -43,7 +66,7 @@ const RegionInfo regions[] = { https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ */ - RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false), + RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD), /* EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] @@ -51,8 +74,7 @@ const RegionInfo regions[] = { https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false), - + RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ @@ -67,33 +89,33 @@ const RegionInfo regions[] = { AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.) https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf */ - RDEF(EU_868, 869.4f, 869.65f, 10, 0, 27, false, false, false), + RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(CN, 470.0f, 510.0f, 100, 0, 19, true, false, false), + RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf https://qiita.com/ammo0613/items/d952154f1195b64dc29f */ - RDEF(JP, 920.5f, 923.5f, 100, 0, 13, true, false, false), + RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD), /* https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf Also used in Brazil. */ - RDEF(ANZ, 915.0f, 928.0f, 100, 0, 30, true, false, false), + RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD), /* 433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100 */ - RDEF(ANZ_433, 433.05f, 434.79f, 100, 0, 14, true, false, false), + RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD), /* https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf @@ -101,13 +123,13 @@ const RegionInfo regions[] = { Note: - We do LBT, so 100% is allowed. */ - RDEF(RU, 868.7f, 869.2f, 100, 0, 20, true, false, false), + RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD), /* https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0 https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters */ - RDEF(KR, 920.0f, 923.0f, 100, 0, 23, true, false, false), + RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD), /* Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor. @@ -115,42 +137,38 @@ const RegionInfo regions[] = { https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283 */ - RDEF(TW, 920.0f, 925.0f, 100, 0, 27, true, false, false), + RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(IN, 865.0f, 867.0f, 100, 0, 30, true, false, false), + RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD), /* https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752 https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf */ - RDEF(NZ_865, 864.0f, 868.0f, 100, 0, 36, true, false, false), + RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(TH, 920.0f, 925.0f, 100, 0, 16, true, false, false), + RDEF(TH, 920.0f, 925.0f, 100, 16, false, false, PROFILE_STD), /* 433,05-434,7 Mhz 10 mW - https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf - */ - RDEF(UA_433, 433.0f, 434.7f, 10, 0, 10, true, false, false), - - /* 868,0-868,6 Mhz 25 mW https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf */ - RDEF(UA_868, 868.0f, 868.6f, 1, 0, 14, true, false, false), + RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD), + RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD), /* Malaysia 433 - 435 MHz at 100mW, no restrictions. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_433, 433.0f, 435.0f, 100, 0, 20, true, false, false), + RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD), /* Malaysia @@ -159,14 +177,14 @@ const RegionInfo regions[] = { Frequency hopping is used for 919 - 923 MHz. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_919, 919.0f, 924.0f, 100, 0, 27, true, true, false), + RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD), /* Singapore SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions. https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf */ - RDEF(SG_923, 917.0f, 925.0f, 100, 0, 20, true, false, false), + RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD), /* Philippines @@ -176,8 +194,9 @@ const RegionInfo regions[] = { https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135 */ - RDEF(PH_433, 433.0f, 434.7f, 100, 0, 10, true, false, false), RDEF(PH_868, 868.0f, 869.4f, 100, 0, 14, true, false, false), - RDEF(PH_915, 915.0f, 918.0f, 100, 0, 24, true, false, false), + RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD), + RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD), + RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD), /* Kazakhstan @@ -185,37 +204,38 @@ const RegionInfo regions[] = { 863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields https://github.com/meshtastic/firmware/issues/7204 */ - RDEF(KZ_433, 433.075f, 434.775f, 100, 0, 10, true, false, false), - RDEF(KZ_863, 863.0f, 868.0f, 100, 0, 30, true, false, false), + RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD), + RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD), /* Nepal 865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use, specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf */ - RDEF(NP_865, 865.0f, 868.0f, 100, 0, 30, true, false, false), + RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD), /* Brazil 902 - 907.5 MHz , 1W power limit, no duty cycle restrictions https://github.com/meshtastic/firmware/issues/3741 */ - RDEF(BR_902, 902.0f, 907.5f, 100, 0, 30, true, false, false), + RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD), /* 2.4 GHZ WLAN Band equivalent. Only for SX128x chips. */ - RDEF(LORA_24, 2400.0f, 2483.5f, 100, 0, 10, true, false, true), + RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD), /* This needs to be last. Same as US. */ - RDEF(UNSET, 902.0f, 928.0f, 100, 0, 30, true, false, false) + RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF) }; const RegionInfo *myRegion; bool RadioInterface::uses_default_frequency_slot = true; +bool RadioInterface::uses_custom_channel_name = false; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1]; @@ -501,45 +521,14 @@ void initRegion() myRegion = r; } -void RadioInterface::bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig) +const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) { - if (!loraConfig.use_preset) { - return; - } - - // Find region info to determine whether "wide" LoRa is permitted (2.4 GHz uses wider bandwidth codes). const RegionInfo *r = regions; - for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != loraConfig.region; r++) + for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != code; r++) ; - - const bool regionWideLora = r->wideLora; - - float bwKHz = 0; - uint8_t sf = 0; - uint8_t cr = 0; - modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr); - - // If selected preset requests a bandwidth larger than the region span, fall back to LONG_FAST. - if (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && (r->freqEnd - r->freqStart) < (bwKHz / 1000.0f)) { - loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr); - } - - loraConfig.bandwidth = bwKHzToCode(bwKHz); - loraConfig.spread_factor = sf; + return r; } -/** - * ## LoRaWAN for North America - -LoRaWAN defines 64, 125 kHz channels from 902.3 to 914.9 MHz increments. - -The maximum output power for North America is +30 dBM. - -The band is from 902 to 928 MHz. It mentions channel number and its respective channel frequency. All the 13 channels are -separated by 2.16 MHz with respect to the adjacent channels. Channel zero starts at 903.08 MHz center frequency. -*/ - uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received) { uint32_t pl = 0; @@ -749,7 +738,7 @@ void RadioInterface::saveFreq(float freq) } /** - * Save our channel for later reuse. + * Save our frequency slot (aka channel) for later reuse. */ void RadioInterface::saveChannelNum(uint32_t channel_num) { @@ -772,113 +761,297 @@ uint32_t RadioInterface::getChannelNum() return savedChannelNum; } +/** + * Send an error-level client notification. Safe to call when service is null (e.g. in tests). + */ +static void sendErrorNotification(const char *msg) +{ + if (!service) + return; + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + if (!cn) + return; + cn->level = meshtastic_LogRecord_Level_ERROR; + snprintf(cn->message, sizeof(cn->message), "%s", msg); + service->sendClientNotification(cn); +} + +/** + * Checks if a region is valid for the current settings. + * Returns false if not compatible. + */ +bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig) +{ + const RegionInfo *newRegion = getRegion(loraConfig.region); + + // If you are not licensed, you can't use ham regions. + if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { + char err_string[160]; + snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + return false; + } + + return true; +} + +/** + * Internal helper: validate or clamp a LoRa config against its region. + * When clamp==false, returns false on first error (pure validation). + * When clamp==true, fixes invalid settings in-place and returns true. + */ +bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp) +{ + char err_string[160]; + float check_bw; + + const RegionInfo *newRegion = getRegion(loraConfig.region); + + const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + + // Check preset validity (only when use_preset is true) + if (loraConfig.use_preset) { + check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + + bool preset_valid = false; + for (size_t i = 0; i < newRegion->getNumPresets(); i++) { + if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) { + preset_valid = true; + break; + } + } + if (!preset_valid) { + const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true); + if (clamp) { + snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name, + defaultName); + } else { + snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name); + } + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + loraConfig.modem_preset = newRegion->getDefaultPreset(); + check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + } else { + return false; + } + } + } else { + check_bw = bwCodeToKHz(loraConfig.bandwidth); + } + + // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region: + // spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band + // padding = gap at the beginning and end of the slots (0 for no padding) + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); + + // Check if the region supports the requested bandwidth + if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) { + const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f; + snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora)); + check_bw = bwCodeToKHz(loraConfig.bandwidth); + + // Recompute slot width and number of slots based on the new bandwidth + freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); + } else { + return false; + } + } + + const char *channelName = channels.getName(channels.getPrimaryIndex()); + const char *presetNameDisplay = + DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; + uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots; + + if (loraConfig.override_frequency == 0) { + + // Check if we use the default frequency slot + uses_default_frequency_slot = + (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default + (newRegion->profile->overrideSlot != 0 && + loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override + ((newRegion->profile->overrideSlot == 0) && + ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override + + // check if user setting different to preset name + uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0); + + if (loraConfig.channel_num > numFreqSlots) { + snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num, + newRegion->name, numFreqSlots); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + if (uses_custom_channel_name) { // clamp to channel name hash + loraConfig.channel_num = + channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + } else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot + loraConfig.channel_num = + newRegion->profile->overrideSlot; // use the override slot specified by the region profile + uses_default_frequency_slot = true; + } else if (loraConfig.use_preset) { // clamp to preset slot + loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + uses_default_frequency_slot = true; + } else { // if not using preset, and no custom channel name, just clamp to default anyway + uses_default_frequency_slot = true; + }; + } else { + return false; + } + } // end of channel number check + } else { + // if we have a frequency override, we ignore the channel number and just use the override frequency + snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency); + } + return true; +} + +bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig) +{ + auto copy = loraConfig; + return checkOrClampConfigLora(copy, false); +} + +void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig) +{ + checkOrClampConfigLora(loraConfig, true); +} + /** * Pull our channel settings etc... from protobufs to the dumb interface settings + * Note: this must be given only settings which have been validated or clamped! */ void RadioInterface::applyModemConfig() { // Set up default configuration // No Sync Words in LORA mode meshtastic_Config_LoRaConfig &loraConfig = config.lora; - bool validConfig = false; // We need to check for a valid configuration - while (!validConfig) { - if (loraConfig.use_preset) { - modemPresetToParams(loraConfig.modem_preset, myRegion->wideLora, bw, sf, cr); - if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate != cr) { - cr = loraConfig.coding_rate; - LOG_INFO("Using custom Coding Rate %u", cr); - } - } else { - sf = loraConfig.spread_factor; + const RegionInfo *newRegion = getRegion(loraConfig.region); + myRegion = newRegion; + + if (loraConfig.use_preset) { + if (!validateConfigLora(loraConfig)) { + loraConfig.modem_preset = newRegion->getDefaultPreset(); + } + uint8_t newcr; + modemPresetToParams(loraConfig.modem_preset, newRegion->wideLora, bw, sf, newcr); + // If custom CR is being used already, check if the new preset is higher + if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) { + cr = newcr; + LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr); + } + // If the custom CR is higher than the preset, use it + else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) { cr = loraConfig.coding_rate; - bw = bwCodeToKHz(loraConfig.bandwidth); - } - - if ((myRegion->freqEnd - myRegion->freqStart) < bw / 1000) { - const float regionSpanKHz = (myRegion->freqEnd - myRegion->freqStart) * 1000.0f; - const float requestedBwKHz = bw; - const bool isWideRequest = requestedBwKHz >= 499.5f; // treat as 500 kHz preset - const char *presetName = - DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); - - char err_string[160]; - if (isWideRequest) { - snprintf(err_string, sizeof(err_string), "%s region too narrow for 500kHz preset (%s). Falling back to LongFast.", - myRegion->name, presetName); - } else { - snprintf(err_string, sizeof(err_string), "%s region span %.0fkHz < requested %.0fkHz. Falling back to LongFast.", - myRegion->name, regionSpanKHz, requestedBwKHz); - } - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); - cn->level = meshtastic_LogRecord_Level_ERROR; - snprintf(cn->message, sizeof(cn->message), "%s", err_string); - service->sendClientNotification(cn); - - // Set to default modem preset - loraConfig.use_preset = true; - loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + LOG_INFO("Using custom Coding Rate %u", cr); } else { - validConfig = true; + cr = loraConfig.coding_rate; } + + } else { // if not using preset, then just use the custom settings + if (validateConfigLora(loraConfig)) { + } else { + LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults", + newRegion->name); + clampConfigLora(loraConfig); + } + bw = bwCodeToKHz(loraConfig.bandwidth); + sf = loraConfig.spread_factor; + cr = loraConfig.coding_rate; } power = loraConfig.tx_power; - if ((power == 0) || ((power > myRegion->powerLimit) && !devicestate.owner.is_licensed)) - power = myRegion->powerLimit; + if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed)) + power = newRegion->powerLimit; if (power == 0) - power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of myRegion defaults + power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of newRegion defaults // to 0, currently no region has an actual power limit of 0 [dBm] so we can assume regions which have this // variable set to 0 don't have a valid power limit) // Set final tx_power back onto config loraConfig.tx_power = (int8_t)power; // cppcheck-suppress assignmentAddressToInteger - // Calculate the number of channels - uint32_t numChannels = floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000))); + uint32_t channel_num; + float freq; - // If user has manually specified a channel num, then use that, otherwise generate one by hashing the name - const char *channelName = channels.getName(channels.getPrimaryIndex()); - // channel_num is actually (channel_num - 1), since modulus (%) returns values from 0 to (numChannels - 1) - uint32_t channel_num = (loraConfig.channel_num ? loraConfig.channel_num - 1 : hash(channelName)) % numChannels; + // Calculate number of frequency slots (aka Channels): + // spacing = gap between channels (0 for continuous spectrum) and at the beginning of the band + // padding = gap at the beginning and end of the channel (0 for no padding) + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (bw / 1000); // in MHz + uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); - // Check if we use the default frequency slot - RadioInterface::uses_default_frequency_slot = - channel_num == - hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset)) % numChannels; - - // Old frequency selection formula - // float freq = myRegion->freqStart + ((((myRegion->freqEnd - myRegion->freqStart) / numChannels) / 2) * channel_num); - - // New frequency selection formula - float freq = myRegion->freqStart + (bw / 2000) + (channel_num * (bw / 1000)); + // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one. + // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to + // (numFreqSlots - 1). + uint32_t presetNameHashSlot = + hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; // override if we have a verbatim frequency if (loraConfig.override_frequency) { freq = loraConfig.override_frequency; channel_num = -1; + uses_default_frequency_slot = false; + } else { + + // If user has not manually specified a frequency slot, or has not specified one that is different than the default or the + // override for the new region, then use the default or override. If the user has not specified one, but has specified a + // custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually + // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1). + // NB: channel_num is also know as frequency slot but it's too late to fix now. + if (uses_default_frequency_slot) { + // if there's an override slot, use that + if (newRegion->profile->overrideSlot != 0) { + channel_num = newRegion->profile->overrideSlot - 1; + } else { + channel_num = presetNameHashSlot; + } + } else { // use the manually defined one + channel_num = loraConfig.channel_num - 1; + } + + // Calculate frequency: freqStart is band edge, add half bandwidth (plus optional padding) to get middle of first channel + // subsequent channels are spaced by freqSlotWidth + freq = newRegion->freqStart + (bw / 2000) + newRegion->profile->padding + (channel_num * freqSlotWidth); // in MHz } saveChannelNum(channel_num); saveFreq(freq + loraConfig.frequency_offset); + const char *channelName = channels.getName(channels.getPrimaryIndex()); slotTimeMsec = computeSlotTimeMsec(); preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw); LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset); - LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", myRegion->name, channelName, loraConfig.modem_preset, + LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", newRegion->name, channelName, loraConfig.modem_preset, channel_num, power); - LOG_INFO("myRegion->freqStart -> myRegion->freqEnd: %f -> %f (%f MHz)", myRegion->freqStart, myRegion->freqEnd, - myRegion->freqEnd - myRegion->freqStart); - LOG_INFO("numChannels: %d x %.3fkHz", numChannels, bw); + LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd, + newRegion->freqEnd - newRegion->freqStart); + LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw); + if (newRegion->profile->overrideSlot != 0) { + LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot); + } LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec); -} +} // end of applyModemConfig /** Slottime is the time to detect a transmission has started, consisting of: - CAD duration; @@ -992,4 +1165,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) sendingPacket = p; return p->encrypted.size + sizeof(PacketHeader); -} +} \ No newline at end of file diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 8f793f47a..a7176f388 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -127,7 +127,7 @@ class RadioInterface * Coerce LoRa config fields (bandwidth/spread_factor) derived from presets. * This is used during early bootstrapping so UIs that display these fields directly remain consistent. */ - static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); + // static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded? /** * Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving) @@ -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() diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2e4c4d7d9..836cd1a22 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -11,6 +11,9 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" #endif @@ -95,6 +98,20 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) return true; } +#if HAS_TRAFFIC_MANAGEMENT + // When router_preserve_hops is enabled, preserve hops for decoded packets that are not + // position or telemetry (those have their own exhaust_hop controls). + if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled && + moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) { + LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p)); + if (trafficManagementModule) { + trafficManagementModule->recordRouterHopPreserved(); + } + return false; + } +#endif + // For subsequent hops, check if previous relay is a favorite router // Optimized search for favorite routers with matching last byte // Check ordering optimized for IoT devices (cheapest checks first) @@ -785,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 } @@ -834,6 +875,12 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) return; } + if (shouldDropPacketForPreHop(*p)) { + logHopStartDrop(*p, "pre-hop drop"); + packetPool.release(p); + return; + } + if (shouldFilterReceived(p)) { LOG_DEBUG("Incoming msg was filtered from 0x%x", p->from); packetPool.release(p); diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 4dd90b6e6..a4e82011e 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -60,7 +60,7 @@ template bool SX126xInterface::init() loraFEMInterface.init(); // Apply saved FEM LNA mode from config if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED); + loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); } #endif @@ -253,6 +253,11 @@ template bool SX126xInterface::reconfigure() LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err); assert(err == RADIOLIB_ERR_NONE); + // Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err); + startReceive(); // restart receiving return RADIOLIB_ERR_NONE; diff --git a/src/mesh/api/ServerAPI.cpp b/src/mesh/api/ServerAPI.cpp index 7bb1a8108..f3e7854ca 100644 --- a/src/mesh/api/ServerAPI.cpp +++ b/src/mesh/api/ServerAPI.cpp @@ -1,7 +1,10 @@ #include "ServerAPI.h" +#include "Throttle.h" #include "configuration.h" #include +static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL; + template ServerAPI::ServerAPI(T &_client) : StreamAPI(&client), concurrency::OSThread("ServerAPI"), client(_client) { @@ -28,6 +31,12 @@ template bool ServerAPI::checkIsConnected() template int32_t ServerAPI::runOnce() { if (client.connected()) { + if (lastContactMsec > 0 && !Throttle::isWithinTimespanMs(lastContactMsec, TCP_IDLE_TIMEOUT_MS)) { + LOG_WARN("TCP connection timeout, no data for %lu ms", (unsigned long)(millis() - lastContactMsec)); + close(); + enabled = false; + return 0; + } return StreamAPI::runOncePart(); } else { LOG_INFO("Client dropped connection, suspend API service"); @@ -57,7 +66,7 @@ template int32_t APIServerPort::runOnce() #else auto client = U::available(); #endif -#elif defined(ARCH_RP2040) +#elif defined(ARCH_RP2040) || defined(ARCH_NRF52) auto client = U::accept(); #else auto client = U::available(); diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp index 10ff06df2..43ed74cf8 100644 --- a/src/mesh/api/ethServerAPI.cpp +++ b/src/mesh/api/ethServerAPI.cpp @@ -17,6 +17,15 @@ void initApiServer(int port) } } +void deInitApiServer() +{ + if (apiPort) { + LOG_INFO("Deinit API server"); + delete apiPort; + apiPort = nullptr; + } +} + ethServerAPI::ethServerAPI(EthernetClient &_client) : ServerAPI(_client) { LOG_INFO("Incoming ethernet connection"); diff --git a/src/mesh/api/ethServerAPI.h b/src/mesh/api/ethServerAPI.h index c616c87be..8f81ee6ff 100644 --- a/src/mesh/api/ethServerAPI.h +++ b/src/mesh/api/ethServerAPI.h @@ -24,4 +24,5 @@ class ethServerPort : public APIServerPort }; void initApiServer(int port = SERVER_API_DEFAULT_PORT); +void deInitApiServer(); #endif diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index a811ec16c..80741810a 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -32,6 +32,69 @@ static Periodic *ethEvent; static int32_t reconnectETH() { if (config.network.eth_enabled) { + + // Detect W5100S chip reset by verifying the MAC address register. + // PoE power instability can brownout the W5100S while the MCU keeps running, + // causing all chip registers (MAC, IP, sockets) to revert to defaults. + uint8_t currentMac[6]; + Ethernet.MACAddress(currentMac); + + uint8_t expectedMac[6]; + getMacAddr(expectedMac); + expectedMac[0] &= 0xfe; + + if (memcmp(currentMac, expectedMac, 6) != 0) { + LOG_WARN("W5100S MAC mismatch (chip reset detected), reinitializing Ethernet"); + + syslog.disable(); +#if !MESHTASTIC_EXCLUDE_SOCKETAPI + deInitApiServer(); +#endif +#if HAS_UDP_MULTICAST + if (udpHandler) { + udpHandler->stop(); + } +#endif + + ethStartupComplete = false; +#ifndef DISABLE_NTP + ntp_renew = 0; +#endif + +#ifdef PIN_ETHERNET_RESET + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, LOW); + delay(100); + digitalWrite(PIN_ETHERNET_RESET, HIGH); + delay(100); +#endif + +#ifdef RAK11310 + ETH_SPI_PORT.setSCK(PIN_SPI0_SCK); + ETH_SPI_PORT.setTX(PIN_SPI0_MOSI); + ETH_SPI_PORT.setRX(PIN_SPI0_MISO); + ETH_SPI_PORT.begin(); +#endif + Ethernet.init(ETH_SPI_PORT, PIN_ETHERNET_SS); + + int status = 0; + if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_DHCP) { + status = Ethernet.begin(expectedMac); + } else if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_STATIC) { + Ethernet.begin(expectedMac, config.network.ipv4_config.ip, config.network.ipv4_config.dns, + config.network.ipv4_config.gateway, config.network.ipv4_config.subnet); + status = 1; + } + + if (status == 0) { + LOG_ERROR("Ethernet re-initialization failed, will retry"); + return 5000; + } + + LOG_INFO("Ethernet reinitialized - IP %u.%u.%u.%u", Ethernet.localIP()[0], Ethernet.localIP()[1], + Ethernet.localIP()[2], Ethernet.localIP()[3]); + } + Ethernet.maintain(); if (!ethStartupComplete) { // Start web server diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 4c68b2005..477c3b31b 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -306,6 +306,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_MINI_EPAPER_S3 = 125, /* LilyGo T-Display S3 Pro LR1121 */ meshtastic_HardwareModel_TDISPLAY_S3_PRO = 126, + /* Heltec Mesh Node T096 board features an nRF52840 CPU and a TFT screen. */ + meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 = 127, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index e4f65aa28..eea7d4efc 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -69,6 +69,22 @@ static inline int get_max_num_nodes() /// Max number of channels allowed #define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0])) +// Traffic Management module configuration +// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 0 +#endif + +// Cache size for traffic management (number of nodes to track) +// Can be overridden per-variant based on available memory +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#if HAS_TRAFFIC_MANAGEMENT +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 +#else +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 0 +#endif +#endif + /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic /// returns the encoded packet size size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct); @@ -90,4 +106,4 @@ bool writecb(pb_ostream_t *stream, const uint8_t *buf, size_t count); */ bool is_in_helper(uint32_t n, const uint32_t *array, pb_size_t count); -#define is_in_repeated(name, n) is_in_helper(n, name, name##_count) \ No newline at end of file +#define is_in_repeated(name, n) is_in_helper(n, name, name##_count) diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index f88b48d62..493cc5353 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -69,14 +69,16 @@ 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) { + // Drop packets with spoofed local origin — no legitimate LAN node should send from=0 or our own nodeNum + if (isFromUs(&mp)) { + LOG_WARN("UDP packet with spoofed local from=0x%x, dropping", mp.from); + 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; @@ -113,4 +115,4 @@ class UdpMulticastHandler final AsyncUDP udp; bool isRunning; }; -#endif // HAS_UDP_MULTICAST \ No newline at end of file +#endif // HAS_UDP_MULTICAST diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 8f0296227..e3a501097 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -24,7 +24,9 @@ #include "Default.h" #include "MeshRadio.h" +#include "RadioInterface.h" #include "TypeConversions.h" +#include "mesh/RadioLibInterface.h" #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" @@ -197,10 +199,35 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta handleSetOwner(r->set_owner); break; - case meshtastic_AdminMessage_set_config_tag: + case meshtastic_AdminMessage_set_config_tag: { LOG_DEBUG("Client set config"); - handleSetConfig(r->set_config); + + // Non-LoRa configs need no further validation. + if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) { + LOG_DEBUG("Non-LoRa config, applying directly"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + // Only LORA_24 requires hardware capability validation. + if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { + LOG_DEBUG("LoRa config, region is not LORA_24, applying directly"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + // Hardware supports 2.4 GHz — apply the config. + // Fail closed: null instance is treated as incapable. + if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) { + LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region"); + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; + } case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); @@ -626,7 +653,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) } } -void AdminModule::handleSetConfig(const meshtastic_Config &c) +void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) { auto changes = SEGMENT_CONFIG; auto existingRole = config.device.role; @@ -768,18 +795,57 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) validatedLora.spread_factor = LORA_SF_DEFAULT; } - // If no lora radio parameters change, don't need to reboot - if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region && - oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth && - oldLoraConfig.spread_factor == validatedLora.spread_factor && - oldLoraConfig.coding_rate == validatedLora.coding_rate && oldLoraConfig.tx_power == validatedLora.tx_power && - oldLoraConfig.frequency_offset == validatedLora.frequency_offset && - oldLoraConfig.override_frequency == validatedLora.override_frequency && - oldLoraConfig.channel_num == validatedLora.channel_num && - oldLoraConfig.sx126x_rx_boosted_gain == validatedLora.sx126x_rx_boosted_gain) { - requiresReboot = false; + // If we're setting a new region, check the region is valid and then init the region or discard the change + if (validatedLora.region != myRegion->code) { + // Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid + if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) { + // If we're setting region for the first time, init the region and regenerate the keys + if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); + } +#endif + // new region is valid and we're coming from an unset region, so enable tx + validatedLora.tx_enabled = true; + } + // If we're unsetting the region for some reason, disable tx + if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + validatedLora.tx_enabled = false; + } + // Ensure initRegion() uses the newly validated region + config.lora.region = validatedLora.region; + initRegion(); + if (myRegion->dutyCycle < 100) { + validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit + } + if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { + // Default root is in use, so subscribe to the appropriate MQTT topic for this region + sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); + } + changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; + } else { + // Region validation has failed, so just copy all of the old config over the new config + validatedLora = oldLoraConfig; + } + } // end of new region handling + + if (!RadioInterface::validateConfigLora(validatedLora)) { + if (fromOthers) { + LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); + // modem_preset set to use the old setting if the check fails + validatedLora.modem_preset = oldLoraConfig.modem_preset; + } else { + LOG_WARN("Invalid LoRa config received from client, using corrected values"); + RadioInterface::clampConfigLora(validatedLora); + } + // use_preset and bandwidth are coerced into valid values by the check. } + // All LoRa radio changes apply live via configChanged observer → reconfigure(). + // reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive. + requiresReboot = false; + #if defined(ARCH_PORTDUINO) // If running on portduino and using SimRadio, do not require reboot if (SimRadio::instance) { @@ -795,63 +861,21 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) digitalWrite(RF95_FAN_EN, HIGH ^ 0); } #endif - config.lora = validatedLora; #if HAS_LORA_FEM // Apply FEM LNA mode from config (only meaningful on hardware that supports it) + // Note that a rejected lora config will revert this as well. if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED); - } else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { + loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); + } else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { // Hardware FEM does not support LNA control; normalize stored config to match actual capability LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT"); - config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; + validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; } #endif - // If we're setting region for the first time, init the region and regenerate the keys - if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } - } -#endif - config.lora.tx_enabled = true; - initRegion(); - if (myRegion->dutyCycle < 100) { - config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit - } - // Compare the entire string, we are sure of the length as a topic has never been set - if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) { - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; - } - } - if (config.lora.region != myRegion->code) { - // Region has changed so check whether there is a regulatory one we should be using instead. - // Additionally as a side-effect, assume a new value under myRegion - initRegion(); - if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { - // Default root is in use, so subscribe to the appropriate MQTT topic for this region - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - } + config.lora = validatedLora; // Finally, return the validated config back to the main config - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; - } break; } case meshtastic_Config_bluetooth_tag: @@ -899,10 +923,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) } if (requiresReboot && !hasOpenEditTransaction) { disableBluetooth(); - } + } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); -} +} // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) { @@ -1008,6 +1032,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.statusmessage = c.payload_variant.statusmessage; shouldReboot = false; break; + case meshtastic_ModuleConfig_traffic_management_tag: + LOG_INFO("Set module config: Traffic Management"); + moduleConfig.has_traffic_management = true; + moduleConfig.traffic_management = c.payload_variant.traffic_management; + break; } saveChanges(SEGMENT_MODULECONFIG, shouldReboot); return true; @@ -1122,78 +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: + 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 @@ -1376,23 +1412,17 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) { // Validate ham parameters before setting since this would bypass validation in the owner struct - if (*p.call_sign) { - const char *start = p.call_sign; - // Skip all whitespace - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham call_sign: must contain at least 1 non-whitespace character"); - return; - } - } - if (*p.short_name) { - const char *start = p.short_name; - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham short_name: must contain at least 1 non-whitespace character"); - return; + const char *fieldsToCheck[] = {p.call_sign, p.short_name}; + const char *fieldNames[] = {"call_sign", "short_name"}; + for (int i = 0; i < 2; i++) { + if (*fieldsToCheck[i]) { + const char *start = fieldsToCheck[i]; + while (*start && isspace((unsigned char)*start)) + start++; + if (*start == '\0') { + LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]); + return; + } } } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index c446887b3..5c690abbd 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -60,7 +60,11 @@ class AdminModule : public ProtobufModule, public Obser */ void handleSetOwner(const meshtastic_User &o); void handleSetChannel(const meshtastic_Channel &cc); - void handleSetConfig(const meshtastic_Config &c); + + protected: + void handleSetConfig(const meshtastic_Config &c, bool fromOthers); + + private: bool handleSetModuleConfig(const meshtastic_ModuleConfig &c); void handleSetChannel(); void handleSetHamMode(const meshtastic_HamParameters &req); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index ae25de0cb..65e903134 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -13,10 +13,12 @@ #include "buzz.h" #include "detect/ScanI2C.h" #include "gps/RTC.h" +#include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/MessageRenderer.h" #include "graphics/draw/NotificationRenderer.h" +#include "graphics/draw/UIRenderer.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "input/SerialKeyboard.h" @@ -45,71 +47,6 @@ extern MessageStore messageStore; // Remove Canned message screen if no action is taken for some milliseconds #define INACTIVATE_AFTER_MS 20000 -// Tokenize a message string into emote/text segments -static std::vector> tokenizeMessageWithEmotes(const char *msg) -{ - std::vector> tokens; - int msgLen = strlen(msg); - int pos = 0; - while (pos < msgLen) { - const graphics::Emote *foundEmote = nullptr; - int foundLen = 0; - for (int j = 0; j < graphics::numEmotes; j++) { - const char *label = graphics::emotes[j].label; - int labelLen = strlen(label); - if (labelLen == 0) - continue; - if (strncmp(msg + pos, label, labelLen) == 0) { - if (!foundEmote || labelLen > foundLen) { - foundEmote = &graphics::emotes[j]; - foundLen = labelLen; - } - } - } - if (foundEmote) { - tokens.emplace_back(true, String(foundEmote->label)); - pos += foundLen; - } else { - // Find next emote - int nextEmote = msgLen; - for (int j = 0; j < graphics::numEmotes; j++) { - const char *label = graphics::emotes[j].label; - if (!label || !*label) - continue; - const char *found = strstr(msg + pos, label); - if (found && (found - msg) < nextEmote) { - nextEmote = found - msg; - } - } - int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos); - if (textLen > 0) { - tokens.emplace_back(false, String(msg + pos).substring(0, textLen)); - pos += textLen; - } else { - break; - } - } - } - return tokens; -} - -// Render a single emote token centered vertically on a row -static void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label) -{ - const graphics::Emote *emote = nullptr; - for (int j = 0; j < graphics::numEmotes; j++) { - if (label == graphics::emotes[j].label) { - emote = &graphics::emotes[j]; - break; - } - } - if (emote) { - int emoteYOffset = (rowHeight - emote->height) / 2; // vertically center the emote - display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap); - nextX += emote->width + 2; // spacing between tokens - } -} - namespace graphics { extern int bannerSignalBars; @@ -264,19 +201,20 @@ int CannedMessageModule::splitConfiguredMessages() } void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer) { - if (graphics::currentResolution == graphics::ScreenResolution::High) { - if (this->dest == NODENUM_BROADCAST) { - display->drawStringf(x, y, buffer, "To: #%s", channels.getName(this->channel)); - } else { - display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest)); - } + (void)buffer; + + char header[96]; + if (this->dest == NODENUM_BROADCAST) { + const char *channelName = channels.getName(this->channel); + snprintf(header, sizeof(header), "To: #%s", channelName ? channelName : "?"); } else { - if (this->dest == NODENUM_BROADCAST) { - display->drawStringf(x, y, buffer, "To: #%.20s", channels.getName(this->channel)); - } else { - display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest)); - } + snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest)); } + + const int maxWidth = std::max(0, display->getWidth() - x); + char truncatedHeader[96]; + graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth); + graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false); } void CannedMessageModule::resetSearch() @@ -370,6 +308,92 @@ bool CannedMessageModule::isCharInputAllowed() const { return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; } + +static int getRowHeightForEmoteText(const char *text, int minimumHeight, int emoteSpacing = 2) +{ + // Grow the row only when an emote is taller than the font. + const auto metrics = + graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", 0, graphics::emotes, graphics::numEmotes, emoteSpacing); + return std::max(minimumHeight, metrics.tallestHeight + 2); +} + +static void drawCenteredEmoteText(OLEDDisplay *display, int x, int y, int rowHeight, const char *text, int emoteSpacing = 2) +{ + // Center mixed text and emotes inside the row height. + const auto metrics = graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes, + graphics::numEmotes, emoteSpacing); + const int contentHeight = std::max(FONT_HEIGHT_SMALL, metrics.tallestHeight); + const int drawY = y + ((rowHeight - contentHeight) / 2); + graphics::EmoteRenderer::drawStringWithEmotes(display, x, drawY, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes, + graphics::numEmotes, emoteSpacing, false); +} + +static size_t firstWrappedTokenLen(const char *text) +{ + // Fall back to one full emote or one UTF-8 glyph when width is tiny. + if (!text || !*text) + return 0; + + const size_t textLen = strlen(text); + size_t matchLen = 0; + if (graphics::EmoteRenderer::findEmoteAt(text, textLen, 0, matchLen, graphics::emotes, graphics::numEmotes)) + return matchLen; + + return graphics::EmoteRenderer::utf8CharLen(static_cast(text[0])); +} + +static void drawWrappedEmoteText(OLEDDisplay *display, int x, int y, const char *text, int maxWidth, int minimumRowHeight, + int emoteSpacing = 2) +{ + // Wrap onto multiple rows without splitting emotes. + if (!display || !text || maxWidth <= 0) + return; + + constexpr size_t kLineBufferSize = 256; + char lineBuffer[kLineBufferSize]; + const size_t textLen = strlen(text); + size_t offset = 0; + int yCursor = y; + + while (offset < textLen) { + size_t copied = graphics::EmoteRenderer::truncateToWidth(display, text + offset, lineBuffer, sizeof(lineBuffer), maxWidth, + "", graphics::emotes, graphics::numEmotes, emoteSpacing); + size_t consumed = copied; + + if (copied == 0) { + consumed = firstWrappedTokenLen(text + offset); + if (consumed == 0) + break; + + const size_t fallbackLen = std::min(consumed, sizeof(lineBuffer) - 1); + memcpy(lineBuffer, text + offset, fallbackLen); + lineBuffer[fallbackLen] = '\0'; + consumed = fallbackLen; + } else if (text[offset + copied] != '\0') { + // Prefer wrapping at the last space when a full line does not fit. + size_t lastSpace = copied; + while (lastSpace > 0 && lineBuffer[lastSpace - 1] != ' ') + --lastSpace; + + if (lastSpace > 0) { + consumed = lastSpace; + while (consumed > 0 && lineBuffer[consumed - 1] == ' ') + --consumed; + lineBuffer[consumed] = '\0'; + } + } + + if (lineBuffer[0]) { + const int rowHeight = getRowHeightForEmoteText(lineBuffer, minimumRowHeight, emoteSpacing); + drawCenteredEmoteText(display, x, yCursor, rowHeight, lineBuffer, emoteSpacing); + yCursor += rowHeight; + } + + offset += std::max(consumed, 1); + while (offset < textLen && text[offset] == ' ') + ++offset; + } +} /** * Main input event dispatcher for CannedMessageModule. * Routes keyboard/button/touch input to the correct handler based on the current runState. @@ -491,18 +515,16 @@ bool CannedMessageModule::handleTabSwitch(const InputEvent *event) if (event->kbchar != 0x09) return false; - updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT - : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION); + const cannedMessageModuleRunState targetState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) + ? CANNED_MESSAGE_RUN_STATE_FREETEXT + : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; destIndex = 0; scrollIndex = 0; - // RESTORE THIS! - if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) + if (targetState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) updateDestinationSelectionList(); - updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT - : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION, - true); + updateState(targetState, true); UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; @@ -1686,55 +1708,51 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O int xOffset = 0; int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset; - char entryText[64] = ""; + std::string entryText; // Draw Channels First if (itemIndex < numActiveChannels) { uint8_t channelIndex = this->activeChannelIndices[itemIndex]; - snprintf(entryText, sizeof(entryText), "#%s", channels.getName(channelIndex)); + const char *channelName = channels.getName(channelIndex); + entryText = std::string("#") + (channelName ? channelName : "?"); } // Then Draw Nodes else { int nodeIndex = itemIndex - numActiveChannels; if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) { meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; - if (node && node->user.long_name) { - strncpy(entryText, node->user.long_name, sizeof(entryText) - 1); - entryText[sizeof(entryText) - 1] = '\0'; + if (node) { + if (display->getWidth() <= 64) { + entryText = node->user.short_name; + } else if (node->user.long_name[0]) { + entryText = node->user.long_name; + } else { + entryText = node->user.short_name; + } } + int availWidth = display->getWidth() - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - ((node && node->is_favorite) ? 10 : 0); if (availWidth < 0) availWidth = 0; - - size_t origLen = strlen(entryText); - while (entryText[0] && display->getStringWidth(entryText) > availWidth) { - entryText[strlen(entryText) - 1] = '\0'; - } - if (strlen(entryText) < origLen) { - strcat(entryText, "..."); - } + char truncatedEntry[96]; + graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry), + availWidth); + entryText = truncatedEntry; // Prepend "* " if this is a favorite if (node && node->is_favorite) { - size_t len = strlen(entryText); - if (len + 2 < sizeof(entryText)) { - memmove(entryText + 2, entryText, len + 1); - entryText[0] = '*'; - entryText[1] = ' '; - } - } - if (node) { - if (display->getWidth() <= 64) { - snprintf(entryText, sizeof(entryText), "%s", node->user.short_name); - } + entryText = "* " + entryText; } + graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry), + availWidth); + entryText = truncatedEntry; } } - if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0) - strcpy(entryText, "?"); + if (entryText.empty() || entryText == "Unknown") + entryText = "?"; // Highlight background (if selected) if (itemIndex == destIndex) { @@ -1744,7 +1762,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O } // Draw entry text - display->drawString(xOffset + 2, yOffset, entryText); + graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false); display->setColor(WHITE); // Draw key icon (after highlight) @@ -1785,15 +1803,10 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla { const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height const int headerMargin = 2; // Extra pixels below header - const int labelGap = 6; + const int labelGap = 4; const int bitmapGapX = 4; - // Find max emote height (assume all same, or precalculated) - int maxEmoteHeight = 0; - for (int i = 0; i < graphics::numEmotes; ++i) - if (graphics::emotes[i].height > maxEmoteHeight) - maxEmoteHeight = graphics::emotes[i].height; - + const int maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight(); const int rowHeight = maxEmoteHeight + 2; // Place header at top, then compute start of emote list @@ -1840,14 +1853,16 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla display->setColor(BLACK); } - // Emote bitmap (left), 1px margin from highlight bar top - int emoteY = rowY + 1; - display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap); + // Emote bitmap (left), centered inside the row + int labelStartX = x + bitmapGapX; + const int emoteY = rowY + ((rowHeight - emote.height) / 2); + display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap); + labelStartX += emote.width; // Emote label (right of bitmap) display->setFont(FONT_MEDIUM); int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2); - display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label); + display->drawString(labelStartX + labelGap, labelY, emote.label); if (emoteIdx == emotePickerIndex) display->setColor(WHITE); @@ -2007,91 +2022,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st { int inputY = 0 + y + FONT_HEIGHT_SMALL; String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); - - // Tokenize input into (isEmote, token) pairs - const char *msg = msgWithCursor.c_str(); - std::vector> tokens = tokenizeMessageWithEmotes(msg); - - // Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed) - std::vector>> lines; - std::vector> currentLine; - int lineWidth = 0; - int maxWidth = display->getWidth(); - for (auto &token : tokens) { - if (token.first) { - // Emote - int tokenWidth = 0; - for (int j = 0; j < graphics::numEmotes; j++) { - if (token.second == graphics::emotes[j].label) { - tokenWidth = graphics::emotes[j].width + 2; - break; - } - } - if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - currentLine.push_back(token); - lineWidth += tokenWidth; - } else { - // Text: split by words and wrap inside word if needed - String text = token.second; - int pos = 0; - while (pos < static_cast(text.length())) { - // Find next space (or end) - int spacePos = text.indexOf(' ', pos); - int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space - String word = text.substring(pos, endPos); - int wordWidth = display->getStringWidth(word); - - if (lineWidth + wordWidth > maxWidth && lineWidth > 0) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - // If word itself too big, split by character - if (wordWidth > maxWidth) { - uint16_t charPos = 0; - while (charPos < word.length()) { - String oneChar = word.substring(charPos, charPos + 1); - int charWidth = display->getStringWidth(oneChar); - if (lineWidth + charWidth > maxWidth && lineWidth > 0) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - currentLine.push_back({false, oneChar}); - lineWidth += charWidth; - charPos++; - } - } else { - currentLine.push_back({false, word}); - lineWidth += wordWidth; - } - pos = endPos; - } - } - } - if (!currentLine.empty()) - lines.push_back(currentLine); - - // Draw lines with emotes - int rowHeight = FONT_HEIGHT_SMALL; - int yLine = inputY; - for (const auto &line : lines) { - int nextX = x; - for (const auto &token : line) { - if (token.first) { - // Emote rendering centralized in helper - renderEmote(display, nextX, yLine, rowHeight, token.second); - } else { - display->drawString(nextX, yLine, token.second); - nextX += display->getStringWidth(token.second); - } - } - yLine += rowHeight; - } + drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL); } #endif return; @@ -2106,7 +2037,6 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st const int baseRowSpacing = FONT_HEIGHT_SMALL - 4; int topMsg; - std::vector rowHeights; int _visibleRows; // Draw header (To: ...) @@ -2122,36 +2052,15 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st : 0; int countRows = std::min(messagesCount, _visibleRows); - // Build per-row max height based on all emotes in line - for (int i = 0; i < countRows; i++) { - const char *msg = getMessageByIndex(topMsg + i); - int maxEmoteHeight = 0; - for (int j = 0; j < graphics::numEmotes; j++) { - const char *label = graphics::emotes[j].label; - if (!label || !*label) - continue; - const char *search = msg; - while ((search = strstr(search, label))) { - if (graphics::emotes[j].height > maxEmoteHeight) - maxEmoteHeight = graphics::emotes[j].height; - search += strlen(label); // Advance past this emote - } - } - rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2)); - } - // Draw all message rows with multi-emote support int yCursor = listYOffset; for (int vis = 0; vis < countRows; vis++) { int msgIdx = topMsg + vis; int lineY = yCursor; const char *msg = getMessageByIndex(msgIdx); - int rowHeight = rowHeights[vis]; + int rowHeight = getRowHeightForEmoteText(msg, baseRowSpacing); bool _highlight = (msgIdx == currentMessageIndex); - // Multi-emote tokenization - std::vector> tokens = tokenizeMessageWithEmotes(msg); - // Vertically center based on rowHeight int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2; @@ -2168,17 +2077,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st int nextX = x + (_highlight ? 2 : 0); #endif - // Draw all tokens left to right - for (const auto &token : tokens) { - if (token.first) { - // Emote rendering centralized in helper - renderEmote(display, nextX, lineY, rowHeight, token.second); - } else { - // Text - display->drawString(nextX, lineY + textYOffset, token.second); - nextX += display->getStringWidth(token.second); - } - } + if (msg && *msg) + drawCenteredEmoteText(display, nextX, lineY, rowHeight, msg); #ifndef USE_EINK if (_highlight) display->setColor(WHITE); diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index cc7124f0e..3addc4b3a 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -350,12 +350,6 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP { // Trigger external notification if enabled and not muted; isSilenced is from temporary mute toggles if (moduleConfig.external_notification.enabled && !isSilenced) { -#ifdef T_WATCH_S3 - drv.setWaveform(0, 75); - drv.setWaveform(1, 56); - drv.setWaveform(2, 0); - drv.go(); -#endif if (!isFromUs(&mp)) { // Check if the message contains a bell character. Don't do this loop for every pin, just once. auto &p = mp.decoded; @@ -380,60 +374,69 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); - if (containsBell || !is_muted) { - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_message || - moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_message_vibra || - ((moduleConfig.external_notification.alert_bell_buzzer || - moduleConfig.external_notification.alert_message_buzzer) && - canBuzz())) { - nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout - ? (moduleConfig.external_notification.nag_timeout * 1000) - : moduleConfig.external_notification.output_ms); - LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); - isNagging = true; - } + // Each output evaluates its own alert condition independently: + // alert_bell_* fires only when a bell character is present. + // alert_message_* fires on any non-muted message. - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_message) { - LOG_INFO("externalNotificationModule - Notification Module or Bell"); - setExternalState(0, true); - } + // Alert when receiving a bell = alertBell: true + // Alert when receiving a message = alertMessage: true + const bool genericShouldAlert = (moduleConfig.external_notification.alert_bell && containsBell) || + (moduleConfig.external_notification.alert_message && !is_muted); - if (moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_message_vibra) { - LOG_INFO("externalNotificationModule - Notification Module or Bell (Vibra)"); - setExternalState(1, true); - } + // Alert GPIO Vibra when receiving a bell = alertBellVibra: true + // Alert GPIO Vibra when receiving a message = alertMessageVibra: true + const bool vibraShouldAlert = (moduleConfig.external_notification.alert_bell_vibra && containsBell) || + (moduleConfig.external_notification.alert_message_vibra && !is_muted); - if ((moduleConfig.external_notification.alert_bell_buzzer || - moduleConfig.external_notification.alert_message_buzzer) && - canBuzz()) { - LOG_INFO("externalNotificationModule - Notification Module or Bell (Buzzer)"); - if (buzzerModeIsDirectOnly && !isDmToUs && !containsBell) { - LOG_INFO("Message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); - } else { - // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us -#ifdef T_LORA_PAGER - drv.setWaveform(0, 16); // Long buzzer 100% - drv.setWaveform(1, 0); // Pause - drv.setWaveform(2, 16); - drv.setWaveform(3, 0); - drv.setWaveform(4, 16); - drv.setWaveform(5, 0); - drv.setWaveform(6, 16); - drv.setWaveform(7, 0); - drv.go(); + // Alert GPIO Buzzer when receiving a bell = alertBellBuzzer: true + // Alert GPIO Buzzer when receiving a message = alertMessageBuzzer: true + const bool buzzerShouldAlert = canBuzz() && ((moduleConfig.external_notification.alert_bell_buzzer && containsBell) || + (moduleConfig.external_notification.alert_message_buzzer && !is_muted)); + + if (genericShouldAlert || vibraShouldAlert || buzzerShouldAlert) { + nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout + ? (moduleConfig.external_notification.nag_timeout * 1000) + : moduleConfig.external_notification.output_ms); + LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); + isNagging = true; + } + + if (genericShouldAlert) { + LOG_INFO("externalNotificationModule - Generic alert"); + setExternalState(0, true); + } + + if (vibraShouldAlert) { + LOG_INFO("externalNotificationModule - Vibra alert"); + setExternalState(1, true); + } + + if (buzzerShouldAlert) { + LOG_INFO("externalNotificationModule - Buzzer alert"); + if (buzzerModeIsDirectOnly && !isDmToUs && !containsBell) { + LOG_INFO("Message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); + } else { + // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us +#ifdef HAS_DRV2605 + drv.setWaveform(0, 16); // Long buzzer 100% + drv.setWaveform(1, 0); // Pause + drv.setWaveform(2, 16); + drv.setWaveform(3, 0); + drv.setWaveform(4, 16); + drv.setWaveform(5, 0); + drv.setWaveform(6, 16); + drv.setWaveform(7, 0); + drv.go(); #endif + + if (moduleConfig.external_notification.use_i2s_as_buzzer) { #ifdef HAS_I2S - if (moduleConfig.external_notification.use_i2s_as_buzzer) { - audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); - } else + audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); #endif - if (moduleConfig.external_notification.use_pwm) { - rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); - } else { - setExternalState(2, true); - } + } else if (moduleConfig.external_notification.use_pwm) { + rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); + } else { + setExternalState(2, true); } } } @@ -513,4 +516,4 @@ int ExternalNotificationModule::handleInputEvent(const InputEvent *event) return 1; } return 0; -} \ No newline at end of file +} diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 64e90c9c2..d3ab9076d 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -38,6 +38,9 @@ #include "modules/PowerStressModule.h" #endif #include "modules/RoutingModule.h" +#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "modules/TextMessageModule.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" @@ -120,6 +123,14 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_REPLYBOT new ReplyBotModule(); #endif + +#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT + // Instantiate only when enabled to avoid extra memory use and background work. + if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) { + trafficManagementModule = new TrafficManagementModule(); + } +#endif + #if !MESHTASTIC_EXCLUDE_ADMIN adminModule = new AdminModule(); #endif diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 79bda557c..f41fafdee 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -30,7 +30,8 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes auto p = *pptr; - if (mp.decoded.want_response) { + // Suppress replies to senders we've replied to recently (12H window) + if (mp.decoded.want_response && !isFromUs(&mp)) { const NodeNum sender = getFrom(&mp); const uint32_t now = mp.rx_time ? mp.rx_time : getTime(); auto it = lastNodeInfoSeen.find(sender); @@ -119,7 +120,13 @@ void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha meshtastic_MeshPacket *NodeInfoModule::allocReply() { - if (suppressReplyForCurrentRequest) { + // Only apply suppression when actually replying to someone else's request, not for periodic broadcasts. + const bool isReplyingToExternalRequest = currentRequest && + currentRequest->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + currentRequest->decoded.portnum == meshtastic_PortNum_NODEINFO_APP && + currentRequest->decoded.want_response && !isFromUs(currentRequest); + + if (suppressReplyForCurrentRequest && isReplyingToExternalRequest) { LOG_DEBUG("Skip send NodeInfo since we heard the requester <12h ago"); ignoreRequest = true; suppressReplyForCurrentRequest = false; diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index d87cf3a44..85e7f8c06 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -20,10 +20,11 @@ bool RoutingModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mesh if ((nodeDB->getMeshNode(mp.from) == NULL || !nodeDB->getMeshNode(mp.from)->has_user) && (nodeDB->getMeshNode(mp.to) == NULL || !nodeDB->getMeshNode(mp.to)->has_user)) return false; - } else if (owner.is_licensed && nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) { - // Don't let licensed users to rebroadcast packets from unlicensed users + } else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) || + (nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) { + // Don't let licensed users to rebroadcast packets to or from unlicensed users // If we know they are in-fact unlicensed - LOG_DEBUG("Packet from unlicensed user, ignoring packet"); + LOG_DEBUG("Packet to or from unlicensed user, ignoring packet"); return false; } diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp index b0f0f9071..2feac6d5f 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp @@ -23,7 +23,7 @@ bool SEN5XSensor::getVersion() } delay(20); // From Sensirion Datasheet - uint8_t versionBuffer[12]; + uint8_t versionBuffer[12]{}; size_t charNumber = readBuffer(&versionBuffer[0], 3); if (charNumber == 0) { LOG_ERROR("SEN5X: Error getting data ready flag value"); @@ -638,7 +638,7 @@ bool SEN5XSensor::readValues() LOG_DEBUG("SEN5X: Reading PM Values"); delay(20); // From Sensirion Datasheet - uint8_t dataBuffer[16]; + uint8_t dataBuffer[16]{}; size_t receivedNumber = readBuffer(&dataBuffer[0], 24); if (receivedNumber == 0) { LOG_ERROR("SEN5X: Error getting values"); @@ -691,7 +691,7 @@ bool SEN5XSensor::readPNValues(bool cumulative) LOG_DEBUG("SEN5X: Reading PN Values"); delay(20); // From Sensirion Datasheet - uint8_t dataBuffer[20]; + uint8_t dataBuffer[20]{}; size_t receivedNumber = readBuffer(&dataBuffer[0], 30); if (receivedNumber == 0) { LOG_ERROR("SEN5X: Error getting PN values"); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp new file mode 100644 index 000000000..6936ef682 --- /dev/null +++ b/src/modules/TrafficManagementModule.cpp @@ -0,0 +1,1410 @@ +#include "TrafficManagementModule.h" + +#if HAS_TRAFFIC_MANAGEMENT + +#include "Default.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "Router.h" +#include "TypeConversions.h" +#include "concurrency/LockGuard.h" +#include "configuration.h" +#include "mesh-pb-constants.h" +#include "meshUtils.h" +#include +#include + +#define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) +#define TM_LOG_INFO(fmt, ...) LOG_INFO("[TM] " fmt, ##__VA_ARGS__) +#define TM_LOG_WARN(fmt, ...) LOG_WARN("[TM] " fmt, ##__VA_ARGS__) + +// ============================================================================= +// Anonymous Namespace - Internal Helpers +// ============================================================================= + +namespace +{ + +constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval +constexpr uint32_t kUnknownResetMs = 60 * 1000UL; // Unknown packet window +constexpr uint8_t kMaxCuckooKicks = 16; // Max displacement chain length + +// NodeInfo direct response: enforced maximum hops by device role +// Both use maxHops logic (respond when hopsAway <= threshold) +// Config value is clamped to these role-based limits +// Note: nodeinfo_direct_response must also be enabled for this to take effect +constexpr uint32_t kRouterDefaultMaxHops = 3; // Routers: max 3 hops (can set lower via config) +constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot increase) + +/** + * Convert seconds to milliseconds with overflow protection. + */ +uint32_t secsToMs(uint32_t secs) +{ + uint64_t ms = static_cast(secs) * 1000ULL; + if (ms > UINT32_MAX) + return UINT32_MAX; + return static_cast(ms); +} + +/** + * Clamp precision to a valid dedup range. + * Invalid values use the module default precision. + */ +uint8_t sanitizePositionPrecision(uint8_t precision) +{ + if (precision > 0 && precision <= 32) + return precision; + + const uint8_t defaultPrecision = static_cast(default_traffic_mgmt_position_precision_bits); + if (defaultPrecision > 0 && defaultPrecision <= 32) + return defaultPrecision; + + // Someone done messed up if we reach here + return 32; +} + +/** + * Check if a timestamp is within a time window. + * Handles wrap-around correctly using unsigned subtraction. + */ +bool isWithinWindow(uint32_t nowMs, uint32_t startMs, uint32_t intervalMs) +{ + if (intervalMs == 0 || startMs == 0) + return false; + return (nowMs - startMs) < intervalMs; +} + +/** + * Truncate lat/lon to specified precision for position deduplication. + * + * The truncation works by masking off lower bits and rounding to the center + * of the resulting grid cell. This creates a stable truncated value even + * when GPS jitter causes small coordinate changes. + * + * @param value Raw latitude_i or longitude_i from position + * @param precision Number of significant bits to keep (0-32) + * @return Truncated and centered coordinate value + */ +int32_t truncateLatLon(int32_t value, uint8_t precision) +{ + if (precision == 0 || precision >= 32) + return value; + + // Create mask to zero out lower bits + uint32_t mask = UINT32_MAX << (32 - precision); + uint32_t truncated = static_cast(value) & mask; + + // Add half the truncation step to center in the grid cell + truncated += (1u << (31 - precision)); + return static_cast(truncated); +} + +/** + * Saturating increment for uint8_t counters. + * Prevents overflow by capping at UINT8_MAX (255). + */ +inline void saturatingIncrement(uint8_t &counter) +{ + if (counter < UINT8_MAX) + counter++; +} + +/** + * Return a short human-readable name for common port numbers. + * Falls back to "port:" for unknown ports. + */ +const char *portName(int portnum) +{ + switch (portnum) { + case meshtastic_PortNum_TEXT_MESSAGE_APP: + return "text"; + case meshtastic_PortNum_POSITION_APP: + return "position"; + case meshtastic_PortNum_NODEINFO_APP: + return "nodeinfo"; + case meshtastic_PortNum_ROUTING_APP: + return "routing"; + case meshtastic_PortNum_ADMIN_APP: + return "admin"; + case meshtastic_PortNum_TELEMETRY_APP: + return "telemetry"; + case meshtastic_PortNum_TRACEROUTE_APP: + return "traceroute"; + case meshtastic_PortNum_NEIGHBORINFO_APP: + return "neighborinfo"; + case meshtastic_PortNum_STORE_FORWARD_APP: + return "store-forward"; + case meshtastic_PortNum_WAYPOINT_APP: + return "waypoint"; + default: + return nullptr; + } +} + +} // namespace + +// ============================================================================= +// Module Instance +// ============================================================================= + +TrafficManagementModule *trafficManagementModule; + +// ============================================================================= +// Constructor +// ============================================================================= + +TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") +{ + // Module configuration + isPromiscuous = true; // See all packets, not just those addressed to us + encryptedOk = true; // Can process encrypted packets + stats = meshtastic_TrafficManagementStats_init_zero; + + // Initialize rolling epoch for relative timestamps + cacheEpochMs = millis(); + + // Calculate adaptive time resolutions from config (config changes require reboot) + // Resolution = max(60, min(339, interval/2)) for ~24 hour range with good precision + posTimeResolution = calcTimeResolution(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + rateTimeResolution = calcTimeResolution(moduleConfig.traffic_management.rate_limit_window_secs); + unknownTimeResolution = calcTimeResolution(kUnknownResetMs / 1000); // ~5 min default + + const auto &cfg = moduleConfig.traffic_management; + TM_LOG_INFO("Enabled: pos_dedup=%d nodeinfo_resp=%d rate_limit=%d drop_unknown=%d exhaust_telem=%d exhaust_pos=%d " + "preserve_hops=%d", + cfg.position_dedup_enabled, cfg.nodeinfo_direct_response, cfg.rate_limit_enabled, cfg.drop_unknown_enabled, + cfg.exhaust_hop_telemetry, cfg.exhaust_hop_position, cfg.router_preserve_hops); + TM_LOG_DEBUG("Time resolutions: pos=%us, rate=%us, unknown=%us", posTimeResolution, rateTimeResolution, + unknownTimeResolution); + +// Allocate unified cache (10 bytes/entry for all platforms) +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + const uint16_t allocSize = cacheSize(); + TM_LOG_INFO("Allocating unified cache: %u entries (%u bytes)", allocSize, + static_cast(allocSize * sizeof(UnifiedCacheEntry))); + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + // ESP32 with PSRAM: prefer PSRAM for large allocations + cache = static_cast(ps_calloc(allocSize, sizeof(UnifiedCacheEntry))); + if (cache) { + cacheFromPsram = true; + } else { + TM_LOG_WARN("PSRAM allocation failed, falling back to heap"); + cache = new UnifiedCacheEntry[allocSize](); + } +#else + // All other platforms: heap allocation + cache = new UnifiedCacheEntry[allocSize](); +#endif + +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + TM_LOG_INFO("Allocating NodeInfo cache: target=%u occupancy=%u%% payload=%u bytes (PSRAM) tags=%u bytes (%u-bit, %u slots, " + "%u buckets x %u)", + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetOccupancyPercent()), + static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry)), + static_cast(nodeInfoIndexMetadataBudgetBytes()), static_cast(nodeInfoTagBits()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), + static_cast(nodeInfoBucketSize())); + + nodeInfoIndex = static_cast(calloc(nodeInfoIndexMetadataBudgetBytes(), sizeof(uint8_t))); + if (!nodeInfoIndex) { + TM_LOG_WARN("NodeInfo index allocation failed; direct responses will fall back to NodeDB"); + } else { + nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); + if (nodeInfoPayload) { + nodeInfoPayloadFromPsram = true; + TM_LOG_INFO("NodeInfo bucketed cuckoo cache ready"); + } else { + TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); + free(nodeInfoIndex); + nodeInfoIndex = nullptr; + } + } +#else + TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target"); +#endif + + setIntervalFromNow(kMaintenanceIntervalMs); +} + +// Cache may have been allocated via ps_calloc (PSRAM, C allocator) or new[] (heap). +// Must use the matching deallocator: free() for ps_calloc, delete[] for new[]. +TrafficManagementModule::~TrafficManagementModule() +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (cache) { + // Cache may be from ps_calloc (PSRAM, C allocator) or new[] (heap). + // Use the matching deallocator for the allocation source. + if (cacheFromPsram) + free(cache); + else + delete[] cache; + cache = nullptr; + } +#endif + + if (nodeInfoPayload) { + if (nodeInfoPayloadFromPsram) + free(nodeInfoPayload); + else + delete[] nodeInfoPayload; + nodeInfoPayload = nullptr; + } + + if (nodeInfoIndex) { + free(nodeInfoIndex); + nodeInfoIndex = nullptr; + } +} + +// ============================================================================= +// Statistics +// ============================================================================= + +meshtastic_TrafficManagementStats TrafficManagementModule::getStats() const +{ + concurrency::LockGuard guard(&cacheLock); + return stats; +} + +void TrafficManagementModule::resetStats() +{ + concurrency::LockGuard guard(&cacheLock); + stats = meshtastic_TrafficManagementStats_init_zero; +} + +void TrafficManagementModule::recordRouterHopPreserved() +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return; + incrementStat(&stats.router_hops_preserved); +} + +void TrafficManagementModule::incrementStat(uint32_t *field) +{ + concurrency::LockGuard guard(&cacheLock); + (*field)++; +} + +// ============================================================================= +// Cuckoo Hash Table Operations +// ============================================================================= + +/** + * Find an existing entry for the given node. + * + * Cuckoo hashing guarantees that if an entry exists, it's in one of exactly + * two locations: hash1(node) or hash2(node). This provides O(1) lookup. + * + * @param node NodeNum to search for + * @return Pointer to entry if found, nullptr otherwise + */ +TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + return nullptr; +#else + if (!cache || node == 0) + return nullptr; + + // Check primary location + uint16_t h1 = cuckooHash1(node); + if (cache[h1].node == node) + return &cache[h1]; + + // Check alternate location + uint16_t h2 = cuckooHash2(node); + if (cache[h2].node == node) + return &cache[h2]; + + return nullptr; +#endif +} + +/** + * Find or create an entry for the given node using cuckoo hashing. + * + * If the node exists, returns the existing entry. Otherwise, attempts to + * insert a new entry using cuckoo displacement: + * + * 1. Try to insert at h1(node) - if empty, done + * 2. Try to insert at h2(node) - if empty, done + * 3. Kick existing entry from h1 to its alternate location + * 4. Repeat up to kMaxCuckooKicks times + * 5. If cycle detected or max kicks exceeded, evict oldest entry + * + * @param node NodeNum to find or create + * @param isNew Set to true if a new entry was created + * @return Pointer to entry, or nullptr if allocation failed + */ +TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + if (isNew) + *isNew = false; + return nullptr; +#else + if (!cache || node == 0) { + if (isNew) + *isNew = false; + return nullptr; + } + + // Check if entry already exists (O(1) lookup) + uint16_t h1 = cuckooHash1(node); + if (cache[h1].node == node) { + if (isNew) + *isNew = false; + return &cache[h1]; + } + + uint16_t h2 = cuckooHash2(node); + if (cache[h2].node == node) { + if (isNew) + *isNew = false; + return &cache[h2]; + } + + // Entry doesn't exist - try to insert + + // Prefer empty slot at h1 + if (cache[h1].node == 0) { + memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); + cache[h1].node = node; + if (isNew) + *isNew = true; + return &cache[h1]; + } + + // Try empty slot at h2 + if (cache[h2].node == 0) { + memset(&cache[h2], 0, sizeof(UnifiedCacheEntry)); + cache[h2].node = node; + if (isNew) + *isNew = true; + return &cache[h2]; + } + + // Both slots occupied - perform cuckoo displacement + // Start by kicking entry at h1 to its alternate location + UnifiedCacheEntry displaced = cache[h1]; + memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); + cache[h1].node = node; + + for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { + // Find alternate location for displaced entry + uint16_t altH1 = cuckooHash1(displaced.node); + uint16_t altH2 = cuckooHash2(displaced.node); + uint16_t altSlot = (altH1 == h1) ? altH2 : altH1; + + if (cache[altSlot].node == 0) { + // Found empty slot - insert displaced entry + cache[altSlot] = displaced; + if (isNew) + *isNew = true; + return &cache[h1]; + } + + // Kick entry from alternate slot + UnifiedCacheEntry temp = cache[altSlot]; + cache[altSlot] = displaced; + displaced = temp; + h1 = altSlot; + } + + // Cuckoo cycle detected or max kicks exceeded. + // The displaced entry has no valid cuckoo slot — drop it to preserve cache integrity. + // Placing it at an arbitrary slot would make it unreachable by findEntry(). + TM_LOG_DEBUG("Cuckoo cycle, evicting node 0x%08x", displaced.node); + + if (isNew) + *isNew = true; + return &cache[cuckooHash1(node)]; +#endif +} + +const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return nullptr; + + uint16_t payloadIndex = findNodeInfoPayloadIndex(node); + if (payloadIndex >= nodeInfoTargetEntries()) + return nullptr; + + return &nodeInfoPayload[payloadIndex]; +#else + (void)node; + return nullptr; +#endif +} + +uint16_t TrafficManagementModule::encodeNodeInfoTag(uint16_t payloadIndex) const +{ + if (payloadIndex >= nodeInfoTargetEntries()) + return 0; + return static_cast(payloadIndex + 1u); +} + +uint16_t TrafficManagementModule::decodeNodeInfoPayloadIndex(uint16_t tag) const +{ + if (tag == 0 || tag > nodeInfoTargetEntries()) + return UINT16_MAX; + return static_cast(tag - 1u); +} + +uint16_t TrafficManagementModule::getNodeInfoTag(uint16_t slot) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) + return 0; + + const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); + const uint16_t byteOffset = static_cast(bitOffset >> 3); + const uint8_t shift = static_cast(bitOffset & 7u); + uint32_t packed = 0; + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset]); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; + + return static_cast((packed >> shift) & nodeInfoTagMask()); +#else + (void)slot; + return 0; +#endif +} + +void TrafficManagementModule::setNodeInfoTag(uint16_t slot, uint16_t tag) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) + return; + + const uint16_t normalizedTag = static_cast(tag & nodeInfoTagMask()); + const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); + const uint16_t byteOffset = static_cast(bitOffset >> 3); + const uint8_t shift = static_cast(bitOffset & 7u); + uint32_t packed = 0; + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset]); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; + + const uint32_t mask = static_cast(nodeInfoTagMask()) << shift; + packed = (packed & ~mask) | ((static_cast(normalizedTag) << shift) & mask); + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset] = static_cast(packed & 0xFFu); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset + 1u] = static_cast((packed >> 8) & 0xFFu); + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset + 2u] = static_cast((packed >> 16) & 0xFFu); +#else + (void)slot; + (void)tag; +#endif +} + +uint16_t TrafficManagementModule::findNodeInfoPayloadIndex(NodeNum node) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return UINT16_MAX; + + const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; + + for (uint8_t b = 0; b < 2; b++) { + const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + uint16_t tag = getNodeInfoTag(static_cast(base + slot)); + if (tag == 0) + continue; + + uint16_t payloadIndex = decodeNodeInfoPayloadIndex(tag); + if (payloadIndex >= nodeInfoTargetEntries()) + continue; + + if (nodeInfoPayload[payloadIndex].node == node) + return payloadIndex; + } + } + + return UINT16_MAX; +#else + (void)node; + return UINT16_MAX; +#endif +} + +bool TrafficManagementModule::removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || node == 0 || payloadIndex >= nodeInfoTargetEntries()) + return false; + + const uint16_t payloadTag = encodeNodeInfoTag(payloadIndex); + if (payloadTag == 0) + return false; + const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; + + for (uint8_t b = 0; b < 2; b++) { + const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + const uint16_t indexSlot = static_cast(base + slot); + if (getNodeInfoTag(indexSlot) == payloadTag) { + setNodeInfoTag(indexSlot, 0); + return true; + } + } + } + + return false; +#else + (void)node; + (void)payloadIndex; + return false; +#endif +} + +uint16_t TrafficManagementModule::allocateNodeInfoPayloadSlot() +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload) + return UINT16_MAX; + + for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { + uint16_t idx = static_cast((nodeInfoAllocHint + tries) % nodeInfoTargetEntries()); + if (nodeInfoPayload[idx].node == 0) { + nodeInfoAllocHint = static_cast((idx + 1u) % nodeInfoTargetEntries()); + return idx; + } + } +#endif + return UINT16_MAX; +} + +uint16_t TrafficManagementModule::evictNodeInfoPayloadSlot() +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex) + return UINT16_MAX; + + for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { + uint16_t idx = static_cast(nodeInfoEvictCursor % nodeInfoTargetEntries()); + nodeInfoEvictCursor = static_cast((nodeInfoEvictCursor + 1u) % nodeInfoTargetEntries()); + + NodeNum oldNode = nodeInfoPayload[idx].node; + if (oldNode == 0) + continue; + + removeNodeInfoIndexEntry(oldNode, idx); // best effort; cache tolerates occasional stale miss + nodeInfoPayload[idx].node = 0; + return idx; + } +#endif + return UINT16_MAX; +} + +bool TrafficManagementModule::tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || !nodeInfoPayload || bucket >= nodeInfoBucketCount() || tag == 0) + return false; + + const uint16_t base = static_cast(bucket * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + const uint16_t indexSlot = static_cast(base + slot); + const uint16_t existingTag = getNodeInfoTag(indexSlot); + if (existingTag == 0) { + setNodeInfoTag(indexSlot, tag); + return true; + } + + // Opportunistically reuse stale tags that point at empty/invalid payload slots. + const uint16_t payloadIndex = decodeNodeInfoPayloadIndex(existingTag); + if (payloadIndex >= nodeInfoTargetEntries() || nodeInfoPayload[payloadIndex].node == 0) { + setNodeInfoTag(indexSlot, tag); + return true; + } + } +#else + (void)bucket; + (void)tag; +#endif + return false; +} + +TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, + bool *usedEmptySlot) +{ + if (usedEmptySlot) + *usedEmptySlot = false; + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return nullptr; + + uint16_t existing = findNodeInfoPayloadIndex(node); + if (existing < nodeInfoTargetEntries()) + return &nodeInfoPayload[existing]; + + const uint16_t beforeCount = countNodeInfoEntriesLocked(); + + uint16_t payloadIndex = allocateNodeInfoPayloadSlot(); + if (payloadIndex == UINT16_MAX) { + payloadIndex = evictNodeInfoPayloadSlot(); + if (payloadIndex == UINT16_MAX) + return nullptr; + } + + nodeInfoPayload[payloadIndex].node = node; + + // 4-way bucketed cuckoo insertion mirrors Cuckoo Filter practice from + // Fan et al. (CoNEXT 2014): high occupancy with short relocation chains. + uint16_t pending = encodeNodeInfoTag(payloadIndex); + uint16_t h1 = nodeInfoHash1(node); + uint16_t h2 = nodeInfoHash2(node); + + if (!tryInsertNodeInfoEntryInBucket(h1, pending) && !tryInsertNodeInfoEntryInBucket(h2, pending)) { + uint16_t currentBucket = h1; + for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { + const uint16_t base = static_cast(currentBucket * nodeInfoBucketSize()); + const uint16_t kickSlot = static_cast((node + kicks) & (nodeInfoBucketSize() - 1u)); + const uint16_t pos = static_cast(base + kickSlot); + + uint16_t displaced = getNodeInfoTag(pos); + setNodeInfoTag(pos, pending); + pending = displaced; + + uint16_t displacedPayload = decodeNodeInfoPayloadIndex(pending); + if (displacedPayload >= nodeInfoTargetEntries()) { + pending = 0; + break; + } + + NodeNum displacedNode = nodeInfoPayload[displacedPayload].node; + if (displacedNode == 0) { + pending = 0; + break; + } + + uint16_t altH1 = nodeInfoHash1(displacedNode); + uint16_t altH2 = nodeInfoHash2(displacedNode); + uint16_t altBucket = (altH1 == currentBucket) ? altH2 : altH1; + + if (tryInsertNodeInfoEntryInBucket(altBucket, pending)) { + pending = 0; + break; + } + + currentBucket = altBucket; + } + + if (pending != 0) { + uint16_t droppedPayload = decodeNodeInfoPayloadIndex(pending); + if (droppedPayload < nodeInfoTargetEntries()) + nodeInfoPayload[droppedPayload].node = 0; + TM_LOG_DEBUG("NodeInfo bucketed cuckoo overflow, dropped payload idx=%u", + static_cast(droppedPayload < nodeInfoTargetEntries() ? droppedPayload : UINT16_MAX)); + } + } + + uint16_t finalIndex = findNodeInfoPayloadIndex(node); + if (finalIndex >= nodeInfoTargetEntries()) { + // New entry did not survive insertion chain. + if (payloadIndex < nodeInfoTargetEntries() && nodeInfoPayload[payloadIndex].node == node) + nodeInfoPayload[payloadIndex].node = 0; + return nullptr; + } + + if (usedEmptySlot) { + const uint16_t afterCount = countNodeInfoEntriesLocked(); + *usedEmptySlot = afterCount > beforeCount; + } + + return &nodeInfoPayload[finalIndex]; +#else + (void)node; + return nullptr; +#endif +} + +uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex) + return 0; + + uint16_t count = 0; + for (uint16_t i = 0; i < nodeInfoIndexSlots(); i++) { + if (getNodeInfoTag(i) != 0) + count++; + } + return count; +#else + return 0; +#endif +} + +void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || mp.decoded.payload.size == 0) + return; + + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) + return; + + // Normalize user.id to the packet sender's node number. + snprintf(user.id, sizeof(user.id), "!%08x", getFrom(&mp)); + + bool usedEmptySlot = false; + uint16_t cachedCount = 0; + { + concurrency::LockGuard guard(&cacheLock); + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(getFrom(&mp), &usedEmptySlot); + if (!entry) + return; + + // Cache both payload and response metadata so direct replies can use + // richer context than "just the user protobuf" when PSRAM is present. + // This path is intentionally independent from NodeInfoModule/NodeDB. + entry->user = user; + entry->lastObservedMs = millis(); + entry->lastObservedRxTime = mp.rx_time; + entry->sourceChannel = mp.channel; + entry->hasDecodedBitfield = mp.decoded.has_bitfield; + entry->decodedBitfield = mp.decoded.bitfield; + + if (usedEmptySlot) + cachedCount = countNodeInfoEntriesLocked(); + } + + if (usedEmptySlot) { + TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u target (%u packed slots, %u-bit tags, %u-byte DRAM index)", + static_cast(cachedCount), static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoTagBits()), + static_cast(nodeInfoIndexMetadataBudgetBytes())); + } +#else + (void)mp; +#endif +} + +// ============================================================================= +// Epoch Management +// ============================================================================= + +/** + * Reset the timestamp epoch when relative offsets approach overflow. + * + * Called when epoch age exceeds ~19 hours (approaching 8-bit minute overflow). + * Invalidates all cached per-node traffic state. + */ +void TrafficManagementModule::resetEpoch(uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + TM_LOG_DEBUG("Resetting cache epoch"); + cacheEpochMs = nowMs; + + // Full flush avoids stale dedup identity/counters surviving epoch rollover. + memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); +#else + (void)nowMs; +#endif +} + +// ============================================================================= +// Position Hash (Compact Mode) +// ============================================================================= + +/** + * Compute 8-bit position fingerprint from truncated lat/lon coordinates. + * + * Unlike a hash, this is deterministic: adjacent grid cells have sequential + * fingerprints, so nearby positions never collide. The fingerprint extracts + * the lower 4 significant bits from each truncated coordinate. + * + * Example with precision=16: + * lat_truncated = 0x12340000 (top 16 bits significant) + * Significant portion = 0x1234, lower 4 bits = 0x4 + * + * fingerprint = (lat_low4 << 4) | lon_low4 = 8 bits total + * + * Collision: Two positions collide only if they differ by a multiple of 16 + * grid cells in BOTH lat and lon dimensions simultaneously - very unlikely + * for typical position update patterns. + * + * @param lat_truncated Precision-truncated latitude + * @param lon_truncated Precision-truncated longitude + * @param precision Number of significant bits (1-32) + * @return 8-bit fingerprint (4 bits lat + 4 bits lon) + */ +uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision) +{ + precision = sanitizePositionPrecision(precision); + + // Guard: if precision < 4, we have fewer bits to work with + // Take min(precision, 4) bits from each coordinate + uint8_t bitsToTake = (precision < 4) ? precision : 4; + + // Shift to move significant bits to bottom, then mask lower bits + // For precision=16: shift by 16 to get the 16 significant bits at bottom + uint8_t shift = 32 - precision; + uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); + uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); + + return static_cast((latBits << 4) | lonBits); +} + +// ============================================================================= +// Packet Handling +// ============================================================================= + +// Processing order matters: this module runs BEFORE RoutingModule in the callModules() loop. +// - STOP prevents RoutingModule from calling sniffReceived() → perhapsRebroadcast(), +// so the packet is fully consumed (not forwarded). +// - ignoreRequest suppresses the default "no one responded" NAK for want_response packets. +// - exhaustRequested is set by alterReceived() and checked by perhapsRebroadcast() to +// force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. +ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return ProcessMessage::CONTINUE; + + ignoreRequest = false; + exhaustRequested = false; // Reset per-packet; may be set by alterReceived() below + exhaustRequestedFrom = 0; + exhaustRequestedId = 0; + incrementStat(&stats.packets_inspected); + + const auto &cfg = moduleConfig.traffic_management; + const uint32_t nowMs = millis(); + + // ------------------------------------------------------------------------- + // Undecoded Packet Handling + // ------------------------------------------------------------------------- + // Packets we can't decode (wrong key, corruption, etc.) may indicate + // a misbehaving node. Track and optionally drop repeat offenders. + + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + if (cfg.drop_unknown_enabled && cfg.unknown_packet_threshold > 0) { + if (shouldDropUnknown(&mp, nowMs)) { + logAction("drop", &mp, "unknown"); + incrementStat(&stats.unknown_packet_drops); + ignoreRequest = true; // Suppress NAK for want_response packets + return ProcessMessage::STOP; // Consumed — will not be rebroadcast + } + } + return ProcessMessage::CONTINUE; + } + + // Learn NodeInfo payloads into the dedicated PSRAM cache. + if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) + cacheNodeInfoPacket(mp); + + // ------------------------------------------------------------------------- + // NodeInfo Direct Response + // ------------------------------------------------------------------------- + // When we see a unicast NodeInfo request for a node we know about, + // respond directly from cache instead of forwarding the request. + // STOP prevents the request from being rebroadcast toward the target node, + // and our cached response is sent back to the requestor with hop_limit=0. + + if (cfg.nodeinfo_direct_response && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && + !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { + if (shouldRespondToNodeInfo(&mp, true)) { + meshtastic_User requester = meshtastic_User_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { + nodeDB->updateUser(getFrom(&mp), requester, mp.channel); + } + logAction("respond", &mp, "nodeinfo-cache"); + incrementStat(&stats.nodeinfo_cache_hits); + ignoreRequest = true; // We responded; suppress default NAK + return ProcessMessage::STOP; // Consumed — request will not be forwarded + } + } + + // ------------------------------------------------------------------------- + // Position Deduplication + // ------------------------------------------------------------------------- + // Drop position broadcasts that haven't moved significantly since the + // last broadcast from this node. Uses truncated coordinates to ignore + // GPS jitter within the configured precision. + + if (!isFromUs(&mp) && !isToUs(&mp)) { + if (cfg.position_dedup_enabled && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position pos = meshtastic_Position_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { + if (shouldDropPosition(&mp, &pos, nowMs)) { + logAction("drop", &mp, "position-dedup"); + incrementStat(&stats.position_dedup_drops); + ignoreRequest = true; // Suppress NAK + return ProcessMessage::STOP; // Consumed — duplicate will not be rebroadcast + } + } + } + + // --------------------------------------------------------------------- + // Rate Limiting + // --------------------------------------------------------------------- + // Throttle nodes sending too many packets within a time window. + // Excludes routing and admin packets which are essential for mesh operation. + + if (cfg.rate_limit_enabled && cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { + if (mp.decoded.portnum != meshtastic_PortNum_ROUTING_APP && mp.decoded.portnum != meshtastic_PortNum_ADMIN_APP) { + if (isRateLimited(mp.from, nowMs)) { + logAction("drop", &mp, "rate-limit"); + incrementStat(&stats.rate_limit_drops); + ignoreRequest = true; // Suppress NAK + return ProcessMessage::STOP; // Consumed — throttled packet will not be rebroadcast + } + } + } + } + + return ProcessMessage::CONTINUE; +} + +void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return; + + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return; + + if (isFromUs(&mp)) + return; + + // ------------------------------------------------------------------------- + // Relayed Broadcast Hop Exhaustion + // ------------------------------------------------------------------------- + // For relayed telemetry or position broadcasts from other nodes, optionally + // set hop_limit=0 so they don't propagate further through the mesh. + + const auto &cfg = moduleConfig.traffic_management; + const bool isTelemetry = mp.decoded.portnum == meshtastic_PortNum_TELEMETRY_APP; + const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; + const bool shouldExhaust = (isTelemetry && cfg.exhaust_hop_telemetry) || (isPosition && cfg.exhaust_hop_position); + + if (!shouldExhaust || !isBroadcast(mp.to)) + return; + + if (mp.hop_limit > 0) { + const char *reason = isTelemetry ? "exhaust-hop-telemetry" : "exhaust-hop-position"; + logAction("exhaust", &mp, reason); + // Adjust hop_start so downstream nodes compute correct hopsAway (hop_start - hop_limit). + // Without this, hop_limit=0 with original hop_start would show inflated hopsAway. + mp.hop_start = mp.hop_start - mp.hop_limit + 1; + mp.hop_limit = 0; + // Signal perhapsRebroadcast() to allow one final relay with hop_limit=0. + // Without this flag, perhapsRebroadcast() would skip the packet since hop_limit==0. + // The packet-scoped flag is checked in NextHopRouter::perhapsRebroadcast() + // and forces tosend->hop_limit=0, ensuring no further propagation beyond the + // next node. + exhaustRequested = true; + exhaustRequestedFrom = getFrom(&mp); + exhaustRequestedId = mp.id; + incrementStat(&stats.hop_exhausted_packets); + } +} + +// ============================================================================= +// Periodic Maintenance +// ============================================================================= + +int32_t TrafficManagementModule::runOnce() +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return INT32_MAX; + +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + const uint32_t nowMs = millis(); + + // Check if epoch reset needed (~3.5 hours approaching 8-bit minute overflow) + if (needsEpochReset(nowMs)) { + concurrency::LockGuard guard(&cacheLock); + resetEpoch(nowMs); + return kMaintenanceIntervalMs; + } + + // Calculate TTLs for cache expiration + const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + const uint32_t positionTtlMs = positionIntervalMs * 4; + + const uint32_t rateIntervalMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + const uint32_t rateTtlMs = (rateIntervalMs > 0) ? rateIntervalMs * 2 : (10 * 60 * 1000UL); + + const uint32_t unknownTtlMs = kUnknownResetMs * 5; + + // Sweep cache and clear expired entries + uint16_t activeEntries = 0; + uint16_t expiredEntries = 0; + const uint32_t sweepStartMs = millis(); + + concurrency::LockGuard guard(&cacheLock); + for (uint16_t i = 0; i < cacheSize(); i++) { + if (cache[i].node == 0) + continue; + + bool anyValid = false; + + // Check and clear expired position data + if (cache[i].pos_time != 0) { + uint32_t posTimeMs = fromRelativePosTime(cache[i].pos_time); + if (!isWithinWindow(nowMs, posTimeMs, positionTtlMs)) { + cache[i].pos_fingerprint = 0; + cache[i].pos_time = 0; + } else { + anyValid = true; + } + } + + // Check and clear expired rate limit data + if (cache[i].rate_time != 0) { + uint32_t rateTimeMs = fromRelativeRateTime(cache[i].rate_time); + if (!isWithinWindow(nowMs, rateTimeMs, rateTtlMs)) { + cache[i].rate_count = 0; + cache[i].rate_time = 0; + } else { + anyValid = true; + } + } + + // Check and clear expired unknown tracking data + if (cache[i].unknown_time != 0) { + uint32_t unknownTimeMs = fromRelativeUnknownTime(cache[i].unknown_time); + if (!isWithinWindow(nowMs, unknownTimeMs, unknownTtlMs)) { + cache[i].unknown_count = 0; + cache[i].unknown_time = 0; + } else { + anyValid = true; + } + } + + // If all data expired, free the slot entirely + if (!anyValid) { + memset(&cache[i], 0, sizeof(UnifiedCacheEntry)); + expiredEntries++; + } else { + activeEntries++; + } + } + + TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, + static_cast(activeEntries), static_cast(cacheSize()), + static_cast(millis() - sweepStartMs)); + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (nodeInfoPayload && nodeInfoIndex) { + TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u target (%u packed slots, %u buckets, %u-bit tags, %u-byte index)", + static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), + static_cast(nodeInfoTagBits()), static_cast(nodeInfoIndexMetadataBudgetBytes())); + } +#endif + +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + + return kMaintenanceIntervalMs; +} + +// ============================================================================= +// Traffic Management Logic +// ============================================================================= + +bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)p; + (void)pos; + (void)nowMs; + return false; +#else + if (!pos->has_latitude_i || !pos->has_longitude_i) + return false; + + uint8_t precision = Default::getConfiguredOrDefault(moduleConfig.traffic_management.position_precision_bits, + default_traffic_mgmt_position_precision_bits); + precision = sanitizePositionPrecision(precision); + + const int32_t lat_truncated = truncateLatLon(pos->latitude_i, precision); + const int32_t lon_truncated = truncateLatLon(pos->longitude_i, precision); + const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); + const uint32_t minIntervalMs = secsToMs(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(p->from, &isNew); + if (!entry) + return false; + + // Compare fingerprint and check time window + // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop) + const bool hasPositionState = !isNew && entry->pos_time != 0; + const bool samePosition = hasPositionState && entry->pos_fingerprint == fingerprint; + const bool withinInterval = + hasPositionState && (minIntervalMs != 0) && isWithinWindow(nowMs, fromRelativePosTime(entry->pos_time), minIntervalMs); + + TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, + entry->pos_fingerprint, samePosition, withinInterval, isNew); + + // Update cache entry + entry->pos_fingerprint = fingerprint; + entry->pos_time = toRelativePosTime(nowMs); + + // Drop only if same position AND within the minimum interval + return samePosition && withinInterval; +#endif +} + +bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse) +{ + // Caller already verified: nodeinfo_direct_response, portnum, want_response, + // !isBroadcast, !isToUs, !isFromUs + + if (!isMinHopsFromRequestor(p)) + return false; + + meshtastic_User cachedUser = meshtastic_User_init_zero; + bool hasCachedUser = false; + + // Extra metadata consumed only by the PSRAM-backed cache path. + // Defaults preserve previous behavior when cache metadata is unavailable. + bool cachedHasDecodedBitfield = false; + uint8_t cachedDecodedBitfield = 0; + uint8_t cachedSourceChannel = 0; + uint32_t cachedLastObservedMs = 0; + uint32_t cachedLastObservedRxTime = 0; + + { + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(p->to); + if (entry) { + cachedUser = entry->user; + hasCachedUser = true; + cachedHasDecodedBitfield = entry->hasDecodedBitfield; + cachedDecodedBitfield = entry->decodedBitfield; + cachedSourceChannel = entry->sourceChannel; + cachedLastObservedMs = entry->lastObservedMs; + cachedLastObservedRxTime = entry->lastObservedRxTime; + } + } + + if (!hasCachedUser) { + // If the PSRAM cache exists but misses, we intentionally do not fall back + // to the node-wide table. This keeps the PSRAM direct-reply path separate + // from NodeInfoModule/NodeDB behavior when PSRAM is available. + if (nodeInfoPayload && nodeInfoIndex) { + TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to); + return false; + } + + // Fallback only when PSRAM cache is unavailable on this target. + // In this mode we use the node-wide table maintained by NodeInfoModule. + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); + if (!node || !node->has_user) + return false; + cachedUser = TypeConversions::ConvertToUser(node->num, node->user); + } + + if (!sendResponse) + return true; + + meshtastic_MeshPacket *reply = router->allocForSending(); + if (!reply) { + TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); + return false; + } + + reply->decoded.portnum = meshtastic_PortNum_NODEINFO_APP; + reply->decoded.payload.size = + pb_encode_to_bytes(reply->decoded.payload.bytes, sizeof(reply->decoded.payload.bytes), &meshtastic_User_msg, &cachedUser); + reply->decoded.want_response = false; + + // Start from cached bitfield metadata when available. This lets direct + // responses preserve more of the original packet semantics (PSRAM path), + // while still enforcing local policy for OK_TO_MQTT below. + if (cachedHasDecodedBitfield) + reply->decoded.bitfield = cachedDecodedBitfield; + else + reply->decoded.bitfield = 0; + + // Respect the node-wide config_ok_to_mqtt setting for direct NodeInfo replies. + // This response is spoofed from another node, so Router::perhapsEncode() + // will not auto-populate the bitfield via config_ok_to_mqtt for us. + reply->decoded.has_bitfield = true; + // Update only the OK_TO_MQTT bit; keep any other cached bits intact. + reply->decoded.bitfield &= ~BITFIELD_OK_TO_MQTT_MASK; + if (config.lora.config_ok_to_mqtt) + reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; + + if (hasCachedUser && cachedLastObservedMs != 0) { + uint32_t ageMs = millis() - cachedLastObservedMs; + TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, + static_cast(ageMs), static_cast(cachedSourceChannel), + static_cast(p->channel), static_cast(cachedLastObservedRxTime)); + } + + // Spoof the sender as the target node so the requestor sees a valid NodeInfo response. + // hop_limit=0 ensures this reply travels only one hop (direct to requestor). + reply->from = p->to; + reply->to = getFrom(p); + reply->channel = p->channel; + reply->decoded.request_id = p->id; + reply->hop_limit = 0; + // hop_start=0 is set explicitly because Router::send() only sets it for isFromUs(), + // and our spoofed from means isFromUs() is false. + reply->hop_start = 0; + reply->next_hop = nodeDB->getLastByteOfNodeNum(getFrom(p)); + reply->priority = meshtastic_MeshPacket_Priority_DEFAULT; + + service->sendToMesh(reply); + return true; +} + +bool TrafficManagementModule::isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const +{ + int8_t hopsAway = getHopsAway(*p, -1); + if (hopsAway < 0) + return false; + + // Both routers and clients use maxHops logic (respond when hopsAway <= threshold) + // Role determines the maximum allowed value (enforced limit, not just default) + bool isRouter = IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE); + + uint32_t roleLimit = isRouter ? kRouterDefaultMaxHops : kClientDefaultMaxHops; + uint32_t configValue = moduleConfig.traffic_management.nodeinfo_direct_response_max_hops; + + // Use config value if set, otherwise use role default, but always clamp to role limit + uint32_t maxHops = (configValue > 0) ? configValue : roleLimit; + if (maxHops > roleLimit) + maxHops = roleLimit; + + bool result = static_cast(hopsAway) <= maxHops; + TM_LOG_DEBUG("NodeInfo hops check: hopsAway=%d maxHops=%u roleLimit=%u isRouter=%d -> %s", hopsAway, maxHops, roleLimit, + isRouter, result ? "respond" : "skip"); + return result; +} + +bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)from; + (void)nowMs; + return false; +#else + const uint32_t windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + if (windowMs == 0 || moduleConfig.traffic_management.rate_limit_max_packets == 0) + return false; + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(from, &isNew); + if (!entry) + return false; + + // Check if window has expired + if (isNew || !isWithinWindow(nowMs, fromRelativeRateTime(entry->rate_time), windowMs)) { + entry->rate_time = toRelativeRateTime(nowMs); + entry->rate_count = 1; + return false; + } + + // Increment counter (saturates at 255) + saturatingIncrement(entry->rate_count); + + // Check against threshold (uint8_t max is 255, but config is uint32_t) + uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; + if (threshold > 255) + threshold = 255; + + bool limited = entry->rate_count > threshold; + if (limited || entry->rate_count == threshold) { + TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, entry->rate_count, threshold, + limited ? "DROP" : "at-limit"); + } + return limited; +#endif +} + +bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)p; + (void)nowMs; + return false; +#else + if (!moduleConfig.traffic_management.drop_unknown_enabled || moduleConfig.traffic_management.unknown_packet_threshold == 0) + return false; + + uint32_t windowMs = kUnknownResetMs; + if (moduleConfig.traffic_management.rate_limit_window_secs > 0) + windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(p->from, &isNew); + if (!entry) + return false; + + // Check if window has expired + if (isNew || !isWithinWindow(nowMs, fromRelativeUnknownTime(entry->unknown_time), windowMs)) { + entry->unknown_time = toRelativeUnknownTime(nowMs); + entry->unknown_count = 0; + } + + // Increment counter (saturates at 255) + saturatingIncrement(entry->unknown_count); + + // Check against threshold + uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; + if (threshold > 255) + threshold = 255; + + bool drop = entry->unknown_count > threshold; + if (drop || entry->unknown_count == threshold) { + TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, entry->unknown_count, threshold, + drop ? "DROP" : "at-limit"); + } + return drop; +#endif +} + +void TrafficManagementModule::logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const +{ + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + const char *name = portName(p->decoded.portnum); + if (name) { + TM_LOG_INFO("%s %s from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, name, getFrom(p), p->to, p->hop_limit, + p->hop_start, reason); + } else { + TM_LOG_INFO("%s port=%d from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, p->decoded.portnum, getFrom(p), p->to, + p->hop_limit, p->hop_start, reason); + } + } else { + TM_LOG_INFO("%s encrypted from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, getFrom(p), p->to, p->hop_limit, + p->hop_start, reason); + } +} + +#endif diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h new file mode 100644 index 000000000..fe3483a8e --- /dev/null +++ b/src/modules/TrafficManagementModule.h @@ -0,0 +1,434 @@ +#pragma once + +#include "MeshModule.h" +#include "concurrency/Lock.h" +#include "concurrency/OSThread.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include "mesh/generated/meshtastic/telemetry.pb.h" + +#if HAS_TRAFFIC_MANAGEMENT + +/** + * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. + * + * This module provides: + * - Position deduplication (drop redundant position broadcasts) + * - Per-node rate limiting (throttle chatty nodes) + * - Unknown packet filtering (drop undecoded packets from repeat offenders) + * - NodeInfo direct response (answer queries from cache to reduce mesh chatter) + * - Local-only telemetry/position (exhaust hop_limit for local broadcasts) + * - Router hop preservation (maintain hop_limit for router-to-router traffic) + * + * Memory Optimization: + * Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction + * compared to separate per-feature caches. Timestamps are stored as 8-bit relative + * offsets from a rolling epoch to further reduce memory footprint. + */ +class TrafficManagementModule : public MeshModule, private concurrency::OSThread +{ + public: + TrafficManagementModule(); + ~TrafficManagementModule(); + + // Singleton — no copying or moving + TrafficManagementModule(const TrafficManagementModule &) = delete; + TrafficManagementModule &operator=(const TrafficManagementModule &) = delete; + + meshtastic_TrafficManagementStats getStats() const; + void resetStats(); + void recordRouterHopPreserved(); + + /** + * Check if this packet should have its hops exhausted. + * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of + * router_preserve_hops or favorite node logic. + */ + bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const + { + return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id; + } + + protected: + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + bool wantPacket(const meshtastic_MeshPacket *p) override { return true; } + void alterReceived(meshtastic_MeshPacket &mp) override; + int32_t runOnce() override; + // Protected so test shims can force epoch rollover behavior. + void resetEpoch(uint32_t nowMs); + + private: + // ========================================================================= + // Unified Cache Entry (10 bytes) - Same for ALL platforms + // ========================================================================= + // + // A single compact structure used across ESP32, NRF52, and all other platforms. + // Memory: 10 bytes × 2048 entries = 20KB + // + // Position Fingerprinting: + // Instead of storing full coordinates (8 bytes) or a computed hash, + // we store an 8-bit fingerprint derived deterministically from the + // truncated lat/lon. This extracts the lower 4 significant bits from + // each coordinate: fingerprint = (lat_low4 << 4) | lon_low4 + // + // Benefits over hash: + // - Adjacent grid cells have sequential fingerprints (no collision) + // - Two positions only collide if 16+ grid cells apart in BOTH dimensions + // - Deterministic: same input always produces same output + // + // Adaptive Timestamp Resolution: + // All timestamps use 8-bit values with adaptive resolution calculated + // from config at startup. Resolution = max(60, min(339, interval/2)). + // - Min 60 seconds ensures reasonable precision + // - Max 339 seconds allows ~24 hour range (255 * 339 = 86445 sec) + // - interval/2 ensures at least 2 ticks per configured interval + // + // Layout: + // [0-3] node - NodeNum (4 bytes) + // [4] pos_fingerprint - 4 bits lat + 4 bits lon (1 byte) + // [5] rate_count - Packets in current window (1 byte) + // [6] unknown_count - Unknown packets count (1 byte) + // [7] pos_time - Position timestamp (1 byte, adaptive resolution) + // [8] rate_time - Rate window start (1 byte, adaptive resolution) + // [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution) + // + struct __attribute__((packed)) UnifiedCacheEntry { + NodeNum node; // 4 bytes - Node identifier (0 = empty slot) + uint8_t pos_fingerprint; // 1 byte - Lower 4 bits of lat + lon + uint8_t rate_count; // 1 byte - Packet count (saturates at 255) + uint8_t unknown_count; // 1 byte - Unknown packet count (saturates at 255) + uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution) + uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution) + uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution) + }; + static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes"); + + // ========================================================================= + // Cuckoo Hash Table Implementation + // ========================================================================= + // + // Cuckoo hashing provides O(1) worst-case lookup time using two hash functions. + // Each key can be in one of two possible locations (h1 or h2). On collision, + // the existing entry is "kicked" to its alternate location. + // + // Benefits over linear scan: + // - O(1) lookup vs O(n) - critical at packet processing rates + // - O(1) insertion (amortized) with simple eviction on cycles + // - ~95% load factor achievable + // + // Cache size rounds to power-of-2 for fast modulo via bitmask. + // TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048 + // + static constexpr uint16_t cacheSize(); + static constexpr uint16_t cacheMask(); + + // Hash functions for cuckoo hashing + inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); } + inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); } + static constexpr uint8_t cuckooHashBits(); + + // NodeInfo cache configuration (PSRAM path): + // - Payload lives in PSRAM + // - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing + // (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty". + static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store + static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95; + static constexpr uint8_t kNodeInfoBucketSize = 4; + static constexpr uint8_t kNodeInfoTagBits = 12; + static constexpr uint16_t kNodeInfoTagMask = static_cast((1u << kNodeInfoTagBits) - 1u); + static constexpr uint16_t kNodeInfoIndexSlotsRaw = + static_cast((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits); + static constexpr uint16_t kNodeInfoIndexSlots = + static_cast(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize)); + static constexpr uint16_t kNodeInfoTargetEntries = + static_cast((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u); + static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size"); + static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index"); + + static constexpr uint16_t nodeInfoTargetEntries(); + static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes(); + static constexpr uint8_t nodeInfoTargetOccupancyPercent(); + static constexpr uint8_t nodeInfoBucketSize(); + static constexpr uint8_t nodeInfoTagBits(); + static constexpr uint16_t nodeInfoTagMask(); + static constexpr uint16_t nodeInfoIndexSlots(); + static constexpr uint16_t nodeInfoBucketCount(); + static constexpr uint16_t nodeInfoBucketMask(); + static constexpr uint8_t nodeInfoBucketHashBits(); + inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); } + inline uint16_t nodeInfoHash2(NodeNum node) const + { + return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask(); + } + + // ========================================================================= + // Adaptive Timestamp Resolution + // ========================================================================= + // + // All timestamps use 8-bit values with adaptive resolution calculated from + // config at startup. This allows ~24 hour range while maintaining precision. + // + // Resolution formula: max(60, min(339, interval/2)) + // - 60 sec minimum ensures reasonable precision + // - 339 sec maximum allows 24 hour range (255 * 339 ≈ 86400 sec) + // - interval/2 ensures at least 2 ticks per configured interval + // + // Since config changes require reboot, resolution is calculated once. + // + uint32_t cacheEpochMs = 0; + uint16_t posTimeResolution = 60; // Seconds per tick for position + uint16_t rateTimeResolution = 60; // Seconds per tick for rate limiting + uint16_t unknownTimeResolution = 60; // Seconds per tick for unknown tracking + + // Calculate resolution from configured interval (called once at startup) + static uint16_t calcTimeResolution(uint32_t intervalSecs) + { + // Resolution = interval/2 to ensure at least 2 ticks per interval + // Clamped to [60, 339] for min precision and max 24h range + uint32_t res = (intervalSecs > 0) ? (intervalSecs / 2) : 60; + if (res < 60) + res = 60; + if (res > 339) + res = 339; + return static_cast(res); + } + + // Convert to/from 8-bit relative timestamps with given resolution + uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const + { + uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL); + return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast(ticks); + } + uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const + { + return cacheEpochMs + (static_cast(ticks) * resolutionSecs * 1000UL); + } + + // Convenience wrappers for each timestamp type + uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); } + uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); } + + uint8_t toRelativeRateTime(uint32_t nowMs) const { return toRelativeTime(nowMs, rateTimeResolution); } + uint32_t fromRelativeRateTime(uint8_t t) const { return fromRelativeTime(t, rateTimeResolution); } + + uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); } + uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); } + + // Epoch reset when any timestamp approaches overflow + // With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max) + bool needsEpochReset(uint32_t nowMs) const + { + uint16_t maxRes = posTimeResolution; + if (rateTimeResolution > maxRes) + maxRes = rateTimeResolution; + if (unknownTimeResolution > maxRes) + maxRes = unknownTimeResolution; + return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL); + } + // ========================================================================= + // Position Fingerprint + // ========================================================================= + // + // Computes 8-bit fingerprint from truncated lat/lon coordinates. + // Extracts lower 4 significant bits from each coordinate. + // + // fingerprint = (lat_low4 << 4) | lon_low4 + // + // Unlike a hash, adjacent grid cells have sequential fingerprints, + // so nearby positions never collide. Collisions only occur for + // positions 16+ grid cells apart in both dimensions. + // + // Guards: If precision < 4 bits, uses min(precision, 4) bits. + // + static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision); + + // ========================================================================= + // Cache Storage + // ========================================================================= + + mutable concurrency::Lock cacheLock; // Protects all cache access + UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms) + bool cacheFromPsram = false; // Tracks allocator for correct deallocation + + struct NodeInfoPayloadEntry { + // Node identifier associated with this payload slot. + // 0 means the slot is currently unused. + NodeNum node; + + // Cached NODEINFO_APP payload body. This is separate from NodeDB and is only + // used by the PSRAM-backed direct-response path in this module. + meshtastic_User user; + + // Extra response metadata captured from the latest observed NODEINFO_APP + // packet for this node. shouldRespondToNodeInfo() uses this metadata when + // building spoofed replies for requesting clients. + + // Last local uptime tick (millis) when this entry was refreshed. + uint32_t lastObservedMs; + + // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. + // If unavailable in packet, remains 0. + uint32_t lastObservedRxTime; + + // Channel where we most recently heard this node's NodeInfo. + uint8_t sourceChannel; + + // Cached decoded bitfield metadata from the source packet. + // We preserve non-OK_TO_MQTT bits in direct replies when available. + bool hasDecodedBitfield; + uint8_t decodedBitfield; + }; + + NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM + bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation + uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM + uint16_t nodeInfoAllocHint = 0; + uint16_t nodeInfoEvictCursor = 0; + + meshtastic_TrafficManagementStats stats; + + // Flag set during alterReceived() when packet should be exhausted. + // Checked by perhapsRebroadcast() to force hop_limit = 0 only for the + // matching packet key (from + id). Reset at start of handleReceived(). + bool exhaustRequested = false; + NodeNum exhaustRequestedFrom = 0; + PacketId exhaustRequestedId = 0; + + // ========================================================================= + // Cache Operations + // ========================================================================= + + // Find or create entry for node using cuckoo hashing + // Returns nullptr if cache is full and eviction fails + UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew); + + // Find existing entry (no creation) + UnifiedCacheEntry *findEntry(NodeNum node); + + // NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads) + const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; + NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot); + uint16_t findNodeInfoPayloadIndex(NodeNum node) const; + bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex); + uint16_t allocateNodeInfoPayloadSlot(); + uint16_t evictNodeInfoPayloadSlot(); + bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag); + uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const; + uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const; + uint16_t getNodeInfoTag(uint16_t slot) const; + void setNodeInfoTag(uint16_t slot, uint16_t tag); + uint16_t countNodeInfoEntriesLocked() const; + void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); + + // ========================================================================= + // Traffic Management Logic + // ========================================================================= + + bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs); + bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse); + bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const; + bool isRateLimited(NodeNum from, uint32_t nowMs); + bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs); + + void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const; + void incrementStat(uint32_t *field); +}; + +// ========================================================================= +// Compile-time Cache Size Calculations +// ========================================================================= +// +// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient +// cuckoo hash indexing (allows bitmask instead of modulo). +// +// These use C++11-compatible constexpr (single return statement). +// + +namespace detail +{ +// Helper: round up to next power of 2 using bit manipulation +constexpr uint16_t nextPow2(uint16_t n) +{ + return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1); +} + +// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr. +constexpr uint8_t log2Floor(uint16_t n) +{ + return n <= 1 ? 0 : static_cast(1 + log2Floor(static_cast(n >> 1))); +} + +// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr. +constexpr uint8_t log2Ceil(uint16_t n) +{ + return n <= 1 ? 0 : static_cast(1 + log2Floor(static_cast(n - 1))); +} +} // namespace detail + +constexpr uint16_t TrafficManagementModule::cacheSize() +{ + return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE); +} + +constexpr uint16_t TrafficManagementModule::cacheMask() +{ + return cacheSize() > 0 ? cacheSize() - 1 : 0; +} + +constexpr uint8_t TrafficManagementModule::cuckooHashBits() +{ + return detail::log2Floor(cacheSize()); +} + +constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries() +{ + return kNodeInfoTargetEntries; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes() +{ + return kNodeInfoIndexMetadataBudgetBytes; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent() +{ + return kNodeInfoTargetOccupancyPercent; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize() +{ + return kNodeInfoBucketSize; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoTagBits() +{ + return kNodeInfoTagBits; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoTagMask() +{ + return kNodeInfoTagMask; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots() +{ + return kNodeInfoIndexSlots; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount() +{ + return static_cast(nodeInfoIndexSlots() / nodeInfoBucketSize()); +} + +constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask() +{ + return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits() +{ + return detail::log2Floor(nodeInfoBucketCount()); +} + +extern TrafficManagementModule *trafficManagementModule; + +#endif diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index 77cc94359..37e3e9184 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -100,7 +100,7 @@ AudioModule::AudioModule() : SinglePortModule("Audio", meshtastic_PortNum_AUDIO_ // moduleConfig.audio.i2s_sck = 14; // moduleConfig.audio.ptt_pin = 39; - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { LOG_INFO("Set up codec2 in mode %u", (moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1); codec2 = codec2_create((moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1); memcpy(tx_header.magic, c2_magic, sizeof(c2_magic)); @@ -143,7 +143,7 @@ void AudioModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int int32_t AudioModule::runOnce() { - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { esp_err_t res; if (firstTime) { // Set up I2S Processor configuration. This will produce 16bit samples at 8 kHz instead of 12 from the ADC @@ -270,7 +270,7 @@ void AudioModule::sendPayload(NodeNum dest, bool wantReplies) ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp) { - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { auto &p = mp.decoded; if (!isFromUs(&mp)) { memcpy(rx_encode_frame, p.payload.bytes, p.payload.size); diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index c8183cfde..ac022a1ab 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -651,22 +651,34 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC if (config.enabled && !config.proxy_to_client_enabled) { #if HAS_NETWORKING - std::unique_ptr clientConnection; if (config.tls_enabled) { -#if MQTT_SUPPORTS_TLS - MQTTClientTLS *tlsClient = new MQTTClientTLS; - clientConnection.reset(tlsClient); - tlsClient->setInsecure(); -#else +#if !MQTT_SUPPORTS_TLS LOG_ERROR("Invalid MQTT config: tls_enabled is not supported on this node"); return false; #endif - } else { - clientConnection.reset(new MQTTClient); } - std::unique_ptr pubSub(new PubSubClient); + // Perform a lightweight TCP connectivity check without using connectPubSub(), + // which mutates the module's isConnected state. This only checks if the server + // is reachable — it does not establish an MQTT session. + // Settings are always saved regardless of the result. if (isConnectedToNetwork()) { - return connectPubSub(parsed, *pubSub, (client != nullptr) ? *client : *clientConnection); + MQTTClient testClient; + if (!testClient.connect(parsed.serverAddr.c_str(), parsed.serverPort)) { + const char *warning = "Could not reach the MQTT server. Settings will be saved, but please verify the server " + "address and credentials."; + LOG_WARN(warning); +#if !IS_RUNNING_TESTS + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + if (cn) { + cn->level = meshtastic_LogRecord_Level_WARNING; + cn->time = getValidTime(RTCQualityFromNet); + strncpy(cn->message, warning, sizeof(cn->message) - 1); + cn->message[sizeof(cn->message) - 1] = '\0'; + service->sendClientNotification(cn); + } +#endif + } + testClient.stop(); } #else const char *warning = "Invalid MQTT config: proxy_to_client_enabled must be enabled on nodes that do not have a network"; diff --git a/src/sleep.cpp b/src/sleep.cpp index 4fec16571..9c044eaf7 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -429,8 +429,13 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r gpio_num_t pin = (gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); gpio_wakeup_enable(pin, GPIO_INTR_LOW_LEVEL); #endif -#ifdef INPUTDRIVER_ENCODER_BTN - gpio_wakeup_enable((gpio_num_t)INPUTDRIVER_ENCODER_BTN, GPIO_INTR_LOW_LEVEL); +#if defined(INPUTDRIVER_TWO_WAY_ROCKER_BTN) || defined(INPUTDRIVER_ENCODER_BTN) +#if defined(INPUTDRIVER_TWO_WAY_ROCKER_BTN) +#define INPUTDRIVER_WAKE_BTN_PIN INPUTDRIVER_TWO_WAY_ROCKER_BTN +#else +#define INPUTDRIVER_WAKE_BTN_PIN INPUTDRIVER_ENCODER_BTN +#endif + gpio_wakeup_enable((gpio_num_t)INPUTDRIVER_WAKE_BTN_PIN, GPIO_INTR_LOW_LEVEL); #endif #if defined(WAKE_ON_TOUCH) gpio_wakeup_enable((gpio_num_t)SCREEN_TOUCH_INT, GPIO_INTR_LOW_LEVEL); @@ -471,8 +476,9 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r // Disable wake-on-button interrupt. Re-attach normal button-interrupts gpio_wakeup_disable(pin); #endif -#if defined(INPUTDRIVER_ENCODER_BTN) - gpio_wakeup_disable((gpio_num_t)INPUTDRIVER_ENCODER_BTN); +#ifdef INPUTDRIVER_WAKE_BTN_PIN + gpio_wakeup_disable((gpio_num_t)INPUTDRIVER_WAKE_BTN_PIN); +#undef INPUTDRIVER_WAKE_BTN_PIN #endif #if defined(WAKE_ON_TOUCH) gpio_wakeup_disable((gpio_num_t)SCREEN_TOUCH_INT); diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp new file mode 100644 index 000000000..9906bb94c --- /dev/null +++ b/test/test_admin_radio/test_main.cpp @@ -0,0 +1,814 @@ +/** + * Tests for the radio configuration validation and clamping functions + * introduced in the radio_interface_cherrypick branch. + * + * Targets: + * 1. getRegion() + * 2. RadioInterface::validateConfigRegion() + * 3. RadioInterface::validateConfigLora() + * 4. RadioInterface::clampConfigLora() + * 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF) + * 6. Channel spacing calculation (placeholder for future protobuf changes) + */ + +#include "MeshRadio.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "TestUtil.h" +#include "modules/AdminModule.h" +#include + +#include "meshtastic/config.pb.h" + +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +static MockMeshService *mockMeshService; + +// ----------------------------------------------------------------------- +// getRegion() tests +// ----------------------------------------------------------------------- +extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code); + +static void test_getRegion_returnsCorrectRegion_US() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code); + TEST_ASSERT_EQUAL_STRING("US", r->name); +} + +static void test_getRegion_returnsCorrectRegion_EU868() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code); + TEST_ASSERT_EQUAL_STRING("EU_868", r->name); +} + +static void test_getRegion_returnsCorrectRegion_LORA24() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code); + TEST_ASSERT_TRUE(r->wideLora); +} + +static void test_getRegion_unsetCodeReturnsUnsetEntry() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); + TEST_ASSERT_EQUAL_STRING("UNSET", r->name); +} + +static void test_getRegion_unknownCodeFallsToUnset() +{ + // A code not in the table should iterate to the UNSET sentinel + const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); +} + +// ----------------------------------------------------------------------- +// validateConfigRegion() tests +// ----------------------------------------------------------------------- + +static void test_validateConfigRegion_validRegionReturnsTrue() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + + // Ensure owner is not licensed (should not matter for non-licensed-only regions) + devicestate.owner.is_licensed = false; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg)); +} + +static void test_validateConfigRegion_unsetRegionReturnsTrue() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + + devicestate.owner.is_licensed = false; + + // UNSET region has licensedOnly=false, so should pass + TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg)); +} + +// ----------------------------------------------------------------------- +// Shadow tables for testing (preset lists → profiles → regions → lookup) +// ----------------------------------------------------------------------- + +// A minimal preset list with only one entry +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + MODEM_PRESET_END, +}; + +// A preset list that includes all turbo variants only +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = { + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + MODEM_PRESET_END, +}; + +// A restricted list simulating a hypothetical tight-regulation region +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + MODEM_PRESET_END, +}; + +// Mirrors PROFILE_STD but with non-zero spacing/padding for testing +static const RegionProfile TEST_PROFILE_SPACED = { + TEST_PRESETS_SINGLE, + /* spacing */ 0.025f, + /* padding */ 0.010f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ 0, +}; + +// A licensed-only profile for testing access control +static const RegionProfile TEST_PROFILE_LICENSED = { + TEST_PRESETS_RESTRICTED, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ false, + /* licensedOnly */ true, + /* textThrottle */ 5, + /* positionThrottle */ 10, + /* telemetryThrottle */ 10, + /* overrideSlot */ 3, +}; + +// Turbo-only profile +static const RegionProfile TEST_PROFILE_TURBO = { + TEST_PRESETS_TURBO_ONLY, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ 0, +}; + +static const RegionInfo testRegions[] = { + // A wide US-like region with spacing + padding + {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"}, + + // A narrow band simulating tight EU regulation + {meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED, + "TEST_EU_LICENSED"}, + + // A wide-LoRa region with turbo-only presets + {meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO, + "TEST_LORA24_TURBO"}, + + // Sentinel — must be last + {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"}, +}; + +static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code) +{ + const RegionInfo *r = testRegions; + while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + if (r->code == code) + return r; + r++; + } + return r; // Returns the UNSET sentinel +} + +// ----------------------------------------------------------------------- +// Shadow table tests +// ----------------------------------------------------------------------- + +static void test_shadowTable_spacedProfileHasNonZeroSpacing() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding); +} + +static void test_shadowTable_licensedProfileFlagsCorrect() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_TRUE(r->profile->licensedOnly); + TEST_ASSERT_FALSE(r->profile->audioPermitted); + TEST_ASSERT_EQUAL(3, r->profile->overrideSlot); +} + +static void test_shadowTable_presetCountMatchesExpected() +{ + const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(1, spaced->getNumPresets()); + + const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(2, licensed->getNumPresets()); + + const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(2, turbo->getNumPresets()); +} + +static void test_shadowTable_defaultPresetIsFirstInList() +{ + const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset()); + + const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset()); + + const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset()); +} + +static void test_shadowTable_channelSpacingWithPadding() +{ + // Verify channel count when spacing + padding are non-zero + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora); + float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f); + + // spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250 + // channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing); + + uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f); + // (928 - 902 + 0.025) / 0.295 = 88.2 → 88 + TEST_ASSERT_EQUAL_UINT32(88, numChannels); +} + +static void test_shadowTable_turboOnlyOnWideLora() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_TRUE(r->wideLora); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset()); + + // Verify wide-LoRa bandwidth for SHORT_TURBO + float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora); + TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode +} + +static void test_shadowTable_unknownCodeFallsToSentinel() +{ + const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); + TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name); +} + +// ----------------------------------------------------------------------- +// validateConfigLora() tests +// ----------------------------------------------------------------------- + +static void test_validateConfigLora_validPresetForUS() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_allStdPresetsValidForUS() +{ + meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }; + + for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = stdPresets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US"); + } +} + +static void test_validateConfigLora_turboPresetsInvalidForEU868() +{ + // EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868"); +} + +static void test_validateConfigLora_validPresetsForEU868() +{ + meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + }; + + for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + cfg.modem_preset = eu868Presets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868"); + } +} + +static void test_validateConfigLora_customBandwidthTooWideForEU868() +{ + // EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz + // A 500 kHz custom BW should be rejected + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 500; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_customBandwidthFitsUS() +{ + // US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = false; + cfg.bandwidth = 250; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_customBandwidthFitsEU868() +{ + // EU_868 spans 250 kHz, 125 kHz BW should fit + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 125; + cfg.spread_factor = 12; + cfg.coding_rate = 8; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_bogusPresetRejected() +{ + // A fabricated preset value not in any list should be rejected + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast() +{ + // UNSET uses PROFILE_UNDEF which has only LONG_FAST + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET"); +} + +static void test_validateConfigLora_allPresetsValidForLORA24() +{ + // LORA_24 uses PROFILE_STD (9 presets) with wideLora=true + meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }; + + for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + cfg.use_preset = true; + cfg.modem_preset = stdPresets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24"); + } +} + +// ----------------------------------------------------------------------- +// clampConfigLora() tests +// ----------------------------------------------------------------------- + +static void test_clampConfigLora_invalidPresetClampedToDefault() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset); +} + +static void test_clampConfigLora_validPresetUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_customBwTooWideClampedToDefaultBw() +{ + // EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW. + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 500; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora); + TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth); +} + +static void test_clampConfigLora_customBwValidLeftUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = false; + cfg.bandwidth = 125; + cfg.spread_factor = 12; + cfg.coding_rate = 8; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth); +} + +static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() +{ + // UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault() +{ + // LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD) + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset); +} + +// ----------------------------------------------------------------------- +// RegionInfo preset list integrity tests +// ----------------------------------------------------------------------- + +static void test_presetsStd_hasNineEntries() +{ + // PROFILE_STD should have exactly 9 presets + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(9, us->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets()); +} + +static void test_presetsEU868_hasSevenEntries() +{ + const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(7, eu->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets()); +} + +static void test_presetsUndef_hasOneEntry() +{ + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_EQUAL(1, unset->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets()); +} + +static void test_defaultPresetIsInAvailablePresets() +{ + // For every region, the defaultPreset must appear in its own availablePresets list + const RegionInfo *r = regions; + while (true) { + bool found = false; + for (size_t i = 0; i < r->getNumPresets(); i++) { + if (r->getAvailablePresets()[i] == r->getDefaultPreset()) { + found = true; + break; + } + } + char msg[80]; + snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name); + TEST_ASSERT_TRUE_MESSAGE(found, msg); + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; // UNSET is the sentinel, stop after it + r++; + } +} + +static void test_regionFieldsAreSane() +{ + // Basic sanity check: all regions have freqEnd > freqStart and a non-null name + const RegionInfo *r = regions; + while (true) { + char msg[80]; + snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name); + TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg); + TEST_ASSERT_NOT_NULL(r->name); + TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0"); + TEST_ASSERT_NOT_NULL(r->getAvailablePresets()); + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; + r++; + } +} + +static void test_onlyLORA24HasWideLora() +{ + // Verify that LORA_24 is the only region with wideLora=true + const RegionInfo *r = regions; + while (true) { + char msg[80]; + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { + snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name); + TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg); + } else { + snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name); + TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg); + } + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; + r++; + } +} + +// ----------------------------------------------------------------------- +// Channel spacing calculation (placeholder for future protobuf updates) +// ----------------------------------------------------------------------- + +static void test_channelSpacingCalculation_US_LONG_FAST() +{ + // Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000) + // US: spacing=0, padding=0 + // LONG_FAST on non-wide region: bw=250 kHz + // channelSpacing = 0 + 0 + 0.250 = 0.250 MHz + // numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104 + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f); + uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing); + TEST_ASSERT_EQUAL_UINT32(104, numChannels); +} + +static void test_channelSpacingCalculation_EU868_LONG_FAST() +{ + // EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0 + // LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz + // numChannels = round((0.25 + 0) / 0.250) = 1 + const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora); + float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f); + uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing); + TEST_ASSERT_EQUAL_UINT32(1, numChannels); +} + +// Placeholder: when protobuf region definitions include non-zero padding/spacing, +// add tests here to verify the channel count and frequency calculations. +static void test_channelSpacingCalculation_placeholder() +{ + // TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values, + // verify: + // - Channel count matches expected value for each (region, preset) pair + // - First channel frequency = freqStart + (bw/2000) + padding + // - Nth channel frequency = first + (n * channelSpacing) + // - overrideSlot, when non-zero, forces the channel_num + TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields"); +} + +// ----------------------------------------------------------------------- +// handleSetConfig fromOthers dispatch tests +// ----------------------------------------------------------------------- + +class AdminModuleTestShim : public AdminModule +{ + public: + using AdminModule::handleSetConfig; +}; + +static AdminModuleTestShim *testAdmin; + +static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, + meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora.region = region; + c.payload_variant.lora.use_preset = usePreset; + c.payload_variant.lora.modem_preset = preset; + return c; +} + +static void test_handleSetConfig_fromOthers_invalidPresetRejected() +{ + // Set up a known-good baseline in the global config + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with an invalid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + + testAdmin->handleSetConfig(c, true); // fromOthers = true + + // fromOthers=true: invalid preset should be rejected, old preset preserved + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); +} + +static void test_handleSetConfig_fromLocal_invalidPresetClamped() +{ + // Set up a known-good baseline + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with an invalid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + + testAdmin->handleSetConfig(c, false); // fromOthers = false (local client) + + // fromOthers=false: invalid preset should be clamped to the region's default + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset); +} + +static void test_handleSetConfig_fromOthers_validPresetAccepted() +{ + // Set up baseline + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with a valid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + + testAdmin->handleSetConfig(c, true); // fromOthers = true + + // Valid preset should be accepted regardless of fromOthers + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); +} + +// ----------------------------------------------------------------------- +// Test runner +// ----------------------------------------------------------------------- + +void setUp(void) +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; + testAdmin = new AdminModuleTestShim(); +} +void tearDown(void) +{ + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; + delete testAdmin; + testAdmin = nullptr; +} + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + + UNITY_BEGIN(); + + // getRegion() + RUN_TEST(test_getRegion_returnsCorrectRegion_US); + RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); + RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); + RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry); + RUN_TEST(test_getRegion_unknownCodeFallsToUnset); + + // validateConfigRegion() + RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue); + RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue); + + // Shadow table tests + RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing); + RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect); + RUN_TEST(test_shadowTable_presetCountMatchesExpected); + RUN_TEST(test_shadowTable_defaultPresetIsFirstInList); + RUN_TEST(test_shadowTable_channelSpacingWithPadding); + RUN_TEST(test_shadowTable_turboOnlyOnWideLora); + RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel); + + // validateConfigLora() + RUN_TEST(test_validateConfigLora_validPresetForUS); + RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS); + RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868); + RUN_TEST(test_validateConfigLora_validPresetsForEU868); + RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868); + RUN_TEST(test_validateConfigLora_customBandwidthFitsUS); + RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868); + RUN_TEST(test_validateConfigLora_bogusPresetRejected); + RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast); + RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24); + + // clampConfigLora() + RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); + RUN_TEST(test_clampConfigLora_validPresetUnchanged); + RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw); + RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged); + RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); + RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault); + + // RegionInfo preset list integrity + RUN_TEST(test_presetsStd_hasNineEntries); + RUN_TEST(test_presetsEU868_hasSevenEntries); + RUN_TEST(test_presetsUndef_hasOneEntry); + RUN_TEST(test_defaultPresetIsInAvailablePresets); + RUN_TEST(test_regionFieldsAreSane); + RUN_TEST(test_onlyLORA24HasWideLora); + + // Channel spacing (current + placeholder) + RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST); + RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST); + RUN_TEST(test_channelSpacingCalculation_placeholder); + + // handleSetConfig fromOthers dispatch + RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected); + RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped); + RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 4a2eed87d..edf9a3983 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -818,16 +818,13 @@ void test_configEmptyIsValid(void) TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } -// Empty 'enabled' configuration is valid. +// Empty 'enabled' configuration is valid. A lightweight TCP check may be performed +// but does not affect the result. void test_configEnabledEmptyIsValid(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true}; - MockPubSubServer client; - TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client)); - TEST_ASSERT_TRUE(client.connected_); - TEST_ASSERT_EQUAL_STRING(default_mqtt_address, client.host_.c_str()); - TEST_ASSERT_EQUAL(1883, client.port_); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } // Configuration with the default server is valid. @@ -846,38 +843,32 @@ void test_configWithDefaultServerAndInvalidPort(void) TEST_ASSERT_FALSE(MQTT::isValidConfig(config)); } -// isValidConfig connects to a custom host and port. +// Custom host and port is valid. TCP reachability is checked but does not block saving. void test_configCustomHostAndPort(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server:1234"}; - MockPubSubServer client; - TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client)); - TEST_ASSERT_TRUE(client.connected_); - TEST_ASSERT_EQUAL_STRING("server", client.host_.c_str()); - TEST_ASSERT_EQUAL(1234, client.port_); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } -// isValidConfig returns false if a connection cannot be established. -void test_configWithConnectionFailure(void) +// An unreachable server is still a valid config — settings always save. +// A warning notification is sent in non-test builds, but isValidConfig returns true. +void test_configWithUnreachableServerIsStillValid(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server"}; - MockPubSubServer client; - client.refuseConnection_ = true; - TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client)); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } // isValidConfig returns true when tls_enabled is supported, or false otherwise. void test_configWithTLSEnabled(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server", .tls_enabled = true}; - MockPubSubServer client; #if MQTT_SUPPORTS_TLS - TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client)); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); #else - TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client)); + TEST_ASSERT_FALSE(MQTT::isValidConfig(config)); #endif } @@ -927,7 +918,7 @@ void setup() RUN_TEST(test_configWithDefaultServer); RUN_TEST(test_configWithDefaultServerAndInvalidPort); RUN_TEST(test_configCustomHostAndPort); - RUN_TEST(test_configWithConnectionFailure); + RUN_TEST(test_configWithUnreachableServerIsStillValid); RUN_TEST(test_configWithTLSEnabled); exit(UNITY_END()); } diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index fbe2b1b13..49eaabe5b 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -1,10 +1,19 @@ #include "MeshRadio.h" +#include "MeshService.h" #include "RadioInterface.h" #include "TestUtil.h" #include #include "meshtastic/config.pb.h" +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +static MockMeshService *mockMeshService; + static void test_bwCodeToKHz_specialMappings() { TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31)); @@ -21,7 +30,7 @@ static void test_bwCodeToKHz_passthrough() TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250)); } -static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse() +static void test_validateConfigLora_noopWhenUsePresetFalse() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = false; @@ -30,55 +39,78 @@ static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse() cfg.bandwidth = 123; cfg.spread_factor = 8; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); + RadioInterface::validateConfigLora(cfg); TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth); TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); } -static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion() +static void test_validateConfigLora_validPreset_nonWideRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor); + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); } -static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion() +static void test_validateConfigLora_validPreset_wideRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL_UINT16(800, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor); + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); } -static void test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan() +static void test_validateConfigLora_rejectsInvalidPresetForRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); - TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(11, cfg.spread_factor); + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); } -void setUp(void) {} -void tearDown(void) {} +static void test_clampConfigLora_invalidPresetClampedToDefault() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.use_preset = true; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_validPresetUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.use_preset = true; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); +} + +void setUp(void) +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; +} +void tearDown(void) +{ + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; +} void setup() { @@ -90,10 +122,12 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_bwCodeToKHz_specialMappings); RUN_TEST(test_bwCodeToKHz_passthrough); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan); + RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse); + RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion); + RUN_TEST(test_validateConfigLora_validPreset_wideRegion); + RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion); + RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); + RUN_TEST(test_clampConfigLora_validPresetUnchanged); exit(UNITY_END()); } diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp new file mode 100644 index 000000000..ec54f2312 --- /dev/null +++ b/test/test_traffic_management/test_main.cpp @@ -0,0 +1,1160 @@ +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define TM_TEST_ENTRY extern "C" +#else +#define TM_TEST_ENTRY +#endif + +#if HAS_TRAFFIC_MANAGEMENT + +#include "mesh/CryptoEngine.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#include "modules/TrafficManagementModule.h" +#include +#include +#include +#include +#include + +namespace +{ + +constexpr NodeNum kLocalNode = 0x11111111; +constexpr NodeNum kRemoteNode = 0x22222222; +constexpr NodeNum kTargetNode = 0x33333333; + +class MockNodeDB : public NodeDB +{ + public: + meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override + { + if (hasCachedNode && n == cachedNodeNum) + return &cachedNode; + return NodeDB::getMeshNode(n); + } + + void clearCachedNode() + { + hasCachedNode = false; + cachedNodeNum = 0; + cachedNode = meshtastic_NodeInfoLite_init_zero; + } + + void setCachedNode(NodeNum n) + { + clearCachedNode(); + hasCachedNode = true; + cachedNodeNum = n; + cachedNode.num = n; + cachedNode.has_user = true; + } + + private: + bool hasCachedNode = false; + NodeNum cachedNodeNum = 0; + meshtastic_NodeInfoLite cachedNode = meshtastic_NodeInfoLite_init_zero; +}; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } +}; + +class MockRouter : public Router +{ + public: + ~MockRouter() + { + // Router allocates a global crypt lock in its constructor. + // Clean it up here so each test can build a fresh mock router. + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +class TrafficManagementModuleTestShim : public TrafficManagementModule +{ + public: + using TrafficManagementModule::alterReceived; + using TrafficManagementModule::handleReceived; + using TrafficManagementModule::resetEpoch; + using TrafficManagementModule::runOnce; + + bool ignoreRequestFlag() const { return ignoreRequest; } +}; + +MockNodeDB *mockNodeDB = nullptr; + +static void resetTrafficConfig() +{ + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + moduleConfig.has_traffic_management = true; + moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; + moduleConfig.traffic_management.enabled = true; + + config = meshtastic_LocalConfig_init_zero; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + myNodeInfo.my_node_num = kLocalNode; + + router = nullptr; + service = nullptr; + + mockNodeDB->resetNodes(); + mockNodeDB->clearCachedNode(); + nodeDB = mockNodeDB; +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x1001; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + packet.decoded.has_bitfield = true; + packet.decoded.bitfield = 0; + return packet; +} + +static meshtastic_MeshPacket makeUnknownPacket(NodeNum from, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x2001; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.encrypted.size = 0; + return packet; +} + +static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32_t lon, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, to); + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.has_longitude_i = true; + pos.latitude_i = lat; + pos.longitude_i = lon; + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos); + return packet; +} + +static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + strncpy(user.short_name, shortName, sizeof(user.short_name) - 1); + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + +/** + * Verify the module is a no-op when traffic management is disabled. + * Important so config toggles cannot accidentally change routing behavior. + */ +static void test_tm_moduleDisabled_doesNothing(void) +{ + moduleConfig.has_traffic_management = false; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage result = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected); + TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify unknown-packet dropping uses N+1 threshold semantics. + * Important to catch off-by-one regressions in drop decisions. + */ +static void test_tm_unknownPackets_dropOnNPlusOne(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 2; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); + TEST_ASSERT_EQUAL_UINT32(3, stats.packets_inspected); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify duplicate position broadcasts inside the dedup window are dropped. + * Important because this is the primary airtime-saving behavior. + */ +static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_GREATER_THAN_UINT32(0, first.decoded.payload.size); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify changed coordinates are forwarded even with dedup enabled. + * Important so real movement updates are never suppressed as duplicates. + */ +static void test_tm_positionDedup_allowsMovedPosition(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket moved = makePositionPacket(kRemoteNode, 384221234, -1210845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(moved); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify rate limiting drops only after exceeding the configured threshold. + * Important to protect threshold semantics from off-by-one regressions. + */ +static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 3; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + ProcessMessage r4 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r4)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify routing/admin traffic is exempt from rate limiting. + * Important because throttling control traffic can destabilize the mesh. + */ +static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode); + meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode); + + for (int i = 0; i < 4; i++) { + ProcessMessage rr = module.handleReceived(routingPacket); + ProcessMessage ar = module.handleReceived(adminPacket); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(rr)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(ar)); + } + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify packets sourced from this node bypass dedup and rate limiting. + * Important so local transmissions are not accidentally self-throttled. + */ +static void test_tm_fromUs_bypassesPositionAndRateFilters(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); + meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode); + + ProcessMessage p1 = module.handleReceived(positionPacket); + ProcessMessage p2 = module.handleReceived(positionPacket); + ProcessMessage t1 = module.handleReceived(textPacket); + ProcessMessage t2 = module.handleReceived(textPacket); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify locally addressed packets are never dropped by transit shaping. + * Important so dedup/rate limiting do not suppress end-user delivery. + */ +static void test_tm_localDestination_bypassesTransitFilters(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + + ProcessMessage p1 = module.handleReceived(position1); + ProcessMessage p2 = module.handleReceived(position2); + ProcessMessage t1 = module.handleReceived(text1); + ProcessMessage t2 = module.handleReceived(text2); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify router role clamps NodeInfo response hops to router-safe maximum. + * Important so large config values cannot widen response scope unexpectedly. + */ +static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 5; + request.hop_limit = 1; // 4 hops away; router clamp should cap max at 3 + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify NodeInfo direct-response success path and reply packet fields. + * Important because this path consumes the request and generates a spoofed cached reply. + */ +static void test_tm_nodeinfo_directResponse_respondsFromCache(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.id = 0x13572468; + request.hop_start = 3; + request.hop_limit = 3; // direct request (0 hops away) + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); + TEST_ASSERT_FALSE(reply.decoded.want_response); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start); + TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield); +} + +/** + * Verify cached direct replies still preserve requester NodeInfo learning. + * Important so consuming the request does not skip NodeDB refresh for observers. + */ +static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); + request.to = kTargetNode; + request.decoded.want_response = true; + request.id = 0x01020304; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_TRUE(requestor->has_user); + TEST_ASSERT_EQUAL_STRING("requester-long", requestor->user.long_name); + TEST_ASSERT_EQUAL_STRING("rq", requestor->user.short_name); + TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); +} + +/** + * Verify client role only answers direct (0-hop) NodeInfo requests. + * Important so clients do not answer relayed requests outside intended scope. + */ +static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 2; + request.hop_limit = 1; // 1 hop away; clients are clamped to max 0 + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) +/** + * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. + * Important because fallback should only happen through node-wide data when + * the dedicated PSRAM cache does not exist. + */ +static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +/** + * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that + * shouldRespondToNodeInfo() uses cached bitfield metadata. + */ +static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + observed.decoded.has_bitfield = true; + observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; + observed.channel = 2; + observed.rx_time = 123456; + + ProcessMessage observedResult = module.handleReceived(observed); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.id = 0x24681357; + request.channel = 1; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(static_cast(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); +} + +/** + * Verify PSRAM cache misses do not fall back to NodeDB. + * Important so the dedicated PSRAM index stays logically separate from + * NodeInfoModule/NodeDB when PSRAM is available. + */ +static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif + +/** + * Verify relayed telemetry broadcasts are hop-exhausted when enabled. + * Important to prevent further mesh propagation while still allowing one relay step. + */ +static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 3; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_start); + TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify hop exhaustion skips unicast and local-origin packets. + * Important to avoid mutating traffic that should retain normal forwarding behavior. + */ +static void test_tm_alterReceived_skipsLocalAndUnicast(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode); + unicast.hop_start = 5; + unicast.hop_limit = 3; + module.alterReceived(unicast); + TEST_ASSERT_EQUAL_UINT8(3, unicast.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(unicast)); + + meshtastic_MeshPacket fromUs = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kLocalNode, NODENUM_BROADCAST); + fromUs.hop_start = 5; + fromUs.hop_limit = 3; + module.alterReceived(fromUs); + TEST_ASSERT_EQUAL_UINT8(3, fromUs.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(fromUs)); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); +} + +/** + * Verify position dedup window expires and later duplicates are allowed. + * Important so periodic identical reports can resume after cooldown. + */ +static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket third = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(third); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify interval=0 disables position deduplication. + * Important because this is an explicit configuration escape hatch. + */ +static void test_tm_positionDedup_intervalZero_neverDrops(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 0; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify precision values above 32 fall back to default precision. + * Important so invalid config uses the documented default behavior. + */ +static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 99; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify precision=32 does not collapse all positions to one fingerprint. + * Important to prevent false duplicate drops at the full-precision boundary. + */ +static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 32; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify invalid precision=0 is treated as full precision. + * Important so invalid config does not collapse all positions into one fingerprint. + */ +static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 0; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify epoch reset invalidates stale position identity for dedup. + * Important so reset paths cannot leak prior packet identity into new windows. + */ +static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterReset = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + module.resetEpoch(millis()); + ProcessMessage r2 = module.handleReceived(afterReset); + ProcessMessage r3 = module.handleReceived(duplicate); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify non-position cache state does not make the first fingerprint-0 position look duplicated. + * Important so unified cache entries from other features cannot leak into dedup decisions. + */ +static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 10; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); + meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); + + ProcessMessage seeded = module.handleReceived(text); + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(duplicate); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(seeded)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify rate-limit counters reset after the window expires. + * Important so temporary bursts do not cause persistent throttling. + */ +static void test_tm_rateLimit_resetsAfterWindowExpires(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 1; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); +} + +/** + * Verify rate-limit thresholds above 255 effectively clamp to 255. + * Important because counters are uint8_t and must not overflow behavior. + */ +static void test_tm_rateLimit_thresholdAbove255_clamps(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 300; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + for (int i = 0; i < 255; i++) { + ProcessMessage result = module.handleReceived(packet); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + } + ProcessMessage dropped = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); +} + +/** + * Verify unknown-packet tracking resets after its active window expires. + * Important so old unknown traffic does not trigger delayed drops. + */ +static void test_tm_unknownPackets_resetAfterWindowExpires(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 1; + moduleConfig.traffic_management.rate_limit_window_secs = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); +} + +/** + * Verify unknown threshold values above 255 clamp to the counter ceiling. + * Important to align config semantics with saturating counter storage. + */ +static void test_tm_unknownPackets_thresholdAbove255_clamps(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 300; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + for (int i = 0; i < 255; i++) { + ProcessMessage result = module.handleReceived(packet); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + } + ProcessMessage dropped = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); +} + +/** + * Verify relayed position broadcasts can also be hop-exhausted. + * Important because telemetry and position use separate exhaust flags. + */ +static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) +{ + moduleConfig.traffic_management.exhaust_hop_position = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 2; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); + TEST_ASSERT_EQUAL_UINT8(4, packet.hop_start); + TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify hop exhaustion ignores undecoded/encrypted packets. + * Important so we never mutate packets that were not decoded by this module. + */ +static void test_tm_alterReceived_skipsUndecodedPackets(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 3; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); +} + +/** + * Verify exhaustRequested is per-packet and resets on next handleReceived(). + * Important so a prior packet cannot leak hop-exhaust state into later packets. + */ +static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + telemetry.hop_start = 5; + telemetry.hop_limit = 3; + module.alterReceived(telemetry); + TEST_ASSERT_TRUE(module.shouldExhaustHops(telemetry)); + + meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + ProcessMessage result = module.handleReceived(text); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify exhaust requests are packet-scoped (from + id). + * Important so stale state from one packet cannot influence unrelated packets + * that pass through duplicate/rebroadcast paths before handleReceived(). + */ +static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + exhausted.id = 0x1010; + exhausted.hop_start = 5; + exhausted.hop_limit = 3; + module.alterReceived(exhausted); + + meshtastic_MeshPacket unrelated = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); + unrelated.id = 0x2020; + unrelated.hop_start = 4; + unrelated.hop_limit = 0; + + TEST_ASSERT_TRUE(module.shouldExhaustHops(exhausted)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(unrelated)); +} + +/** + * Verify runOnce() returns sleep-forever interval when module is disabled. + * Important to ensure the maintenance thread is effectively inert when off. + */ +static void test_tm_runOnce_disabledReturnsMaxInterval(void) +{ + moduleConfig.traffic_management.enabled = false; + TrafficManagementModuleTestShim module; + + int32_t interval = module.runOnce(); + + TEST_ASSERT_EQUAL_INT32(INT32_MAX, interval); +} + +/** + * Verify runOnce() returns the maintenance cadence when enabled. + * Important so periodic cache housekeeping continues at expected interval. + */ +static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void) +{ + TrafficManagementModuleTestShim module; + + int32_t interval = module.runOnce(); + + TEST_ASSERT_EQUAL_INT32(60 * 1000, interval); +} + +} // namespace + +void setUp(void) +{ + resetTrafficConfig(); +} +void tearDown(void) {} + +TM_TEST_ENTRY void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + + UNITY_BEGIN(); + RUN_TEST(test_tm_moduleDisabled_doesNothing); + RUN_TEST(test_tm_unknownPackets_dropOnNPlusOne); + RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow); + RUN_TEST(test_tm_positionDedup_allowsMovedPosition); + RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold); + RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts); + RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters); + RUN_TEST(test_tm_localDestination_bypassesTransitFilters); + RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops); + RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache); + RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); + RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) + RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); +#endif +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); + RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); +#endif + RUN_TEST(test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast); + RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); + RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); + RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); + RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); + RUN_TEST(test_tm_positionDedup_precision32_allowsDistinctPositions); + RUN_TEST(test_tm_positionDedup_precisionZero_allowsDistinctPositions); + RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset); + RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero); + RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires); + RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps); + RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires); + RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps); + RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast); + RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets); + RUN_TEST(test_tm_alterReceived_resetExhaustFlagOnNextPacket); + RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped); + RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval); + RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval); + exit(UNITY_END()); +} + +TM_TEST_ENTRY void loop() {} + +#else + +void setUp(void) {} +void tearDown(void) {} + +TM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +TM_TEST_ENTRY void loop() {} + +#endif diff --git a/variants/esp32/tlora_v1/platformio.ini b/variants/esp32/tlora_v1/platformio.ini index c45cc2ce9..5f72d634e 100644 --- a/variants/esp32/tlora_v1/platformio.ini +++ b/variants/esp32/tlora_v1/platformio.ini @@ -13,4 +13,5 @@ build_flags = ${esp32_base.build_flags} -D TLORA_V1 -I variants/esp32/tlora_v1 + -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32s3/heltec_v4/variant.h b/variants/esp32s3/heltec_v4/variant.h index 83443c5f3..72f55d09f 100644 --- a/variants/esp32s3/heltec_v4/variant.h +++ b/variants/esp32s3/heltec_v4/variant.h @@ -29,6 +29,14 @@ #define SX126X_DIO2_AS_RF_SWITCH #define SX126X_DIO3_TCXO_VOLTAGE 1.8 +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + // ---- GC1109 RF FRONT END CONFIGURATION ---- // The Heltec V4.2 uses a GC1109 FEM chip with integrated PA and LNA // RF path: SX1262 -> Pi attenuator -> GC1109 PA -> Antenna @@ -89,4 +97,4 @@ // Seems to be missing on this new board #define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU #define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS -#define GPS_THREAD_INTERVAL 50 \ No newline at end of file +#define GPS_THREAD_INTERVAL 50 diff --git a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h new file mode 100644 index 000000000..86da4b8ce --- /dev/null +++ b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h @@ -0,0 +1,131 @@ +#pragma once + +#include "configuration.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +// InkHUD-specific components +#include "graphics/niche/InkHUD/InkHUD.h" + +// Applets +#include "graphics/niche/InkHUD/Applet.h" +#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" +#include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" +#include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Shared NicheGraphics components +#include "graphics/niche/Drivers/EInk/GDEW0102T4.h" +#include "graphics/niche/Inputs/TwoButtonExtended.h" + +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // Power-enable the E-Ink panel on this board before any SPI traffic. + pinMode(PIN_EINK_EN, OUTPUT); + digitalWrite(PIN_EINK_EN, HIGH); + delay(10); + + // Display uses HSPI on this board + SPIClass *hspi = new SPIClass(HSPI); + hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + + Drivers::GDEW0102T4 *displayDriver = new Drivers::GDEW0102T4; + displayDriver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY, PIN_EINK_RES); + // Tuned fast-refresh values reg30 reg50 reg82 lutW2 lutB2 = 11 F2 04 11 0D + displayDriver->setFastConfig({0x11, 0xF2, 0x04, 0x11, 0x0D}); + Drivers::EInk *driver = displayDriver; + + InkHUD::InkHUD *inkhud = InkHUD::InkHUD::getInstance(); + inkhud->setDriver(driver); + // Slightly stricter FAST/FULL + inkhud->setDisplayResilience(5, 1.5); + inkhud->twoWayRocker = true; + + // Fonts + InkHUD::Applet::fontLarge = FREESANS_9PT_WIN1252; + InkHUD::Applet::fontMedium = FREESANS_6PT_WIN1252; + InkHUD::Applet::fontSmall = FREESANS_6PT_WIN1252; + + // Small display defaults + inkhud->persistence->settings.rotation = 0; + inkhud->persistence->settings.userTiles.maxCount = 1; + inkhud->persistence->settings.userTiles.count = 1; + inkhud->persistence->settings.joystick.enabled = true; + inkhud->persistence->settings.joystick.aligned = true; + inkhud->persistence->settings.optionalMenuItems.nextTile = false; + + // Pick applets + // Note: order of applets determines priority of "auto-show" feature + inkhud->addApplet("All Messages", new InkHUD::AllMessageApplet, false, false); // - + inkhud->addApplet("DMs", new InkHUD::DMApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown + inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // - + inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - + inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, false, false); // - + inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 + // Start running InkHUD + inkhud->begin(); + + // Enforce two-way rocker behavior regardless of persisted settings. + inkhud->persistence->settings.joystick.enabled = true; + inkhud->persistence->settings.joystick.aligned = true; + inkhud->persistence->settings.optionalMenuItems.nextTile = false; + + // Inputs + Inputs::TwoButtonExtended *buttons = Inputs::TwoButtonExtended::getInstance(); + + // Center press (boot button) + buttons->setWiring(0, INPUTDRIVER_TWO_WAY_ROCKER_BTN, true); + // Match baseUI encoder long-press feel. + buttons->setTiming(0, 75, 300); + buttons->setHandlerShortPress(0, [inkhud]() { inkhud->shortpress(); }); + buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); + + // LEFT rocker pin is IO4; RIGHT rocker pin is IO3. + buttons->setTwoWayRockerWiring(INPUTDRIVER_TWO_WAY_ROCKER_LEFT, INPUTDRIVER_TWO_WAY_ROCKER_RIGHT, true); + buttons->setJoystickDebounce(50); + + // Two-way rocker behavior: + // - when a system applet is handling input (menu, tips, etc): LEFT=up, RIGHT=down + // - otherwise: LEFT=previous applet, RIGHT=next applet + buttons->setTwoWayRockerPressHandlers( + [inkhud]() { + bool systemHandlingInput = false; + for (InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + if (systemHandlingInput) + inkhud->navUp(); + else + inkhud->prevApplet(); + }, + [inkhud]() { + bool systemHandlingInput = false; + for (InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + if (systemHandlingInput) + inkhud->navDown(); + else + inkhud->nextApplet(); + }); + + buttons->start(); +} + +#endif diff --git a/variants/esp32s3/mini-epaper-s3/pins_arduino.h b/variants/esp32s3/mini-epaper-s3/pins_arduino.h index a4b3c4bf7..afb2428a0 100644 --- a/variants/esp32s3/mini-epaper-s3/pins_arduino.h +++ b/variants/esp32s3/mini-epaper-s3/pins_arduino.h @@ -3,24 +3,23 @@ #include -#define USB_VID 0x303a +#define USB_VID 0x303A #define USB_PID 0x1001 // The default Wire will be mapped to PMU and RTC static const uint8_t SDA = 18; static const uint8_t SCL = 9; -// Default SPI will be mapped to Radio +// Default SPI (LoRa bus) static const uint8_t SS = -1; static const uint8_t MOSI = 17; static const uint8_t MISO = 6; static const uint8_t SCK = 8; +// SD card SPI bus #define SPI_MOSI (39) #define SPI_SCK (41) #define SPI_MISO (38) #define SPI_CS (40) -#define SDCARD_CS SPI_CS - #endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/mini-epaper-s3/platformio.ini b/variants/esp32s3/mini-epaper-s3/platformio.ini index f49be707f..5c3e64681 100644 --- a/variants/esp32s3/mini-epaper-s3/platformio.ini +++ b/variants/esp32s3/mini-epaper-s3/platformio.ini @@ -17,11 +17,15 @@ upload_protocol = esptool build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/mini-epaper-s3 - -DMINI_EPAPER_S3 - -DUSE_EINK - -DEINK_DISPLAY_MODEL=GxEPD2_102 - -DEINK_WIDTH=128 - -DEINK_HEIGHT=80 + -D MINI_EPAPER_S3 + -D USE_EINK + -D EINK_DISPLAY_MODEL=GxEPD2_102 + -D EINK_WIDTH=128 + -D EINK_HEIGHT=80 + -D USE_EINK_DYNAMICDISPLAY + -D EINK_LIMIT_FASTREFRESH=3 + -D EINK_BACKGROUND_USES_FAST + -D EINK_HASQUIRK_GHOSTING lib_deps = ${esp32s3_base.lib_deps} @@ -29,3 +33,22 @@ lib_deps = https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 + +[env:mini-epaper-s3-inkhud] +extends = esp32s3_base, inkhud +board = mini-epaper-s3 +board_check = true +upload_protocol = esptool +build_src_filter = + ${esp32s3_base.build_src_filter} + ${inkhud.build_src_filter} +build_flags = + ${esp32s3_base.build_flags} + ${inkhud.build_flags} + -I variants/esp32s3/mini-epaper-s3 + -D MINI_EPAPER_S3 +lib_deps = + ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.3.4 diff --git a/variants/esp32s3/mini-epaper-s3/variant.h b/variants/esp32s3/mini-epaper-s3/variant.h index b464c9b4a..0b640f9cf 100644 --- a/variants/esp32s3/mini-epaper-s3/variant.h +++ b/variants/esp32s3/mini-epaper-s3/variant.h @@ -1,46 +1,46 @@ -// Display (E-Ink) +#pragma once -#define PIN_EINK_CS 13 -#define PIN_EINK_BUSY 10 -#define PIN_EINK_RES 11 -#define PIN_EINK_SCLK 14 -#define PIN_EINK_MOSI 15 -#define PIN_EINK_DC 12 -#define PIN_EINK_EN 42 +#define GPS_DEFAULT_NOT_PRESENT 1 -#define SPI_INTERFACES_COUNT 2 -#define PIN_SPI1_MISO -1 -#define PIN_SPI1_MOSI PIN_EINK_MOSI -#define PIN_SPI1_SCK PIN_EINK_SCLK -#define DISPLAY_FORCE_SMALL_FONTS +// SD card (TF) +#define HAS_SDCARD +#define SDCARD_USE_SPI1 +#define SDCARD_CS 40 +#define SD_SPI_FREQUENCY 25000000U +// Built-in RTC (I2C) +#define PCF8563_RTC 0x51 +#define HAS_RTC 1 #define I2C_SDA SDA #define I2C_SCL SCL +// Battery voltage monitoring #define BATTERY_PIN 2 // A battery voltage measurement pin, voltage divider connected here to // measure battery voltage ratio of voltage divider = 2.0 (assumption) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. #define ADC_CHANNEL ADC1_GPIO2_CHANNEL -#define HAS_GPS 0 -#undef GPS_RX_PIN -#undef GPS_TX_PIN +// Display (E-Ink) +#define PIN_EINK_EN 42 +#define PIN_EINK_CS 13 +#define PIN_EINK_BUSY 10 +#define PIN_EINK_DC 12 +#define PIN_EINK_RES 11 +#define PIN_EINK_SCLK 14 +#define PIN_EINK_MOSI 15 +#define DISPLAY_FORCE_SMALL_FONTS -#define BUTTON_PIN 3 -#define BUTTON_NEED_PULLUP -#define ALT_BUTTON_PIN 4 -#define ALT_BUTTON_ACTIVE_LOW true -#define ALT_BUTTON_ACTIVE_PULLUP true -#define PIN_BUTTON3 0 - -// #define HAS_SDCARD 1 -// #define SDCARD_USE_SOFT_SPI - -// PCF85063 RTC Module -#define PCF85063_RTC 0x51 -#define HAS_RTC 1 +// Two-Way Rocker input (left/right + boot as press) +#define INPUTDRIVER_TWO_WAY_ROCKER +#define INPUTDRIVER_ENCODER_TYPE 2 +#define INPUTDRIVER_TWO_WAY_ROCKER_RIGHT 3 +#define INPUTDRIVER_TWO_WAY_ROCKER_LEFT 4 +#define INPUTDRIVER_TWO_WAY_ROCKER_BTN 0 +#define UPDOWN_LONG_PRESS_REPEAT_INTERVAL 150 +// LoRa (SX1262) #define USE_SX1262 + #define LORA_DIO1 5 #define LORA_SCK 8 #define LORA_MISO 6 diff --git a/variants/esp32s3/seeed_xiao_s3/variant.h b/variants/esp32s3/seeed_xiao_s3/variant.h index 11bf48521..cbdbf8eb8 100644 --- a/variants/esp32s3/seeed_xiao_s3/variant.h +++ b/variants/esp32s3/seeed_xiao_s3/variant.h @@ -50,7 +50,6 @@ L76K GPS Module Information : https://www.seeedstudio.com/L76K-GNSS-Module-for-S #define GPS_RX_PIN 44 #define GPS_TX_PIN 43 #define HAS_GPS 1 -#define GPS_BAUDRATE 9600 #define GPS_THREAD_INTERVAL 50 #define PIN_SERIAL1_RX PIN_GPS_TX #define PIN_SERIAL1_TX PIN_GPS_RX diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index 8f0b4b220..2d65a042c 100644 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -40,6 +40,14 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2 #define SX126X_MAX_POWER 19 #endif +// Enable Traffic Management Module for Station G2 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + /* #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define ADC_CHANNEL ADC1_GPIO4_CHANNEL diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index d35562c2f..dc96113b0 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -26,7 +26,6 @@ build_flags = ${esp32s3_base.build_flags} -D T_LORA_PAGER -D BOARD_HAS_PSRAM -D HAS_SDCARD - -D SDCARD_USE_SPI1 -D ENABLE_ROTARY_PULLUP -D ENABLE_BUTTON_PULLUP -D ROTARY_BUXTRONICS diff --git a/variants/native/portduino/variant.h b/variants/native/portduino/variant.h index cba4aaedd..c23d17b8d 100644 --- a/variants/native/portduino/variant.h +++ b/variants/native/portduino/variant.h @@ -8,3 +8,11 @@ // RAK12002 RTC Module #define RV3028_RTC (uint8_t)0b1010010 + +// Enable Traffic Management Module for native/portduino +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif diff --git a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini new file mode 100644 index 000000000..e1bdd529d --- /dev/null +++ b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini @@ -0,0 +1,34 @@ +; First prototype nrf52840/sx1262 device +[env:heltec-mesh-node-t096] +custom_meshtastic_hw_model = 127 +custom_meshtastic_hw_model_slug = HELTEC_MESH_NODE_T096 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec Mesh Node 096 +custom_meshtastic_images = heltec-mesh-node-t096.svg, heltec-mesh-node-t096-case.svg +custom_meshtastic_tags = Heltec + +extends = nrf52840_base +board = heltec_mesh_node_t096 +board_level = pr +debug_tool = jlink + +build_flags = ${nrf52840_base.build_flags} + -Ivariants/nrf52840/heltec_mesh_node_t096 + -D HAS_LORA_FEM=1 + -D HELTEC_MESH_NODE_T096 + -D USE_TFTDISPLAY=1 + -D USER_SETUP_LOADED + -D ST7735_DRIVER + -D ST7735_REDTAB160x80 + -D TFT_SPI_PORT=SPI1 + -D TFT_CS=ST7735_CS ; Chip select control + -D TFT_DC=ST7735_RS ; Data Command control pin + -D TFT_RST=ST7735_RESET ; Reset pin + -D TFT_BL=ST7735_BL ; LED back-light + -D TFT_BACKLIGHT_ON=LOW +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_node_t096> +lib_deps = + ${nrf52840_base.lib_deps} + bodmer/TFT_eSPI@2.5.43 ; renovate: datasource=platformio-registry depName=bodmer/TFT_eSPI diff --git a/variants/nrf52840/heltec_mesh_node_t096/variant.cpp b/variants/nrf52840/heltec_mesh_node_t096/variant.cpp new file mode 100644 index 000000000..29158e8ba --- /dev/null +++ b/variants/nrf52840/heltec_mesh_node_t096/variant.cpp @@ -0,0 +1,76 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + // LED1 + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); +} + +void variant_shutdown() +{ + nrf_gpio_cfg_default(VEXT_ENABLE); + nrf_gpio_cfg_default(ST7735_CS); + nrf_gpio_cfg_default(ST7735_RS); + nrf_gpio_cfg_default(ST7735_SDA); + nrf_gpio_cfg_default(ST7735_SCK); + nrf_gpio_cfg_default(ST7735_RESET); + nrf_gpio_cfg_default(ST7735_BL); + + nrf_gpio_cfg_default(PIN_LED1); + + // nrf_gpio_cfg_default(LORA_PA_POWER); + pinMode(LORA_PA_POWER, OUTPUT); + digitalWrite(LORA_PA_POWER, LOW); + + nrf_gpio_cfg_default(LORA_KCT8103L_PA_CSD); + nrf_gpio_cfg_default(LORA_KCT8103L_PA_CTX); + + pinMode(ADC_CTRL, OUTPUT); + digitalWrite(ADC_CTRL, LOW); + + nrf_gpio_cfg_default(SX126X_CS); + nrf_gpio_cfg_default(SX126X_DIO1); + nrf_gpio_cfg_default(SX126X_BUSY); + nrf_gpio_cfg_default(SX126X_RESET); + + nrf_gpio_cfg_default(PIN_SPI_MISO); + nrf_gpio_cfg_default(PIN_SPI_MOSI); + nrf_gpio_cfg_default(PIN_SPI_SCK); + + nrf_gpio_cfg_default(PIN_GPS_PPS); + nrf_gpio_cfg_default(PIN_GPS_RESET); + nrf_gpio_cfg_default(PIN_GPS_EN); + nrf_gpio_cfg_default(GPS_TX_PIN); + nrf_gpio_cfg_default(GPS_RX_PIN); +} \ No newline at end of file diff --git a/variants/nrf52840/heltec_mesh_node_t096/variant.h b/variants/nrf52840/heltec_mesh_node_t096/variant.h new file mode 100644 index 000000000..04e22af26 --- /dev/null +++ b/variants/nrf52840/heltec_mesh_node_t096/variant.h @@ -0,0 +1,202 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_HELTEC_NRF_ +#define _VARIANT_HELTEC_NRF_ +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +#define VEXT_ENABLE (0 + 26) +#define VEXT_ON_VALUE HIGH + +// ST7735S TFT LCD +#define ST7735_CS (0 + 22) +#define ST7735_RS (0 + 15) // DC +#define ST7735_SDA (0 + 17) // MOSI +#define ST7735_SCK (0 + 20) +#define ST7735_RESET (0 + 13) +#define ST7735_MISO -1 +#define ST7735_BUSY -1 +#define ST7735_BL (32 + 12) +#define SPI_FREQUENCY 40000000 +#define SPI_READ_FREQUENCY 16000000 +#define SCREEN_ROTATE +#define TFT_HEIGHT 160 +#define TFT_WIDTH 80 +#define TFT_OFFSET_X 24 +#define TFT_OFFSET_Y 0 +#define TFT_INVERT false +#define SCREEN_TRANSITION_FRAMERATE 3 // fps +#define DISPLAY_FORCE_SMALL_FONTS + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (0 + 28) // green (confirmed on 1.0 board) +#define LED_BLUE PIN_LED1 // fake for bluefruit library +#define LED_GREEN PIN_LED1 +#define LED_STATE_ON 1 // State when LED is lit + +// #define HAS_NEOPIXEL // Enable the use of neopixels +// #define NEOPIXEL_COUNT 2 // How many neopixels are connected +// #define NEOPIXEL_DATA 14 // gpio pin used to send data to the neopixels +// #define NEOPIXEL_TYPE (NEO_GRB + NEO_KHZ800) // type of neopixels in use + +/* + * Buttons + */ +#define PIN_BUTTON1 (32 + 10) +// #define PIN_BUTTON2 (0 + 18) // 0.18 is labeled on the board as RESET but we configure it in the bootloader as a regular +// GPIO + +/* +No longer populated on PCB +*/ +#define PIN_SERIAL2_RX (0 + 9) +#define PIN_SERIAL2_TX (0 + 10) + +/* + * I2C + */ + +#define WIRE_INTERFACES_COUNT 2 + +// I2C bus 0 +#define PIN_WIRE_SDA (0 + 7) // SDA +#define PIN_WIRE_SCL (0 + 8) // SCL + +// I2C bus 1 +#define PIN_WIRE1_SDA (0 + 4) // SDA (secondary bus) +#define PIN_WIRE1_SCL (0 + 27) // SCL (secondary bus) + +/* + * Lora radio + */ + +#define USE_SX1262 +#define SX126X_CS (0 + 5) // FIXME - we really should define LORA_CS instead +#define LORA_CS (0 + 5) +#define SX126X_DIO1 (0 + 21) +#define SX126X_BUSY (0 + 19) +#define SX126X_RESET (0 + 16) +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The heltec_wireless_tracker_v2 uses a KCT8103L FEM chip with integrated PA and LNA +// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna +// Control logic (from KCT8103L datasheet): +// Transmit PA: CSD=1, CTX=1, CPS=1 +// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF) +// Receive bypass: CSD=1, CTX=1, CPS=0 +// Shutdown: CSD=0, CTX=X, CPS=X +// Pin mapping: +// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH) +// CSD (pin 4) -> GPIO12: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO41: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO30 +// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH) + +#define USE_KCT8103L_PA +#define LORA_PA_POWER (0 + 30) // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD (0 + 12) // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX \ + (32 + 9) // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 2 + +// For LORA, spi 0 +#define PIN_SPI_MISO (0 + 14) +#define PIN_SPI_MOSI (0 + 11) +#define PIN_SPI_SCK (32 + 8) + +#define PIN_SPI1_MISO \ + ST7735_MISO // FIXME not really needed, but for now the SPI code requires something to be defined, pick an used GPIO +#define PIN_SPI1_MOSI ST7735_SDA +#define PIN_SPI1_SCK ST7735_SCK + +/* + * GPS pins + */ +#define GPS_UC6580 +#define GPS_BAUDRATE 115200 +#define PIN_GPS_RESET (32 + 14) // An output to reset UC6580 GPS. As per datasheet, low for > 100ms will reset the UC6580 +#define GPS_RESET_MODE LOW +#define PIN_GPS_EN (0 + 6) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (32 + 11) +#define GPS_TX_PIN (0 + 25) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (0 + 23) // This is for bits going TOWARDS the GPS + +#define GPS_THREAD_INTERVAL 50 + +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN + +#define ADC_CTRL (32 + 15) +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_PIN (0 + 3) +#define ADC_RESOLUTION 14 + +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (4.916F) + +// rf52840 AIN1 = Pin 3 +#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_1 + +// We have AIN1 with a VBAT divider so AIN1 = VBAT * (100/490) +// We have the device going deep sleep under 3.1V, which is AIN1 = 0.63V +// So we can wake up when VBAT>=VDD is restored to 3.3V, where AIN2 = 0.67V +// Ratio 0.67/3.3 = 0.20, so we can pick a bit higher, 2/8 VDD, which means +// VBAT=4.04V +#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_2_8 + +#define HAS_RTC 0 +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif diff --git a/variants/nrf52840/tracker-t1000-e/variant.h b/variants/nrf52840/tracker-t1000-e/variant.h index b064dbc9f..de6916ac7 100644 --- a/variants/nrf52840/tracker-t1000-e/variant.h +++ b/variants/nrf52840/tracker-t1000-e/variant.h @@ -154,6 +154,15 @@ extern "C" { #define HAS_SCREEN 0 +// Enable Traffic Management Module for testing on T1000-E +// NRF52840 has 256KB RAM - 1024 entries uses ~10KB +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1024 +#endif + #ifdef __cplusplus } #endif