Merge branch 'develop' into mui-wifi-maps

This commit is contained in:
Manuel
2026-03-26 16:58:49 +01:00
committed by GitHub
107 changed files with 7032 additions and 1568 deletions
-2
View File
@@ -8,8 +8,6 @@ runs:
uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Install dependencies
shell: bash
+3 -5
View File
@@ -16,8 +16,7 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
jobs:
build-debian-src:
@@ -28,8 +27,6 @@ jobs:
with:
submodules: recursive
path: meshtasticd
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Install deps
shell: bash
@@ -42,6 +39,7 @@ jobs:
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
- name: Import GPG key
if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.PPA_GPG_PRIVATE_KEY }}
@@ -60,7 +58,7 @@ jobs:
run: debian/ci_pack_sdeb.sh
env:
SERIES: ${{ inputs.series }}
GPG_KEY_ID: ${{ steps.gpg.outputs.keyid }}
GPG_KEY_ID: ${{ steps.gpg.outputs.keyid || '' }}
PKG_VERSION: ${{ steps.version.outputs.deb }}
- name: Store binaries as an artifact
-2
View File
@@ -26,8 +26,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Build ${{ inputs.platform }}
id: build
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
gather-artifacts:
permissions:
contents: write
contents: read
pull-requests: write
runs-on: ubuntu-latest
needs: [version, build]
+2 -2
View File
@@ -5,7 +5,7 @@ on:
workflow_dispatch:
push:
branches:
- master
- develop # Default branch, same as 'cron' above
paths:
- debian/**
- "*.rpkg"
@@ -16,7 +16,7 @@ on:
- .github/workflows/hook_copr.yml
permissions:
contents: write
contents: read
packages: write
jobs:
+1 -3
View File
@@ -37,7 +37,7 @@ on:
value: ${{ jobs.docker-build.outputs.digest }}
permissions:
contents: write
contents: read
packages: write
jobs:
@@ -50,8 +50,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Get release version string
run: |
+1 -3
View File
@@ -12,7 +12,7 @@ on:
type: string
permissions:
contents: write
contents: read
packages: write
jobs:
@@ -86,8 +86,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Get release version string
run: |
+1 -4
View File
@@ -11,8 +11,7 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
jobs:
build-copr-hook:
@@ -22,8 +21,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{ github.ref }}
repository: ${{ github.repository }}
- name: Trigger COPR build
uses: vidplace7/copr-build@main
+18 -21
View File
@@ -15,8 +15,7 @@ on:
- "**.md"
- version.properties
# Note: This is different from "pull_request". Need to specify ref when doing checkouts.
pull_request_target:
pull_request:
branches:
- master
- develop
@@ -29,6 +28,8 @@ on:
workflow_dispatch:
permissions: read-all
jobs:
setup:
strategy:
@@ -88,8 +89,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Check ${{ matrix.check.board }}
uses: meshtastic/gh-action-firmware@main
with:
@@ -126,9 +125,16 @@ jobs:
test-native:
if: ${{ !contains(github.ref_name, 'event/') && github.repository == 'meshtastic/firmware' }}
permissions: # Needed for dorny/test-reporter.
contents: read
actions: read
checks: write
uses: ./.github/workflows/test_native.yml
docker:
permissions: # Needed for pushing to GHCR.
contents: read
packages: write
strategy:
fail-fast: false
matrix:
@@ -153,9 +159,6 @@ jobs:
gather-artifacts:
# trunk-ignore(checkov/CKV2_GHA_1)
if: github.repository == 'meshtastic/firmware'
permissions:
contents: write
pull-requests: write
strategy:
fail-fast: false
matrix:
@@ -173,9 +176,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- uses: actions/download-artifact@v8
with:
@@ -231,13 +231,6 @@ jobs:
path: ./*.elf
retention-days: 30
- uses: scruplelesswizard/comment-artifact@main
if: ${{ github.event_name == 'pull_request' }}
with:
name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation"
github-token: ${{ secrets.GITHUB_TOKEN }}
shame:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
@@ -245,7 +238,7 @@ jobs:
needs: [build]
steps:
- uses: actions/checkout@v6
if: github.event_name == 'pull_request_target'
if: github.event_name == 'pull_request'
with:
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
fetch-depth: 0
@@ -263,24 +256,26 @@ jobs:
overwrite: true
path: manifests-new/*.mt.json
- name: Find the merge base
if: github.event_name == 'pull_request_target'
if: github.event_name == 'pull_request'
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
env:
base: ${{ github.base_ref }}
head: ${{ github.sha }}
# Currently broken (for-loop through EVERY artifact -- rate limiting)
# - name: Download the old manifests
# if: github.event_name == 'pull_request_target'
# if: github.event_name == 'pull_request'
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
# env:
# GH_TOKEN: ${{ github.token }}
# merge_base: ${{ env.MERGE_BASE }}
# repo: ${{ github.repository }}
# - name: Do scan and post comment
# if: github.event_name == 'pull_request_target'
# if: github.event_name == 'pull_request'
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
release-artifacts:
permissions: # Needed for 'gh release upload'.
contents: write
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
outputs:
@@ -372,6 +367,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-firmware:
permissions: # Needed for 'gh release upload'.
contents: write
strategy:
fail-fast: false
matrix:
-371
View File
@@ -1,371 +0,0 @@
name: Merge Queue
# Not sure how concurrency works in merge_queue, removing for now.
# concurrency:
# group: merge-queue-${{ github.head_ref || github.run_id }}
# cancel-in-progress: true
on:
# Merge group is a special trigger that is used to trigger the workflow when a merge group is created.
merge_group:
jobs:
setup:
strategy:
fail-fast: true
matrix:
arch:
- all
- check
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
cache: pip
- run: pip install -U platformio
- name: Generate matrix
id: jsonStep
run: |
if [[ "$GITHUB_HEAD_REF" == "" ]]; then
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
else
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr)
fi
echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF"
echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT
outputs:
all: ${{ steps.jsonStep.outputs.all }}
check: ${{ steps.jsonStep.outputs.check }}
version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Get release version string
run: |
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
echo "deb=$(./bin/buildinfo.py deb)" >> $GITHUB_OUTPUT
id: version
env:
BUILD_LOCATION: local
outputs:
long: ${{ steps.version.outputs.long }}
deb: ${{ steps.version.outputs.deb }}
check:
needs: setup
strategy:
fail-fast: true
matrix:
check: ${{ fromJson(needs.setup.outputs.check) }}
runs-on: ubuntu-latest
if: ${{ github.event_name != 'workflow_dispatch' }}
steps:
- uses: actions/checkout@v6
- name: Build base
id: base
uses: ./.github/actions/setup-base
- name: Check ${{ matrix.check.board }}
run: bin/check-all.sh ${{ matrix.check.board }}
build:
needs: [setup, version]
strategy:
matrix:
build: ${{ fromJson(needs.setup.outputs.all) }}
uses: ./.github/workflows/build_firmware.yml
with:
version: ${{ needs.version.outputs.long }}
pio_env: ${{ matrix.build.board }}
platform: ${{ matrix.build.platform }}
build-debian-src:
if: github.repository == 'meshtastic/firmware'
uses: ./.github/workflows/build_debian_src.yml
with:
series: UNRELEASED
build_location: local
secrets: inherit
package-pio-deps-native-tft:
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/package_pio_deps.yml
with:
pio_env: native-tft
secrets: inherit
test-native:
if: ${{ !contains(github.ref_name, 'event/') }}
uses: ./.github/workflows/test_native.yml
docker:
strategy:
fail-fast: false
matrix:
distro: [debian, alpine]
platform: [linux/amd64, linux/arm64, linux/arm/v7]
pio_env: [native, native-tft]
exclude:
- distro: alpine
platform: linux/arm/v7
- pio_env: native-tft
platform: linux/arm64
- pio_env: native-tft
platform: linux/arm/v7
uses: ./.github/workflows/docker_build.yml
with:
distro: ${{ matrix.distro }}
platform: ${{ matrix.platform }}
runs-on: ${{ contains(matrix.platform, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
pio_env: ${{ matrix.pio_env }}
push: false
gather-artifacts:
# trunk-ignore(checkov/CKV2_GHA_1)
permissions:
contents: write
pull-requests: write
strategy:
fail-fast: false
matrix:
arch:
- esp32
- esp32s3
- esp32c3
- esp32c6
- nrf52840
- rp2040
- rp2350
- stm32
runs-on: ubuntu-latest
needs: [version, build]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- uses: actions/download-artifact@v8
with:
path: ./
pattern: firmware-${{matrix.arch}}-*
merge-multiple: true
- name: Display structure of downloaded files
run: ls -R
- name: Move files up
run: mv -b -t ./ ./bin/device-*.sh ./bin/device-*.bat
- name: Repackage in single firmware zip
uses: actions/upload-artifact@v7
with:
name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
overwrite: true
path: |
./firmware-*.bin
./firmware-*.uf2
./firmware-*.hex
./firmware-*.zip
./device-*.sh
./device-*.bat
./littlefs-*.bin
./bleota*bin
./Meshtastic_nRF52_factory_erase*.uf2
retention-days: 30
- uses: actions/download-artifact@v8
with:
name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
merge-multiple: true
path: ./output
# For diagnostics
- name: Show artifacts
run: ls -lR
- name: Device scripts permissions
run: |
chmod +x ./output/device-install.sh || true
chmod +x ./output/device-update.sh || true
- name: Zip firmware
run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output
- name: Repackage in single elfs zip
uses: actions/upload-artifact@v7
with:
name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}
overwrite: true
path: ./*.elf
retention-days: 30
- uses: scruplelesswizard/comment-artifact@main
if: ${{ github.event_name == 'pull_request' }}
with:
name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation"
github-token: ${{ secrets.GITHUB_TOKEN }}
release-artifacts:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' }}
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
needs:
- version
- gather-artifacts
- build-debian-src
- package-pio-deps-native-tft
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Create release
uses: softprops/action-gh-release@v2
id: create_release
with:
draft: true
prerelease: true
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
tag_name: v${{ needs.version.outputs.long }}
body: |
Autogenerated by github action, developer should edit as required before publishing...
- name: Download source deb
uses: actions/download-artifact@v8
with:
pattern: firmware-debian-${{ needs.version.outputs.deb }}~UNRELEASED-src
merge-multiple: true
path: ./output/debian-src
- name: Download `native-tft` pio deps
uses: actions/download-artifact@v8
with:
pattern: platformio-deps-native-tft-${{ needs.version.outputs.long }}
merge-multiple: true
path: ./output/pio-deps-native-tft
- name: Zip Linux sources
working-directory: output
run: |
zip -j -9 -r ./meshtasticd-${{ needs.version.outputs.deb }}-src.zip ./debian-src
zip -9 -r ./platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip ./pio-deps-native-tft
# For diagnostics
- name: Display structure of downloaded files
run: ls -lR
- name: Add Linux sources to GtiHub Release
# Only run when targeting master branch with workflow_dispatch
if: ${{ github.ref_name == 'master' }}
run: |
gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip
gh release upload v${{ needs.version.outputs.long }} ./output/platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-firmware:
strategy:
fail-fast: false
matrix:
arch:
- esp32
- esp32s3
- esp32c3
- esp32c6
- nrf52840
- rp2040
- rp2350
- stm32
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' }}
needs: [release-artifacts, version]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: 3.x
- uses: actions/download-artifact@v8
with:
pattern: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}
merge-multiple: true
path: ./output
- name: Display structure of downloaded files
run: ls -lR
- name: Device scripts permissions
run: |
chmod +x ./output/device-install.sh || true
chmod +x ./output/device-update.sh || true
- name: Zip firmware
run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output
- uses: actions/download-artifact@v8
with:
name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}
merge-multiple: true
path: ./elfs
- name: Zip debug elfs
run: zip -j -9 -r ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./elfs
# For diagnostics
- name: Display structure of downloaded files
run: ls -lR
- name: Add bins and debug elfs to GitHub Release
# Only run when targeting master branch with workflow_dispatch
if: ${{ github.ref_name == 'master' }}
run: |
gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip
gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-firmware:
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'workflow_dispatch' }}
needs: [release-firmware, version]
env:
targets: |-
esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: 3.x
- uses: actions/download-artifact@v8
with:
pattern: firmware-{${{ env.targets }}}-${{ needs.version.outputs.long }}
merge-multiple: true
path: ./publish
- name: Publish firmware to meshtastic.github.io
uses: peaceiris/actions-gh-pages@v4
env:
# On event/* branches, use the event name as the destination prefix
DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }}
with:
deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }}
external_repository: meshtastic/meshtastic.github.io
publish_branch: master
publish_dir: ./publish
destination_dir: ${{ env.DEST_PREFIX }}firmware-${{ needs.version.outputs.long }}
keep_files: true
user_name: github-actions[bot]
user_email: github-actions[bot]@users.noreply.github.com
commit_message: ${{ needs.version.outputs.long }}
enable_jekyll: true
+1 -2
View File
@@ -18,8 +18,7 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
jobs:
build-debian-src:
+1 -4
View File
@@ -16,8 +16,7 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
jobs:
pkg-pio-libdeps:
@@ -27,8 +26,6 @@ jobs:
uses: actions/checkout@v6
with:
submodules: recursive
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Setup Python
uses: actions/setup-python@v6
+2 -4
View File
@@ -16,8 +16,7 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
jobs:
build-debian-src:
@@ -36,8 +35,6 @@ jobs:
with:
submodules: recursive
path: meshtasticd
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Install deps
shell: bash
@@ -70,5 +67,6 @@ jobs:
- name: Publish with dput
if: ${{ github.event_name != 'pull_request_target' && github.event_name != 'pull_request' }}
timeout-minutes: 15 # dput is terrible, sometimes runs 'forever'
run: |
dput ${{ inputs.ppa_repo }} meshtasticd_${{ steps.version.outputs.deb }}~${{ inputs.series }}_source.changes
-7
View File
@@ -16,8 +16,6 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
submodules: recursive
- name: Setup native build
@@ -72,8 +70,6 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
submodules: recursive
- name: Setup native build
@@ -128,9 +124,6 @@ jobs:
if: always()
steps:
- uses: actions/checkout@v6
with:
ref: ${{github.event.pull_request.head.ref}}
repository: ${{github.event.pull_request.head.repo.full_name}}
- name: Get release version string
run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -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:
+3 -3
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
{
"build": {
"arduino": {
"ldscript": "nrf52840_s140_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DNRF52840_XXAA",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x4405"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x2886", "0x1667"]
],
"usb_product": "HT-n5262G",
"mcu": "nrf52840",
"variant": "heltec_mesh_node_t096",
"variants_dir": "variants",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS140",
"sd_name": "s140",
"sd_version": "6.1.1",
"sd_fwid": "0x00B6"
},
"bootloader": {
"settings_addr": "0xFF000"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52840_xxAA",
"onboard_tools": ["jlink"],
"svd_path": "nrf52840.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino"],
"name": "Heltec nrf (Adafruit BSP)",
"upload": {
"maximum_ram_size": 248832,
"maximum_size": 815104,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
"use_1200bps_touch": true,
"require_upload_port": true,
"wait_for_upload_port": true
},
"url": "https://heltec.org/",
"vendor": "Heltec"
}
+7 -2
View File
@@ -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
+2 -1
View File
@@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC
bool usePreset)
{
// If use_preset is false, always return "Custom"
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
// rely on this being a stable literal for channel-name hashing and default-channel detection.
if (!usePreset) {
return "Custom";
}
+1 -1
View File
@@ -345,7 +345,7 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...)
return;
}
void RedirectablePrint::hexDump(const char *logLevel, unsigned char *buf, uint16_t len)
void RedirectablePrint::hexDump(const char *logLevel, const unsigned char *buf, uint16_t len)
{
const char alphabet[17] = "0123456789abcdef";
log(logLevel, " +------------------------------------------------+ +----------------+");
+1 -1
View File
@@ -44,7 +44,7 @@ class RedirectablePrint : public Print
/** like printf but va_list based */
size_t vprintf(const char *logLevel, const char *format, va_list arg);
void hexDump(const char *logLevel, unsigned char *buf, uint16_t len);
void hexDump(const char *logLevel, const unsigned char *buf, uint16_t len);
std::string mt_sprintf(const std::string fmt_str, ...);
+17
View File
@@ -78,6 +78,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// Configuration
// -----------------------------------------------------------------------------
// Pre-hop drop handling (compile-time flag).
#ifndef MESHTASTIC_PREHOP_DROP
#define MESHTASTIC_PREHOP_DROP 0
#endif
/// Convert a preprocessor name into a quoted string
#define xstr(s) ystr(s)
#define ystr(s) #s
@@ -151,8 +156,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#ifdef USE_KCT8103L_PA
// Power Amps are often non-linear, so we can use an array of values for the power curve
#if defined(HELTEC_WIRELESS_TRACKER_V2)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 12, 11, 10, 9, 8, 7
#elif defined(HELTEC_MESH_NODE_T096)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7
#else
// If a board enables USE_KCT8103L_PA but does not match a known variant and has
// not already provided a PA curve, fail at compile time to avoid unsafe defaults.
#if !defined(NUM_PA_POINTS) || !defined(TX_GAIN_LORA)
#error "USE_KCT8103L_PA is defined, but no PA gain curve (NUM_PA_POINTS / TX_GAIN_LORA) is configured for this board."
#endif
#endif
#endif
#ifdef RAK13302
@@ -499,6 +515,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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
+3 -1
View File
@@ -341,7 +341,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
type = DPS310;
break;
}
break;
if (type == DPS310) {
break;
}
default:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // GET_ID
switch (registerValue) {
+8
View File
@@ -103,6 +103,14 @@ static int32_t gpsSwitch()
if (gps) {
int currentState = digitalRead(PIN_GPS_SWITCH);
// Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS.
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
gps->disable();
lastState = currentState;
firstrun = false;
return 1000;
}
// if the switch is set to zero, disable the GPS Thread
if (firstrun)
if (currentState == LOW)
+10 -12
View File
@@ -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<EINK_DISPLAY_MODEL, EINK_DISPLAY_MODEL::HEIGHT>(*lowLevel);
// Init GxEPD2
adafruitDisplay->init();
adafruitDisplay->setRotation(1);
#elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213)
// Detect display model, before starting SPI
+3 -3
View File
@@ -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
+434
View File
@@ -0,0 +1,434 @@
#include "configuration.h"
#if HAS_SCREEN
#include "graphics/EmoteRenderer.h"
#include <algorithm>
#include <cstring>
namespace graphics
{
namespace EmoteRenderer
{
static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len)
{
#if defined(OLED_UA) || defined(OLED_RU)
return display->getStringWidth(text, len, true);
#else
(void)len;
return display->getStringWidth(text);
#endif
}
size_t utf8CharLen(uint8_t c)
{
if ((c & 0xE0) == 0xC0)
return 2;
if ((c & 0xF0) == 0xE0)
return 3;
if ((c & 0xF8) == 0xF0)
return 4;
return 1;
}
static inline bool isPossibleEmoteLead(uint8_t c)
{
// All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges.
return c == 0xE2 || c == 0xF0;
}
static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len)
{
char chunk[5] = {0, 0, 0, 0, 0};
if (len > 4)
len = 4;
memcpy(chunk, text, len);
return getStringWidth(display, chunk, len);
}
static inline bool isFE0FAt(const char *s, size_t pos, size_t len)
{
return pos + 2 < len && static_cast<uint8_t>(s[pos]) == 0xEF && static_cast<uint8_t>(s[pos + 1]) == 0xB8 &&
static_cast<uint8_t>(s[pos + 2]) == 0x8F;
}
static inline bool isSkinToneAt(const char *s, size_t pos, size_t len)
{
return pos + 3 < len && static_cast<uint8_t>(s[pos]) == 0xF0 && static_cast<uint8_t>(s[pos + 1]) == 0x9F &&
static_cast<uint8_t>(s[pos + 2]) == 0x8F &&
(static_cast<uint8_t>(s[pos + 3]) >= 0xBB && static_cast<uint8_t>(s[pos + 3]) <= 0xBF);
}
static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len)
{
// Skip modifiers that do not change which bitmap we render.
if (isFE0FAt(s, pos, len))
return 3;
if (isSkinToneAt(s, pos, len))
return 4;
return 0;
}
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount)
{
if (!label || !*label)
return nullptr;
for (int i = 0; i < emoteCount; ++i) {
if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0)
return &emoteSet[i];
}
return nullptr;
}
static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed,
size_t &matchScore)
{
// Treat FE0F and skin-tone modifiers as optional while matching.
textConsumed = 0;
matchScore = 0;
if (!label || !*label || pos >= textLen)
return false;
const size_t labelLen = strlen(label);
size_t ti = pos;
size_t li = 0;
while (true) {
while (ti < textLen) {
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
if (!skipLen)
break;
ti += skipLen;
}
while (li < labelLen) {
const size_t skipLen = ignorableModifierLenAt(label, li, labelLen);
if (!skipLen)
break;
li += skipLen;
}
if (li >= labelLen) {
while (ti < textLen) {
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
if (!skipLen)
break;
ti += skipLen;
}
textConsumed = ti - pos;
return textConsumed > 0;
}
if (ti >= textLen)
return false;
const uint8_t tc = static_cast<uint8_t>(text[ti]);
const uint8_t lc = static_cast<uint8_t>(label[li]);
const size_t tlen = utf8CharLen(tc);
const size_t llen = utf8CharLen(lc);
if (tlen != llen || ti + tlen > textLen || li + llen > labelLen)
return false;
if (memcmp(text + ti, label + li, tlen) != 0)
return false;
ti += tlen;
li += llen;
matchScore += llen;
}
}
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount)
{
// Prefer the longest matching label at this byte offset.
const Emote *matched = nullptr;
matchLen = 0;
size_t bestScore = 0;
if (!text || pos >= textLen)
return nullptr;
if (!isPossibleEmoteLead(static_cast<uint8_t>(text[pos])))
return nullptr;
for (int i = 0; i < emoteCount; ++i) {
const char *label = emoteSet[i].label;
if (!label || !*label)
continue;
if (static_cast<uint8_t>(label[0]) != static_cast<uint8_t>(text[pos]))
continue;
const size_t labelLen = strlen(label);
if (labelLen == 0)
continue;
size_t candidateLen = 0;
size_t candidateScore = 0;
if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) {
candidateLen = labelLen;
candidateScore = labelLen;
} else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) {
// Matched with FE0F/skin tone modifiers treated as optional.
} else {
continue;
}
if (candidateScore > bestScore) {
matched = &emoteSet[i];
matchLen = candidateLen;
bestScore = candidateScore;
}
}
return matched;
}
static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight,
const Emote *emoteSet, int emoteCount, int emoteSpacing)
{
// Scan once to collect width and tallest emote for this line.
LineMetrics metrics{0, fallbackHeight, false};
if (!line)
return metrics;
for (size_t i = 0; i < lineLen;) {
size_t matchLen = 0;
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
if (matched) {
metrics.hasEmote = true;
metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height);
if (display)
metrics.width += matched->width + emoteSpacing;
i += matchLen;
continue;
}
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
if (skipLen) {
i += skipLen;
continue;
}
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
if (display)
metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
i += charLen;
}
return metrics;
}
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount,
int emoteSpacing)
{
return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing);
}
int maxEmoteHeight(const Emote *emoteSet, int emoteCount)
{
int tallest = 0;
for (int i = 0; i < emoteCount; ++i) {
if (emoteSet[i].label && *emoteSet[i].label)
tallest = std::max(tallest, emoteSet[i].height);
}
return tallest;
}
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing)
{
if (!display)
return 0;
if (!line || !*line)
return 0;
return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width;
}
static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw,
bool fauxBold)
{
// Draw plain-text runs in chunks so UTF-8 stays intact.
if (!text || len == 0)
return cursorX;
char chunk[33];
size_t pos = 0;
while (pos < len) {
size_t chunkLen = 0;
while (pos + chunkLen < len) {
const size_t charLen = utf8CharLen(static_cast<uint8_t>(text[pos + chunkLen]));
if (chunkLen + charLen >= sizeof(chunk))
break;
chunkLen += charLen;
}
if (chunkLen == 0) {
chunkLen = std::min(len - pos, sizeof(chunk) - 1);
}
memcpy(chunk, text + pos, chunkLen);
chunk[chunkLen] = '\0';
if (draw) {
if (fauxBold)
display->drawString(cursorX + 1, fontY, chunk);
display->drawString(cursorX, fontY, chunk);
}
cursorX += getStringWidth(display, chunk, chunkLen);
pos += chunkLen;
}
return cursorX;
}
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis,
const Emote *emoteSet, int emoteCount, int emoteSpacing)
{
if (!out || outSize == 0)
return 0;
out[0] = '\0';
if (!display || !line || maxWidth <= 0)
return 0;
const size_t lineLen = strlen(line);
const int suffixWidth =
(ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0;
const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : "";
const size_t suffixLen = strlen(suffix);
const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0);
if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) {
strncpy(out, line, outSize - 1);
out[outSize - 1] = '\0';
return strlen(out);
}
int used = 0;
size_t cut = 0;
for (size_t i = 0; i < lineLen;) {
// Keep whole emotes together when deciding where to cut.
int tokenWidth = 0;
size_t advance = 0;
if (isPossibleEmoteLead(static_cast<uint8_t>(line[i]))) {
size_t matchLen = 0;
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
if (matched) {
tokenWidth = matched->width + emoteSpacing;
advance = matchLen;
}
}
if (advance == 0) {
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
if (skipLen) {
i += skipLen;
cut = i;
continue;
}
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
tokenWidth = getUtf8ChunkWidth(display, line + i, charLen);
advance = charLen;
}
if (used + tokenWidth > availableWidth)
break;
used += tokenWidth;
i += advance;
cut = i;
}
if (cut == 0) {
strncpy(out, suffix, outSize - 1);
out[outSize - 1] = '\0';
return strlen(out);
}
size_t copyLen = cut;
if (copyLen > outSize - 1)
copyLen = outSize - 1;
if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) {
copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0;
}
memcpy(out, line, copyLen);
size_t totalLen = copyLen;
if (suffixLen > 0 && totalLen < outSize - 1) {
memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen));
totalLen += std::min(suffixLen, outSize - 1 - totalLen);
}
out[totalLen] = '\0';
return totalLen;
}
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet,
int emoteCount, int emoteSpacing, bool fauxBold)
{
if (!line)
return;
const size_t lineLen = strlen(line);
// Center text vertically when any emote is taller than the font.
const int maxIconHeight =
analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight;
const int lineHeight = std::max(fontHeight, maxIconHeight);
const int fontY = y + (lineHeight - fontHeight) / 2;
int cursorX = x;
bool inBold = false;
for (size_t i = 0; i < lineLen;) {
// Toggle faux bold.
if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') {
inBold = !inBold;
i += 2;
continue;
}
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
if (skipLen) {
i += skipLen;
continue;
}
size_t matchLen = 0;
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
if (matched) {
const int iconY = y + (lineHeight - matched->height) / 2;
display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap);
cursorX += matched->width + emoteSpacing;
i += matchLen;
continue;
}
size_t next = i;
while (next < lineLen) {
// Stop the text run before the next emote or bold marker.
if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*')
break;
if (ignorableModifierLenAt(line, next, lineLen))
break;
size_t nextMatchLen = 0;
if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr)
break;
next += utf8CharLen(static_cast<uint8_t>(line[next]));
}
if (next == i)
next += utf8CharLen(static_cast<uint8_t>(line[i]));
cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
i = next;
}
}
} // namespace EmoteRenderer
} // namespace graphics
#endif // HAS_SCREEN
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN
#include "graphics/emotes.h"
#include <Arduino.h>
#include <OLEDDisplay.h>
#include <string>
#include <vector>
namespace graphics
{
namespace EmoteRenderer
{
struct LineMetrics {
int width;
int tallestHeight;
bool hasEmote;
};
size_t utf8CharLen(uint8_t c);
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet = emotes, int emoteCount = numEmotes);
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
int emoteCount = numEmotes);
inline const Emote *findEmoteAt(const std::string &text, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
int emoteCount = numEmotes)
{
return findEmoteAt(text.c_str(), text.length(), pos, matchLen, emoteSet, emoteCount);
}
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight = 0, const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1);
inline LineMetrics analyzeLine(OLEDDisplay *display, const std::string &line, int fallbackHeight = 0,
const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1)
{
return analyzeLine(display, line.c_str(), fallbackHeight, emoteSet, emoteCount, emoteSpacing);
}
int maxEmoteHeight(const Emote *emoteSet = emotes, int emoteCount = numEmotes);
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet = emotes, int emoteCount = numEmotes,
int emoteSpacing = 1);
inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1)
{
return measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing);
}
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
const char *ellipsis = "...", const Emote *emoteSet = emotes, int emoteCount = numEmotes,
int emoteSpacing = 1);
inline std::string truncateToWidth(OLEDDisplay *display, const std::string &line, int maxWidth,
const std::string &ellipsis = "...", const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1)
{
if (!display || maxWidth <= 0)
return "";
if (measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing) <= maxWidth)
return line;
std::vector<char> out(line.length() + ellipsis.length() + 1, '\0');
truncateToWidth(display, line.c_str(), out.data(), out.size(), maxWidth, ellipsis.c_str(), emoteSet, emoteCount,
emoteSpacing);
return std::string(out.data());
}
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1, bool fauxBold = true);
inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1,
bool fauxBold = true)
{
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSet, emoteCount, emoteSpacing, fauxBold);
}
} // namespace EmoteRenderer
} // namespace graphics
#endif // HAS_SCREEN
+5 -6
View File
@@ -121,11 +121,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
}
// === Screen Title ===
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(SCREEN_WIDTH / 2, y, titleStr);
if (config.display.heading_bold) {
display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr);
}
const char *headerTitle = titleStr ? titleStr : "";
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
}
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -515,4 +514,4 @@ std::string sanitizeString(const std::string &input)
}
} // namespace graphics
#endif
#endif
+9 -9
View File
@@ -1348,7 +1348,7 @@ void TFTDisplay::sendCommand(uint8_t com)
digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON);
#elif defined(HACKADAY_COMMUNICATOR)
tft->displayOn();
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE)
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
tft->wakeup();
tft->powerSaveOff();
#endif
@@ -1359,7 +1359,7 @@ void TFTDisplay::sendCommand(uint8_t com)
#ifdef UNPHONE
unphone.backlight(true); // using unPhone library
#endif
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
#elif !defined(M5STACK) && !defined(ST7789_CS) && \
!defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function
tft->setBrightness(172);
@@ -1375,7 +1375,7 @@ void TFTDisplay::sendCommand(uint8_t com)
digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON);
#elif defined(HACKADAY_COMMUNICATOR)
tft->displayOff();
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE)
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
tft->sleep();
tft->powerSaveOn();
#endif
@@ -1386,7 +1386,7 @@ void TFTDisplay::sendCommand(uint8_t com)
#ifdef UNPHONE
unphone.backlight(false); // using unPhone library
#endif
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
tft->setBrightness(0);
#endif
@@ -1401,7 +1401,7 @@ void TFTDisplay::sendCommand(uint8_t com)
void TFTDisplay::setDisplayBrightness(uint8_t _brightness)
{
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
// todo
#elif !defined(HACKADAY_COMMUNICATOR)
tft->setBrightness(_brightness);
@@ -1421,7 +1421,7 @@ bool TFTDisplay::hasTouch(void)
{
#ifdef RAK14014
return true;
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
return tft->touch() != nullptr;
#else
return false;
@@ -1440,7 +1440,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
} else {
return false;
}
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
return tft->getTouch(x, y);
#else
return false;
@@ -1457,7 +1457,7 @@ bool TFTDisplay::connect()
{
concurrency::LockGuard g(spiLock);
LOG_INFO("Do TFT init");
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
tft = new TFT_eSPI;
#elif defined(HACKADAY_COMMUNICATOR)
bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */);
@@ -1494,7 +1494,7 @@ bool TFTDisplay::connect()
ft6336u.begin();
pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SCREEN_TOUCH_INT), rak14014_tpIntHandle, FALLING);
#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2)
#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) || defined(HELTEC_MESH_NODE_T096)
tft->setRotation(1); // T-Deck has the TFT in landscape
#elif defined(T_WATCH_S3)
tft->setRotation(2); // T-Watch S3 left-handed orientation
+11 -2
View File
@@ -408,7 +408,16 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
display->drawString(nameX, getTextPositions(display)[line++], device_role);
// === Third Row: Radio Preset ===
auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset);
// For custom modem settings show the actual parameters; for presets use the preset name.
char modeStr[16];
if (!config.lora.use_preset) {
snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast<unsigned>(config.lora.bandwidth),
static_cast<unsigned>(config.lora.spread_factor), static_cast<unsigned>(config.lora.coding_rate));
} else {
strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true),
sizeof(modeStr) - 1);
modeStr[sizeof(modeStr) - 1] = '\0';
}
char regionradiopreset[25];
const char *region = myRegion ? myRegion->name : NULL;
@@ -416,7 +425,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
if (currentResolution == ScreenResolution::UltraLow) {
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region);
} else {
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode);
snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr);
}
}
textWidth = display->getStringWidth(regionradiopreset);
+24 -25
View File
@@ -18,6 +18,7 @@
#include "main.h"
#include "mesh/Default.h"
#include "mesh/MeshTypes.h"
#include "mesh/RadioLibInterface.h"
#include "modules/AdminModule.h"
#include "modules/CannedMessageModule.h"
#include "modules/ExternalNotificationModule.h"
@@ -25,6 +26,7 @@
#include "modules/TraceRouteModule.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <functional>
#include <utility>
@@ -159,31 +161,20 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return;
}
// Guard: without a reboot, reconfigure() applies the region directly.
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot.
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 &&
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) {
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring LORA_24 selection");
return;
}
config.lora.region = selectedRegion;
auto changes = SEGMENT_CONFIG;
// FIXME: This should be a method consolidated with the same logic in the admin message as well
// This is needed as we wait til picking the LoRa region to generate keys for the first time.
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (!owner.is_licensed) {
bool keygenSuccess = false;
if (config.security.private_key.size == 32) {
// public key is derived from private, so this will always have the same result.
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
keygenSuccess = true;
}
} else {
LOG_INFO("Generate new PKI keys");
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
keygenSuccess = true;
}
if (keygenSuccess) {
config.security.public_key.size = 32;
config.security.private_key.size = 32;
owner.public_key.size = 32;
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
}
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
config.lora.tx_enabled = true;
@@ -199,7 +190,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
}
service->reloadConfig(changes);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
});
bannerOptions.durationMs = duration;
@@ -265,13 +255,24 @@ void menuHandler::FrequencySlotPicker()
optionsEnumArray[options++] = 0;
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
: bwCodeToKHz(loraConfig.bandwidth);
uint32_t numChannels = 0;
if (myRegion) {
numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0)));
// Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round()
const double spacing = myRegion->profile->spacing;
const double padding = myRegion->profile->padding;
const double channelBandwidthMHz = bw / 1000.0;
const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing;
const double denominator = spacing + (padding * 2) + channelBandwidthMHz;
if (denominator > 0.0) {
numChannels = static_cast<uint32_t>(round(numerator / denominator));
} else {
LOG_WARN("Invalid region configuration: non-positive channel spacing/width");
}
} else {
LOG_WARN("Region not set, cannot calculate number of channels");
return;
@@ -307,7 +308,6 @@ void menuHandler::FrequencySlotPicker()
config.lora.channel_num = selected;
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
};
screen->showOverlayBanner(bannerOptions);
@@ -346,7 +346,6 @@ void menuHandler::radioPresetPicker()
config.lora.channel_num = 0; // Reset to default channel for the preset
config.lora.override_frequency = 0; // Clear any custom frequency
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
});
screen->showOverlayBanner(bannerOptions);
+75 -253
View File
@@ -7,6 +7,7 @@
#include "NodeDB.h"
#include "UIRenderer.h"
#include "gps/RTC.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
@@ -34,44 +35,6 @@ static std::vector<std::string> cachedLines;
static std::vector<int> cachedHeights;
static bool manualScrolling = false;
// UTF-8 skip helper
static inline size_t utf8CharLen(uint8_t c)
{
if ((c & 0xE0) == 0xC0)
return 2;
if ((c & 0xF0) == 0xE0)
return 3;
if ((c & 0xF8) == 0xF0)
return 4;
return 1;
}
// Remove variation selectors (FE0F) and skin tone modifiers from emoji so they match your labels
static std::string normalizeEmoji(const std::string &s)
{
std::string out;
for (size_t i = 0; i < s.size();) {
uint8_t c = static_cast<uint8_t>(s[i]);
size_t len = utf8CharLen(c);
if (c == 0xEF && i + 2 < s.size() && (uint8_t)s[i + 1] == 0xB8 && (uint8_t)s[i + 2] == 0x8F) {
i += 3;
continue;
}
// Skip skin tone modifiers
if (c == 0xF0 && i + 3 < s.size() && (uint8_t)s[i + 1] == 0x9F && (uint8_t)s[i + 2] == 0x8F &&
((uint8_t)s[i + 3] >= 0xBB && (uint8_t)s[i + 3] <= 0xBF)) {
i += 4;
continue;
}
out.append(s, i, len);
i += len;
}
return out;
}
// Scroll state (file scope so we can reset on new message)
float scrollY = 0.0f;
uint32_t lastTime = 0;
@@ -110,102 +73,7 @@ void scrollDown()
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
int cursorX = x;
const int fontHeight = FONT_HEIGHT_SMALL;
// Step 1: Find tallest emote in the line
int maxIconHeight = fontHeight;
for (size_t i = 0; i < line.length();) {
bool matched = false;
for (int e = 0; e < emoteCount; ++e) {
size_t emojiLen = strlen(emotes[e].label);
if (line.compare(i, emojiLen, emotes[e].label) == 0) {
if (emotes[e].height > maxIconHeight)
maxIconHeight = emotes[e].height;
i += emojiLen;
matched = true;
break;
}
}
if (!matched) {
i += utf8CharLen(static_cast<uint8_t>(line[i]));
}
}
// Step 2: Baseline alignment
int lineHeight = std::max(fontHeight, maxIconHeight);
int baselineOffset = (lineHeight - fontHeight) / 2;
int fontY = y + baselineOffset;
// Step 3: Render line in segments
size_t i = 0;
bool inBold = false;
while (i < line.length()) {
// Check for ** start/end for faux bold
if (line.compare(i, 2, "**") == 0) {
inBold = !inBold;
i += 2;
continue;
}
// Look ahead for the next emote match
size_t nextEmotePos = std::string::npos;
const Emote *matchedEmote = nullptr;
size_t emojiLen = 0;
for (int e = 0; e < emoteCount; ++e) {
size_t pos = line.find(emotes[e].label, i);
if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) {
nextEmotePos = pos;
matchedEmote = &emotes[e];
emojiLen = strlen(emotes[e].label);
}
}
// Render normal text segment up to the emote or bold toggle
size_t nextControl = std::min(nextEmotePos, line.find("**", i));
if (nextControl == std::string::npos)
nextControl = line.length();
if (nextControl > i) {
std::string textChunk = line.substr(i, nextControl - i);
if (inBold) {
// Faux bold: draw twice, offset by 1px
display->drawString(cursorX + 1, fontY, textChunk.c_str());
}
display->drawString(cursorX, fontY, textChunk.c_str());
#if defined(OLED_UA) || defined(OLED_RU)
cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true);
#else
cursorX += display->getStringWidth(textChunk.c_str());
#endif
i = nextControl;
continue;
}
// Render the emote (if found)
if (matchedEmote && i == nextEmotePos) {
int iconY = y + (lineHeight - matchedEmote->height) / 2;
display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap);
cursorX += matchedEmote->width + 1;
i += emojiLen;
continue;
} else {
// No more emotes — render the rest of the line
std::string remaining = line.substr(i);
if (inBold) {
display->drawString(cursorX + 1, fontY, remaining.c_str());
}
display->drawString(cursorX, fontY, remaining.c_str());
#if defined(OLED_UA) || defined(OLED_RU)
cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true);
#else
cursorX += display->getStringWidth(remaining.c_str());
#endif
break;
}
}
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
}
// Reset scroll state when new messages arrive
@@ -377,32 +245,7 @@ static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8)
static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount)
{
std::string normalized = normalizeEmoji(line);
int totalWidth = 0;
size_t i = 0;
while (i < normalized.length()) {
bool matched = false;
for (int e = 0; e < emoteCount; ++e) {
size_t emojiLen = strlen(emotes[e].label);
if (normalized.compare(i, emojiLen, emotes[e].label) == 0) {
totalWidth += emotes[e].width + 1; // +1 spacing
i += emojiLen;
matched = true;
break;
}
}
if (!matched) {
size_t charLen = utf8CharLen(static_cast<uint8_t>(normalized[i]));
#if defined(OLED_UA) || defined(OLED_RU)
totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str(), charLen, true);
#else
totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str());
#endif
i += charLen;
}
}
return totalWidth;
return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width;
}
struct MessageBlock {
@@ -417,13 +260,7 @@ static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool i
return lineTopY + (FONT_HEIGHT_SMALL - 1);
}
int tallest = FONT_HEIGHT_SMALL;
for (int e = 0; e < numEmotes; ++e) {
if (line.find(emotes[e].label) != std::string::npos) {
if (emotes[e].height > tallest)
tallest = emotes[e].height;
}
}
const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight;
const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest);
const int iconTop = lineTopY + (lineHeight - tallest) / 2;
@@ -536,30 +373,28 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH;
// Title string depending on mode
static char titleBuf[32];
const char *titleStr = "Messages";
char titleStr[48];
snprintf(titleStr, sizeof(titleStr), "Messages");
switch (currentMode) {
case ThreadMode::ALL:
titleStr = "Messages";
snprintf(titleStr, sizeof(titleStr), "Messages");
break;
case ThreadMode::CHANNEL: {
const char *cname = channels.getName(currentChannel);
if (cname && cname[0]) {
snprintf(titleBuf, sizeof(titleBuf), "#%s", cname);
snprintf(titleStr, sizeof(titleStr), "#%s", cname);
} else {
snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel);
snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel);
}
titleStr = titleBuf;
break;
}
case ThreadMode::DIRECT: {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
if (node && node->has_user) {
snprintf(titleBuf, sizeof(titleBuf), "@%s", node->user.short_name);
if (node && node->has_user && node->user.short_name[0]) {
snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name);
} else {
snprintf(titleBuf, sizeof(titleBuf), "@%08x", currentPeer);
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
}
titleStr = titleBuf;
break;
}
}
@@ -666,44 +501,50 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
char senderBuf[48] = "";
char senderName[64] = "";
if (node && node->has_user) {
// Use long name if present
strncpy(senderBuf, node->user.long_name, sizeof(senderBuf) - 1);
senderBuf[sizeof(senderBuf) - 1] = '\0';
} else {
// No long/short name → show NodeID in parentheses
snprintf(senderBuf, sizeof(senderBuf), "(%08x)", m.sender);
if (node->user.long_name[0]) {
strncpy(senderName, node->user.long_name, sizeof(senderName) - 1);
} else if (node->user.short_name[0]) {
strncpy(senderName, node->user.short_name, sizeof(senderName) - 1);
}
senderName[sizeof(senderName) - 1] = '\0';
}
if (!senderName[0]) {
snprintf(senderName, sizeof(senderName), "(%08x)", m.sender);
}
// If this is *our own* message, override senderBuf to who the recipient was
// If this is *our own* message, override senderName to who the recipient was
bool mine = (m.sender == nodeDB->getNodeNum());
if (mine && node_recipient && node_recipient->has_user) {
strcpy(senderBuf, node_recipient->user.long_name);
if (node_recipient->user.long_name[0]) {
strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1);
senderName[sizeof(senderName) - 1] = '\0';
} else if (node_recipient->user.short_name[0]) {
strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1);
senderName[sizeof(senderName) - 1] = '\0';
}
}
// If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
if (mine && (!node_recipient || !node_recipient->has_user ||
(!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) {
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
}
// Shrink Sender name if needed
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
display->getStringWidth(chanType) - display->getStringWidth(" @...");
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @...");
if (availWidth < 0)
availWidth = 0;
size_t origLen = strlen(senderBuf);
while (senderBuf[0] && display->getStringWidth(senderBuf) > availWidth) {
senderBuf[strlen(senderBuf) - 1] = '\0';
}
// If we actually truncated, append "..."
if (strlen(senderBuf) < origLen) {
strcat(senderBuf, "...");
}
char truncatedSender[64];
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
// Final header line
char headerStr[96];
char headerStr[128];
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, senderBuf);
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
} else {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
}
@@ -711,11 +552,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
}
} else {
snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, senderBuf, chanType);
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType);
}
// Push header line
allLines.push_back(std::string(headerStr));
allLines.push_back(headerStr);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
@@ -816,13 +657,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
topY = visualTop - BUBBLE_PAD_TOP_HEADER;
} else {
// Body start
bool thisLineHasEmote = false;
for (int e = 0; e < numEmotes; ++e) {
if (cachedLines[b.start].find(emotes[e].label) != std::string::npos) {
thisLineHasEmote = true;
break;
}
}
const bool thisLineHasEmote =
graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote;
if (thisLineHasEmote) {
constexpr int EMOTE_PADDING_ABOVE = 4;
visualTop -= EMOTE_PADDING_ABOVE;
@@ -851,7 +687,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
for (size_t i = b.start; i <= b.end; ++i) {
int w = 0;
if (isHeader[i]) {
w = display->getStringWidth(cachedLines[i].c_str());
w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
if (b.mine)
w += 12; // room for ACK/NACK/relay mark
} else {
@@ -907,7 +743,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
if (isHeader[i]) {
int w = display->getStringWidth(cachedLines[i].c_str());
int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
int headerX;
if (isMine[i]) {
// push header left to avoid overlap with scrollbar
@@ -917,7 +753,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
} else {
headerX = x + textIndent;
}
display->drawString(headerX, lineY, cachedLines[i].c_str());
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
false);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
@@ -1005,11 +842,7 @@ std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerS
} else {
word += ch;
std::string test = line + word;
#if defined(OLED_UA) || defined(OLED_RU)
uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true);
#else
uint16_t strWidth = display->getStringWidth(test.c_str());
#endif
uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
if (strWidth > textWidth) {
if (!line.empty())
lines.push_back(line);
@@ -1038,31 +871,20 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
std::vector<int> rowHeights;
rowHeights.reserve(lines.size());
std::vector<graphics::EmoteRenderer::LineMetrics> lineMetrics;
lineMetrics.reserve(lines.size());
for (const auto &line : lines) {
lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes));
}
for (size_t idx = 0; idx < lines.size(); ++idx) {
const auto &line = lines[idx];
const int baseHeight = FONT_HEIGHT_SMALL;
int lineHeight = baseHeight;
// Detect if THIS line or NEXT line contains an emote
bool hasEmote = false;
int tallestEmote = baseHeight;
for (int i = 0; i < numEmotes; ++i) {
if (line.find(emotes[i].label) != std::string::npos) {
hasEmote = true;
tallestEmote = std::max(tallestEmote, emotes[i].height);
}
}
bool nextHasEmote = false;
if (idx + 1 < lines.size()) {
for (int i = 0; i < numEmotes; ++i) {
if (lines[idx + 1].find(emotes[i].label) != std::string::npos) {
nextHasEmote = true;
break;
}
}
}
const int tallestEmote = lineMetrics[idx].tallestHeight;
const bool hasEmote = lineMetrics[idx].hasEmote;
const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote;
if (isHeaderVec[idx]) {
// Header line spacing
@@ -1112,22 +934,22 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
// Banner logic
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
char longName[48] = "?";
if (node && node->user.long_name) {
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
char longName[64] = "?";
if (node && node->has_user) {
if (node->user.long_name[0]) {
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
} else if (node->user.short_name[0]) {
strncpy(longName, node->user.short_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
}
}
int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20);
if (availWidth < 0)
availWidth = 0;
size_t origLen = strlen(longName);
while (longName[0] && display->getStringWidth(longName) > availWidth) {
longName[strlen(longName) - 1] = '\0';
}
if (strlen(longName) < origLen) {
strcat(longName, "...");
}
char truncatedLongName[64];
graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName),
availWidth);
const char *msgRaw = reinterpret_cast<const char *>(packet.decoded.payload.bytes);
char banner[256];
@@ -1145,8 +967,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
}
if (isAlert) {
if (longName && longName[0])
snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
if (truncatedLongName[0])
snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName);
else
strcpy(banner, "Alert Received");
} else {
@@ -1154,11 +976,11 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
if (isChannelMuted)
return;
if (longName && longName[0]) {
if (truncatedLongName[0]) {
if (currentResolution == ScreenResolution::UltraLow) {
strcpy(banner, "New Message");
} else {
snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName);
}
} else
strcpy(banner, "New Message");
@@ -1221,4 +1043,4 @@ void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet)
} // namespace MessageRenderer
} // namespace graphics
#endif
#endif
+47 -54
View File
@@ -79,13 +79,15 @@ void scrollDown()
// Utility Functions
// =============================
const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
{
static char nodeName[25]; // single static buffer we return
nodeName[0] = '\0';
(void)display;
(void)columnWidth;
auto writeFallbackId = [&] {
std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast<uint16_t>(node ? (node->num & 0xFFFF) : 0));
auto fallbackId = [&] {
char id[12];
std::snprintf(id, sizeof(id), "(%04X)", static_cast<uint16_t>(node ? (node->num & 0xFFFF) : 0));
return std::string(id);
};
// 1) Choose target candidate (long vs short) only if present
@@ -94,42 +96,10 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
}
// 2) Sanitize (empty if raw is null/empty)
std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{};
// 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed)
if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) {
writeFallbackId();
} else {
// %.*s ensures null-termination and safe truncation to buffer size - 1
std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast<int>(sizeof(nodeName) - 1), s.c_str());
}
// 4) Width-based truncation + ellipsis (long-name mode only)
if (config.display.use_long_node_name && display) {
int availWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
if (availWidth < 0)
availWidth = 0;
const size_t beforeLen = std::strlen(nodeName);
// Trim from the end until it fits or is empty
size_t len = beforeLen;
while (len && display->getStringWidth(nodeName) > availWidth) {
nodeName[--len] = '\0';
}
// If truncated, append "..." (respect buffer size)
if (len < beforeLen) {
// Make sure there's room for "..." and '\0'
const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0'
const size_t needed = 3; // "..."
if (len > capForText - needed) {
len = capForText - needed;
nodeName[len] = '\0';
}
std::strcat(nodeName, "...");
}
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
std::string nodeName = (raw && *raw) ? std::string(raw) : std::string{};
if (nodeName.empty()) {
nodeName = fallbackId();
}
return nodeName;
@@ -163,6 +133,15 @@ const char *getCurrentModeTitle_Location(int screenWidth)
}
}
static int getNodeNameMaxWidth(int columnWidth, int baseWidth)
{
if (!config.display.use_long_node_name)
return baseWidth;
const int legacyLongNameWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
return std::max(0, std::min(baseWidth, legacyLongNameWidth));
}
// Use dynamic timing based on mode
unsigned long getModeCycleIntervalMs()
{
@@ -205,10 +184,13 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth = columnWidth - 25;
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
int timeOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char timeStr[10];
@@ -228,7 +210,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawString(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -255,19 +237,22 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth = columnWidth - 25;
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
int barsXOffset = columnWidth - barsOffset;
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -312,9 +297,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth =
columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
: (isLeftCol ? 20 : 22)));
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char distStr[10] = "";
@@ -368,7 +357,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -414,14 +403,18 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
// Adjust max text width depending on column and screen width
int nameMaxWidth =
columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
: (isLeftCol ? 20 : 22)));
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -828,4 +821,4 @@ void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields
} // namespace NodeListRenderer
} // namespace graphics
#endif
#endif
+2 -1
View File
@@ -4,6 +4,7 @@
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
#include <string>
namespace graphics
{
@@ -56,7 +57,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
// Utility functions
const char *getCurrentModeTitle_Nodes(int screenWidth);
const char *getCurrentModeTitle_Location(int screenWidth);
const char *getSafeNodeName(meshtastic_NodeInfoLite *node, int columnWidth);
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth);
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
// Scrolling controls
+56 -18
View File
@@ -4,6 +4,7 @@
#include "DisplayFormatters.h"
#include "NodeDB.h"
#include "NotificationRenderer.h"
#include "UIRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/images.h"
@@ -299,7 +300,7 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
for (int i = 0; i < lineCount; i++) {
linePointers[i] = lineStarts[i];
}
char scratchLineBuffer[visibleTotalLines - lineCount][40];
char scratchLineBuffer[visibleTotalLines - lineCount][64];
uint8_t firstOptionToShow = 0;
if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) {
@@ -312,28 +313,47 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
}
int scratchLineNum = 0;
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
char temp_name[16] = {0};
if (nodeDB->getMeshNodeByIndex(i + 1)->has_user) {
std::string sanitized = sanitizeString(nodeDB->getMeshNodeByIndex(i + 1)->user.long_name);
strncpy(temp_name, sanitized.c_str(), sizeof(temp_name) - 1);
char tempName[48] = {0};
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
if (node && node->has_user) {
const char *rawName = nullptr;
if (node->user.long_name[0]) {
rawName = node->user.long_name;
} else if (node->user.short_name[0]) {
rawName = node->user.short_name;
}
if (rawName) {
const int arrowWidth = (currentResolution == ScreenResolution::High)
? UIRenderer::measureStringWithEmotes(display, "> <")
: UIRenderer::measureStringWithEmotes(display, "><");
const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth);
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth);
}
} else {
snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF));
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
}
if (!tempName[0]) {
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
}
if (i == curSelected) {
selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num;
selectedNodenum = node ? node->num : 0;
if (currentResolution == ScreenResolution::High) {
strncpy(scratchLineBuffer[scratchLineNum], "> ", 3);
strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36);
strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3);
strncpy(scratchLineBuffer[scratchLineNum] + 2, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 3);
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
strncpy(scratchLineBuffer[scratchLineNum] + used, " <", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
} else {
strncpy(scratchLineBuffer[scratchLineNum], ">", 2);
strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37);
strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2);
strncpy(scratchLineBuffer[scratchLineNum] + 1, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 2);
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
strncpy(scratchLineBuffer[scratchLineNum] + used, "<", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
}
scratchLineBuffer[scratchLineNum][39] = '\0';
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
} else {
strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39);
scratchLineBuffer[scratchLineNum][39] = '\0';
strncpy(scratchLineBuffer[scratchLineNum], tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 1);
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
}
linePointers[linesShown] = scratchLineBuffer[scratchLineNum++];
}
@@ -501,7 +521,13 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
else // if the newline wasn't found, then pull string length from strlen
lineLengths[lineCount] = strlen(lines[lineCount]);
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
if (current_notification_type == notificationTypeEnum::node_picker) {
char measureBuffer[64] = {0};
strncpy(measureBuffer, lines[lineCount], std::min<size_t>(lineLengths[lineCount], sizeof(measureBuffer) - 1));
lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer);
} else {
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
}
// Consider extra width for signal bars on lines that contain "Signal:"
uint16_t potentialWidth = lineWidths[lineCount];
@@ -607,7 +633,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
display->setColor(BLACK);
int yOffset = 3;
display->drawString(textX, lineY - yOffset, lineBuffer);
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(textX, lineY - yOffset, lineBuffer);
}
display->setColor(WHITE);
lineY += (effectiveLineHeight - 2 - background_yOffset);
} else {
@@ -626,7 +656,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
int totalWidth = textWidth + barsWidth;
int groupStartX = boxLeft + (boxWidth - totalWidth) / 2;
display->drawString(groupStartX, lineY, lineBuffer);
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(groupStartX, lineY, lineBuffer);
}
int baseX = groupStartX + textWidth + gap;
int baseY = lineY + effectiveLineHeight - 1;
@@ -642,7 +676,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
}
} else {
display->drawString(textX, lineY, lineBuffer);
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(textX, lineY, lineBuffer);
}
}
lineY += (effectiveLineHeight);
}
+79 -51
View File
@@ -8,6 +8,7 @@
#include "UIRenderer.h"
#include "airtime.h"
#include "gps/GeoCoord.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TimeFormatters.h"
#include "graphics/images.h"
@@ -313,8 +314,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
#endif
currentFavoriteNodeNum = node->num;
// === Create the shortName and title string ===
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
char titlestr[32] = {0};
const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node";
char titlestr[40];
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
// === Draw battery/time/mail header (common across screens) ===
@@ -328,7 +329,6 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
// List of available macro Y positions in order, from top to bottom.
int line = 1; // which slot to use next
std::string usernameStr;
// === 1. Long Name (always try to show first) ===
const char *username;
if (currentResolution == ScreenResolution::UltraLow) {
@@ -338,9 +338,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
}
if (username) {
usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case
// Print node's long name (e.g. "Backpack Node")
display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str());
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
}
// === 2. Signal and Hops (combined on one line, if available) ===
@@ -802,14 +801,12 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
// === Node Identity ===
int textWidth = 0;
int nameX = 0;
char shortnameble[35];
snprintf(shortnameble, sizeof(shortnameble), "%s",
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
const char *shortName = owner.short_name ? owner.short_name : "";
// === ShortName Centered ===
textWidth = display->getStringWidth(shortnameble);
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
#else
if (powerStatus->getHasBattery()) {
char batStr[20];
@@ -904,36 +901,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
int textWidth = 0;
int nameX = 0;
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
std::string longNameStr;
if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
longNameStr = sanitizeString(ourNode->user.long_name);
const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : "";
const char *shortName = owner.short_name ? owner.short_name : "";
char combinedName[96];
if (longName[0] && shortName[0]) {
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName);
} else if (longName[0]) {
strncpy(combinedName, longName, sizeof(combinedName) - 1);
combinedName[sizeof(combinedName) - 1] = '\0';
} else {
strncpy(combinedName, shortName, sizeof(combinedName) - 1);
combinedName[sizeof(combinedName) - 1] = '\0';
}
char shortnameble[35];
snprintf(shortnameble, sizeof(shortnameble), "%s",
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
char combinedName[50];
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble);
if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) {
size_t len = strlen(combinedName);
if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) {
combinedName[len - 3] = '\0'; // Remove the last three characters
}
textWidth = display->getStringWidth(combinedName);
if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(
nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName);
UIRenderer::drawStringWithEmotes(
display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset,
combinedName, FONT_HEIGHT_SMALL, 1, false);
} else {
// === LongName Centered ===
textWidth = display->getStringWidth(longNameStr.c_str());
textWidth = UIRenderer::measureStringWithEmotes(display, longName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str());
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1,
false);
// === ShortName Centered ===
textWidth = display->getStringWidth(shortnameble);
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
false);
}
#endif
graphics::drawCommonFooter(display, x, y);
@@ -1045,12 +1042,12 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
display->setTextAlignment(TEXT_ALIGN_LEFT);
const char *pauseText = "Screen Paused";
const char *idText = owner.short_name;
const bool useId = haveGlyphs(idText);
const bool useId = (idText && idText[0]);
constexpr uint8_t padding = 2;
constexpr uint8_t dividerGap = 1;
// Text widths
const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true);
const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0;
const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding;
const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2);
@@ -1075,7 +1072,7 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
// Draw: text
if (useId)
display->drawString(idTextLeft, idTextTop, idText);
UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false);
display->drawString(pauseTextLeft, pauseTextTop, pauseText);
display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold
@@ -1108,11 +1105,16 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(msgX, msgY, upperMsg);
}
// Draw version and short name in bottom middle
char buf[25];
snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT),
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf);
char footer[64];
if (owner.short_name && owner.short_name[0]) {
snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name);
} else {
snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT));
}
int footerW = UIRenderer::measureStringWithEmotes(display, footer);
int footerX = x + ((SCREEN_WIDTH - footerW) / 2);
UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1,
false);
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1130,12 +1132,15 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(x + 0, y + 0, upperMsg);
// Draw version and short name in upper right
char buf[25];
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT),
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
display->setTextAlignment(TEXT_ALIGN_RIGHT);
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
const char *version = xstr(APP_VERSION_SHORT);
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
display->drawString(versionX, y + 0, version);
if (owner.short_name && owner.short_name[0]) {
const char *shortName = owner.short_name;
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
int shortNameX = x + SCREEN_WIDTH - shortNameW;
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
}
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1365,11 +1370,15 @@ void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, O
display->drawString(x + 0, y + 0, upperMsg);
// Draw version and shortname in upper right
char buf[25];
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : "");
display->setTextAlignment(TEXT_ALIGN_RIGHT);
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
const char *version = xstr(APP_VERSION_SHORT);
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
display->drawString(versionX, y + 0, version);
if (owner.short_name && owner.short_name[0]) {
const char *shortName = owner.short_name;
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
int shortNameX = x + SCREEN_WIDTH - shortNameW;
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
}
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1558,6 +1567,25 @@ std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t mi
return uptime;
}
int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing)
{
return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing);
}
size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
const char *ellipsis, int emoteSpacing)
{
return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes,
graphics::numEmotes, emoteSpacing);
}
void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing,
bool fauxBold)
{
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes,
emoteSpacing, fauxBold);
}
} // namespace graphics
#endif // HAS_SCREEN
+23
View File
@@ -1,6 +1,7 @@
#pragma once
#include "NodeDB.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/emotes.h"
#include <OLEDDisplay.h>
@@ -80,6 +81,28 @@ class UIRenderer
static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime);
// Shared BaseUI emote helpers.
static int measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing = 1);
static inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, int emoteSpacing = 1)
{
return measureStringWithEmotes(display, line.c_str(), emoteSpacing);
}
static size_t truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
const char *ellipsis = "...", int emoteSpacing = 1);
static inline std::string truncateStringWithEmotes(OLEDDisplay *display, const std::string &line, int maxWidth,
const std::string &ellipsis = "...", int emoteSpacing = 1)
{
return graphics::EmoteRenderer::truncateToWidth(display, line, maxWidth, ellipsis, graphics::emotes, graphics::numEmotes,
emoteSpacing);
}
static void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing = 1,
bool fauxBold = true);
static inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
int emoteSpacing = 1, bool fauxBold = true)
{
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold);
}
// Check if the display can render a string (detect special chars; emoji)
static bool haveGlyphs(const char *str);
}; // namespace UIRenderer
+2 -2
View File
@@ -10,7 +10,7 @@ const uint8_t ArialMT_Plain_10_RU[] PROGMEM = {
0xE0, // Number of chars: 224
// Jump Table:
0xFF, 0xFF, 0x00, 0x0A, // 32
0xFF, 0xFF, 0x00, 0x03, // 32
0x00, 0x00, 0x04, 0x03, // 33
0x00, 0x04, 0x05, 0x04, // 34
0x00, 0x09, 0x09, 0x06, // 35
@@ -1766,4 +1766,4 @@ const uint8_t ArialMT_Plain_24_RU[] PROGMEM = {
0x3F, // 255
};
#endif // OLED_RU
#endif // OLED_RU
+2 -2
View File
@@ -9,7 +9,7 @@ const uint8_t ArialMT_Plain_10_UA[] PROGMEM = {
0x20, // First char: 32
0xE0, // Number of chars: 224
// Jump Table:
0xFF, 0xFF, 0x00, 0x0A, // 32
0xFF, 0xFF, 0x00, 0x03, // 32
0x00, 0x00, 0x04, 0x03, // 33
0x00, 0x04, 0x05, 0x04, // 34
0x00, 0x09, 0x09, 0x06, // 35
@@ -1924,4 +1924,4 @@ const uint8_t ArialMT_Plain_24_UA[] PROGMEM = {
0xFF, // 1103
};
#endif // OLED_UA
#endif // OLED_UA
@@ -0,0 +1,178 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./GDEW0102T4.h"
#include <cstring>
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<uint16_t>(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
@@ -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
+203
View File
@@ -0,0 +1,203 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./UC8175.h"
#include <cstring>
#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
+62
View File
@@ -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
@@ -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
@@ -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");
+4 -1
View File
@@ -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
#endif
+9 -3
View File
@@ -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")
@@ -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()
@@ -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
+3 -3
View File
@@ -34,7 +34,7 @@
#if defined(BUTTON_PIN_TOUCH)
ButtonThread *TouchButtonThread = nullptr;
#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN)
#if defined(PIN_EINK_EN)
static bool touchBacklightWasOn = false;
static bool touchBacklightActive = false;
#endif
@@ -220,8 +220,8 @@ void InputBroker::Init()
};
touchConfig.singlePress = INPUT_BROKER_NONE;
touchConfig.longPress = INPUT_BROKER_BACK;
#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN)
// On T-Echo Plus the touch pad should only drive the backlight, not UI navigation/sounds
#if defined(PIN_EINK_EN)
// Touch pad drives the backlight on devices with e-ink backlight pin
touchConfig.longPress = INPUT_BROKER_NONE;
touchConfig.suppressLeadUpSound = true;
touchConfig.onPress = []() {
+9 -1
View File
@@ -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();
}
}
+27
View File
@@ -61,6 +61,33 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
}
return true;
}
bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
{
if (user.is_licensed) {
return false;
}
bool keygenSuccess = false;
if (security.private_key.size == 32) {
if (regeneratePublicKey(security.public_key.bytes, security.private_key.bytes)) {
keygenSuccess = true;
}
} else {
LOG_INFO("Generate new PKI keys");
generateKeyPair(security.public_key.bytes, security.private_key.bytes);
keygenSuccess = true;
}
if (keygenSuccess) {
security.public_key.size = 32;
security.private_key.size = 32;
user.public_key.size = 32;
memcpy(user.public_key.bytes, security.public_key.bytes, 32);
}
return keygenSuccess;
}
#endif
/**
+1
View File
@@ -36,6 +36,7 @@ class CryptoEngine
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
#endif
void setDHPrivateKey(uint8_t *_private_key);
+5
View File
@@ -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
+18 -1
View File
@@ -91,10 +91,27 @@ void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p)
{
if (nodeDB)
nodeDB->updateFrom(*p);
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
// If we got a packet that is not decoded, try to decode it so we can check for traceroute.
auto decodedState = perhapsDecode(const_cast<meshtastic_MeshPacket *>(p));
if (decodedState == DecodeState::DECODE_SUCCESS) {
// parsing was successful, print for debugging
printPacket("reprocessPacket(DUP)", p);
} else {
// Fatal decoding error, we can't do anything with this packet
LOG_WARN(
"FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute",
static_cast<int>(decodedState), p->id, getFrom(p));
return;
}
}
if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP)
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
traceRouteModule->processUpgradedPacket(*p);
}
#endif
}
+5
View File
@@ -192,6 +192,11 @@ template <typename T> bool LR11x0Interface<T>::reconfigure()
err = lora.setOutputPower(power);
assert(err == RADIOLIB_ERR_NONE);
// Apply RX gain mode — valid in STDBY, matches resetAGC() pattern
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
if (err != RADIOLIB_ERR_NONE)
LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err);
startReceive(); // restart receiving
return RADIOLIB_ERR_NONE;
+5 -3
View File
@@ -25,7 +25,7 @@ void LoRaFEMInterface::init(void)
pinMode(LORA_KCT8103L_PA_CSD, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
setLnaCanControl(true);
} else if (digitalRead(LORA_KCT8103L_PA_CSD) == LOW) {
// FEM is GC1109
@@ -66,7 +66,7 @@ void LoRaFEMInterface::init(void)
pinMode(LORA_KCT8103L_PA_CSD, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
setLnaCanControl(true);
#endif
}
@@ -173,14 +173,16 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
#endif
#elif defined(USE_KCT8103L_PA)
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
if (lna_enabled) {
digitalWrite(LORA_KCT8103L_PA_CTX, LOW);
} else {
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
}
#if defined(ARCH_ESP32)
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
#endif
#endif
}
void LoRaFEMInterface::setLNAEnable(bool enabled)
+1 -1
View File
@@ -22,7 +22,7 @@ class LoRaFEMInterface
private:
LoRaFEMType fem_type;
bool lna_enabled = false;
bool lna_enabled = true;
bool lna_can_control = false;
};
extern LoRaFEMInterface loraFEMInterface;
+37 -3
View File
@@ -5,18 +5,52 @@
#include "PointerQueue.h"
#include "configuration.h"
// Sentinel marking the end of a modem preset array
static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END =
static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(0xFF);
// Region profile: bundles the preset list with regulatory parameters shared across regions
struct RegionProfile {
const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default
float spacing; // gaps between radio channels
float padding; // padding at each side of the "operating channel"
bool audioPermitted;
bool licensedOnly; // a region profile for licensed operators only
int8_t textThrottle; // throttle for text - future expansion
int8_t positionThrottle; // throttle for location data - future expansion
int8_t telemetryThrottle; // throttle for telemetry - future expansion
uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place
};
extern const RegionProfile PROFILE_STD;
extern const RegionProfile PROFILE_EU868;
extern const RegionProfile PROFILE_UNDEF;
// extern const RegionProfile PROFILE_LITE;
// extern const RegionProfile PROFILE_NARROW;
// extern const RegionProfile PROFILE_HAM;
// Map from old region names to new region enums
struct RegionInfo {
meshtastic_Config_LoRaConfig_RegionCode code;
float freqStart;
float freqEnd;
float dutyCycle;
float spacing;
float dutyCycle; // modified by getEffectiveDutyCycle
uint8_t powerLimit; // Or zero for not set
bool audioPermitted;
bool freqSwitching;
bool wideLora;
const RegionProfile *profile;
const char *name; // EU433 etc
// Preset accessors (delegate through profile)
meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; }
const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; }
size_t getNumPresets() const
{
size_t n = 0;
while (profile->presets[n] != MODEM_PRESET_END)
n++;
return n;
}
};
extern const RegionInfo regions[];
+19 -3
View File
@@ -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");
+37 -2
View File
@@ -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)
+21
View File
@@ -114,6 +114,27 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p);
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID };
/// Classify hop_start validity for forwarding decisions.
HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p);
inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p)
{
#if !MESHTASTIC_PREHOP_DROP
(void)p;
return false;
#else
if (isFromUs(&p)) {
return false; // local-originated packets should never be dropped by pre-hop drop policy
}
return classifyHopStart(p) != HopStartStatus::VALID;
#endif
}
/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped.
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context);
enum LoadFileResult {
// Successfully opened the file
LOAD_SUCCESS = 1,
+5
View File
@@ -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);
}
+307 -134
View File
@@ -21,6 +21,7 @@
#include <assert.h>
#include <pb_decode.h>
#include <pb_encode.h>
#include <string.h>
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
@@ -32,10 +33,32 @@
#include "STM32WLE5JCInterface.h"
#endif
#define RDEF(name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, frequency_switching, wide_lora) \
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END};
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END};
static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
MODEM_PRESET_END};
// Region profiles: bundle preset list + regulatory parameters shared across regions
// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot
const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0};
const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0};
const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0};
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \
{ \
meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, \
frequency_switching, wide_lora, #name \
meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \
wide_lora, &profile_ptr, #name \
}
const RegionInfo regions[] = {
@@ -43,7 +66,7 @@ const RegionInfo regions[] = {
https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
*/
RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false),
RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD),
/*
EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21]
@@ -51,8 +74,7 @@ const RegionInfo regions[] = {
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf
FIXME: https://github.com/meshtastic/firmware/issues/3371
*/
RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false),
RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD),
/*
https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/
https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/
@@ -67,33 +89,33 @@ const RegionInfo regions[] = {
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
*/
RDEF(EU_868, 869.4f, 869.65f, 10, 0, 27, false, false, false),
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
*/
RDEF(CN, 470.0f, 510.0f, 100, 0, 19, true, false, false),
RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf
https://qiita.com/ammo0613/items/d952154f1195b64dc29f
*/
RDEF(JP, 920.5f, 923.5f, 100, 0, 13, true, false, false),
RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD),
/*
https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
Also used in Brazil.
*/
RDEF(ANZ, 915.0f, 928.0f, 100, 0, 30, true, false, false),
RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD),
/*
433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions
AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence
NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100
*/
RDEF(ANZ_433, 433.05f, 434.79f, 100, 0, 14, true, false, false),
RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD),
/*
https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf
@@ -101,13 +123,13 @@ const RegionInfo regions[] = {
Note:
- We do LBT, so 100% is allowed.
*/
RDEF(RU, 868.7f, 869.2f, 100, 0, 20, true, false, false),
RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD),
/*
https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0
https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters
*/
RDEF(KR, 920.0f, 923.0f, 100, 0, 23, true, false, false),
RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD),
/*
Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor.
@@ -115,42 +137,38 @@ const RegionInfo regions[] = {
https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF
https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283
*/
RDEF(TW, 920.0f, 925.0f, 100, 0, 27, true, false, false),
RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
*/
RDEF(IN, 865.0f, 867.0f, 100, 0, 30, true, false, false),
RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD),
/*
https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752
https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf
*/
RDEF(NZ_865, 864.0f, 868.0f, 100, 0, 36, true, false, false),
RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD),
/*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
*/
RDEF(TH, 920.0f, 925.0f, 100, 0, 16, true, false, false),
RDEF(TH, 920.0f, 925.0f, 100, 16, false, false, PROFILE_STD),
/*
433,05-434,7 Mhz 10 mW
https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
*/
RDEF(UA_433, 433.0f, 434.7f, 10, 0, 10, true, false, false),
/*
868,0-868,6 Mhz 25 mW
https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf
*/
RDEF(UA_868, 868.0f, 868.6f, 1, 0, 14, true, false, false),
RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD),
RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD),
/*
Malaysia
433 - 435 MHz at 100mW, no restrictions.
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
*/
RDEF(MY_433, 433.0f, 435.0f, 100, 0, 20, true, false, false),
RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD),
/*
Malaysia
@@ -159,14 +177,14 @@ const RegionInfo regions[] = {
Frequency hopping is used for 919 - 923 MHz.
https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf
*/
RDEF(MY_919, 919.0f, 924.0f, 100, 0, 27, true, true, false),
RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD),
/*
Singapore
SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions.
https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf
*/
RDEF(SG_923, 917.0f, 925.0f, 100, 0, 20, true, false, false),
RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD),
/*
Philippines
@@ -176,8 +194,9 @@ const RegionInfo regions[] = {
https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135
*/
RDEF(PH_433, 433.0f, 434.7f, 100, 0, 10, true, false, false), RDEF(PH_868, 868.0f, 869.4f, 100, 0, 14, true, false, false),
RDEF(PH_915, 915.0f, 918.0f, 100, 0, 24, true, false, false),
RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD),
RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD),
RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD),
/*
Kazakhstan
@@ -185,37 +204,38 @@ const RegionInfo regions[] = {
863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields
https://github.com/meshtastic/firmware/issues/7204
*/
RDEF(KZ_433, 433.075f, 434.775f, 100, 0, 10, true, false, false),
RDEF(KZ_863, 863.0f, 868.0f, 100, 0, 30, true, false, false),
RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD),
RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD),
/*
Nepal
865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use,
specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf
*/
RDEF(NP_865, 865.0f, 868.0f, 100, 0, 30, true, false, false),
RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD),
/*
Brazil
902 - 907.5 MHz , 1W power limit, no duty cycle restrictions
https://github.com/meshtastic/firmware/issues/3741
*/
RDEF(BR_902, 902.0f, 907.5f, 100, 0, 30, true, false, false),
RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD),
/*
2.4 GHZ WLAN Band equivalent. Only for SX128x chips.
*/
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 0, 10, true, false, true),
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD),
/*
This needs to be last. Same as US.
*/
RDEF(UNSET, 902.0f, 928.0f, 100, 0, 30, true, false, false)
RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF)
};
const RegionInfo *myRegion;
bool RadioInterface::uses_default_frequency_slot = true;
bool RadioInterface::uses_custom_channel_name = false;
static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1];
@@ -501,45 +521,14 @@ void initRegion()
myRegion = r;
}
void RadioInterface::bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig)
const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
{
if (!loraConfig.use_preset) {
return;
}
// Find region info to determine whether "wide" LoRa is permitted (2.4 GHz uses wider bandwidth codes).
const RegionInfo *r = regions;
for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != loraConfig.region; r++)
for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != code; r++)
;
const bool regionWideLora = r->wideLora;
float bwKHz = 0;
uint8_t sf = 0;
uint8_t cr = 0;
modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr);
// If selected preset requests a bandwidth larger than the region span, fall back to LONG_FAST.
if (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && (r->freqEnd - r->freqStart) < (bwKHz / 1000.0f)) {
loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr);
}
loraConfig.bandwidth = bwKHzToCode(bwKHz);
loraConfig.spread_factor = sf;
return r;
}
/**
* ## LoRaWAN for North America
LoRaWAN defines 64, 125 kHz channels from 902.3 to 914.9 MHz increments.
The maximum output power for North America is +30 dBM.
The band is from 902 to 928 MHz. It mentions channel number and its respective channel frequency. All the 13 channels are
separated by 2.16 MHz with respect to the adjacent channels. Channel zero starts at 903.08 MHz center frequency.
*/
uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received)
{
uint32_t pl = 0;
@@ -749,7 +738,7 @@ void RadioInterface::saveFreq(float freq)
}
/**
* Save our channel for later reuse.
* Save our frequency slot (aka channel) for later reuse.
*/
void RadioInterface::saveChannelNum(uint32_t channel_num)
{
@@ -772,113 +761,297 @@ uint32_t RadioInterface::getChannelNum()
return savedChannelNum;
}
/**
* Send an error-level client notification. Safe to call when service is null (e.g. in tests).
*/
static void sendErrorNotification(const char *msg)
{
if (!service)
return;
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
if (!cn)
return;
cn->level = meshtastic_LogRecord_Level_ERROR;
snprintf(cn->message, sizeof(cn->message), "%s", msg);
service->sendClientNotification(cn);
}
/**
* Checks if a region is valid for the current settings.
* Returns false if not compatible.
*/
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
{
const RegionInfo *newRegion = getRegion(loraConfig.region);
// If you are not licensed, you can't use ham regions.
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
char err_string[160];
snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name);
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
return false;
}
return true;
}
/**
* Internal helper: validate or clamp a LoRa config against its region.
* When clamp==false, returns false on first error (pure validation).
* When clamp==true, fixes invalid settings in-place and returns true.
*/
bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp)
{
char err_string[160];
float check_bw;
const RegionInfo *newRegion = getRegion(loraConfig.region);
const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
// Check preset validity (only when use_preset is true)
if (loraConfig.use_preset) {
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
bool preset_valid = false;
for (size_t i = 0; i < newRegion->getNumPresets(); i++) {
if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) {
preset_valid = true;
break;
}
}
if (!preset_valid) {
const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true);
if (clamp) {
snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name,
defaultName);
} else {
snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name);
}
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
if (clamp) {
loraConfig.modem_preset = newRegion->getDefaultPreset();
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
} else {
return false;
}
}
} else {
check_bw = bwCodeToKHz(loraConfig.bandwidth);
}
// Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region:
// spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band
// padding = gap at the beginning and end of the slots (0 for no padding)
float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz
uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
// Check if the region supports the requested bandwidth
if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) {
const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f;
snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw);
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
if (clamp) {
loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora));
check_bw = bwCodeToKHz(loraConfig.bandwidth);
// Recompute slot width and number of slots based on the new bandwidth
freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz
numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
} else {
return false;
}
}
const char *channelName = channels.getName(channels.getPrimaryIndex());
const char *presetNameDisplay =
DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots;
uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots;
if (loraConfig.override_frequency == 0) {
// Check if we use the default frequency slot
uses_default_frequency_slot =
(loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default
(newRegion->profile->overrideSlot != 0 &&
loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override
((newRegion->profile->overrideSlot == 0) &&
((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override
// check if user setting different to preset name
uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0);
if (loraConfig.channel_num > numFreqSlots) {
snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num,
newRegion->name, numFreqSlots);
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
if (clamp) {
if (uses_custom_channel_name) { // clamp to channel name hash
loraConfig.channel_num =
channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
} else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot
loraConfig.channel_num =
newRegion->profile->overrideSlot; // use the override slot specified by the region profile
uses_default_frequency_slot = true;
} else if (loraConfig.use_preset) { // clamp to preset slot
loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1
uses_default_frequency_slot = true;
} else { // if not using preset, and no custom channel name, just clamp to default anyway
uses_default_frequency_slot = true;
};
} else {
return false;
}
} // end of channel number check
} else {
// if we have a frequency override, we ignore the channel number and just use the override frequency
snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency);
}
return true;
}
bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig)
{
auto copy = loraConfig;
return checkOrClampConfigLora(copy, false);
}
void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig)
{
checkOrClampConfigLora(loraConfig, true);
}
/**
* Pull our channel settings etc... from protobufs to the dumb interface settings
* Note: this must be given only settings which have been validated or clamped!
*/
void RadioInterface::applyModemConfig()
{
// Set up default configuration
// No Sync Words in LORA mode
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
bool validConfig = false; // We need to check for a valid configuration
while (!validConfig) {
if (loraConfig.use_preset) {
modemPresetToParams(loraConfig.modem_preset, myRegion->wideLora, bw, sf, cr);
if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate != cr) {
cr = loraConfig.coding_rate;
LOG_INFO("Using custom Coding Rate %u", cr);
}
} else {
sf = loraConfig.spread_factor;
const RegionInfo *newRegion = getRegion(loraConfig.region);
myRegion = newRegion;
if (loraConfig.use_preset) {
if (!validateConfigLora(loraConfig)) {
loraConfig.modem_preset = newRegion->getDefaultPreset();
}
uint8_t newcr;
modemPresetToParams(loraConfig.modem_preset, newRegion->wideLora, bw, sf, newcr);
// If custom CR is being used already, check if the new preset is higher
if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) {
cr = newcr;
LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr);
}
// If the custom CR is higher than the preset, use it
else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) {
cr = loraConfig.coding_rate;
bw = bwCodeToKHz(loraConfig.bandwidth);
}
if ((myRegion->freqEnd - myRegion->freqStart) < bw / 1000) {
const float regionSpanKHz = (myRegion->freqEnd - myRegion->freqStart) * 1000.0f;
const float requestedBwKHz = bw;
const bool isWideRequest = requestedBwKHz >= 499.5f; // treat as 500 kHz preset
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset);
char err_string[160];
if (isWideRequest) {
snprintf(err_string, sizeof(err_string), "%s region too narrow for 500kHz preset (%s). Falling back to LongFast.",
myRegion->name, presetName);
} else {
snprintf(err_string, sizeof(err_string), "%s region span %.0fkHz < requested %.0fkHz. Falling back to LongFast.",
myRegion->name, regionSpanKHz, requestedBwKHz);
}
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
cn->level = meshtastic_LogRecord_Level_ERROR;
snprintf(cn->message, sizeof(cn->message), "%s", err_string);
service->sendClientNotification(cn);
// Set to default modem preset
loraConfig.use_preset = true;
loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
LOG_INFO("Using custom Coding Rate %u", cr);
} else {
validConfig = true;
cr = loraConfig.coding_rate;
}
} else { // if not using preset, then just use the custom settings
if (validateConfigLora(loraConfig)) {
} else {
LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults",
newRegion->name);
clampConfigLora(loraConfig);
}
bw = bwCodeToKHz(loraConfig.bandwidth);
sf = loraConfig.spread_factor;
cr = loraConfig.coding_rate;
}
power = loraConfig.tx_power;
if ((power == 0) || ((power > myRegion->powerLimit) && !devicestate.owner.is_licensed))
power = myRegion->powerLimit;
if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed))
power = newRegion->powerLimit;
if (power == 0)
power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of myRegion defaults
power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of newRegion defaults
// to 0, currently no region has an actual power limit of 0 [dBm] so we can assume regions which have this
// variable set to 0 don't have a valid power limit)
// Set final tx_power back onto config
loraConfig.tx_power = (int8_t)power; // cppcheck-suppress assignmentAddressToInteger
// Calculate the number of channels
uint32_t numChannels = floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000)));
uint32_t channel_num;
float freq;
// If user has manually specified a channel num, then use that, otherwise generate one by hashing the name
const char *channelName = channels.getName(channels.getPrimaryIndex());
// channel_num is actually (channel_num - 1), since modulus (%) returns values from 0 to (numChannels - 1)
uint32_t channel_num = (loraConfig.channel_num ? loraConfig.channel_num - 1 : hash(channelName)) % numChannels;
// Calculate number of frequency slots (aka Channels):
// spacing = gap between channels (0 for continuous spectrum) and at the beginning of the band
// padding = gap at the beginning and end of the channel (0 for no padding)
float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (bw / 1000); // in MHz
uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth);
// Check if we use the default frequency slot
RadioInterface::uses_default_frequency_slot =
channel_num ==
hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset)) % numChannels;
// Old frequency selection formula
// float freq = myRegion->freqStart + ((((myRegion->freqEnd - myRegion->freqStart) / numChannels) / 2) * channel_num);
// New frequency selection formula
float freq = myRegion->freqStart + (bw / 2000) + (channel_num * (bw / 1000));
// Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one.
// Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to
// (numFreqSlots - 1).
uint32_t presetNameHashSlot =
hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots;
// override if we have a verbatim frequency
if (loraConfig.override_frequency) {
freq = loraConfig.override_frequency;
channel_num = -1;
uses_default_frequency_slot = false;
} else {
// If user has not manually specified a frequency slot, or has not specified one that is different than the default or the
// override for the new region, then use the default or override. If the user has not specified one, but has specified a
// custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually
// (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1).
// NB: channel_num is also know as frequency slot but it's too late to fix now.
if (uses_default_frequency_slot) {
// if there's an override slot, use that
if (newRegion->profile->overrideSlot != 0) {
channel_num = newRegion->profile->overrideSlot - 1;
} else {
channel_num = presetNameHashSlot;
}
} else { // use the manually defined one
channel_num = loraConfig.channel_num - 1;
}
// Calculate frequency: freqStart is band edge, add half bandwidth (plus optional padding) to get middle of first channel
// subsequent channels are spaced by freqSlotWidth
freq = newRegion->freqStart + (bw / 2000) + newRegion->profile->padding + (channel_num * freqSlotWidth); // in MHz
}
saveChannelNum(channel_num);
saveFreq(freq + loraConfig.frequency_offset);
const char *channelName = channels.getName(channels.getPrimaryIndex());
slotTimeMsec = computeSlotTimeMsec();
preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw);
LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset);
LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", myRegion->name, channelName, loraConfig.modem_preset,
LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", newRegion->name, channelName, loraConfig.modem_preset,
channel_num, power);
LOG_INFO("myRegion->freqStart -> myRegion->freqEnd: %f -> %f (%f MHz)", myRegion->freqStart, myRegion->freqEnd,
myRegion->freqEnd - myRegion->freqStart);
LOG_INFO("numChannels: %d x %.3fkHz", numChannels, bw);
LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd,
newRegion->freqEnd - newRegion->freqStart);
LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw);
if (newRegion->profile->overrideSlot != 0) {
LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot);
}
LOG_INFO("channel_num: %d", channel_num + 1);
LOG_INFO("frequency: %f", getFreq());
LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec);
}
} // end of applyModemConfig
/** Slottime is the time to detect a transmission has started, consisting of:
- CAD duration;
@@ -992,4 +1165,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
sendingPacket = p;
return p->encrypted.size + sizeof(PacketHeader);
}
}
+15 -1
View File
@@ -127,7 +127,7 @@ class RadioInterface
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
*/
static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig);
// static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded?
/**
* Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving)
@@ -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()
+48 -1
View File
@@ -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);
+6 -1
View File
@@ -60,7 +60,7 @@ template <typename T> bool SX126xInterface<T>::init()
loraFEMInterface.init();
// Apply saved FEM LNA mode from config
if (loraFEMInterface.isLnaCanControl()) {
loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED);
loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
}
#endif
@@ -253,6 +253,11 @@ template <typename T> bool SX126xInterface<T>::reconfigure()
LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err);
assert(err == RADIOLIB_ERR_NONE);
// Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
if (err != RADIOLIB_ERR_NONE)
LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err);
startReceive(); // restart receiving
return RADIOLIB_ERR_NONE;
+10 -1
View File
@@ -1,7 +1,10 @@
#include "ServerAPI.h"
#include "Throttle.h"
#include "configuration.h"
#include <Arduino.h>
static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL;
template <typename T>
ServerAPI<T>::ServerAPI(T &_client) : StreamAPI(&client), concurrency::OSThread("ServerAPI"), client(_client)
{
@@ -28,6 +31,12 @@ template <typename T> bool ServerAPI<T>::checkIsConnected()
template <class T> int32_t ServerAPI<T>::runOnce()
{
if (client.connected()) {
if (lastContactMsec > 0 && !Throttle::isWithinTimespanMs(lastContactMsec, TCP_IDLE_TIMEOUT_MS)) {
LOG_WARN("TCP connection timeout, no data for %lu ms", (unsigned long)(millis() - lastContactMsec));
close();
enabled = false;
return 0;
}
return StreamAPI::runOncePart();
} else {
LOG_INFO("Client dropped connection, suspend API service");
@@ -57,7 +66,7 @@ template <class T, class U> int32_t APIServerPort<T, U>::runOnce()
#else
auto client = U::available();
#endif
#elif defined(ARCH_RP2040)
#elif defined(ARCH_RP2040) || defined(ARCH_NRF52)
auto client = U::accept();
#else
auto client = U::available();
+9
View File
@@ -17,6 +17,15 @@ void initApiServer(int port)
}
}
void deInitApiServer()
{
if (apiPort) {
LOG_INFO("Deinit API server");
delete apiPort;
apiPort = nullptr;
}
}
ethServerAPI::ethServerAPI(EthernetClient &_client) : ServerAPI(_client)
{
LOG_INFO("Incoming ethernet connection");
+1
View File
@@ -24,4 +24,5 @@ class ethServerPort : public APIServerPort<ethServerAPI, EthernetServer>
};
void initApiServer(int port = SERVER_API_DEFAULT_PORT);
void deInitApiServer();
#endif
+63
View File
@@ -32,6 +32,69 @@ static Periodic *ethEvent;
static int32_t reconnectETH()
{
if (config.network.eth_enabled) {
// Detect W5100S chip reset by verifying the MAC address register.
// PoE power instability can brownout the W5100S while the MCU keeps running,
// causing all chip registers (MAC, IP, sockets) to revert to defaults.
uint8_t currentMac[6];
Ethernet.MACAddress(currentMac);
uint8_t expectedMac[6];
getMacAddr(expectedMac);
expectedMac[0] &= 0xfe;
if (memcmp(currentMac, expectedMac, 6) != 0) {
LOG_WARN("W5100S MAC mismatch (chip reset detected), reinitializing Ethernet");
syslog.disable();
#if !MESHTASTIC_EXCLUDE_SOCKETAPI
deInitApiServer();
#endif
#if HAS_UDP_MULTICAST
if (udpHandler) {
udpHandler->stop();
}
#endif
ethStartupComplete = false;
#ifndef DISABLE_NTP
ntp_renew = 0;
#endif
#ifdef PIN_ETHERNET_RESET
pinMode(PIN_ETHERNET_RESET, OUTPUT);
digitalWrite(PIN_ETHERNET_RESET, LOW);
delay(100);
digitalWrite(PIN_ETHERNET_RESET, HIGH);
delay(100);
#endif
#ifdef RAK11310
ETH_SPI_PORT.setSCK(PIN_SPI0_SCK);
ETH_SPI_PORT.setTX(PIN_SPI0_MOSI);
ETH_SPI_PORT.setRX(PIN_SPI0_MISO);
ETH_SPI_PORT.begin();
#endif
Ethernet.init(ETH_SPI_PORT, PIN_ETHERNET_SS);
int status = 0;
if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_DHCP) {
status = Ethernet.begin(expectedMac);
} else if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_STATIC) {
Ethernet.begin(expectedMac, config.network.ipv4_config.ip, config.network.ipv4_config.dns,
config.network.ipv4_config.gateway, config.network.ipv4_config.subnet);
status = 1;
}
if (status == 0) {
LOG_ERROR("Ethernet re-initialization failed, will retry");
return 5000;
}
LOG_INFO("Ethernet reinitialized - IP %u.%u.%u.%u", Ethernet.localIP()[0], Ethernet.localIP()[1],
Ethernet.localIP()[2], Ethernet.localIP()[3]);
}
Ethernet.maintain();
if (!ethStartupComplete) {
// Start web server
+2
View File
@@ -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.
------------------------------------------------------------------------------------------------------------------------------------------ */
+17 -1
View File
@@ -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)
#define is_in_repeated(name, n) is_in_helper(n, name, name##_count)
+7 -5
View File
@@ -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
#endif // HAS_UDP_MULTICAST
+123 -93
View File
@@ -24,7 +24,9 @@
#include "Default.h"
#include "MeshRadio.h"
#include "RadioInterface.h"
#include "TypeConversions.h"
#include "mesh/RadioLibInterface.h"
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
@@ -197,10 +199,35 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
handleSetOwner(r->set_owner);
break;
case meshtastic_AdminMessage_set_config_tag:
case meshtastic_AdminMessage_set_config_tag: {
LOG_DEBUG("Client set config");
handleSetConfig(r->set_config);
// Non-LoRa configs need no further validation.
if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) {
LOG_DEBUG("Non-LoRa config, applying directly");
handleSetConfig(r->set_config, fromOthers);
break;
}
// Only LORA_24 requires hardware capability validation.
if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
LOG_DEBUG("LoRa config, region is not LORA_24, applying directly");
handleSetConfig(r->set_config, fromOthers);
break;
}
// Hardware supports 2.4 GHz — apply the config.
// Fail closed: null instance is treated as incapable.
if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) {
LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying");
handleSetConfig(r->set_config, fromOthers);
break;
}
LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region");
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
break;
}
case meshtastic_AdminMessage_set_module_config_tag:
LOG_DEBUG("Client set module config");
@@ -626,7 +653,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
}
}
void AdminModule::handleSetConfig(const meshtastic_Config &c)
void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
{
auto changes = SEGMENT_CONFIG;
auto existingRole = config.device.role;
@@ -768,18 +795,57 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
validatedLora.spread_factor = LORA_SF_DEFAULT;
}
// If no lora radio parameters change, don't need to reboot
if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region &&
oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth &&
oldLoraConfig.spread_factor == validatedLora.spread_factor &&
oldLoraConfig.coding_rate == validatedLora.coding_rate && oldLoraConfig.tx_power == validatedLora.tx_power &&
oldLoraConfig.frequency_offset == validatedLora.frequency_offset &&
oldLoraConfig.override_frequency == validatedLora.override_frequency &&
oldLoraConfig.channel_num == validatedLora.channel_num &&
oldLoraConfig.sx126x_rx_boosted_gain == validatedLora.sx126x_rx_boosted_gain) {
requiresReboot = false;
// If we're setting a new region, check the region is valid and then init the region or discard the change
if (validatedLora.region != myRegion->code) {
// Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid
if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) {
// If we're setting region for the first time, init the region and regenerate the keys
if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
// new region is valid and we're coming from an unset region, so enable tx
validatedLora.tx_enabled = true;
}
// If we're unsetting the region for some reason, disable tx
if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
validatedLora.tx_enabled = false;
}
// Ensure initRegion() uses the newly validated region
config.lora.region = validatedLora.region;
initRegion();
if (myRegion->dutyCycle < 100) {
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
}
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
} else {
// Region validation has failed, so just copy all of the old config over the new config
validatedLora = oldLoraConfig;
}
} // end of new region handling
if (!RadioInterface::validateConfigLora(validatedLora)) {
if (fromOthers) {
LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
// modem_preset set to use the old setting if the check fails
validatedLora.modem_preset = oldLoraConfig.modem_preset;
} else {
LOG_WARN("Invalid LoRa config received from client, using corrected values");
RadioInterface::clampConfigLora(validatedLora);
}
// use_preset and bandwidth are coerced into valid values by the check.
}
// All LoRa radio changes apply live via configChanged observer → reconfigure().
// reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive.
requiresReboot = false;
#if defined(ARCH_PORTDUINO)
// If running on portduino and using SimRadio, do not require reboot
if (SimRadio::instance) {
@@ -795,63 +861,21 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
digitalWrite(RF95_FAN_EN, HIGH ^ 0);
}
#endif
config.lora = validatedLora;
#if HAS_LORA_FEM
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
// Note that a rejected lora config will revert this as well.
if (loraFEMInterface.isLnaCanControl()) {
loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED);
} else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
} else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT");
config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
}
#endif
// If we're setting region for the first time, init the region and regenerate the keys
if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (!owner.is_licensed) {
bool keygenSuccess = false;
if (config.security.private_key.size == 32) {
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
keygenSuccess = true;
}
} else {
LOG_INFO("Generate new PKI keys");
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
keygenSuccess = true;
}
if (keygenSuccess) {
config.security.public_key.size = 32;
config.security.private_key.size = 32;
owner.public_key.size = 32;
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
}
}
#endif
config.lora.tx_enabled = true;
initRegion();
if (myRegion->dutyCycle < 100) {
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
// Compare the entire string, we are sure of the length as a topic has never been set
if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) {
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
}
}
if (config.lora.region != myRegion->code) {
// Region has changed so check whether there is a regulatory one we should be using instead.
// Additionally as a side-effect, assume a new value under myRegion
initRegion();
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
}
config.lora = validatedLora; // Finally, return the validated config back to the main config
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
}
break;
}
case meshtastic_Config_bluetooth_tag:
@@ -899,10 +923,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
}
if (requiresReboot && !hasOpenEditTransaction) {
disableBluetooth();
}
} // end of switch case which_payload_variant
saveChanges(changes, requiresReboot);
}
} // end of handleSetConfig
bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
{
@@ -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;
}
}
}
+5 -1
View File
@@ -60,7 +60,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
*/
void handleSetOwner(const meshtastic_User &o);
void handleSetChannel(const meshtastic_Channel &cc);
void handleSetConfig(const meshtastic_Config &c);
protected:
void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
private:
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
void handleSetChannel();
void handleSetHamMode(const meshtastic_HamParameters &req);
+140 -240
View File
@@ -13,10 +13,12 @@
#include "buzz.h"
#include "detect/ScanI2C.h"
#include "gps/RTC.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/draw/MessageRenderer.h"
#include "graphics/draw/NotificationRenderer.h"
#include "graphics/draw/UIRenderer.h"
#include "graphics/emotes.h"
#include "graphics/images.h"
#include "input/SerialKeyboard.h"
@@ -45,71 +47,6 @@ extern MessageStore messageStore;
// Remove Canned message screen if no action is taken for some milliseconds
#define INACTIVATE_AFTER_MS 20000
// Tokenize a message string into emote/text segments
static std::vector<std::pair<bool, String>> tokenizeMessageWithEmotes(const char *msg)
{
std::vector<std::pair<bool, String>> tokens;
int msgLen = strlen(msg);
int pos = 0;
while (pos < msgLen) {
const graphics::Emote *foundEmote = nullptr;
int foundLen = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
int labelLen = strlen(label);
if (labelLen == 0)
continue;
if (strncmp(msg + pos, label, labelLen) == 0) {
if (!foundEmote || labelLen > foundLen) {
foundEmote = &graphics::emotes[j];
foundLen = labelLen;
}
}
}
if (foundEmote) {
tokens.emplace_back(true, String(foundEmote->label));
pos += foundLen;
} else {
// Find next emote
int nextEmote = msgLen;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
if (!label || !*label)
continue;
const char *found = strstr(msg + pos, label);
if (found && (found - msg) < nextEmote) {
nextEmote = found - msg;
}
}
int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos);
if (textLen > 0) {
tokens.emplace_back(false, String(msg + pos).substring(0, textLen));
pos += textLen;
} else {
break;
}
}
}
return tokens;
}
// Render a single emote token centered vertically on a row
static void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label)
{
const graphics::Emote *emote = nullptr;
for (int j = 0; j < graphics::numEmotes; j++) {
if (label == graphics::emotes[j].label) {
emote = &graphics::emotes[j];
break;
}
}
if (emote) {
int emoteYOffset = (rowHeight - emote->height) / 2; // vertically center the emote
display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap);
nextX += emote->width + 2; // spacing between tokens
}
}
namespace graphics
{
extern int bannerSignalBars;
@@ -264,19 +201,20 @@ int CannedMessageModule::splitConfiguredMessages()
}
void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer)
{
if (graphics::currentResolution == graphics::ScreenResolution::High) {
if (this->dest == NODENUM_BROADCAST) {
display->drawStringf(x, y, buffer, "To: #%s", channels.getName(this->channel));
} else {
display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest));
}
(void)buffer;
char header[96];
if (this->dest == NODENUM_BROADCAST) {
const char *channelName = channels.getName(this->channel);
snprintf(header, sizeof(header), "To: #%s", channelName ? channelName : "?");
} else {
if (this->dest == NODENUM_BROADCAST) {
display->drawStringf(x, y, buffer, "To: #%.20s", channels.getName(this->channel));
} else {
display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest));
}
snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest));
}
const int maxWidth = std::max(0, display->getWidth() - x);
char truncatedHeader[96];
graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth);
graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false);
}
void CannedMessageModule::resetSearch()
@@ -370,6 +308,92 @@ bool CannedMessageModule::isCharInputAllowed() const
{
return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
}
static int getRowHeightForEmoteText(const char *text, int minimumHeight, int emoteSpacing = 2)
{
// Grow the row only when an emote is taller than the font.
const auto metrics =
graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", 0, graphics::emotes, graphics::numEmotes, emoteSpacing);
return std::max(minimumHeight, metrics.tallestHeight + 2);
}
static void drawCenteredEmoteText(OLEDDisplay *display, int x, int y, int rowHeight, const char *text, int emoteSpacing = 2)
{
// Center mixed text and emotes inside the row height.
const auto metrics = graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes,
graphics::numEmotes, emoteSpacing);
const int contentHeight = std::max(FONT_HEIGHT_SMALL, metrics.tallestHeight);
const int drawY = y + ((rowHeight - contentHeight) / 2);
graphics::EmoteRenderer::drawStringWithEmotes(display, x, drawY, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes,
graphics::numEmotes, emoteSpacing, false);
}
static size_t firstWrappedTokenLen(const char *text)
{
// Fall back to one full emote or one UTF-8 glyph when width is tiny.
if (!text || !*text)
return 0;
const size_t textLen = strlen(text);
size_t matchLen = 0;
if (graphics::EmoteRenderer::findEmoteAt(text, textLen, 0, matchLen, graphics::emotes, graphics::numEmotes))
return matchLen;
return graphics::EmoteRenderer::utf8CharLen(static_cast<uint8_t>(text[0]));
}
static void drawWrappedEmoteText(OLEDDisplay *display, int x, int y, const char *text, int maxWidth, int minimumRowHeight,
int emoteSpacing = 2)
{
// Wrap onto multiple rows without splitting emotes.
if (!display || !text || maxWidth <= 0)
return;
constexpr size_t kLineBufferSize = 256;
char lineBuffer[kLineBufferSize];
const size_t textLen = strlen(text);
size_t offset = 0;
int yCursor = y;
while (offset < textLen) {
size_t copied = graphics::EmoteRenderer::truncateToWidth(display, text + offset, lineBuffer, sizeof(lineBuffer), maxWidth,
"", graphics::emotes, graphics::numEmotes, emoteSpacing);
size_t consumed = copied;
if (copied == 0) {
consumed = firstWrappedTokenLen(text + offset);
if (consumed == 0)
break;
const size_t fallbackLen = std::min(consumed, sizeof(lineBuffer) - 1);
memcpy(lineBuffer, text + offset, fallbackLen);
lineBuffer[fallbackLen] = '\0';
consumed = fallbackLen;
} else if (text[offset + copied] != '\0') {
// Prefer wrapping at the last space when a full line does not fit.
size_t lastSpace = copied;
while (lastSpace > 0 && lineBuffer[lastSpace - 1] != ' ')
--lastSpace;
if (lastSpace > 0) {
consumed = lastSpace;
while (consumed > 0 && lineBuffer[consumed - 1] == ' ')
--consumed;
lineBuffer[consumed] = '\0';
}
}
if (lineBuffer[0]) {
const int rowHeight = getRowHeightForEmoteText(lineBuffer, minimumRowHeight, emoteSpacing);
drawCenteredEmoteText(display, x, yCursor, rowHeight, lineBuffer, emoteSpacing);
yCursor += rowHeight;
}
offset += std::max<size_t>(consumed, 1);
while (offset < textLen && text[offset] == ' ')
++offset;
}
}
/**
* Main input event dispatcher for CannedMessageModule.
* Routes keyboard/button/touch input to the correct handler based on the current runState.
@@ -491,18 +515,16 @@ bool CannedMessageModule::handleTabSwitch(const InputEvent *event)
if (event->kbchar != 0x09)
return false;
updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION);
const cannedMessageModuleRunState targetState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
? CANNED_MESSAGE_RUN_STATE_FREETEXT
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
destIndex = 0;
scrollIndex = 0;
// RESTORE THIS!
if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
if (targetState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
updateDestinationSelectionList();
updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION,
true);
updateState(targetState, true);
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
@@ -1686,55 +1708,51 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
int xOffset = 0;
int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset;
char entryText[64] = "";
std::string entryText;
// Draw Channels First
if (itemIndex < numActiveChannels) {
uint8_t channelIndex = this->activeChannelIndices[itemIndex];
snprintf(entryText, sizeof(entryText), "#%s", channels.getName(channelIndex));
const char *channelName = channels.getName(channelIndex);
entryText = std::string("#") + (channelName ? channelName : "?");
}
// Then Draw Nodes
else {
int nodeIndex = itemIndex - numActiveChannels;
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(this->filteredNodes.size())) {
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
if (node && node->user.long_name) {
strncpy(entryText, node->user.long_name, sizeof(entryText) - 1);
entryText[sizeof(entryText) - 1] = '\0';
if (node) {
if (display->getWidth() <= 64) {
entryText = node->user.short_name;
} else if (node->user.long_name[0]) {
entryText = node->user.long_name;
} else {
entryText = node->user.short_name;
}
}
int availWidth = display->getWidth() -
((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) -
((node && node->is_favorite) ? 10 : 0);
if (availWidth < 0)
availWidth = 0;
size_t origLen = strlen(entryText);
while (entryText[0] && display->getStringWidth(entryText) > availWidth) {
entryText[strlen(entryText) - 1] = '\0';
}
if (strlen(entryText) < origLen) {
strcat(entryText, "...");
}
char truncatedEntry[96];
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
availWidth);
entryText = truncatedEntry;
// Prepend "* " if this is a favorite
if (node && node->is_favorite) {
size_t len = strlen(entryText);
if (len + 2 < sizeof(entryText)) {
memmove(entryText + 2, entryText, len + 1);
entryText[0] = '*';
entryText[1] = ' ';
}
}
if (node) {
if (display->getWidth() <= 64) {
snprintf(entryText, sizeof(entryText), "%s", node->user.short_name);
}
entryText = "* " + entryText;
}
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
availWidth);
entryText = truncatedEntry;
}
}
if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0)
strcpy(entryText, "?");
if (entryText.empty() || entryText == "Unknown")
entryText = "?";
// Highlight background (if selected)
if (itemIndex == destIndex) {
@@ -1744,7 +1762,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
}
// Draw entry text
display->drawString(xOffset + 2, yOffset, entryText);
graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false);
display->setColor(WHITE);
// Draw key icon (after highlight)
@@ -1785,15 +1803,10 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
{
const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height
const int headerMargin = 2; // Extra pixels below header
const int labelGap = 6;
const int labelGap = 4;
const int bitmapGapX = 4;
// Find max emote height (assume all same, or precalculated)
int maxEmoteHeight = 0;
for (int i = 0; i < graphics::numEmotes; ++i)
if (graphics::emotes[i].height > maxEmoteHeight)
maxEmoteHeight = graphics::emotes[i].height;
const int maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight();
const int rowHeight = maxEmoteHeight + 2;
// Place header at top, then compute start of emote list
@@ -1840,14 +1853,16 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
display->setColor(BLACK);
}
// Emote bitmap (left), 1px margin from highlight bar top
int emoteY = rowY + 1;
display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap);
// Emote bitmap (left), centered inside the row
int labelStartX = x + bitmapGapX;
const int emoteY = rowY + ((rowHeight - emote.height) / 2);
display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap);
labelStartX += emote.width;
// Emote label (right of bitmap)
display->setFont(FONT_MEDIUM);
int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2);
display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label);
display->drawString(labelStartX + labelGap, labelY, emote.label);
if (emoteIdx == emotePickerIndex)
display->setColor(WHITE);
@@ -2007,91 +2022,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
{
int inputY = 0 + y + FONT_HEIGHT_SMALL;
String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor);
// Tokenize input into (isEmote, token) pairs
const char *msg = msgWithCursor.c_str();
std::vector<std::pair<bool, String>> tokens = tokenizeMessageWithEmotes(msg);
// Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed)
std::vector<std::vector<std::pair<bool, String>>> lines;
std::vector<std::pair<bool, String>> currentLine;
int lineWidth = 0;
int maxWidth = display->getWidth();
for (auto &token : tokens) {
if (token.first) {
// Emote
int tokenWidth = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
if (token.second == graphics::emotes[j].label) {
tokenWidth = graphics::emotes[j].width + 2;
break;
}
}
if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
currentLine.push_back(token);
lineWidth += tokenWidth;
} else {
// Text: split by words and wrap inside word if needed
String text = token.second;
int pos = 0;
while (pos < static_cast<int>(text.length())) {
// Find next space (or end)
int spacePos = text.indexOf(' ', pos);
int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space
String word = text.substring(pos, endPos);
int wordWidth = display->getStringWidth(word);
if (lineWidth + wordWidth > maxWidth && lineWidth > 0) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
// If word itself too big, split by character
if (wordWidth > maxWidth) {
uint16_t charPos = 0;
while (charPos < word.length()) {
String oneChar = word.substring(charPos, charPos + 1);
int charWidth = display->getStringWidth(oneChar);
if (lineWidth + charWidth > maxWidth && lineWidth > 0) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
currentLine.push_back({false, oneChar});
lineWidth += charWidth;
charPos++;
}
} else {
currentLine.push_back({false, word});
lineWidth += wordWidth;
}
pos = endPos;
}
}
}
if (!currentLine.empty())
lines.push_back(currentLine);
// Draw lines with emotes
int rowHeight = FONT_HEIGHT_SMALL;
int yLine = inputY;
for (const auto &line : lines) {
int nextX = x;
for (const auto &token : line) {
if (token.first) {
// Emote rendering centralized in helper
renderEmote(display, nextX, yLine, rowHeight, token.second);
} else {
display->drawString(nextX, yLine, token.second);
nextX += display->getStringWidth(token.second);
}
}
yLine += rowHeight;
}
drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL);
}
#endif
return;
@@ -2106,7 +2037,6 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
const int baseRowSpacing = FONT_HEIGHT_SMALL - 4;
int topMsg;
std::vector<int> rowHeights;
int _visibleRows;
// Draw header (To: ...)
@@ -2122,36 +2052,15 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
: 0;
int countRows = std::min(messagesCount, _visibleRows);
// Build per-row max height based on all emotes in line
for (int i = 0; i < countRows; i++) {
const char *msg = getMessageByIndex(topMsg + i);
int maxEmoteHeight = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
if (!label || !*label)
continue;
const char *search = msg;
while ((search = strstr(search, label))) {
if (graphics::emotes[j].height > maxEmoteHeight)
maxEmoteHeight = graphics::emotes[j].height;
search += strlen(label); // Advance past this emote
}
}
rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2));
}
// Draw all message rows with multi-emote support
int yCursor = listYOffset;
for (int vis = 0; vis < countRows; vis++) {
int msgIdx = topMsg + vis;
int lineY = yCursor;
const char *msg = getMessageByIndex(msgIdx);
int rowHeight = rowHeights[vis];
int rowHeight = getRowHeightForEmoteText(msg, baseRowSpacing);
bool _highlight = (msgIdx == currentMessageIndex);
// Multi-emote tokenization
std::vector<std::pair<bool, String>> tokens = tokenizeMessageWithEmotes(msg);
// Vertically center based on rowHeight
int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2;
@@ -2168,17 +2077,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
int nextX = x + (_highlight ? 2 : 0);
#endif
// Draw all tokens left to right
for (const auto &token : tokens) {
if (token.first) {
// Emote rendering centralized in helper
renderEmote(display, nextX, lineY, rowHeight, token.second);
} else {
// Text
display->drawString(nextX, lineY + textYOffset, token.second);
nextX += display->getStringWidth(token.second);
}
}
if (msg && *msg)
drawCenteredEmoteText(display, nextX, lineY, rowHeight, msg);
#ifndef USE_EINK
if (_highlight)
display->setColor(WHITE);
+58 -55
View File
@@ -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;
}
}
+11
View File
@@ -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
+9 -2
View File
@@ -30,7 +30,8 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
auto p = *pptr;
if (mp.decoded.want_response) {
// Suppress replies to senders we've replied to recently (12H window)
if (mp.decoded.want_response && !isFromUs(&mp)) {
const NodeNum sender = getFrom(&mp);
const uint32_t now = mp.rx_time ? mp.rx_time : getTime();
auto it = lastNodeInfoSeen.find(sender);
@@ -119,7 +120,13 @@ void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha
meshtastic_MeshPacket *NodeInfoModule::allocReply()
{
if (suppressReplyForCurrentRequest) {
// Only apply suppression when actually replying to someone else's request, not for periodic broadcasts.
const bool isReplyingToExternalRequest = currentRequest &&
currentRequest->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
currentRequest->decoded.portnum == meshtastic_PortNum_NODEINFO_APP &&
currentRequest->decoded.want_response && !isFromUs(currentRequest);
if (suppressReplyForCurrentRequest && isReplyingToExternalRequest) {
LOG_DEBUG("Skip send NodeInfo since we heard the requester <12h ago");
ignoreRequest = true;
suppressReplyForCurrentRequest = false;
+4 -3
View File
@@ -20,10 +20,11 @@ bool RoutingModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mesh
if ((nodeDB->getMeshNode(mp.from) == NULL || !nodeDB->getMeshNode(mp.from)->has_user) &&
(nodeDB->getMeshNode(mp.to) == NULL || !nodeDB->getMeshNode(mp.to)->has_user))
return false;
} else if (owner.is_licensed && nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) {
// Don't let licensed users to rebroadcast packets from unlicensed users
} else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) ||
(nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) {
// Don't let licensed users to rebroadcast packets to or from unlicensed users
// If we know they are in-fact unlicensed
LOG_DEBUG("Packet from unlicensed user, ignoring packet");
LOG_DEBUG("Packet to or from unlicensed user, ignoring packet");
return false;
}
+3 -3
View File
@@ -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");
File diff suppressed because it is too large Load Diff
+434
View File
@@ -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<uint16_t>((1u << kNodeInfoTagBits) - 1u);
static constexpr uint16_t kNodeInfoIndexSlotsRaw =
static_cast<uint16_t>((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits);
static constexpr uint16_t kNodeInfoIndexSlots =
static_cast<uint16_t>(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize));
static constexpr uint16_t kNodeInfoTargetEntries =
static_cast<uint16_t>((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<uint16_t>(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<uint8_t>(ticks);
}
uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const
{
return cacheEpochMs + (static_cast<uint32_t>(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<uint8_t>(1 + log2Floor(static_cast<uint16_t>(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<uint8_t>(1 + log2Floor(static_cast<uint16_t>(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<uint16_t>(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
+3 -3
View File
@@ -100,7 +100,7 @@ AudioModule::AudioModule() : SinglePortModule("Audio", meshtastic_PortNum_AUDIO_
// moduleConfig.audio.i2s_sck = 14;
// moduleConfig.audio.ptt_pin = 39;
if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) {
if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
LOG_INFO("Set up codec2 in mode %u", (moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1);
codec2 = codec2_create((moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1);
memcpy(tx_header.magic, c2_magic, sizeof(c2_magic));
@@ -143,7 +143,7 @@ void AudioModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
int32_t AudioModule::runOnce()
{
if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) {
if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
esp_err_t res;
if (firstTime) {
// Set up I2S Processor configuration. This will produce 16bit samples at 8 kHz instead of 12 from the ADC
@@ -270,7 +270,7 @@ void AudioModule::sendPayload(NodeNum dest, bool wantReplies)
ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp)
{
if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) {
if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
auto &p = mp.decoded;
if (!isFromUs(&mp)) {
memcpy(rx_encode_frame, p.payload.bytes, p.payload.size);
+22 -10
View File
@@ -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<MQTTClient> clientConnection;
if (config.tls_enabled) {
#if MQTT_SUPPORTS_TLS
MQTTClientTLS *tlsClient = new MQTTClientTLS;
clientConnection.reset(tlsClient);
tlsClient->setInsecure();
#else
#if !MQTT_SUPPORTS_TLS
LOG_ERROR("Invalid MQTT config: tls_enabled is not supported on this node");
return false;
#endif
} else {
clientConnection.reset(new MQTTClient);
}
std::unique_ptr<PubSubClient> pubSub(new PubSubClient);
// Perform a lightweight TCP connectivity check without using connectPubSub(),
// which mutates the module's isConnected state. This only checks if the server
// is reachable — it does not establish an MQTT session.
// Settings are always saved regardless of the result.
if (isConnectedToNetwork()) {
return connectPubSub(parsed, *pubSub, (client != nullptr) ? *client : *clientConnection);
MQTTClient testClient;
if (!testClient.connect(parsed.serverAddr.c_str(), parsed.serverPort)) {
const char *warning = "Could not reach the MQTT server. Settings will be saved, but please verify the server "
"address and credentials.";
LOG_WARN(warning);
#if !IS_RUNNING_TESTS
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
if (cn) {
cn->level = meshtastic_LogRecord_Level_WARNING;
cn->time = getValidTime(RTCQualityFromNet);
strncpy(cn->message, warning, sizeof(cn->message) - 1);
cn->message[sizeof(cn->message) - 1] = '\0';
service->sendClientNotification(cn);
}
#endif
}
testClient.stop();
}
#else
const char *warning = "Invalid MQTT config: proxy_to_client_enabled must be enabled on nodes that do not have a network";
+10 -4
View File
@@ -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);
+814
View File
@@ -0,0 +1,814 @@
/**
* Tests for the radio configuration validation and clamping functions
* introduced in the radio_interface_cherrypick branch.
*
* Targets:
* 1. getRegion()
* 2. RadioInterface::validateConfigRegion()
* 3. RadioInterface::validateConfigLora()
* 4. RadioInterface::clampConfigLora()
* 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF)
* 6. Channel spacing calculation (placeholder for future protobuf changes)
*/
#include "MeshRadio.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RadioInterface.h"
#include "TestUtil.h"
#include "modules/AdminModule.h"
#include <unity.h>
#include "meshtastic/config.pb.h"
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
};
static MockMeshService *mockMeshService;
// -----------------------------------------------------------------------
// getRegion() tests
// -----------------------------------------------------------------------
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
static void test_getRegion_returnsCorrectRegion_US()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code);
TEST_ASSERT_EQUAL_STRING("US", r->name);
}
static void test_getRegion_returnsCorrectRegion_EU868()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code);
TEST_ASSERT_EQUAL_STRING("EU_868", r->name);
}
static void test_getRegion_returnsCorrectRegion_LORA24()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code);
TEST_ASSERT_TRUE(r->wideLora);
}
static void test_getRegion_unsetCodeReturnsUnsetEntry()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
TEST_ASSERT_EQUAL_STRING("UNSET", r->name);
}
static void test_getRegion_unknownCodeFallsToUnset()
{
// A code not in the table should iterate to the UNSET sentinel
const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
}
// -----------------------------------------------------------------------
// validateConfigRegion() tests
// -----------------------------------------------------------------------
static void test_validateConfigRegion_validRegionReturnsTrue()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
// Ensure owner is not licensed (should not matter for non-licensed-only regions)
devicestate.owner.is_licensed = false;
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
}
static void test_validateConfigRegion_unsetRegionReturnsTrue()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
devicestate.owner.is_licensed = false;
// UNSET region has licensedOnly=false, so should pass
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
}
// -----------------------------------------------------------------------
// Shadow tables for testing (preset lists → profiles → regions → lookup)
// -----------------------------------------------------------------------
// A minimal preset list with only one entry
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
MODEM_PRESET_END,
};
// A preset list that includes all turbo variants only
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = {
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
MODEM_PRESET_END,
};
// A restricted list simulating a hypothetical tight-regulation region
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
MODEM_PRESET_END,
};
// Mirrors PROFILE_STD but with non-zero spacing/padding for testing
static const RegionProfile TEST_PROFILE_SPACED = {
TEST_PRESETS_SINGLE,
/* spacing */ 0.025f,
/* padding */ 0.010f,
/* audioPermitted */ true,
/* licensedOnly */ false,
/* textThrottle */ 0,
/* positionThrottle */ 0,
/* telemetryThrottle */ 0,
/* overrideSlot */ 0,
};
// A licensed-only profile for testing access control
static const RegionProfile TEST_PROFILE_LICENSED = {
TEST_PRESETS_RESTRICTED,
/* spacing */ 0.0f,
/* padding */ 0.0f,
/* audioPermitted */ false,
/* licensedOnly */ true,
/* textThrottle */ 5,
/* positionThrottle */ 10,
/* telemetryThrottle */ 10,
/* overrideSlot */ 3,
};
// Turbo-only profile
static const RegionProfile TEST_PROFILE_TURBO = {
TEST_PRESETS_TURBO_ONLY,
/* spacing */ 0.0f,
/* padding */ 0.0f,
/* audioPermitted */ true,
/* licensedOnly */ false,
/* textThrottle */ 0,
/* positionThrottle */ 0,
/* telemetryThrottle */ 0,
/* overrideSlot */ 0,
};
static const RegionInfo testRegions[] = {
// A wide US-like region with spacing + padding
{meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"},
// A narrow band simulating tight EU regulation
{meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED,
"TEST_EU_LICENSED"},
// A wide-LoRa region with turbo-only presets
{meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO,
"TEST_LORA24_TURBO"},
// Sentinel — must be last
{meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"},
};
static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code)
{
const RegionInfo *r = testRegions;
while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
if (r->code == code)
return r;
r++;
}
return r; // Returns the UNSET sentinel
}
// -----------------------------------------------------------------------
// Shadow table tests
// -----------------------------------------------------------------------
static void test_shadowTable_spacedProfileHasNonZeroSpacing()
{
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding);
}
static void test_shadowTable_licensedProfileFlagsCorrect()
{
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_TRUE(r->profile->licensedOnly);
TEST_ASSERT_FALSE(r->profile->audioPermitted);
TEST_ASSERT_EQUAL(3, r->profile->overrideSlot);
}
static void test_shadowTable_presetCountMatchesExpected()
{
const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(1, spaced->getNumPresets());
const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(2, licensed->getNumPresets());
const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_EQUAL(2, turbo->getNumPresets());
}
static void test_shadowTable_defaultPresetIsFirstInList()
{
const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset());
const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset());
const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset());
}
static void test_shadowTable_channelSpacingWithPadding()
{
// Verify channel count when spacing + padding are non-zero
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f);
// spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250
// channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing);
uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f);
// (928 - 902 + 0.025) / 0.295 = 88.2 → 88
TEST_ASSERT_EQUAL_UINT32(88, numChannels);
}
static void test_shadowTable_turboOnlyOnWideLora()
{
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_TRUE(r->wideLora);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset());
// Verify wide-LoRa bandwidth for SHORT_TURBO
float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode
}
static void test_shadowTable_unknownCodeFallsToSentinel()
{
const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name);
}
// -----------------------------------------------------------------------
// validateConfigLora() tests
// -----------------------------------------------------------------------
static void test_validateConfigLora_validPresetForUS()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_allStdPresetsValidForUS()
{
meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
};
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = stdPresets[i];
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US");
}
}
static void test_validateConfigLora_turboPresetsInvalidForEU868()
{
// EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868");
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868");
}
static void test_validateConfigLora_validPresetsForEU868()
{
meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
};
for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) {
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = eu868Presets[i];
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868");
}
}
static void test_validateConfigLora_customBandwidthTooWideForEU868()
{
// EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz
// A 500 kHz custom BW should be rejected
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = false;
cfg.bandwidth = 500;
cfg.spread_factor = 11;
cfg.coding_rate = 5;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_customBandwidthFitsUS()
{
// US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = false;
cfg.bandwidth = 250;
cfg.spread_factor = 11;
cfg.coding_rate = 5;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_customBandwidthFitsEU868()
{
// EU_868 spans 250 kHz, 125 kHz BW should fit
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = false;
cfg.bandwidth = 125;
cfg.spread_factor = 12;
cfg.coding_rate = 8;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_bogusPresetRejected()
{
// A fabricated preset value not in any list should be rejected
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast()
{
// UNSET uses PROFILE_UNDEF which has only LONG_FAST
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET");
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET");
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET");
}
static void test_validateConfigLora_allPresetsValidForLORA24()
{
// LORA_24 uses PROFILE_STD (9 presets) with wideLora=true
meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
};
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.use_preset = true;
cfg.modem_preset = stdPresets[i];
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24");
}
}
// -----------------------------------------------------------------------
// clampConfigLora() tests
// -----------------------------------------------------------------------
static void test_clampConfigLora_invalidPresetClampedToDefault()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list
RadioInterface::clampConfigLora(cfg);
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset);
}
static void test_clampConfigLora_validPresetUnchanged()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_customBwTooWideClampedToDefaultBw()
{
// EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = false;
cfg.bandwidth = 500;
cfg.spread_factor = 11;
cfg.coding_rate = 5;
RadioInterface::clampConfigLora(cfg);
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora);
TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth);
}
static void test_clampConfigLora_customBwValidLeftUnchanged()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = false;
cfg.bandwidth = 125;
cfg.spread_factor = 12;
cfg.coding_rate = 8;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth);
}
static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast()
{
// UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
cfg.use_preset = true;
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault()
{
// LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD)
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.use_preset = true;
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
RadioInterface::clampConfigLora(cfg);
const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset);
}
// -----------------------------------------------------------------------
// RegionInfo preset list integrity tests
// -----------------------------------------------------------------------
static void test_presetsStd_hasNineEntries()
{
// PROFILE_STD should have exactly 9 presets
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(9, us->getNumPresets());
TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets());
}
static void test_presetsEU868_hasSevenEntries()
{
const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(7, eu->getNumPresets());
TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets());
}
static void test_presetsUndef_hasOneEntry()
{
const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
TEST_ASSERT_EQUAL(1, unset->getNumPresets());
TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets());
}
static void test_defaultPresetIsInAvailablePresets()
{
// For every region, the defaultPreset must appear in its own availablePresets list
const RegionInfo *r = regions;
while (true) {
bool found = false;
for (size_t i = 0; i < r->getNumPresets(); i++) {
if (r->getAvailablePresets()[i] == r->getDefaultPreset()) {
found = true;
break;
}
}
char msg[80];
snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name);
TEST_ASSERT_TRUE_MESSAGE(found, msg);
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
break; // UNSET is the sentinel, stop after it
r++;
}
}
static void test_regionFieldsAreSane()
{
// Basic sanity check: all regions have freqEnd > freqStart and a non-null name
const RegionInfo *r = regions;
while (true) {
char msg[80];
snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name);
TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg);
TEST_ASSERT_NOT_NULL(r->name);
TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0");
TEST_ASSERT_NOT_NULL(r->getAvailablePresets());
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
break;
r++;
}
}
static void test_onlyLORA24HasWideLora()
{
// Verify that LORA_24 is the only region with wideLora=true
const RegionInfo *r = regions;
while (true) {
char msg[80];
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name);
TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg);
} else {
snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name);
TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg);
}
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
break;
r++;
}
}
// -----------------------------------------------------------------------
// Channel spacing calculation (placeholder for future protobuf updates)
// -----------------------------------------------------------------------
static void test_channelSpacingCalculation_US_LONG_FAST()
{
// Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000)
// US: spacing=0, padding=0
// LONG_FAST on non-wide region: bw=250 kHz
// channelSpacing = 0 + 0 + 0.250 = 0.250 MHz
// numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f);
uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
TEST_ASSERT_EQUAL_UINT32(104, numChannels);
}
static void test_channelSpacingCalculation_EU868_LONG_FAST()
{
// EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0
// LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz
// numChannels = round((0.25 + 0) / 0.250) = 1
const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora);
float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f);
uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
TEST_ASSERT_EQUAL_UINT32(1, numChannels);
}
// Placeholder: when protobuf region definitions include non-zero padding/spacing,
// add tests here to verify the channel count and frequency calculations.
static void test_channelSpacingCalculation_placeholder()
{
// TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values,
// verify:
// - Channel count matches expected value for each (region, preset) pair
// - First channel frequency = freqStart + (bw/2000) + padding
// - Nth channel frequency = first + (n * channelSpacing)
// - overrideSlot, when non-zero, forces the channel_num
TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields");
}
// -----------------------------------------------------------------------
// handleSetConfig fromOthers dispatch tests
// -----------------------------------------------------------------------
class AdminModuleTestShim : public AdminModule
{
public:
using AdminModule::handleSetConfig;
};
static AdminModuleTestShim *testAdmin;
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
meshtastic_Config_LoRaConfig_ModemPreset preset)
{
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_lora_tag;
c.payload_variant.lora.region = region;
c.payload_variant.lora.use_preset = usePreset;
c.payload_variant.lora.modem_preset = preset;
return c;
}
static void test_handleSetConfig_fromOthers_invalidPresetRejected()
{
// Set up a known-good baseline in the global config
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
// Build an admin set_config with an invalid preset for EU_868
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
testAdmin->handleSetConfig(c, true); // fromOthers = true
// fromOthers=true: invalid preset should be rejected, old preset preserved
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
}
static void test_handleSetConfig_fromLocal_invalidPresetClamped()
{
// Set up a known-good baseline
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
// Build an admin set_config with an invalid preset for EU_868
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
testAdmin->handleSetConfig(c, false); // fromOthers = false (local client)
// fromOthers=false: invalid preset should be clamped to the region's default
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset);
}
static void test_handleSetConfig_fromOthers_validPresetAccepted()
{
// Set up baseline
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
// Build an admin set_config with a valid preset for EU_868
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
testAdmin->handleSetConfig(c, true); // fromOthers = true
// Valid preset should be accepted regardless of fromOthers
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
}
// -----------------------------------------------------------------------
// Test runner
// -----------------------------------------------------------------------
void setUp(void)
{
mockMeshService = new MockMeshService();
service = mockMeshService;
testAdmin = new AdminModuleTestShim();
}
void tearDown(void)
{
service = nullptr;
delete mockMeshService;
mockMeshService = nullptr;
delete testAdmin;
testAdmin = nullptr;
}
void setup()
{
delay(10);
delay(2000);
initializeTestEnvironment();
UNITY_BEGIN();
// getRegion()
RUN_TEST(test_getRegion_returnsCorrectRegion_US);
RUN_TEST(test_getRegion_returnsCorrectRegion_EU868);
RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry);
RUN_TEST(test_getRegion_unknownCodeFallsToUnset);
// validateConfigRegion()
RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue);
RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue);
// Shadow table tests
RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing);
RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect);
RUN_TEST(test_shadowTable_presetCountMatchesExpected);
RUN_TEST(test_shadowTable_defaultPresetIsFirstInList);
RUN_TEST(test_shadowTable_channelSpacingWithPadding);
RUN_TEST(test_shadowTable_turboOnlyOnWideLora);
RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel);
// validateConfigLora()
RUN_TEST(test_validateConfigLora_validPresetForUS);
RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS);
RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868);
RUN_TEST(test_validateConfigLora_validPresetsForEU868);
RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868);
RUN_TEST(test_validateConfigLora_customBandwidthFitsUS);
RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868);
RUN_TEST(test_validateConfigLora_bogusPresetRejected);
RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast);
RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24);
// clampConfigLora()
RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
RUN_TEST(test_clampConfigLora_validPresetUnchanged);
RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw);
RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged);
RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast);
RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault);
// RegionInfo preset list integrity
RUN_TEST(test_presetsStd_hasNineEntries);
RUN_TEST(test_presetsEU868_hasSevenEntries);
RUN_TEST(test_presetsUndef_hasOneEntry);
RUN_TEST(test_defaultPresetIsInAvailablePresets);
RUN_TEST(test_regionFieldsAreSane);
RUN_TEST(test_onlyLORA24HasWideLora);
// Channel spacing (current + placeholder)
RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST);
RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST);
RUN_TEST(test_channelSpacingCalculation_placeholder);
// handleSetConfig fromOthers dispatch
RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected);
RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
exit(UNITY_END());
}
void loop() {}
+12 -21
View File
@@ -818,16 +818,13 @@ void test_configEmptyIsValid(void)
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// Empty 'enabled' configuration is valid.
// Empty 'enabled' configuration is valid. A lightweight TCP check may be performed
// but does not affect the result.
void test_configEnabledEmptyIsValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true};
MockPubSubServer client;
TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
TEST_ASSERT_TRUE(client.connected_);
TEST_ASSERT_EQUAL_STRING(default_mqtt_address, client.host_.c_str());
TEST_ASSERT_EQUAL(1883, client.port_);
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// Configuration with the default server is valid.
@@ -846,38 +843,32 @@ void test_configWithDefaultServerAndInvalidPort(void)
TEST_ASSERT_FALSE(MQTT::isValidConfig(config));
}
// isValidConfig connects to a custom host and port.
// Custom host and port is valid. TCP reachability is checked but does not block saving.
void test_configCustomHostAndPort(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server:1234"};
MockPubSubServer client;
TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
TEST_ASSERT_TRUE(client.connected_);
TEST_ASSERT_EQUAL_STRING("server", client.host_.c_str());
TEST_ASSERT_EQUAL(1234, client.port_);
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// isValidConfig returns false if a connection cannot be established.
void test_configWithConnectionFailure(void)
// An unreachable server is still a valid config — settings always save.
// A warning notification is sent in non-test builds, but isValidConfig returns true.
void test_configWithUnreachableServerIsStillValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server"};
MockPubSubServer client;
client.refuseConnection_ = true;
TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client));
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// isValidConfig returns true when tls_enabled is supported, or false otherwise.
void test_configWithTLSEnabled(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server", .tls_enabled = true};
MockPubSubServer client;
#if MQTT_SUPPORTS_TLS
TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
#else
TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client));
TEST_ASSERT_FALSE(MQTT::isValidConfig(config));
#endif
}
@@ -927,7 +918,7 @@ void setup()
RUN_TEST(test_configWithDefaultServer);
RUN_TEST(test_configWithDefaultServerAndInvalidPort);
RUN_TEST(test_configCustomHostAndPort);
RUN_TEST(test_configWithConnectionFailure);
RUN_TEST(test_configWithUnreachableServerIsStillValid);
RUN_TEST(test_configWithTLSEnabled);
exit(UNITY_END());
}
+58 -24
View File
@@ -1,10 +1,19 @@
#include "MeshRadio.h"
#include "MeshService.h"
#include "RadioInterface.h"
#include "TestUtil.h"
#include <unity.h>
#include "meshtastic/config.pb.h"
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); }
};
static MockMeshService *mockMeshService;
static void test_bwCodeToKHz_specialMappings()
{
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31));
@@ -21,7 +30,7 @@ static void test_bwCodeToKHz_passthrough()
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250));
}
static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse()
static void test_validateConfigLora_noopWhenUsePresetFalse()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = false;
@@ -30,55 +39,78 @@ static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse()
cfg.bandwidth = 123;
cfg.spread_factor = 8;
RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
RadioInterface::validateConfigLora(cfg);
TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion()
static void test_validateConfigLora_validPreset_nonWideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor);
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion()
static void test_validateConfigLora_validPreset_wideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
TEST_ASSERT_EQUAL_UINT16(800, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor);
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan()
static void test_validateConfigLora_rejectsInvalidPresetForRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(11, cfg.spread_factor);
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
void setUp(void) {}
void tearDown(void) {}
static void test_clampConfigLora_invalidPresetClampedToDefault()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_validPresetUnchanged()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
void setUp(void)
{
mockMeshService = new MockMeshService();
service = mockMeshService;
}
void tearDown(void)
{
service = nullptr;
delete mockMeshService;
mockMeshService = nullptr;
}
void setup()
{
@@ -90,10 +122,12 @@ void setup()
UNITY_BEGIN();
RUN_TEST(test_bwCodeToKHz_specialMappings);
RUN_TEST(test_bwCodeToKHz_passthrough);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan);
RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse);
RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion);
RUN_TEST(test_validateConfigLora_validPreset_wideRegion);
RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion);
RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
RUN_TEST(test_clampConfigLora_validPresetUnchanged);
exit(UNITY_END());
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,4 +13,5 @@ build_flags =
${esp32_base.build_flags}
-D TLORA_V1
-I variants/esp32/tlora_v1
-ULED_BUILTIN
upload_speed = 115200
+9 -1
View File
@@ -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
#define GPS_THREAD_INTERVAL 50
@@ -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
@@ -3,24 +3,23 @@
#include <stdint.h>
#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 */
+28 -5
View File
@@ -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
+29 -29
View File
@@ -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
-1
View File
@@ -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

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