feat(wire): migrate from protobuf -> wire (#4401)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-03 18:01:12 -06:00
committed by GitHub
parent 9dbc8b7fbf
commit 25657e8f8f
239 changed files with 7149 additions and 6144 deletions

View File

@@ -9,46 +9,26 @@ concurrency:
cancel-in-progress: true
jobs:
lint:
android-check:
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-lint.yml
secrets: inherit
build:
needs: lint
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-android-build.yml
uses: ./.github/workflows/reusable-check.yml
with:
upload_artifacts: false
secrets: inherit
androidTest:
needs: lint
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-android-test.yml
with:
api_levels: '[26, 35]' # Run on both API 26 and 35 for merge queue
test_flavors: 'both' # Run both flavors for merge queue (comprehensive)
api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
flavors: '["google", "fdroid"]'
upload_artifacts: false
secrets: inherit
check-workflow-status:
name: Check Workflow Status # Matches another in pull-request, and is required for merge to main.
name: Check Workflow Status
runs-on: ubuntu-latest
needs:
- lint
- build
- androidTest
- android-check
if: always()
steps:
- name: Check Workflow Status
run: |
exit_on_result() {
if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then
echo "Job '$1' failed or was cancelled."
exit 1
fi
}
exit_on_result "lint" "${{ needs.lint.result }}"
exit_on_result "build" "${{ needs.build.result }}"
exit_on_result "androidTest" "${{ needs.androidTest.result }}"
if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
echo "::error::Android Check failed"
exit 1
fi
echo "All jobs passed successfully"

View File

@@ -32,35 +32,15 @@ jobs:
- '**/src/**'
- '.github/workflows/**'
lint:
android-check:
needs: check-changes
if: needs.check-changes.outputs.code_changed == 'true'
uses: ./.github/workflows/reusable-lint.yml
secrets: inherit
build:
needs:
- check-changes
- lint
if: ${{ needs.check-changes.outputs.code_changed == 'true' && !cancelled() && !failure() }}
uses: ./.github/workflows/reusable-android-build.yml
uses: ./.github/workflows/reusable-check.yml
with:
test_flavors: 'google'
api_levels: '[35]' # Only test latest API on PRs for speed
flavors: '["google"]'
secrets: inherit
androidTest:
needs:
- check-changes
- lint
if: ${{ needs.check-changes.outputs.code_changed == 'true' && !cancelled() && !failure() }}
uses: ./.github/workflows/reusable-android-test.yml
with:
api_levels: '[35]' # Run only on API 35 for PRs
test_flavors: 'google' # Run only Google flavor for PRs (faster)
num_shards: 1 # Run tests in parallel across 1 emulator
secrets: inherit
# This job handles the case when no code changes are detected (docs-only PRs)
skip-notice:
needs: check-changes
if: needs.check-changes.outputs.code_changed != 'true'
@@ -74,34 +54,19 @@ jobs:
runs-on: ubuntu-latest
needs:
- check-changes
- lint
- build
- androidTest
- android-check
if: always()
steps:
- name: Check Workflow Status
run: |
# If no code changed, all jobs are expected to be skipped - that's success
if [[ "${{ needs.check-changes.outputs.code_changed }}" != "true" ]]; then
echo "No code changes - CI jobs skipped as expected"
exit 0
fi
# Code changed - check that all jobs succeeded
check_result() {
local job_name=$1
local result=$2
if [[ "$result" == "failure" ]]; then
echo "::error::Job '$job_name' failed"
exit 1
elif [[ "$result" == "cancelled" ]]; then
echo "::error::Job '$job_name' was cancelled"
exit 1
fi
}
check_result "lint" "${{ needs.lint.result }}"
check_result "build" "${{ needs.build.result }}"
check_result "androidTest" "${{ needs.androidTest.result }}"
if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
echo "::error::Android Check failed"
exit 1
fi
echo "All jobs passed successfully"

View File

@@ -57,9 +57,13 @@ permissions:
jobs:
run-lint:
uses: ./.github/workflows/reusable-lint.yml
uses: ./.github/workflows/reusable-check.yml
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
run_lint: true
run_unit_tests: false
run_instrumented_tests: false
flavors: '["google"]'
upload_artifacts: false
secrets: inherit
prepare-build-info:

View File

@@ -1,144 +0,0 @@
name: Reusable Android Build and Test
on:
workflow_call:
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
DATADOG_APPLICATION_ID:
required: false
DATADOG_CLIENT_TOKEN:
required: false
CODECOV_TOKEN:
required: false
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
inputs:
upload_artifacts:
description: 'Whether to upload build and Detekt artifacts'
required: false
type: boolean
default: true
test_flavors:
description: 'Which flavors to build and test: "google", "fdroid", or "both"'
required: false
type: string
default: 'both'
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
attestations: write
timeout-minutes: 35
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: 'recursive'
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- name: Calculate Version Code
id: calculate_version_code
uses: ./.github/actions/calculate-version-code
- name: Expose Version Code as Environment Variable
run: echo "VERSION_CODE=${{ steps.calculate_version_code.outputs.versionCode }}" >> $GITHUB_ENV
- name: Load secrets
if: env.DATADOG_APPLICATION_ID != '' && env.DATADOG_CLIENT_TOKEN != ''
run: |
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
- name: Determine build tasks
id: build-tasks
run: |
FLAVOR="${{ inputs.test_flavors }}"
if [ "$FLAVOR" = "google" ]; then
echo "tasks=assembleGoogleDebug testGoogleDebugUnitTest" >> $GITHUB_OUTPUT
elif [ "$FLAVOR" = "fdroid" ]; then
echo "tasks=assembleFdroidDebug testFdroidDebugUnitTest" >> $GITHUB_OUTPUT
else
echo "tasks=assembleDebug testGoogleDebugUnitTest testFdroidDebugUnitTest" >> $GITHUB_OUTPUT
fi
- name: Build and Run Unit Tests
run: ./gradlew ${{ steps.build-tasks.outputs.tasks }} koverXmlReport -Pci=true --continue --scan
env:
VERSION_CODE: ${{ env.VERSION_CODE }}
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
report_type: coverage
directory: .
files: "**/build/reports/kover/report.xml"
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
directory: .
files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml"
- name: Upload F-Droid debug artifact
if: ${{ inputs.upload_artifacts && (inputs.test_flavors == 'fdroid' || inputs.test_flavors == 'both') }}
uses: actions/upload-artifact@v6
with:
name: fdroidDebug
path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk
retention-days: 14
- name: Upload Google debug artifact
if: ${{ inputs.upload_artifacts && (inputs.test_flavors == 'google' || inputs.test_flavors == 'both') }}
uses: actions/upload-artifact@v6
with:
name: googleDebug
path: app/build/outputs/apk/google/debug/app-google-debug.apk
retention-days: 14
- name: Upload reports
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v6
with:
name: upload-reports
path: |
build/reports
**/build/reports
retention-days: 14

View File

@@ -1,169 +0,0 @@
name: Reusable Android Instrumented Tests
on:
workflow_call:
inputs:
upload_artifacts:
description: 'Whether to upload Android test reports'
required: false
type: boolean
default: true
api_levels:
description: 'JSON array string of API levels to run tests on (e.g., `[35]` or `[26, 34, 35]`)'
required: false
type: string
default: '[26, 35]'
test_flavors:
description: 'Which flavors to test: "google", "fdroid", or "both"'
required: false
type: string
default: 'both'
num_shards:
description: 'Number of shards to split tests into'
required: false
type: number
default: 1
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
CODECOV_TOKEN:
required: true
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
jobs:
setup-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
API_LEVELS='${{ inputs.api_levels }}'
FLAVORS='${{ inputs.test_flavors }}'
NUM_SHARDS=${{ inputs.num_shards }}
if [ "$FLAVORS" = "both" ]; then
FLAVORS_JSON='["google", "fdroid"]'
else
FLAVORS_JSON="[\"$FLAVORS\"]"
fi
SHARDS_JSON=$(seq 0 $((NUM_SHARDS - 1)) | jq -R . | jq -s -c .)
echo "matrix={\"api_level\":$API_LEVELS,\"flavor\":$FLAVORS_JSON,\"shard\":$SHARDS_JSON}" >> $GITHUB_OUTPUT
androidTest:
needs: setup-matrix
runs-on: ubuntu-latest
timeout-minutes: 45
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: 'recursive'
fetch-depth: 1
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- name: AVD cache
uses: actions/cache@v5
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api_level }}
- name: create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api_level }}
arch: x86_64
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Determine test tasks
id: test-tasks
run: |
if [ "${{ matrix.flavor }}" = "google" ]; then
echo "tasks=connectedGoogleDebugAndroidTest" >> $GITHUB_OUTPUT
else
echo "tasks=connectedFdroidDebugAndroidTest" >> $GITHUB_OUTPUT
fi
- name: Run Sharded Android Instrumented Tests
uses: reactivecircus/android-emulator-runner@v2
env:
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: 60
with:
api-level: ${{ matrix.api_level }}
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew ${{ steps.test-tasks.outputs.tasks }} koverXmlReport -Pci=true -Pandroid.testInstrumentationRunnerArguments.numShards=${{ inputs.num_shards }} -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }} --continue --scan && ( killall -INT crashpad_handler || true )
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: coverage
slug: meshtastic/Meshtastic-Android
files: "**/build/reports/kover/report.xml"
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
directory: .
files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml"
- name: Upload Test Results
if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v6
with:
name: android-test-reports-api-${{ matrix.api_level }}-${{ matrix.flavor }}-shard-${{ matrix.shard }}
path: |
**/build/outputs/androidTest-results/connected/**
**/build/reports/androidTests/connected/**
retention-days: 14

196
.github/workflows/reusable-check.yml vendored Normal file
View File

@@ -0,0 +1,196 @@
name: Reusable Android Check
on:
workflow_call:
inputs:
run_lint:
type: boolean
default: true
run_unit_tests:
type: boolean
default: true
run_instrumented_tests:
type: boolean
default: true
flavors:
type: string
default: '["google"]'
api_levels:
type: string
default: '[35]'
num_shards:
type: number
default: 1
upload_artifacts:
type: boolean
default: true
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
CODECOV_TOKEN:
required: false
DATADOG_APPLICATION_ID:
required: false
DATADOG_CLIENT_TOKEN:
required: false
GOOGLE_MAPS_API_KEY:
required: false
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: true
matrix:
api_level: ${{ fromJson(inputs.api_levels) }}
flavor: ${{ fromJson(inputs.flavors) }}
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: 'recursive'
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- name: Calculate Version Code
id: calculate_version_code
uses: ./.github/actions/calculate-version-code
- name: Determine Tasks
id: tasks
run: |
TASKS=""
# Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources
IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}')
IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"')
if [ "${{ inputs.run_lint }}" = "true" ] && [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
TASKS="$TASKS spotlessCheck detekt "
fi
FLAVOR="${{ matrix.flavor }}"
if [ "$IS_FIRST_API" = "true" ]; then
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS assembleGoogleDebug "
[ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest "
elif [ "$FLAVOR" = "fdroid" ]; then
TASKS="$TASKS assembleFdroidDebug "
[ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest "
fi
fi
# Instrumented Test Tasks
if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS connectedGoogleDebugAndroidTest "
elif [ "$FLAVOR" = "fdroid" ]; then
TASKS="$TASKS connectedFdroidDebugAndroidTest "
fi
fi
# Run coverage report if any tests were executed
if [[ $TASKS == *"test"* ]]; then
TASKS="$TASKS koverXmlReport"
fi
echo "tasks=$TASKS" >> $GITHUB_OUTPUT
echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT
- name: Enable KVM group perms
if: inputs.run_instrumented_tests == true
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Check (with Emulator)
if: inputs.run_instrumented_tests == true
uses: reactivecircus/android-emulator-runner@v2
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
with:
api-level: ${{ matrix.api_level }}
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
- name: Run Check (no Emulator)
if: inputs.run_instrumented_tests == false
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
- name: Upload coverage results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
files: "**/build/reports/kover/report.xml"
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml"
- name: Upload debug artifact
if: ${{ steps.tasks.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.flavor }}Debug
path: app/build/outputs/apk/${{ matrix.flavor }}/debug/app-${{ matrix.flavor }}-debug.apk
retention-days: 14
- name: Report App Size
if: always() && steps.tasks.outputs.is_first_api == 'true'
run: |
echo "### 📦 App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
- name: Upload reports
if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v6
with:
name: reports-${{ matrix.flavor }}-api-${{ matrix.api_level }}
path: |
**/build/reports
**/build/test-results
**/build/outputs/androidTest-results
retention-days: 7

View File

@@ -1,54 +0,0 @@
name: Reusable Lint and Format Check
on:
workflow_call:
inputs:
ref:
description: 'The branch, tag or SHA to checkout'
required: false
type: string
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
jobs:
lint:
runs-on: ubuntu-latest # Lint is fast, doesn't need large runner
timeout-minutes: 10
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || '' }}
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'jetbrains'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- name: Run Spotless and Detekt
run: ./gradlew spotlessCheck detekt -Pci=true --scan

View File

@@ -134,6 +134,8 @@ configure<ApplicationExtension> {
// Disables dependency metadata when building Android App Bundles (for Google Play)
includeInBundle = false
}
testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
}
// Configure existing product flavors (defined by convention plugin)
@@ -231,7 +233,6 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
implementation(libs.org.eclipse.paho.client.mqttv3)
implementation(libs.streamsupport.minifuture)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)

View File

@@ -0,0 +1,415 @@
androidx.activity:activity-compose:1.12.3
androidx.activity:activity-ktx:1.12.3
androidx.activity:activity:1.12.3
androidx.annotation:annotation-experimental:1.5.1
androidx.annotation:annotation-jvm:1.9.1
androidx.annotation:annotation:1.9.1
androidx.appcompat:appcompat-resources:1.7.1
androidx.appcompat:appcompat:1.7.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.cardview:cardview:1.0.0
androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.11.0-alpha04
androidx.compose.animation:animation-core-android:1.11.0-alpha04
androidx.compose.animation:animation-core:1.11.0-alpha04
androidx.compose.animation:animation:1.11.0-alpha04
androidx.compose.foundation:foundation-android:1.11.0-alpha04
androidx.compose.foundation:foundation-layout-android:1.11.0-alpha04
androidx.compose.foundation:foundation-layout:1.11.0-alpha04
androidx.compose.foundation:foundation:1.11.0-alpha04
androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha07
androidx.compose.material3.adaptive:adaptive:1.3.0-alpha07
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha13
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha13
androidx.compose.material3:material3-android:1.5.0-alpha13
androidx.compose.material3:material3:1.5.0-alpha13
androidx.compose.material:material-android:1.11.0-alpha04
androidx.compose.material:material-icons-core-android:1.7.8
androidx.compose.material:material-icons-core:1.7.8
androidx.compose.material:material-icons-extended-android:1.7.8
androidx.compose.material:material-icons-extended:1.7.8
androidx.compose.material:material-ripple-android:1.11.0-alpha04
androidx.compose.material:material-ripple:1.11.0-alpha04
androidx.compose.material:material:1.11.0-alpha04
androidx.compose.runtime:runtime-android:1.11.0-alpha04
androidx.compose.runtime:runtime-annotation-android:1.11.0-alpha04
androidx.compose.runtime:runtime-annotation:1.11.0-alpha04
androidx.compose.runtime:runtime-livedata:1.11.0-alpha04
androidx.compose.runtime:runtime-retain-android:1.11.0-alpha04
androidx.compose.runtime:runtime-retain:1.11.0-alpha04
androidx.compose.runtime:runtime-saveable-android:1.11.0-alpha04
androidx.compose.runtime:runtime-saveable:1.11.0-alpha04
androidx.compose.runtime:runtime-tracing:1.11.0-alpha04
androidx.compose.runtime:runtime:1.11.0-alpha04
androidx.compose.ui:ui-android:1.11.0-alpha04
androidx.compose.ui:ui-geometry-android:1.11.0-alpha04
androidx.compose.ui:ui-geometry:1.11.0-alpha04
androidx.compose.ui:ui-graphics-android:1.11.0-alpha04
androidx.compose.ui:ui-graphics:1.11.0-alpha04
androidx.compose.ui:ui-text-android:1.11.0-alpha04
androidx.compose.ui:ui-text:1.11.0-alpha04
androidx.compose.ui:ui-tooling-android:1.11.0-alpha04
androidx.compose.ui:ui-tooling-data-android:1.11.0-alpha04
androidx.compose.ui:ui-tooling-data:1.11.0-alpha04
androidx.compose.ui:ui-tooling-preview-android:1.11.0-alpha04
androidx.compose.ui:ui-tooling-preview:1.11.0-alpha04
androidx.compose.ui:ui-tooling:1.11.0-alpha04
androidx.compose.ui:ui-unit-android:1.11.0-alpha04
androidx.compose.ui:ui-unit:1.11.0-alpha04
androidx.compose.ui:ui-util-android:1.11.0-alpha04
androidx.compose.ui:ui-util:1.11.0-alpha04
androidx.compose.ui:ui:1.11.0-alpha04
androidx.compose:compose-bom-alpha:2026.01.01
androidx.compose:compose-bom:2026.01.00
androidx.concurrent:concurrent-futures-ktx:1.1.0
androidx.concurrent:concurrent-futures:1.1.0
androidx.constraintlayout:constraintlayout-core:1.0.0
androidx.constraintlayout:constraintlayout:2.1.0
androidx.coordinatorlayout:coordinatorlayout:1.1.0
androidx.core:core-ktx:1.17.0
androidx.core:core-location-altitude-external-protobuf:1.0.0-beta01
androidx.core:core-location-altitude-proto:1.0.0-beta01
androidx.core:core-location-altitude:1.0.0-beta01
androidx.core:core-splashscreen:1.2.0
androidx.core:core-viewtree:1.0.0
androidx.core:core:1.17.0
androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.1.0
androidx.databinding:viewbinding:8.13.2
androidx.datastore:datastore-android:1.2.0
androidx.datastore:datastore-core-android:1.2.0
androidx.datastore:datastore-core-okio-jvm:1.2.0
androidx.datastore:datastore-core-okio:1.2.0
androidx.datastore:datastore-core:1.2.0
androidx.datastore:datastore-preferences-android:1.2.0
androidx.datastore:datastore-preferences-core-android:1.2.0
androidx.datastore:datastore-preferences-core:1.2.0
androidx.datastore:datastore-preferences-external-protobuf:1.2.0
androidx.datastore:datastore-preferences-proto:1.2.0
androidx.datastore:datastore-preferences:1.2.0
androidx.datastore:datastore:1.2.0
androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.1.1
androidx.dynamicanimation:dynamicanimation:1.1.0
androidx.emoji2:emoji2-emojipicker:1.6.0
androidx.emoji2:emoji2-views-helper:1.6.0
androidx.emoji2:emoji2:1.6.0
androidx.exifinterface:exifinterface:1.4.1
androidx.fragment:fragment-ktx:1.6.2
androidx.fragment:fragment:1.6.2
androidx.graphics:graphics-path:1.0.1
androidx.graphics:graphics-shapes-android:1.0.1
androidx.graphics:graphics-shapes:1.0.1
androidx.hilt:hilt-common:1.3.0
androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0
androidx.hilt:hilt-lifecycle-viewmodel:1.3.0
androidx.hilt:hilt-work:1.3.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.10.0
androidx.lifecycle:lifecycle-common-jvm:2.10.0
androidx.lifecycle:lifecycle-common:2.10.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata-core:2.10.0
androidx.lifecycle:lifecycle-livedata-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata:2.10.0
androidx.lifecycle:lifecycle-process:2.10.0
androidx.lifecycle:lifecycle-runtime-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
androidx.lifecycle:lifecycle-runtime:2.10.0
androidx.lifecycle:lifecycle-service:2.10.0
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0
androidx.lifecycle:lifecycle-viewmodel:2.10.0
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
androidx.metrics:metrics-performance:1.0.0-beta03
androidx.navigation3:navigation3-runtime-android:1.0.0
androidx.navigation3:navigation3-runtime:1.0.0
androidx.navigation3:navigation3-ui-android:1.0.0
androidx.navigation3:navigation3-ui:1.0.0
androidx.navigation:navigation-common-android:2.9.7
androidx.navigation:navigation-common:2.9.7
androidx.navigation:navigation-compose-android:2.9.7
androidx.navigation:navigation-compose:2.9.7
androidx.navigation:navigation-fragment:2.9.7
androidx.navigation:navigation-runtime-android:2.9.7
androidx.navigation:navigation-runtime:2.9.7
androidx.navigationevent:navigationevent-android:1.0.2
androidx.navigationevent:navigationevent-compose-android:1.0.2
androidx.navigationevent:navigationevent-compose:1.0.2
androidx.navigationevent:navigationevent:1.0.2
androidx.paging:paging-common-android:3.4.0
androidx.paging:paging-common:3.4.0
androidx.paging:paging-compose-android:3.4.0
androidx.paging:paging-compose:3.4.0
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.1.0-beta11
androidx.privacysandbox.ads:ads-adservices:1.1.0-beta11
androidx.profileinstaller:profileinstaller:1.4.1
androidx.recyclerview:recyclerview:1.3.2
androidx.resourceinspection:resourceinspection-annotation:1.0.1
androidx.room:room-common-jvm:2.8.4
androidx.room:room-common:2.8.4
androidx.room:room-paging-android:2.8.4
androidx.room:room-paging:2.8.4
androidx.room:room-runtime-android:2.8.4
androidx.room:room-runtime:2.8.4
androidx.savedstate:savedstate-android:1.4.0
androidx.savedstate:savedstate-compose-android:1.4.0
androidx.savedstate:savedstate-compose:1.4.0
androidx.savedstate:savedstate-ktx:1.4.0
androidx.savedstate:savedstate:1.4.0
androidx.slidingpanelayout:slidingpanelayout:1.2.0
androidx.sqlite:sqlite-android:2.6.2
androidx.sqlite:sqlite-framework-android:2.6.2
androidx.sqlite:sqlite-framework:2.6.2
androidx.sqlite:sqlite:2.6.2
androidx.startup:startup-runtime:1.2.0
androidx.tracing:tracing-ktx:1.2.0
androidx.tracing:tracing-perfetto:1.0.1
androidx.tracing:tracing:1.2.0
androidx.transition:transition:1.6.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager2:viewpager2:1.1.0-beta02
androidx.viewpager:viewpager:1.0.0
androidx.window:window-core-android:1.5.0
androidx.window:window-core:1.5.0
androidx.window:window:1.5.0
androidx.work:work-runtime-ktx:2.11.1
androidx.work:work-runtime:2.11.1
co.touchlab:kermit-android:2.0.8
co.touchlab:kermit-core-android:2.0.8
co.touchlab:kermit-core:2.0.8
co.touchlab:kermit:2.0.8
com.caverock:androidsvg-aar:1.4
com.datadoghq:dd-sdk-android-compose:3.6.0
com.datadoghq:dd-sdk-android-core:3.6.0
com.datadoghq:dd-sdk-android-internal:3.6.0
com.datadoghq:dd-sdk-android-logs:3.6.0
com.datadoghq:dd-sdk-android-okhttp:3.6.0
com.datadoghq:dd-sdk-android-rum:3.6.0
com.datadoghq:dd-sdk-android-session-replay-compose:3.6.0
com.datadoghq:dd-sdk-android-session-replay:3.6.0
com.datadoghq:dd-sdk-android-timber:3.6.0
com.datadoghq:dd-sdk-android-trace-api:3.6.0
com.datadoghq:dd-sdk-android-trace-internal:3.6.0
com.datadoghq:dd-sdk-android-trace-otel:3.6.0
com.datadoghq:dd-sdk-android-trace:3.6.0
com.github.mik3y:usb-serial-for-android:3.10.0
com.google.accompanist:accompanist-drawablepainter:0.37.3
com.google.accompanist:accompanist-permissions:0.37.3
com.google.android.datatransport:transport-api:3.2.0
com.google.android.datatransport:transport-backend-cct:3.3.0
com.google.android.datatransport:transport-runtime:3.3.0
com.google.android.gms:play-services-ads-identifier:18.0.0
com.google.android.gms:play-services-base:18.5.0
com.google.android.gms:play-services-basement:18.9.0
com.google.android.gms:play-services-location:21.3.0
com.google.android.gms:play-services-maps:20.0.0
com.google.android.gms:play-services-measurement-api:23.0.0
com.google.android.gms:play-services-measurement-base:23.0.0
com.google.android.gms:play-services-measurement-impl:23.0.0
com.google.android.gms:play-services-measurement-sdk-api:23.0.0
com.google.android.gms:play-services-measurement-sdk:23.0.0
com.google.android.gms:play-services-measurement:23.0.0
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.4.0
com.google.android.material:material:1.13.0
com.google.auto.value:auto-value-annotations:1.6.3
com.google.code.findbugs:jsr305:3.0.2
com.google.code.gson:gson:2.13.2
com.google.dagger:dagger-lint-aar:2.59
com.google.dagger:dagger:2.59
com.google.dagger:hilt-android:2.59
com.google.dagger:hilt-core:2.59
com.google.errorprone:error_prone_annotations:2.41.0
com.google.firebase:firebase-analytics:23.0.0
com.google.firebase:firebase-annotations:17.0.0
com.google.firebase:firebase-bom:34.8.0
com.google.firebase:firebase-common:22.0.1
com.google.firebase:firebase-components:19.0.0
com.google.firebase:firebase-config-interop:16.0.1
com.google.firebase:firebase-crashlytics:20.0.4
com.google.firebase:firebase-datatransport:19.0.0
com.google.firebase:firebase-encoders-json:18.0.1
com.google.firebase:firebase-encoders-proto:16.0.0
com.google.firebase:firebase-encoders:17.0.0
com.google.firebase:firebase-installations-interop:17.2.0
com.google.firebase:firebase-installations:19.0.1
com.google.firebase:firebase-measurement-connector:20.0.1
com.google.firebase:firebase-sessions:3.0.4
com.google.guava:failureaccess:1.0.3
com.google.guava:guava:33.5.0-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:3.1
com.google.maps.android:android-maps-utils:4.0.0
com.google.maps.android:maps-compose-utils:8.0.0
com.google.maps.android:maps-compose-widgets:8.0.0
com.google.maps.android:maps-compose:8.0.0
com.google.maps.android:maps-ktx:6.0.0
com.google.maps.android:maps-utils-ktx:6.0.0
com.google.re2j:re2j:1.7
com.google.zxing:core:3.5.4
com.jakewharton.timber:timber:5.0.1
com.journeyapps:zxing-android-embedded:4.3.0
com.lyft.kronos:kronos-android:0.0.1-alpha11
com.lyft.kronos:kronos-java:0.0.1-alpha11
com.mikepenz:aboutlibraries-compose-core-android:13.2.1
com.mikepenz:aboutlibraries-compose-core:13.2.1
com.mikepenz:aboutlibraries-compose-m3-android:13.2.1
com.mikepenz:aboutlibraries-compose-m3:13.2.1
com.mikepenz:aboutlibraries-core-android:13.2.1
com.mikepenz:aboutlibraries-core:13.2.1
com.mikepenz:multiplatform-markdown-renderer-android:0.39.2
com.mikepenz:multiplatform-markdown-renderer-m3-android:0.39.2
com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2
com.mikepenz:multiplatform-markdown-renderer:0.39.2
com.patrykandpatrick.vico:compose-android:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m2-android:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m2:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m3-android:3.0.0-beta.3
com.patrykandpatrick.vico:compose-m3:3.0.0-beta.3
com.patrykandpatrick.vico:compose:3.0.0-beta.3
com.squareup.okhttp3:logging-interceptor:5.3.2
com.squareup.okhttp3:okhttp-android:5.3.2
com.squareup.okhttp3:okhttp:5.3.2
com.squareup.okio:okio-jvm:3.16.4
com.squareup.okio:okio:3.16.4
com.squareup.wire:wire-runtime-jvm:5.2.1
com.squareup.wire:wire-runtime:5.2.1
io.coil-kt.coil3:coil-android:3.3.0
io.coil-kt.coil3:coil-compose-android:3.3.0
io.coil-kt.coil3:coil-compose-core-android:3.3.0
io.coil-kt.coil3:coil-compose-core:3.3.0
io.coil-kt.coil3:coil-compose:3.3.0
io.coil-kt.coil3:coil-core-android:3.3.0
io.coil-kt.coil3:coil-core:3.3.0
io.coil-kt.coil3:coil-network-core-android:3.3.0
io.coil-kt.coil3:coil-network-core:3.3.0
io.coil-kt.coil3:coil-network-okhttp-jvm:3.3.0
io.coil-kt.coil3:coil-network-okhttp:3.3.0
io.coil-kt.coil3:coil-svg-android:3.3.0
io.coil-kt.coil3:coil-svg:3.3.0
io.coil-kt.coil3:coil:3.3.0
io.ktor:ktor-client-content-negotiation-jvm:3.4.0
io.ktor:ktor-client-content-negotiation:3.4.0
io.ktor:ktor-client-core-jvm:3.4.0
io.ktor:ktor-client-core:3.4.0
io.ktor:ktor-client-okhttp-jvm:3.4.0
io.ktor:ktor-client-okhttp:3.4.0
io.ktor:ktor-events-jvm:3.4.0
io.ktor:ktor-events:3.4.0
io.ktor:ktor-http-cio-jvm:3.4.0
io.ktor:ktor-http-cio:3.4.0
io.ktor:ktor-http-jvm:3.4.0
io.ktor:ktor-http:3.4.0
io.ktor:ktor-io-jvm:3.4.0
io.ktor:ktor-io:3.4.0
io.ktor:ktor-network-jvm:3.4.0
io.ktor:ktor-network:3.4.0
io.ktor:ktor-serialization-jvm:3.4.0
io.ktor:ktor-serialization-kotlinx-json-jvm:3.4.0
io.ktor:ktor-serialization-kotlinx-json:3.4.0
io.ktor:ktor-serialization-kotlinx-jvm:3.4.0
io.ktor:ktor-serialization-kotlinx:3.4.0
io.ktor:ktor-serialization:3.4.0
io.ktor:ktor-sse-jvm:3.4.0
io.ktor:ktor-sse:3.4.0
io.ktor:ktor-utils-jvm:3.4.0
io.ktor:ktor-utils:3.4.0
io.ktor:ktor-websocket-serialization-jvm:3.4.0
io.ktor:ktor-websocket-serialization:3.4.0
io.ktor:ktor-websockets-jvm:3.4.0
io.ktor:ktor-websockets:3.4.0
io.opentelemetry:opentelemetry-api:1.40.0
io.opentelemetry:opentelemetry-context:1.40.0
jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1
no.nordicsemi.android:dfu:2.10.1
no.nordicsemi.kotlin.ble:client-android:2.0.0-alpha12
no.nordicsemi.kotlin.ble:client-core-android:2.0.0-alpha12
no.nordicsemi.kotlin.ble:client-core:2.0.0-alpha12
no.nordicsemi.kotlin.ble:core:2.0.0-alpha12
org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5
org.jctools:jctools-core:3.3.0
org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6
org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6
org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6
org.jetbrains.androidx.savedstate:savedstate:1.3.6
org.jetbrains.compose.animation:animation-core:1.10.0
org.jetbrains.compose.animation:animation:1.10.0
org.jetbrains.compose.annotation-internal:annotation:1.10.0
org.jetbrains.compose.collection-internal:collection:1.10.0
org.jetbrains.compose.components:components-resources-android:1.10.0
org.jetbrains.compose.components:components-resources:1.10.0
org.jetbrains.compose.foundation:foundation-layout:1.10.0
org.jetbrains.compose.foundation:foundation:1.10.0
org.jetbrains.compose.material3:material3:1.9.0
org.jetbrains.compose.material:material-ripple:1.10.0
org.jetbrains.compose.material:material:1.10.0
org.jetbrains.compose.runtime:runtime-saveable:1.10.0
org.jetbrains.compose.runtime:runtime:1.10.0
org.jetbrains.compose.ui:ui-backhandler-android:1.9.1
org.jetbrains.compose.ui:ui-backhandler:1.9.1
org.jetbrains.compose.ui:ui-geometry:1.10.0
org.jetbrains.compose.ui:ui-graphics:1.10.0
org.jetbrains.compose.ui:ui-text:1.10.0
org.jetbrains.compose.ui:ui-tooling-preview:1.10.0-rc02
org.jetbrains.compose.ui:ui-unit:1.10.0
org.jetbrains.compose.ui:ui-util:1.10.0
org.jetbrains.compose.ui:ui:1.10.0
org.jetbrains.kotlin:kotlin-bom:1.8.22
org.jetbrains.kotlin:kotlin-parcelize-runtime:2.3.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.3.0
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21
org.jetbrains.kotlin:kotlin-stdlib:2.3.0
org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0
org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2
org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1-0.6.x-compat
org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat
org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.2
org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.2
org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.2
org.jetbrains.kotlinx:kotlinx-io-core:0.8.2
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-core:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0
org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0
org.jetbrains:annotations:23.0.0
org.jetbrains:markdown-jvm:0.7.3
org.jetbrains:markdown:0.7.3
org.jspecify:jspecify:1.0.0
org.slf4j:slf4j-api:2.0.17

View File

@@ -1,17 +1,14 @@
<?xml version="1.0" ?>
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CommentSpacing:BLEException.kt$BLEConnectionClosing$/// Our interface is being shut down</ID>
<ID>CommentSpacing:Constants.kt$/// a bool true means we expect this condition to continue until, false means device might come back</ID>
<ID>CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib</ID>
<ID>CyclomaticComplexMethod:BleError.kt$BleError.Companion$fun from(exception: Throwable): BleError</ID>
<ID>CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>CyclomaticComplexMethod:MeshMessageProcessor.kt$MeshMessageProcessor$private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?)</ID>
<ID>CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController)</ID>
<ID>EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
<ID>EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}</ID>
<ID>FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
@@ -21,27 +18,15 @@
<ID>FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt</ID>
<ID>FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt</ID>
<ID>FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt</ID>
<ID>FinalNewline:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt</ID>
<ID>FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt</ID>
<ID>FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt</ID>
<ID>FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>LargeClass:MeshService.kt$MeshService : Service</ID>
<ID>LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
<ID>MagicNumber:Contacts.kt$7</ID>
<ID>MagicNumber:Contacts.kt$8</ID>
<ID>MagicNumber:MQTTRepository.kt$MQTTRepository$512</ID>
<ID>MagicNumber:MeshService.kt$MeshService$0xffffffff</ID>
<ID>MagicNumber:MeshService.kt$MeshService$1000</ID>
<ID>MagicNumber:MeshService.kt$MeshService$1000.0</ID>
<ID>MagicNumber:MeshService.kt$MeshService$1000L</ID>
<ID>MagicNumber:MeshService.kt$MeshService$16</ID>
<ID>MagicNumber:MeshService.kt$MeshService$30</ID>
<ID>MagicNumber:MeshService.kt$MeshService$32</ID>
<ID>MagicNumber:MeshService.kt$MeshService$60000</ID>
<ID>MagicNumber:MeshService.kt$MeshService$8</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
@@ -55,17 +40,10 @@
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"Neighbor info response filtered: ToUs=$isAddressedToUs, isRecentRequest=$isRecentRequest"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService.&lt;no name provided&gt;$"sendData dest=${p.to}, id=${p.id} &lt;- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"</ID>
<ID>NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage)</ID>
<ID>NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
@@ -75,7 +53,6 @@
<ID>NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt</ID>
<ID>NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt</ID>
<ID>NewLineAtEndOfFile:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt</ID>
<ID>NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt</ID>
<ID>NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt</ID>
<ID>NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
@@ -83,37 +60,33 @@
<ID>NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$ </ID>
<ID>NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$ </ID>
<ID>NoConsecutiveBlankLines:Constants.kt$ </ID>
<ID>NoConsecutiveBlankLines:DebugLogFile.kt$ </ID>
<ID>NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>NoSemicolons:DateUtils.kt$DateUtils$;</ID>
<ID>OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract</ID>
<ID>RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex</ID>
<ID>ReturnCount:MeshDataHandler.kt$MeshDataHandler$@Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket)</ID>
<ID>ReturnCount:MeshDataHandler.kt$MeshDataHandler$private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean</ID>
<ID>SwallowedException:Exceptions.kt$ex: Throwable</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshDataHandler.kt$MeshDataHandler$e: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService.&lt;no name provided&gt;$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable</ID>
<ID>TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable</ID>
<ID>TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooGenericExceptionThrown:MeshService.kt$MeshService$throw Exception("Can't set user without a NodeInfo")</ID>
<ID>TooGenericExceptionThrown:MeshService.kt$MeshService.&lt;no name provided&gt;$throw Exception("Port numbers must be non-zero!")</ID>
<ID>TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Haven't called connect")</ID>
<ID>TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Service not bound")</ID>
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("SyncContinuation timeout")</ID>
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen")</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService : Service</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService$&lt;no name provided&gt; : Stub</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface</ID>
<ID>TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService</ID>
<ID>TooManyFunctions:UIState.kt$UIViewModel : ViewModel</ID>
<ID>TopLevelPropertyNaming:Constants.kt$const val prefix = "com.geeksville.mesh"</ID>
<ID>UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.model
import android.app.Application
@@ -65,9 +64,9 @@ import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
// Given a human name, strip out the first letter of the first three words and return that as the
@@ -119,11 +118,11 @@ constructor(
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition }
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
val clientNotification: StateFlow<MeshProtos.ClientNotification?> = serviceRepository.clientNotification
val clientNotification: StateFlow<ClientNotification?> = serviceRepository.clientNotification
fun clearClientNotification(notification: MeshProtos.ClientNotification) {
fun clearClientNotification(notification: ClientNotification) {
serviceRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)
}
@@ -215,8 +214,8 @@ constructor(
Logger.d { "ViewModel created" }
}
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<AdminProtos.SharedContact?>
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<SharedContact?>
get() = _sharedContactRequested.asStateFlow()
fun setSharedContactRequested(url: Uri, onFailure: () -> Unit) {
@@ -236,8 +235,8 @@ constructor(
val connectionState
get() = serviceRepository.connectionState
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
val requestChannelSet: StateFlow<ChannelSet?>
get() = _requestChannelSet
fun requestChannelUrl(url: Uri, onFailure: () -> Unit) =

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,16 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.network
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.ignoreException
import com.google.protobuf.ByteString
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import okio.ByteString.Companion.toByteString
import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.MqttAsyncClient
@@ -35,8 +34,7 @@ import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.subscribeList
import org.meshtastic.proto.MeshProtos.MqttClientProxyMessage
import org.meshtastic.proto.mqttClientProxyMessage
import org.meshtastic.proto.MqttClientProxyMessage
import java.net.URI
import java.security.SecureRandom
import javax.inject.Inject
@@ -87,14 +85,14 @@ constructor(
// Create a custom SSLContext that trusts all certificates
sslContext.init(null, arrayOf<TrustManager>(TrustAllX509TrustManager()), SecureRandom())
val rootTopic = mqttConfig.root.ifEmpty { DEFAULT_TOPIC_ROOT }
val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT }
val connectOptions =
MqttConnectOptions().apply {
userName = mqttConfig.username
password = mqttConfig.password.toCharArray()
userName = mqttConfig?.username
password = mqttConfig?.password?.toCharArray()
isAutomaticReconnect = true
if (mqttConfig.tlsEnabled) {
if (mqttConfig?.tls_enabled == true) {
socketFactory = sslContext.socketFactory
}
}
@@ -117,7 +115,7 @@ constructor(
}
.forEach { globalId ->
subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+")
if (mqttConfig.jsonEnabled) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
if (mqttConfig?.json_enabled == true) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
}
subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+")
}
@@ -129,11 +127,11 @@ constructor(
override fun messageArrived(topic: String, message: MqttMessage) {
trySend(
mqttClientProxyMessage {
this.topic = topic
data = ByteString.copyFrom(message.payload)
retained = message.isRetained
},
MqttClientProxyMessage(
topic = topic,
data_ = message.payload.toByteString(),
retained = message.isRetained,
),
)
}
@@ -142,12 +140,11 @@ constructor(
}
}
val scheme = if (mqttConfig.tlsEnabled) "ssl" else "tcp"
val scheme = if (mqttConfig?.tls_enabled == true) "ssl" else "tcp"
val (host, port) =
mqttConfig.address
.ifEmpty { DEFAULT_SERVER_ADDRESS }
.split(":", limit = 2)
.let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1) }
(mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let {
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1)
}
mqttClient =
MqttAsyncClient(URI(scheme, null, host, port, "", "", "").toString(), ownerId, MemoryPersistence()).apply {

View File

@@ -19,39 +19,40 @@ package com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.model.getInitials
import com.google.protobuf.ByteString
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigKt
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.ModuleConfigProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.channel
import org.meshtastic.proto.config
import org.meshtastic.proto.deviceMetadata
import org.meshtastic.proto.fromRadio
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.queueStatus
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.User
import kotlin.random.Random
import org.meshtastic.proto.Channel as ProtoChannel
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
private val defaultLoRaConfig =
ConfigKt.loRaConfig {
usePreset = true
region = ConfigProtos.Config.LoRaConfig.RegionCode.TW
}
private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW)
private val defaultChannel = channel {
settings = Channel.default.settings
role = ChannelProtos.Channel.Role.PRIMARY
}
private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY)
/** A simulated interface that is used for testing in the simulator */
@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
@@ -77,46 +78,57 @@ constructor(
}
override fun handleSendToRadio(p: ByteArray) {
val pr = MeshProtos.ToRadio.parseFrom(p)
sendQueueStatus(pr.packet.id)
val pr = ToRadio.ADAPTER.decode(p)
val packet = pr.packet
if (packet != null) {
sendQueueStatus(packet.id)
}
val data = if (pr.hasPacket()) pr.packet.decoded else null
val data = packet?.decoded
when {
pr.wantConfigId != 0 -> sendConfigResponse(pr.wantConfigId)
data != null && data.portnum == Portnums.PortNum.ADMIN_APP ->
handleAdminPacket(pr, AdminProtos.AdminMessage.parseFrom(data.payload))
pr.hasPacket() && pr.packet.wantAck -> sendFakeAck(pr)
(pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0)
data != null && data.portnum == PortNum.ADMIN_APP ->
handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload))
packet != null && packet.want_ack == true -> sendFakeAck(pr)
else -> Logger.i { "Ignoring data sent to mock interface $pr" }
}
}
private fun handleAdminPacket(pr: MeshProtos.ToRadio, d: AdminProtos.AdminMessage) {
private fun handleAdminPacket(pr: ToRadio, d: AdminMessage) {
val packet = pr.packet ?: return
when {
d.getConfigRequest == AdminProtos.AdminMessage.ConfigType.LORA_CONFIG ->
sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) {
getConfigResponse = config { lora = defaultLoRaConfig }
d.get_config_request == AdminMessage.ConfigType.LORA_CONFIG ->
sendAdmin(packet.to, packet.from, packet.id) {
copy(get_config_response = Config(lora = defaultLoRaConfig))
}
d.getChannelRequest != 0 ->
sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) {
getChannelResponse = channel {
index = d.getChannelRequest - 1 // 0 based on the response
if (d.getChannelRequest == 1) {
settings = Channel.default.settings
role = ChannelProtos.Channel.Role.PRIMARY
}
}
(d.get_channel_request ?: 0) != 0 ->
sendAdmin(packet.to, packet.from, packet.id) {
copy(
get_channel_response =
ProtoChannel(
index = (d.get_channel_request ?: 0) - 1, // 0 based on the response
settings = if (d.get_channel_request == 1) Channel.default.settings else null,
role =
if (d.get_channel_request == 1) {
ProtoChannel.Role.PRIMARY
} else {
ProtoChannel.Role.DISABLED
},
),
)
}
d.getModuleConfigRequest == AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG ->
sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) {
getModuleConfigResponse = moduleConfig {
statusmessage =
ModuleConfigProtos.ModuleConfig.StatusMessageConfig.newBuilder()
.setNodeStatus("Going to the farm.. to grow wheat.")
.build()
}
d.get_module_config_request == AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG ->
sendAdmin(packet.to, packet.from, packet.id) {
copy(
get_module_config_response =
ModuleConfig(
statusmessage =
ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."),
),
)
}
else -> Logger.i { "Ignoring admin sent to mock interface $d" }
@@ -128,207 +140,169 @@ constructor(
}
// / Generate a fake text message from a node
private fun makeTextMessage(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply {
private fun makeTextMessage(numIn: Int) = FromRadio(
packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = packetIdSequence.next()
from = numIn
to = 0xffffffff.toInt() // ugly way of saying broadcast
rxTime = (System.currentTimeMillis() / 1000).toInt()
rxSnr = 1.5f
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnum = Portnums.PortNum.TEXT_MESSAGE_APP
payload = ByteString.copyFromUtf8("This simulated node sends Hi!")
}
.build()
}
.build()
}
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "This simulated node sends Hi!".encodeUtf8(),
),
),
)
private fun makeNeighborInfo(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply {
private fun makeNeighborInfo(numIn: Int) = FromRadio(
packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = packetIdSequence.next()
from = numIn
to = 0xffffffff.toInt() // broadcast
rxTime = (System.currentTimeMillis() / 1000).toInt()
rxSnr = 1.5f
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnum = Portnums.PortNum.NEIGHBORINFO_APP
payload =
MeshProtos.NeighborInfo.newBuilder()
.setNodeId(numIn)
.setLastSentById(numIn)
.setNodeBroadcastIntervalSecs(60)
.addNeighbors(
MeshProtos.Neighbor.newBuilder()
.setNodeId(numIn + 1)
.setSnr(10.0f)
.setLastRxTime((System.currentTimeMillis() / 1000).toInt())
.setNodeBroadcastIntervalSecs(60)
.build(),
)
.addNeighbors(
MeshProtos.Neighbor.newBuilder()
.setNodeId(numIn + 2)
.setSnr(12.0f)
.setLastRxTime((System.currentTimeMillis() / 1000).toInt())
.setNodeBroadcastIntervalSecs(60)
.build(),
)
.build()
.toByteString()
}
.build()
}
.build()
}
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
payload =
NeighborInfo(
node_id = numIn,
last_sent_by_id = numIn,
node_broadcast_interval_secs = 60,
neighbors =
listOf(
Neighbor(
node_id = numIn + 1,
snr = 10.0f,
last_rx_time = (System.currentTimeMillis() / 1000).toInt(),
node_broadcast_interval_secs = 60,
),
Neighbor(
node_id = numIn + 2,
snr = 12.0f,
last_rx_time = (System.currentTimeMillis() / 1000).toInt(),
node_broadcast_interval_secs = 60,
),
),
)
.encode()
.toByteString(),
),
),
)
private fun makePosition(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply {
private fun makePosition(numIn: Int) = FromRadio(
packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = packetIdSequence.next()
from = numIn
to = 0xffffffff.toInt() // ugly way of saying broadcast
rxTime = (System.currentTimeMillis() / 1000).toInt()
rxSnr = 1.5f
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnum = Portnums.PortNum.POSITION_APP
payload =
MeshProtos.Position.newBuilder()
.setLatitudeI(Position.degI(32.776665))
.setLongitudeI(Position.degI(-96.796989))
.setAltitude(150)
.setTime((System.currentTimeMillis() / 1000).toInt())
.setPrecisionBits(15)
.build()
.toByteString()
}
.build()
}
.build()
}
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload =
ProtoPosition(
latitude_i = org.meshtastic.core.model.Position.degI(32.776665),
longitude_i = org.meshtastic.core.model.Position.degI(-96.796989),
altitude = 150,
time = (System.currentTimeMillis() / 1000).toInt(),
precision_bits = 15,
)
.encode()
.toByteString(),
),
),
)
private fun makeTelemetry(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply {
private fun makeTelemetry(numIn: Int) = FromRadio(
packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = packetIdSequence.next()
from = numIn
to = 0xffffffff.toInt() // broadcast
rxTime = (System.currentTimeMillis() / 1000).toInt()
rxSnr = 1.5f
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnum = Portnums.PortNum.TELEMETRY_APP
payload =
TelemetryProtos.Telemetry.newBuilder()
.setTime((System.currentTimeMillis() / 1000).toInt())
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(85)
.setVoltage(4.1f)
.setChannelUtilization(0.12f)
.setAirUtilTx(0.05f)
.setUptimeSeconds(123456)
.build(),
)
.build()
.toByteString()
}
.build()
}
.build()
}
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.TELEMETRY_APP,
payload =
Telemetry(
time = (System.currentTimeMillis() / 1000).toInt(),
device_metrics =
DeviceMetrics(
battery_level = 85,
voltage = 4.1f,
channel_utilization = 0.12f,
air_util_tx = 0.05f,
uptime_seconds = 123456,
),
)
.encode()
.toByteString(),
),
),
)
private fun makeNodeStatus(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply {
private fun makeNodeStatus(numIn: Int) = FromRadio(
packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = packetIdSequence.next()
from = numIn
to = 0xffffffff.toInt() // broadcast
rxTime = (System.currentTimeMillis() / 1000).toInt()
rxSnr = 1.5f
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnum = Portnums.PortNum.NODE_STATUS_APP
payload =
MeshProtos.StatusMessage.newBuilder()
.setStatus("Going to the farm.. to grow wheat.")
.build()
.toByteString()
}
.build()
}
.build()
}
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.NODE_STATUS_APP,
payload =
StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(),
),
),
)
private fun makeDataPacket(fromIn: Int, toIn: Int, data: MeshProtos.Data.Builder) =
MeshProtos.FromRadio.newBuilder().apply {
packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = packetIdSequence.next()
from = fromIn
to = toIn
rxTime = (System.currentTimeMillis() / 1000).toInt()
rxSnr = 1.5f
decoded = data.build()
}
.build()
}
private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = fromIn,
to = toIn,
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_snr = 1.5f,
decoded = data,
),
)
private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = makeDataPacket(
fromIn,
toIn,
MeshProtos.Data.newBuilder().apply {
portnum = Portnums.PortNum.ROUTING_APP
payload = MeshProtos.Routing.newBuilder().apply {}.build().toByteString()
requestId = msgId
},
Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId),
)
private fun sendQueueStatus(msgId: Int) = service.handleFromRadio(
fromRadio {
queueStatus = queueStatus {
res = 0
free = 16
meshPacketId = msgId
}
}
.toByteArray(),
FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(),
)
private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminProtos.AdminMessage.Builder.() -> Unit) {
private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) {
val adminMsg = AdminMessage().initFn()
val p =
makeDataPacket(
fromIn,
toIn,
MeshProtos.Data.newBuilder().apply {
portnum = Portnums.PortNum.ADMIN_APP
payload = AdminProtos.AdminMessage.newBuilder().also { initFn(it) }.build().toByteString()
requestId = reqId
},
Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId),
)
service.handleFromRadio(p.build().toByteArray())
service.handleFromRadio(p.encode())
}
// / Send a fake ack packet back if the sender asked for want_ack
private fun sendFakeAck(pr: MeshProtos.ToRadio) = service.serviceScope.handledLaunch {
private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch {
val packet = pr.packet ?: return@handledLaunch
delay(2000)
service.handleFromRadio(makeAck(MY_NODE + 1, pr.packet.from, pr.packet.id).build().toByteArray())
service.handleFromRadio(makeAck(MY_NODE + 1, packet.from ?: 0, packet.id).encode())
}
private fun sendConfigResponse(configId: Int) {
@@ -336,57 +310,45 @@ constructor(
// / Generate a fake node info entry
@Suppress("MagicNumber")
fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = MeshProtos.FromRadio.newBuilder().apply {
nodeInfo =
MeshProtos.NodeInfo.newBuilder()
.apply {
num = numIn
user =
MeshProtos.User.newBuilder()
.apply {
id = DataPacket.nodeNumToDefaultId(numIn)
longName = "Sim " + Integer.toHexString(num)
shortName = getInitials(longName)
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
}
.build()
position =
MeshProtos.Position.newBuilder()
.apply {
latitudeI = Position.degI(lat)
longitudeI = Position.degI(lon)
altitude = 35
time = (System.currentTimeMillis() / 1000).toInt()
precisionBits = Random.nextInt(10, 19)
}
.build()
}
.build()
}
fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio(
node_info =
NodeInfo(
num = numIn,
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
long_name = "Sim " + Integer.toHexString(numIn),
short_name = getInitials("Sim " + Integer.toHexString(numIn)),
hw_model = HardwareModel.ANDROID_SIM,
),
position =
ProtoPosition(
latitude_i = org.meshtastic.core.model.Position.degI(lat),
longitude_i = org.meshtastic.core.model.Position.degI(lon),
altitude = 35,
time = (System.currentTimeMillis() / 1000).toInt(),
precision_bits = Random.nextInt(10, 19),
),
),
)
// Simulated network data to feed to our app
val packets =
arrayOf(
// MyNodeInfo
MeshProtos.FromRadio.newBuilder().apply {
myInfo = MeshProtos.MyNodeInfo.newBuilder().apply { myNodeNum = MY_NODE }.build()
},
MeshProtos.FromRadio.newBuilder().apply {
metadata = deviceMetadata {
firmwareVersion = "9.9.9.abcdefg"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
}
},
FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)),
FromRadio(
metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM),
),
// Fake NodeDB
makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas
makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
MeshProtos.FromRadio.newBuilder().apply { config = config { lora = defaultLoRaConfig } },
MeshProtos.FromRadio.newBuilder().apply { channel = defaultChannel },
MeshProtos.FromRadio.newBuilder().apply { configCompleteId = configId },
FromRadio(config = Config(lora = defaultLoRaConfig)),
FromRadio(channel = defaultChannel),
FromRadio(config_complete_id = configId),
// Done with config response, now pretend to receive some text messages
makeTextMessage(MY_NODE + 1),
makeNeighborInfo(MY_NODE + 1),
makePosition(MY_NODE + 1),
@@ -394,6 +356,6 @@ constructor(
makeNodeStatus(MY_NODE + 1),
)
packets.forEach { p -> service.handleFromRadio(p.build().toByteArray()) }
packets.forEach { p -> service.handleFromRadio(p.encode()) }
}
}

View File

@@ -49,7 +49,8 @@ import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
@@ -149,9 +150,8 @@ constructor(
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
if (radioIf is SerialInterface) {
Logger.i { "Sending ToRadio heartbeat" }
val heartbeat =
MeshProtos.ToRadio.newBuilder().setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance()).build()
handleSendToRadio(heartbeat.toByteArray())
val heartbeat = ToRadio(heartbeat = Heartbeat())
handleSendToRadio(heartbeat.encode())
} else {
// For BLE and TCP this will check if the connection is still alive
radioIf.keepAlive()
@@ -234,8 +234,6 @@ constructor(
}
}
// ignoreException { Logger.d { "FromRadio: ${MeshProtos.FromRadio.parseFrom(p }}" } }
try {
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
emitReceiveActivity()

View File

@@ -25,6 +25,8 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.IOException
@@ -166,11 +168,8 @@ constructor(
override fun keepAlive() {
Logger.d { "[$address] TCP keepAlive" }
val heartbeat =
org.meshtastic.proto.MeshProtos.ToRadio.newBuilder()
.setHeartbeat(org.meshtastic.proto.MeshProtos.Heartbeat.getDefaultInstance())
.build()
handleSendToRadio(heartbeat.toByteArray())
val heartbeat = ToRadio(heartbeat = Heartbeat())
handleSendToRadio(heartbeat.encode())
}
// Create a socket to make the connection with the server

View File

@@ -19,13 +19,13 @@ package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.FromRadio
import javax.inject.Inject
import javax.inject.Singleton
/**
* Dispatches non-packet [MeshProtos.FromRadio] variants to their respective handlers. This class is stateless and
* handles routing for config, metadata, and specialized system messages.
* Dispatches non-packet [FromRadio] variants to their respective handlers. This class is stateless and handles routing
* for config, metadata, and specialized system messages.
*/
@Singleton
class FromRadioPacketHandler
@@ -38,41 +38,47 @@ constructor(
private val serviceNotifications: MeshServiceNotifications,
) {
@Suppress("CyclomaticComplexMethod")
fun handleFromRadio(proto: MeshProtos.FromRadio) {
when (proto.payloadVariantCase) {
MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> router.configFlowManager.handleMyInfo(proto.myInfo)
MeshProtos.FromRadio.PayloadVariantCase.METADATA ->
router.configFlowManager.handleLocalMetadata(proto.metadata)
MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> {
router.configFlowManager.handleNodeInfo(proto.nodeInfo)
fun handleFromRadio(proto: FromRadio) {
val myInfo = proto.my_info
val metadata = proto.metadata
val nodeInfo = proto.node_info
val configCompleteId = proto.config_complete_id
val mqttProxyMessage = proto.mqttClientProxyMessage
val queueStatus = proto.queueStatus
val config = proto.config
val moduleConfig = proto.moduleConfig
val channel = proto.channel
val clientNotification = proto.clientNotification
when {
myInfo != null -> router.configFlowManager.handleMyInfo(myInfo)
metadata != null -> router.configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
router.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})")
}
MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID ->
router.configFlowManager.handleConfigComplete(proto.configCompleteId)
MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE ->
mqttManager.handleMqttProxyMessage(proto.mqttClientProxyMessage)
MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> packetHandler.handleQueueStatus(proto.queueStatus)
MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> router.configHandler.handleDeviceConfig(proto.config)
MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG ->
router.configHandler.handleModuleConfig(proto.moduleConfig)
MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> router.configHandler.handleChannel(proto.channel)
MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION -> {
serviceRepository.setClientNotification(proto.clientNotification)
serviceNotifications.showClientNotification(proto.clientNotification)
packetHandler.removeResponse(proto.clientNotification.replyId, complete = false)
configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
config != null -> router.configHandler.handleDeviceConfig(config)
moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig)
channel != null -> router.configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false)
}
// Logging-only variants are handled by MeshMessageProcessor before dispatching here
MeshProtos.FromRadio.PayloadVariantCase.PACKET,
MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD,
MeshProtos.FromRadio.PayloadVariantCase.REBOOTED,
MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET,
MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG,
MeshProtos.FromRadio.PayloadVariantCase.FILEINFO,
-> {
proto.packet != null ||
proto.log_record != null ||
proto.rebooted != null ||
proto.xmodemPacket != null ||
proto.deviceuiConfig != null ||
proto.fileInfo != null -> {
/* No specialized routing needed here */
}
else -> Logger.d { "Dispatcher ignoring ${proto.payloadVariantCase}" }
else -> Logger.d { "Dispatcher ignoring FromRadio variant" }
}
}
}

View File

@@ -18,11 +18,11 @@ package com.geeksville.mesh.service
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.util.ignoreException
import com.google.protobuf.ByteString
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.PacketRepository
@@ -34,17 +34,17 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.ModuleConfigProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.user
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.OTAMode
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
import javax.inject.Inject
import javax.inject.Singleton
@Suppress("LongParameterList", "TooManyFunctions")
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class MeshActionHandler
@Inject
@@ -80,10 +80,12 @@ constructor(
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
commandSender.sendAdmin(myNodeNum) { addContact = action.contact }
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
is ServiceAction.GetDeviceMetadata -> {
commandSender.sendAdmin(action.destNum, wantResponse = true) { getDeviceMetadataRequest = true }
commandSender.sendAdmin(action.destNum, wantResponse = true) {
AdminMessage(get_device_metadata_request = true)
}
}
}
}
@@ -92,7 +94,11 @@ constructor(
private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isFavorite) removeFavoriteNode = node.num else setFavoriteNode = node.num
if (node.isFavorite) {
AdminMessage(remove_favorite_node = node.num)
} else {
AdminMessage(set_favorite_node = node.num)
}
}
nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
}
@@ -100,14 +106,18 @@ constructor(
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isIgnored) removeIgnoredNode = node.num else setIgnoredNode = node.num
if (node.isIgnored) {
AdminMessage(remove_ignored_node = node.num)
} else {
AdminMessage(set_ignored_node = node.num)
}
}
nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) { toggleMutedNode = node.num }
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
nodeManager.updateNodeInfo(node.num) { it.isMuted = !node.isMuted }
}
@@ -118,8 +128,8 @@ constructor(
org.meshtastic.core.model
.DataPacket(
to = destId,
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = action.emoji.encodeToByteArray(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
bytes = action.emoji.encodeToByteArray().toByteString(),
channel = channel,
replyId = action.replyId,
wantAck = true,
@@ -131,9 +141,13 @@ constructor(
}
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
val verifiedContact = action.contact.toBuilder().setManuallyVerified(true).build()
commandSender.sendAdmin(myNodeNum) { addContact = verifiedContact }
nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true)
val verifiedContact = action.contact.copy(manually_verified = true)
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
nodeManager.handleReceivedUser(
verifiedContact.node_num ?: 0,
verifiedContact.user ?: User(),
manuallyVerified = true,
)
}
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
@@ -158,30 +172,16 @@ constructor(
}
fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) {
commandSender.sendAdmin(myNodeNum) {
setOwner = user {
id = u.id
longName = u.longName
shortName = u.shortName
isLicensed = u.isLicensed
}
}
nodeManager.handleReceivedUser(
myNodeNum,
user {
id = u.id
longName = u.longName
shortName = u.shortName
isLicensed = u.isLicensed
},
)
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
}
fun handleSend(p: DataPacket, myNodeNum: Int) {
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p)
dataHandler.rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: ByteArray(0)
val bytes = p.bytes ?: okio.ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
@@ -200,79 +200,83 @@ constructor(
fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
commandSender.sendAdmin(myNodeNum, requestId) { removeByNodenum = nodeNum }
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
}
fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = MeshProtos.User.parseFrom(payload)
commandSender.sendAdmin(destNum, id) { setOwner = u }
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
}
fun handleGetRemoteOwner(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { getOwnerRequest = true }
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
}
fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
val c = ConfigProtos.Config.parseFrom(payload)
commandSender.sendAdmin(myNodeNum) { setConfig = c }
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
}
fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ConfigProtos.Config.parseFrom(payload)
commandSender.sendAdmin(destNum, id) { setConfig = c }
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
}
fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) {
getDeviceMetadataRequest = true
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
AdminMessage(get_device_metadata_request = true)
} else {
getConfigRequestValue = config
AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config))
}
}
}
fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
commandSender.sendAdmin(destNum, id) { setModuleConfig = c }
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
}
fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { getModuleConfigRequestValue = config }
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
}
}
fun handleSetRingtone(destNum: Int, ringtone: String) {
commandSender.sendAdmin(destNum) { setRingtoneMessage = ringtone }
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
}
fun handleGetRingtone(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { getRingtoneRequest = true }
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
}
fun handleSetCannedMessages(destNum: Int, messages: String) {
commandSender.sendAdmin(destNum) { setCannedMessageModuleMessages = messages }
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
}
fun handleGetCannedMessages(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { getCannedMessageModuleMessagesRequest = true }
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_canned_message_module_messages_request = true)
}
}
fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
if (payload != null) {
val c = ChannelProtos.Channel.parseFrom(payload)
commandSender.sendAdmin(myNodeNum) { setChannel = c }
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
}
}
fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
if (payload != null) {
val c = ChannelProtos.Channel.parseFrom(payload)
commandSender.sendAdmin(destNum, id) { setChannel = c }
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
}
}
fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { getChannelRequest = index + 1 }
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
}
fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
@@ -280,15 +284,15 @@ constructor(
}
fun handleBeginEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { beginEditSettings = true }
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
}
fun handleCommitEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { commitEditSettings = true }
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
}
fun handleRebootToDfu(destNum: Int) {
commandSender.sendAdmin(destNum) { enterDfuModeRequest = true }
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
}
fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
@@ -296,33 +300,32 @@ constructor(
}
fun handleRequestShutdown(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { shutdownSeconds = DEFAULT_REBOOT_DELAY }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
}
fun handleRequestReboot(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { rebootSeconds = DEFAULT_REBOOT_DELAY }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = AdminProtos.OTAMode.forNumber(mode) ?: AdminProtos.OTAMode.NO_REBOOT_OTA
val otaEventBuilder = AdminProtos.AdminMessage.OTAEvent.newBuilder()
otaEventBuilder.rebootOtaMode = otaMode
if (hash != null) {
otaEventBuilder.otaHash = ByteString.copyFrom(hash)
}
commandSender.sendAdmin(destNum, requestId) { otaRequest = otaEventBuilder.build() }
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { factoryResetDevice = 1 }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
commandSender.sendAdmin(destNum, requestId) { nodedbReset = preserveFavorites }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
}
fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId, wantResponse = true) { getDeviceConnectionStatusRequest = true }
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
AdminMessage(get_device_connection_status_request = true)
}
}
fun handleUpdateLastAddress(deviceAddr: String?) {

View File

@@ -19,29 +19,31 @@ package com.geeksville.mesh.service
import android.os.RemoteException
import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.paxcount
import org.meshtastic.proto.position
import org.meshtastic.proto.telemetry
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
@@ -68,17 +70,13 @@ constructor(
val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
val neighborInfoStartTimes = ConcurrentHashMap<Int, Long>()
private val localConfig = MutableStateFlow(LocalConfig.getDefaultInstance())
private val channelSet = MutableStateFlow(ChannelSet.getDefaultInstance())
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
@Volatile var lastNeighborInfo: MeshProtos.NeighborInfo? = null
@Volatile var lastNeighborInfo: NeighborInfo? = null
private val rememberDataType =
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.ALERT_APP_VALUE,
Portnums.PortNum.WAYPOINT_APP_VALUE,
)
setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.ALERT_APP.value, PortNum.WAYPOINT_APP.value)
fun start(scope: CoroutineScope) {
this.scope = scope
@@ -100,7 +98,7 @@ constructor(
sessionPasskey.set(key)
}
private fun computeHopLimit(): Int = localConfig.value.lora.hopLimit.takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
val myNum = nodeManager?.myNodeNum ?: return 0
@@ -112,7 +110,7 @@ constructor(
myNum == toNum -> 0
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
else ->
channelSet.value.settingsList
channelSet.value.settings
.indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
.coerceAtLeast(0)
}
@@ -121,11 +119,22 @@ constructor(
fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteArray(0)
val bytes = p.bytes ?: ByteString.EMPTY
require(p.dataType != 0) { "Port numbers must be non-zero!" }
if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) {
// Use Wire extension for accurate size validation
val data =
Data(
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
payload = bytes,
reply_id = p.replyId ?: 0,
emoji = p.emoji,
)
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
val actualSize = Data.ADAPTER.encodedSize(data)
p.status = MessageStatus.ERROR
throw RemoteException("Message too long")
throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
} else {
p.status = MessageStatus.QUEUED
}
@@ -144,17 +153,20 @@ constructor(
private fun sendNow(p: DataPacket) {
val meshPacket =
newMeshPacketTo(p.to ?: DataPacket.ID_BROADCAST).buildMeshPacket(
buildMeshPacket(
to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST),
id = p.id,
wantAck = p.wantAck,
hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(),
channel = p.channel,
) {
portnumValue = p.dataType
payload = ByteString.copyFrom(p.bytes ?: ByteArray(0))
p.replyId?.let { if (it != 0) replyId = it }
if (p.emoji != 0) emoji = p.emoji
}
decoded =
Data(
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
payload = p.bytes ?: ByteString.EMPTY,
reply_id = p.replyId ?: 0,
emoji = p.emoji,
),
)
p.time = System.currentTimeMillis()
packetHandler?.sendToRadio(meshPacket)
}
@@ -182,64 +194,73 @@ constructor(
destNum: Int,
requestId: Int = generatePacketId(),
wantResponse: Boolean = false,
initFn: AdminProtos.AdminMessage.Builder.() -> Unit,
initFn: () -> AdminMessage,
) {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.get())
val packet =
newMeshPacketTo(destNum).buildAdminPacket(id = requestId, wantResponse = wantResponse, initFn = initFn)
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
packetHandler?.sendToRadio(packet)
}
fun sendPosition(pos: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) {
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) {
val myNum = nodeManager?.myNodeNum ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum ${Position(pos)}" }
Logger.d { "Sending our position/time to=$idNum $pos" }
if (!localConfig.value.position.fixedPosition) {
if (localConfig.value.position?.fixed_position != true) {
nodeManager.handleReceivedPosition(myNum, myNum, pos)
}
packetHandler?.sendToRadio(
newMeshPacketTo(idNum).buildMeshPacket(
buildMeshPacket(
to = idNum,
channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
priority = MeshPacket.Priority.BACKGROUND,
) {
portnumValue = Portnums.PortNum.POSITION_APP_VALUE
payload = pos.toByteString()
this.wantResponse = wantResponse
},
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload = pos.encode().toByteString(),
want_response = wantResponse,
),
),
)
}
fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition = position {
latitudeI = Position.degI(currentPosition.latitude)
longitudeI = Position.degI(currentPosition.longitude)
altitude = currentPosition.altitude
time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt()
}
val meshPosition =
org.meshtastic.proto.Position(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt(),
)
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
buildMeshPacket(
to = destNum,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
priority = MeshPacket.Priority.BACKGROUND,
) {
portnumValue = Portnums.PortNum.POSITION_APP_VALUE
payload = meshPosition.toByteString()
wantResponse = true
},
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload = meshPosition.encode().toByteString(),
want_response = true,
),
),
)
}
fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos = position {
latitudeI = Position.degI(pos.latitude)
longitudeI = Position.degI(pos.longitude)
altitude = pos.altitude
}
val meshPos =
org.meshtastic.proto.Position(
latitude_i = Position.degI(pos.latitude),
longitude_i = Position.degI(pos.longitude),
altitude = pos.altitude,
)
sendAdmin(destNum) {
if (pos != Position(0.0, 0.0, 0)) {
setFixedPosition = meshPos
AdminMessage(set_fixed_position = meshPos)
} else {
removeFixedPosition = true
AdminMessage(remove_fixed_position = true)
}
}
nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos)
@@ -249,64 +270,67 @@ constructor(
val myNum = nodeManager?.myNodeNum ?: return
val myNode = nodeManager.getOrCreateNodeInfo(myNum)
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0) {
portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE
wantResponse = true
payload = myNode.user.toByteString()
},
buildMeshPacket(
to = destNum,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded =
Data(
portnum = PortNum.NODEINFO_APP,
want_response = true,
payload = myNode.user.encode().toByteString(),
),
),
)
}
fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = System.currentTimeMillis()
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
) {
portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE
wantResponse = true
},
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true),
),
)
}
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: Portnums.PortNum
val portNum: PortNum
val payloadBytes: ByteString
if (type == TelemetryType.PAX) {
portNum = Portnums.PortNum.PAXCOUNTER_APP
payloadBytes = paxcount {}.toByteString()
portNum = PortNum.PAXCOUNTER_APP
payloadBytes = org.meshtastic.proto.Paxcount().encode().toByteString()
} else {
portNum = Portnums.PortNum.TELEMETRY_APP
portNum = PortNum.TELEMETRY_APP
payloadBytes =
telemetry {
when (type) {
TelemetryType.ENVIRONMENT ->
environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
TelemetryType.AIR_QUALITY ->
airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance()
TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
TelemetryType.HOST -> hostMetrics = TelemetryProtos.HostMetrics.getDefaultInstance()
}
}
Telemetry(
device_metrics =
if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null,
environment_metrics =
if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null,
air_quality_metrics =
if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null,
power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null,
local_stats =
if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null,
host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null,
)
.encode()
.toByteString()
}
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
buildMeshPacket(
to = destNum,
id = requestId,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
) {
portnumValue = portNum.number
payload = payloadBytes
wantResponse = true
},
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true),
),
)
}
@@ -319,44 +343,47 @@ constructor(
?: run {
val oneHour = 1.hours.inWholeMinutes.toInt()
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
MeshProtos.NeighborInfo.newBuilder()
.setNodeId(myNum)
.setLastSentById(myNum)
.setNodeBroadcastIntervalSecs(oneHour)
.addNeighbors(
MeshProtos.Neighbor.newBuilder()
.setNodeId(0) // Dummy node ID that can be intercepted
.setSnr(0f)
.setLastRxTime((System.currentTimeMillis() / TIME_MS_TO_S).toInt())
.setNodeBroadcastIntervalSecs(oneHour)
.build(),
)
.build()
NeighborInfo(
node_id = myNum,
last_sent_by_id = myNum,
node_broadcast_interval_secs = oneHour,
neighbors =
listOf(
Neighbor(
node_id = 0, // Dummy node ID that can be intercepted
snr = 0f,
last_rx_time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt(),
node_broadcast_interval_secs = oneHour,
),
),
)
}
// Send the neighbor info from our connected radio to ourselves (simulated)
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
) {
portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE
payload = neighborInfoToSend.toByteString()
wantResponse = true
},
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
payload = neighborInfoToSend.encode().toByteString(),
want_response = true,
),
),
)
} else {
// Send request to remote
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
) {
portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE
wantResponse = true
},
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true),
),
)
}
}
@@ -377,59 +404,60 @@ constructor(
}
}
private fun newMeshPacketTo(toId: String): MeshPacket.Builder {
val destNum = resolveNodeNum(toId)
return newMeshPacketTo(destNum)
}
private fun newMeshPacketTo(destNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply { to = destNum }
private fun MeshPacket.Builder.buildMeshPacket(
private fun buildMeshPacket(
to: Int,
wantAck: Boolean = false,
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
hopLimit: Int = 0,
channel: Int = 0,
priority: MeshPacket.Priority = MeshPacket.Priority.UNSET,
initFn: MeshProtos.Data.Builder.() -> Unit,
decoded: Data,
): MeshPacket {
this.id = id
this.wantAck = wantAck
val actualHopLimit = if (hopLimit > 0) hopLimit else computeHopLimit()
this.hopLimit = actualHopLimit
this.hopStart = actualHopLimit
this.priority = priority
var pkiEncrypted = false
var publicKey: ByteString = ByteString.EMPTY
var actualChannel = channel
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.publicKey?.let { publicKey = it }
} else {
this.channel = channel
publicKey = nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.public_key ?: ByteString.EMPTY
actualChannel = 0
}
this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build()
return build()
return MeshPacket(
to = to,
id = id,
want_ack = wantAck,
hop_limit = actualHopLimit,
hop_start = actualHopLimit,
priority = priority,
pki_encrypted = pkiEncrypted,
public_key = publicKey,
channel = actualChannel,
decoded = decoded,
)
}
private fun MeshPacket.Builder.buildAdminPacket(
private fun buildAdminPacket(
to: Int,
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
wantResponse: Boolean = false,
initFn: AdminProtos.AdminMessage.Builder.() -> Unit,
adminMessage: AdminMessage,
): MeshPacket =
buildMeshPacket(
to = to,
id = id,
wantAck = true,
channel = getAdminChannelIndex(to),
priority = MeshPacket.Priority.RELIABLE,
) {
this.wantResponse = wantResponse
portnumValue = Portnums.PortNum.ADMIN_APP_VALUE
payload =
AdminProtos.AdminMessage.newBuilder()
.apply(initFn)
.setSessionPasskey(sessionPasskey.get())
.build()
.toByteString()
}
decoded =
Data(
want_response = wantResponse,
portnum = PortNum.ADMIN_APP,
payload = adminMessage.encode().toByteString(),
),
)
companion object {
private const val PACKET_ID_MASK = 0xffffffffL

View File

@@ -28,7 +28,12 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -57,11 +62,11 @@ constructor(
this.scope = scope
}
private val newNodes = mutableListOf<MeshProtos.NodeInfo>()
private val newNodes = mutableListOf<NodeInfo>()
val newNodeCount: Int
get() = newNodes.size
private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null
private var rawMyNodeInfo: MyNodeInfo? = null
private var newMyNodeInfo: MyNodeEntity? = null
private var myNodeInfo: MyNodeEntity? = null
@@ -92,9 +97,7 @@ constructor(
private fun sendHeartbeat() {
try {
packetHandler.sendToRadio(
MeshProtos.ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() },
)
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat()))
Logger.d { "Heartbeat sent between nonce stages" }
} catch (ex: IOException) {
Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
@@ -127,10 +130,10 @@ constructor(
analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
}
fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.myNodeNum}" }
fun handleMyInfo(myInfo: MyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
rawMyNodeInfo = myInfo
nodeManager.myNodeNum = myInfo.myNodeNum
nodeManager.myNodeNum = myInfo.my_node_num ?: 0
regenMyNodeInfo()
scope.handledLaunch {
@@ -140,42 +143,42 @@ constructor(
}
}
fun handleLocalMetadata(metadata: MeshProtos.DeviceMetadata) {
fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received" }
regenMyNodeInfo(metadata)
}
fun handleNodeInfo(info: MeshProtos.NodeInfo) {
fun handleNodeInfo(info: NodeInfo) {
newNodes.add(info)
}
private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata? = MeshProtos.DeviceMetadata.getDefaultInstance()) {
private fun regenMyNodeInfo(metadata: DeviceMetadata? = DeviceMetadata()) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
val mi =
with(myInfo) {
MyNodeEntity(
myNodeNum = myNodeNum,
myNodeNum = my_node_num ?: 0,
model =
when (val hwModel = metadata?.hwModel) {
when (val hwModel = metadata?.hw_model) {
null,
MeshProtos.HardwareModel.UNSET,
HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = metadata?.firmwareVersion,
firmwareVersion = metadata?.firmware_version,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
messageTimeoutMsec = 300000,
minAppVersion = minAppVersion,
minAppVersion = min_app_version ?: 0,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
deviceId = deviceId.toStringUtf8(),
pioEnv = if (myInfo.pioEnv.isNullOrEmpty()) null else myInfo.pioEnv,
deviceId = device_id?.utf8() ?: "",
pioEnv = if (myInfo.pio_env.isNullOrEmpty()) null else myInfo.pio_env,
)
}
if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) {
if (metadata != null && metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
}
newMyNodeInfo = mi

View File

@@ -26,11 +26,11 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
import org.meshtastic.proto.ModuleConfigProtos
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import javax.inject.Inject
import javax.inject.Singleton
@@ -44,15 +44,12 @@ constructor(
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance())
private val _localConfig = MutableStateFlow(LocalConfig())
val localConfig = _localConfig.asStateFlow()
private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance())
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
val moduleConfig = _moduleConfig.asStateFlow()
private val configTotal = ConfigProtos.Config.getDescriptor().fields.size
private val moduleTotal = ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size
fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
@@ -60,28 +57,27 @@ constructor(
radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
}
fun handleDeviceConfig(config: ConfigProtos.Config) {
fun handleDeviceConfig(config: Config) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
val configCount = _localConfig.value.allFields.size
serviceRepository.setStatusMessage("Device config ($configCount / $configTotal)")
serviceRepository.setStatusMessage("Device config received")
}
fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
fun handleModuleConfig(config: ModuleConfig) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
val moduleCount = _moduleConfig.value.allFields.size
serviceRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)")
serviceRepository.setStatusMessage("Module config received")
}
fun handleChannel(ch: ChannelProtos.Channel) {
fun handleChannel(ch: Channel) {
// We always want to save channel settings we receive from the radio
scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) }
// Update status message if we have node info, otherwise use a generic one
val mi = nodeManager.getMyNodeInfo()
val index = ch.index ?: 0
if (mi != null) {
serviceRepository.setStatusMessage("Channels (${ch.index + 1} / ${mi.maxChannels})")
serviceRepository.setStatusMessage("Channels (${index + 1} / ${mi.maxChannels})")
} else {
serviceRepository.setStatusMessage("Channels (${ch.index + 1})")
serviceRepository.setStatusMessage("Channels (${index + 1})")
}
}
}

View File

@@ -42,9 +42,10 @@ import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos.ToRadio
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -101,8 +102,8 @@ constructor(
private fun onRadioConnectionState(newState: ConnectionState) {
scope.handledLaunch {
val localConfig = radioConfigRepository.localConfigFlow.first()
val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER
val lsEnabled = localConfig.power.isPowerSaving || isRouter
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
val effectiveState =
when (newState) {
@@ -161,7 +162,7 @@ constructor(
scope.handledLaunch {
try {
val localConfig = radioConfigRepository.localConfigFlow.first()
val timeout = (localConfig.power?.lsSecs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
delay(timeout.seconds)
Logger.w { "Device timeout out, setting disconnected" }
@@ -191,11 +192,11 @@ constructor(
}
fun startConfigOnly() {
packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = CONFIG_ONLY_NONCE })
packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
}
fun startNodeInfoOnly() {
packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = NODE_INFO_NONCE })
packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE))
}
fun onHasSettings() {
@@ -204,7 +205,11 @@ constructor(
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
mqttManager.start(scope, moduleConfig.mqtt.enabled, moduleConfig.mqtt.proxyToClientEnabled)
mqttManager.start(
scope,
moduleConfig.mqtt?.enabled == true,
moduleConfig.mqtt?.proxy_to_client_enabled == true,
)
}
reportConnection()
@@ -213,12 +218,14 @@ constructor(
// Request history
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
historyManager.requestHistoryReplay("onHasSettings", myNodeNum, moduleConfig.storeForward, "Unknown")
moduleConfig.store_forward?.let {
historyManager.requestHistoryReplay("onHasSettings", myNodeNum, it, "Unknown")
}
}
// Set time
commandSender.sendAdmin(myNodeNum) {
setTimeOnly = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()
AdminMessage(set_time_only = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt())
}
updateStatusNotification()
}
@@ -234,11 +241,11 @@ constructor(
)
}
fun updateTelemetry(telemetry: TelemetryProtos.Telemetry) {
fun updateTelemetry(telemetry: Telemetry) {
updateStatusNotification(telemetry)
}
fun updateStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification {
fun updateStatusNotification(telemetry: Telemetry? = null): Notification {
val summary =
when (connectionStateHolder.connectionState.value) {
is ConnectionState.Connected ->

View File

@@ -21,7 +21,6 @@ import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.InterfaceId
import com.google.protobuf.InvalidProtocolBufferException
import com.meshtastic.core.strings.getString
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
@@ -29,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.PacketRepository
@@ -38,6 +38,8 @@ import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.RetryEvent
@@ -48,20 +50,25 @@ import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.error_duty_cycle
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.strings.waypoint_received
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.StoreAndForwardProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass")
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
@Singleton
class MeshDataHandler
@Inject
@@ -93,10 +100,10 @@ constructor(
private val rememberDataType =
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.ALERT_APP_VALUE,
Portnums.PortNum.WAYPOINT_APP_VALUE,
Portnums.PortNum.NODE_STATUS_APP_VALUE,
PortNum.TEXT_MESSAGE_APP.value,
PortNum.ALERT_APP.value,
PortNum.WAYPOINT_APP.value,
PortNum.NODE_STATUS_APP.value,
)
fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) {
@@ -121,14 +128,15 @@ constructor(
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
when (packet.decoded.portnumValue) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleTextMessage(packet, dataPacket, myNodeNum)
Portnums.PortNum.NODE_STATUS_APP_VALUE -> handleNodeStatus(packet, dataPacket, myNodeNum)
Portnums.PortNum.ALERT_APP_VALUE -> rememberDataPacket(dataPacket, myNodeNum)
Portnums.PortNum.WAYPOINT_APP_VALUE -> handleWaypoint(packet, dataPacket, myNodeNum)
Portnums.PortNum.POSITION_APP_VALUE -> handlePosition(packet, dataPacket, myNodeNum)
Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromUs) handleNodeInfo(packet)
Portnums.PortNum.TELEMETRY_APP_VALUE -> handleTelemetry(packet, dataPacket, myNodeNum)
val decoded = packet.decoded ?: return shouldBroadcast
when (decoded.portnum) {
PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum)
PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum)
PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum)
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
PortNum.TELEMETRY_APP -> handleTelemetry(packet, dataPacket, myNodeNum)
else -> shouldBroadcast = handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob)
}
return shouldBroadcast
@@ -142,189 +150,192 @@ constructor(
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = false
when (packet.decoded.portnumValue) {
Portnums.PortNum.TRACEROUTE_APP_VALUE -> {
val decoded = packet.decoded ?: return shouldBroadcast
when (decoded.portnum) {
PortNum.TRACEROUTE_APP -> {
tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
shouldBroadcast = false
}
Portnums.PortNum.ROUTING_APP_VALUE -> {
PortNum.ROUTING_APP -> {
handleRouting(packet, dataPacket)
shouldBroadcast = true
}
Portnums.PortNum.PAXCOUNTER_APP_VALUE -> {
PortNum.PAXCOUNTER_APP -> {
handlePaxCounter(packet)
shouldBroadcast = false
}
Portnums.PortNum.STORE_FORWARD_APP_VALUE -> {
PortNum.STORE_FORWARD_APP -> {
handleStoreAndForward(packet, dataPacket, myNodeNum)
shouldBroadcast = false
}
Portnums.PortNum.STORE_FORWARD_PLUSPLUS_APP_VALUE -> {
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
handleStoreForwardPlusPlus(packet)
shouldBroadcast = false
}
Portnums.PortNum.ADMIN_APP_VALUE -> {
PortNum.ADMIN_APP -> {
handleAdminMessage(packet, myNodeNum)
shouldBroadcast = false
}
Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> {
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
shouldBroadcast = true
}
Portnums.PortNum.RANGE_TEST_APP_VALUE,
Portnums.PortNum.DETECTION_SENSOR_APP_VALUE,
PortNum.RANGE_TEST_APP,
PortNum.DETECTION_SENSOR_APP,
-> {
handleRangeTest(dataPacket, myNodeNum)
shouldBroadcast = false
}
else -> {}
}
return shouldBroadcast
}
private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val u = StoreAndForwardProtos.StoreAndForward.parseFrom(packet.decoded.payload)
val payload = packet.decoded?.payload ?: return
val u = StoreAndForward.ADAPTER.decode(payload)
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
MeshProtos.StoreForwardPlusPlus.parseFrom(packet.decoded.payload)
} catch (e: InvalidProtocolBufferException) {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfppMessageType) {
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> {
val isFragment = sfpp.sfppMessageType != MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
// If it has a commit hash, it's already on the chain (Confirmed)
// Otherwise it's still being routed via SF++ (Routing)
val status = if (sfpp.commitHash.isEmpty) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
val status =
if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
// Prefer a full 16-byte hash calculated from the message bytes if available
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
val hash =
when {
!sfpp.messageHash.isEmpty -> sfpp.messageHash.toByteArray()
!isFragment && !sfpp.message.isEmpty -> {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
to =
if (sfpp.encapsulatedTo == 0) DataPacket.NODENUM_BROADCAST else sfpp.encapsulatedTo,
from = sfpp.encapsulatedFrom,
id = sfpp.encapsulatedId,
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulatedId} from=${sfpp.encapsulatedFrom} " +
"to=${sfpp.encapsulatedTo} myNodeNum=${nodeManager.myNodeNum} status=$status"
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository
.get()
.updateSFPPStatus(
packetId = sfpp.encapsulatedId,
from = sfpp.encapsulatedFrom,
to = sfpp.encapsulatedTo,
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulatedId, status)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
scope.handledLaunch {
packetRepository
.get()
.updateSFPPStatusByHash(
hash = sfpp.messageHash.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL,
)
sfpp.message_hash.let {
packetRepository
.get()
.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
}
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
else -> {}
}
}
private fun handlePaxCounter(packet: MeshPacket) {
val p = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
val payload = packet.decoded?.payload ?: return
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
nodeManager.handleReceivedPaxcounter(packet.from, p)
}
private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val p = MeshProtos.Position.parseFrom(packet.decoded.payload)
val payload = packet.decoded?.payload ?: return
val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return
Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" }
nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time)
}
private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val u = MeshProtos.Waypoint.parseFrom(packet.decoded.payload)
if (u.lockedTo != 0 && u.lockedTo != packet.from) return
val payload = packet.decoded?.payload ?: return
val u = Waypoint.ADAPTER.decode(payload)
if (u.locked_to != 0 && u.locked_to != packet.from) return
val currentSecond = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
}
private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val u = AdminProtos.AdminMessage.parseFrom(packet.decoded.payload)
commandSender.setSessionPasskey(u.sessionPasskey)
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
u.session_passkey.let { commandSender.setSessionPasskey(it) }
if (packet.from == myNodeNum) {
when (u.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE ->
configHandler.handleDeviceConfig(u.getConfigResponse)
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE ->
configHandler.handleChannel(u.getChannelResponse)
else -> {}
}
val fromNum = packet.from
if (fromNum == myNodeNum) {
u.get_config_response?.let { configHandler.handleDeviceConfig(it) }
u.get_channel_response?.let { configHandler.handleChannel(it) }
}
if (u.payloadVariantCase == AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE) {
if (packet.from == myNodeNum) {
configFlowManager.handleLocalMetadata(u.getDeviceMetadataResponse)
u.get_device_metadata_response?.let { metadata ->
if (fromNum == myNodeNum) {
configFlowManager.handleLocalMetadata(metadata)
} else {
nodeManager.insertMetadata(packet.from, u.getDeviceMetadataResponse)
nodeManager.insertMetadata(fromNum, metadata)
}
}
}
private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
if (packet.decoded.replyId != 0 && packet.decoded.emoji != 0) {
val decoded = packet.decoded ?: return
if (decoded.reply_id != 0 && decoded.emoji != 0) {
rememberReaction(packet)
} else {
rememberDataPacket(dataPacket, myNodeNum)
@@ -332,25 +343,28 @@ constructor(
}
private fun handleNodeInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val u =
MeshProtos.User.parseFrom(packet.decoded.payload).copy {
if (isLicensed) clearPublicKey()
if (packet.viaMqtt) longName = "$longName (MQTT)"
}
User.ADAPTER.decode(payload)
.let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it }
.let { if (packet.via_mqtt == true) it.copy(long_name = "${it.long_name} (MQTT)") else it }
nodeManager.handleReceivedUser(packet.from, u, packet.channel)
}
private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val s = MeshProtos.StatusMessage.parseFrom(packet.decoded.payload)
val payload = packet.decoded?.payload ?: return
val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return
nodeManager.handleReceivedNodeStatus(packet.from, s)
rememberDataPacket(dataPacket, myNodeNum)
}
private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val t =
TelemetryProtos.Telemetry.parseFrom(packet.decoded.payload).copy {
if (time == 0) time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
}
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
@@ -358,13 +372,16 @@ constructor(
}
nodeManager.updateNodeInfo(fromNum) { nodeEntity ->
val metrics = t.device_metrics
val environment = t.environment_metrics
val power = t.power_metrics
when {
t.hasDeviceMetrics() -> {
metrics != null -> {
nodeEntity.deviceTelemetry = t
if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
if (
t.deviceMetrics.voltage > BATTERY_PERCENT_UNSUPPORTED &&
t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_LOW_THRESHOLD
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
@@ -378,24 +395,26 @@ constructor(
}
}
t.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = t
t.hasPowerMetrics() -> nodeEntity.powerTelemetry = t
environment != null -> nodeEntity.environmentTelemetry = t
power != null -> nodeEntity.powerTelemetry = t
}
}
}
private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry, myNodeNum: Int): Boolean {
private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
var forceDisplay = false
val metrics = t.device_metrics ?: return false
val batteryLevel = metrics.battery_level ?: 0
when {
t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
shouldDisplay = true
forceDisplay = true
}
t.deviceMetrics.batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
t.deviceMetrics.batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
isRemote -> shouldDisplay = true
}
@@ -411,31 +430,32 @@ constructor(
}
private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
val r = MeshProtos.Routing.parseFrom(packet.decoded.payload)
if (r.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) {
val payload = packet.decoded?.payload ?: return
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle))
}
handleAckNak(
packet.decoded.requestId,
packet.decoded?.request_id ?: 0,
dataMapper.toNodeID(packet.from),
r.errorReasonValue,
r.error_reason?.value ?: 0,
dataPacket.relayNode,
)
packetHandler.removeResponse(packet.decoded.requestId, complete = true)
packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) }
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
scope.handledLaunch {
val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE
val isAck = routingError == Routing.Error.NONE.value
val p = packetRepository.get().getPacketById(requestId)
val reaction = packetRepository.get().getReactionByPacketId(requestId)
val isMaxRetransmit = routingError == MeshProtos.Routing.Error.MAX_RETRANSMIT_VALUE
val isMaxRetransmit = routingError == Routing.Error.MAX_RETRANSMIT.value
val shouldRetry =
isMaxRetransmit &&
p != null &&
p.port_num == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE &&
p.port_num == PortNum.TEXT_MESSAGE_APP.value &&
(p.data.from == DataPacket.ID_LOCAL || p.data.from == nodeManager.getMyId()) &&
p.data.retryCount < MAX_RETRY_ATTEMPTS
@@ -482,7 +502,7 @@ constructor(
relayNode = null,
)
val updatedPacket =
p.copy(packetId = newId, data = updatedData, routingError = MeshProtos.Routing.Error.NONE_VALUE)
p.copy(packetId = newId, data = updatedData, routingError = Routing.Error.NONE.value)
packetRepository.get().update(updatedPacket)
Logger.w { "[ackNak] retrying req=$requestId newId=$newId retry=$newRetryCount" }
@@ -496,7 +516,7 @@ constructor(
return@handledLaunch
}
if (shouldRetryReaction && reaction != null) {
if (shouldRetryReaction) {
val newRetryCount = reaction.retryCount + 1
// Emit retry event to UI and wait for user response
@@ -519,8 +539,8 @@ constructor(
DataPacket(
to = reaction.to,
channel = reaction.channel,
bytes = reaction.emoji.toByteArray(Charsets.UTF_8),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = reaction.emoji.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
replyId = reaction.replyId,
wantAck = true,
emoji = reaction.emoji.codePointAt(0),
@@ -534,7 +554,7 @@ constructor(
status = MessageStatus.QUEUED,
retryCount = newRetryCount,
relayNode = null,
routingError = MeshProtos.Routing.Error.NONE_VALUE,
routingError = Routing.Error.NONE.value,
)
packetRepository.get().updateReaction(updatedReaction)
@@ -579,59 +599,53 @@ constructor(
}
}
private fun handleReceivedStoreAndForward(
dataPacket: DataPacket,
s: StoreAndForwardProtos.StoreAndForward,
myNodeNum: Int,
) {
Logger.d { "StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}" }
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
val transport = currentTransport()
val isHistory = s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY
val lastRequest = if (isHistory) s.history.lastRequest else 0
val h = s.history
val lastRequest = h?.last_request ?: 0
val baseContext = "transport=$transport from=${dataPacket.from}"
historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" }
when (s.variantCase) {
StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> {
historyLog { "rxStoreForward $baseContext lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
val u =
dataPacket.copy(
bytes = text.encodeToByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
}
StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> {
val h = s.history
h != null -> {
@Suppress("MaxLineLength")
historyLog(Log.DEBUG) {
"routerHistory $baseContext messages=${h.historyMessages} window=${h.window} lastReq=${h.lastRequest}"
"routerHistory $baseContext messages=${h.history_messages} window=${h.window} lastReq=${h.last_request}"
}
val text =
"Total messages: ${h.historyMessages}\n" +
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
"Last request: ${h.lastRequest}"
"Last request: ${h.last_request}"
val u =
dataPacket.copy(
bytes = text.encodeToByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
historyManager.updateStoreForwardLastRequest("router_history", h.lastRequest, transport)
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, transport)
}
StoreAndForwardProtos.StoreAndForward.VariantCase.HEARTBEAT -> {
val hb = s.heartbeat
s.heartbeat != null -> {
val hb = s.heartbeat!!
historyLog { "rxHeartbeat $baseContext period=${hb.period} secondary=${hb.secondary}" }
}
StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> {
if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
@Suppress("MaxLineLength")
historyLog(Log.DEBUG) {
"rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} to=${dataPacket.to} decision=remember"
}
val u =
dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
else -> {}
@@ -689,7 +703,7 @@ constructor(
}
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
if (dataPacket.dataType != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) return false
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
}
@@ -703,7 +717,7 @@ constructor(
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (packet.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isSilent) {
if (packet.port_num == PortNum.ALERT_APP.value && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
@@ -717,18 +731,18 @@ constructor(
private fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.longName ?: getString(Res.string.unknown_username)
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.longName ?: getString(Res.string.unknown_username)
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
when (dataPacket.dataType) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
PortNum.TEXT_MESSAGE_APP.value -> {
val message = dataPacket.text!!
val channelName =
if (dataPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow.first().settingsList.getOrNull(dataPacket.channel)?.name
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
} else {
null
}
@@ -742,7 +756,7 @@ constructor(
)
}
Portnums.PortNum.WAYPOINT_APP_VALUE -> {
PortNum.WAYPOINT_APP.value -> {
val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
serviceNotifications.updateWaypointNotification(
contactKey,
@@ -759,24 +773,25 @@ constructor(
@Suppress("LongMethod", "KotlinConstantConditions")
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
val emoji = packet.decoded.payload.toByteArray().decodeToString()
val decoded = packet.decoded ?: return@handledLaunch
val emoji = decoded.payload.toByteArray().decodeToString()
val fromId = dataMapper.toNodeID(packet.from)
val toId = dataMapper.toNodeID(packet.to)
val reaction =
ReactionEntity(
myNodeNum = nodeManager.myNodeNum ?: 0,
replyId = packet.decoded.replyId,
replyId = decoded.reply_id,
userId = fromId,
emoji = emoji,
timestamp = System.currentTimeMillis(),
snr = packet.rxSnr,
rssi = packet.rxRssi,
snr = packet.rx_snr,
rssi = packet.rx_rssi,
hopsAway =
if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) {
if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {
HOPS_AWAY_UNAVAILABLE
} else {
packet.hopStart - packet.hopLimit
packet.hop_start - packet.hop_limit
},
packetId = packet.id,
status = MessageStatus.RECEIVED,
@@ -788,7 +803,7 @@ constructor(
val existingReactions = packetRepository.get().findReactionsWithId(packet.id)
if (existingReactions.isNotEmpty()) {
Logger.d {
"Skipping duplicate reaction: packetId=${packet.id} replyId=${packet.decoded.replyId} " +
"Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " +
"from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))"
}
return@handledLaunch
@@ -797,7 +812,7 @@ constructor(
packetRepository.get().insertReaction(reaction)
// Find the original packet to get the contactKey
packetRepository.get().getPacketByPacketId(packet.decoded.replyId)?.let { original ->
packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { original ->
// Skip notification if the original message was filtered
if (original.packet.filtered) return@let
@@ -811,7 +826,7 @@ constructor(
if (original.packet.data.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow
.first()
.settingsList
.settings
.getOrNull(original.packet.data.channel)
?.name
} else {

View File

@@ -16,8 +16,9 @@
*/
package com.geeksville.mesh.service
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshPacket
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,27 +30,25 @@ class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManage
nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
}
fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) {
null
} else {
val data = packet.decoded
DataPacket(
fun toDataPacket(packet: MeshPacket): DataPacket? {
val decoded = packet.decoded ?: return null
return DataPacket(
from = toNodeID(packet.from),
to = toNodeID(packet.to),
time = packet.rxTime * 1000L,
time = packet.rx_time * 1000L,
id = packet.id,
dataType = data.portnumValue,
bytes = data.payload.toByteArray(),
hopLimit = packet.hopLimit,
channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.wantAck,
hopStart = packet.hopStart,
snr = packet.rxSnr,
rssi = packet.rxRssi,
replyId = data.replyId,
relayNode = packet.relayNode,
viaMqtt = packet.viaMqtt,
emoji = data.emoji,
dataType = decoded.portnum.value,
bytes = decoded.payload.toByteArray().toByteString(),
hopLimit = packet.hop_limit,
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.want_ack == true,
hopStart = packet.hop_start,
snr = packet.rx_snr,
rssi = packet.rx_rssi,
replyId = decoded.reply_id,
relayNode = packet.relay_node,
viaMqtt = packet.via_mqtt == true,
emoji = decoded.emoji,
)
}
}

View File

@@ -21,12 +21,13 @@ import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.google.protobuf.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.ModuleConfigProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.StoreAndForwardProtos
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import javax.inject.Inject
import javax.inject.Singleton
@@ -47,15 +48,14 @@ constructor(
lastRequest: Int,
historyReturnWindow: Int,
historyReturnMax: Int,
): StoreAndForwardProtos.StoreAndForward {
val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder()
if (lastRequest > 0) historyBuilder.lastRequest = lastRequest
if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow
if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax
return StoreAndForwardProtos.StoreAndForward.newBuilder()
.setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY)
.setHistory(historyBuilder)
.build()
): StoreAndForward {
val history =
StoreAndForward.History(
last_request = lastRequest.coerceAtLeast(0),
window = historyReturnWindow.coerceAtLeast(0),
history_messages = historyReturnMax.coerceAtLeast(0),
)
return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history)
}
@VisibleForTesting
@@ -86,7 +86,7 @@ constructor(
fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfigProtos.ModuleConfig.StoreForwardConfig?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
transport: String,
) {
val address = activeDeviceAddress()
@@ -99,8 +99,8 @@ constructor(
val lastRequest = meshPrefs.getStoreForwardLastRequest(address)
val (window, max) =
resolveHistoryRequestParameters(
storeForwardConfig?.historyReturnWindow ?: 0,
storeForwardConfig?.historyReturnMax ?: 0,
storeForwardConfig?.history_return_window ?: 0,
storeForwardConfig?.history_return_max ?: 0,
)
val request = buildStoreForwardHistoryRequest(lastRequest, window, max)
@@ -112,19 +112,11 @@ constructor(
runCatching {
packetHandler.sendToRadio(
MeshPacket.newBuilder()
.apply {
to = myNodeNum
decoded =
org.meshtastic.proto.MeshProtos.Data.newBuilder()
.apply {
portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE
payload = ByteString.copyFrom(request.toByteArray())
}
.build()
priority = MeshPacket.Priority.BACKGROUND
}
.build(),
MeshPacket(
to = myNodeNum,
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()),
priority = MeshPacket.Priority.BACKGROUND,
),
)
}
.onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } }

View File

@@ -29,11 +29,10 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.model.Position
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.position
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import org.meshtastic.proto.Position as ProtoPosition
@Singleton
class MeshLocationManager
@@ -46,7 +45,7 @@ constructor(
private var locationFlow: Job? = null
@SuppressLint("MissingPermission")
fun start(scope: CoroutineScope, sendPositionFn: (MeshProtos.Position) -> Unit) {
fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {
this.scope = scope
if (locationFlow?.isActive == true) return
@@ -56,18 +55,21 @@ constructor(
.getLocations()
.onEach { location ->
sendPositionFn(
position {
latitudeI = Position.degI(location.latitude)
longitudeI = Position.degI(location.longitude)
ProtoPosition(
latitude_i = Position.degI(location.latitude),
longitude_i = Position.degI(location.longitude),
altitude =
if (LocationCompat.hasMslAltitude(location)) {
altitude = LocationCompat.getMslAltitudeMeters(location).toInt()
}
altitudeHae = location.altitude.toInt()
time = (location.time.milliseconds.inWholeSeconds).toInt()
groundSpeed = location.speed.toInt()
groundTrack = location.bearing.toInt()
locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL
},
LocationCompat.getMslAltitudeMeters(location).toInt()
} else {
null
},
altitude_hae = location.altitude.toInt(),
time = (location.time.milliseconds.inWholeSeconds).toInt(),
ground_speed = location.speed.toInt(),
ground_track = location.bearing.toInt(),
location_source = ProtoPosition.LocSource.LOC_EXTERNAL,
),
)
}
.launchIn(scope)

View File

@@ -30,11 +30,10 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.FromRadio.PayloadVariantCase
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.fromRadio
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import java.util.ArrayDeque
import java.util.Locale
import java.util.UUID
@@ -77,17 +76,12 @@ constructor(
}
fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
runCatching { MeshProtos.FromRadio.parseFrom(bytes) }
.onSuccess { proto ->
if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) {
Logger.w { "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()}" }
}
processFromRadio(proto, myNodeNum)
}
runCatching { FromRadio.ADAPTER.decode(bytes) }
.onSuccess { proto -> processFromRadio(proto, myNodeNum) }
.onFailure { primaryException ->
runCatching {
val logRecord = MeshProtos.LogRecord.parseFrom(bytes)
processFromRadio(fromRadio { this.logRecord = logRecord }, myNodeNum)
val logRecord = LogRecord.ADAPTER.decode(bytes)
processFromRadio(FromRadio(log_record = logRecord), myNodeNum)
}
.onFailure { _ ->
Logger.e(primaryException) {
@@ -98,31 +92,32 @@ constructor(
}
}
private fun processFromRadio(proto: MeshProtos.FromRadio, myNodeNum: Int?) {
private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) {
// Audit log every incoming variant
logVariant(proto)
if (proto.payloadVariantCase == PayloadVariantCase.PACKET) {
handleReceivedMeshPacket(proto.packet, myNodeNum)
val packet = proto.packet
if (packet != null) {
handleReceivedMeshPacket(packet, myNodeNum)
} else {
fromRadioDispatcher.handleFromRadio(proto)
}
}
private fun logVariant(proto: MeshProtos.FromRadio) {
private fun logVariant(proto: FromRadio) {
val (type, message) =
when (proto.payloadVariantCase) {
PayloadVariantCase.LOG_RECORD -> "LogRecord" to proto.logRecord.toString()
PayloadVariantCase.REBOOTED -> "Rebooted" to proto.rebooted.toString()
PayloadVariantCase.XMODEMPACKET -> "XmodemPacket" to proto.xmodemPacket.toString()
PayloadVariantCase.DEVICEUICONFIG -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
PayloadVariantCase.FILEINFO -> "FileInfo" to proto.fileInfo.toString()
PayloadVariantCase.MY_INFO -> "MyInfo" to proto.myInfo.toString()
PayloadVariantCase.NODE_INFO -> "NodeInfo" to proto.nodeInfo.toString()
PayloadVariantCase.CONFIG -> "Config" to proto.config.toString()
PayloadVariantCase.MODULECONFIG -> "ModuleConfig" to proto.moduleConfig.toString()
PayloadVariantCase.CHANNEL -> "Channel" to proto.channel.toString()
PayloadVariantCase.CLIENTNOTIFICATION -> "ClientNotification" to proto.clientNotification.toString()
when {
proto.log_record != null -> "LogRecord" to proto.log_record.toString()
proto.rebooted != null -> "Rebooted" to proto.rebooted.toString()
proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString()
proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString()
proto.my_info != null -> "MyInfo" to proto.my_info.toString()
proto.node_info != null -> "NodeInfo" to proto.node_info.toString()
proto.config != null -> "Config" to proto.config.toString()
proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString()
proto.channel != null -> "Channel" to proto.channel.toString()
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
else -> return
}
@@ -139,8 +134,12 @@ constructor(
fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val rxTime =
if (packet.rxTime == 0) (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() else packet.rxTime
val preparedPacket = packet.toBuilder().setRxTime(rxTime).build()
if (packet.rx_time == 0) {
(System.currentTimeMillis().milliseconds.inWholeSeconds).toInt()
} else {
packet.rx_time
}
val preparedPacket = packet.copy(rx_time = rxTime)
if (nodeManager.isNodeDbReady.value) {
processReceivedMeshPacket(preparedPacket, myNodeNum)
@@ -151,23 +150,15 @@ constructor(
val dropped = earlyReceivedPackets.removeFirst()
historyLog(Log.WARN) {
val portLabel =
if (dropped.hasDecoded()) {
Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name
?: dropped.decoded.portnumValue.toString()
} else {
"unknown"
}
dropped.decoded?.portnum?.name ?: dropped.decoded?.portnum?.value?.toString() ?: "unknown"
"dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel"
}
}
earlyReceivedPackets.addLast(preparedPacket)
val portLabel =
if (preparedPacket.hasDecoded()) {
Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name
?: preparedPacket.decoded.portnumValue.toString()
} else {
"unknown"
}
preparedPacket.decoded?.portnum?.name
?: preparedPacket.decoded?.portnum?.value?.toString()
?: "unknown"
historyLog {
"queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel"
}
@@ -189,7 +180,7 @@ constructor(
}
private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
if (!packet.hasDecoded()) return
val decoded = packet.decoded ?: return
val log =
MeshLog(
uuid = UUID.randomUUID().toString(),
@@ -197,8 +188,8 @@ constructor(
received_date = System.currentTimeMillis(),
raw_message = packet.toString(),
fromNum = packet.from,
portNum = packet.decoded.portnumValue,
fromRadio = fromRadio { this.packet = packet },
portNum = decoded.portnum.value,
fromRadio = FromRadio(packet = packet),
)
val logJob = insertMeshLog(log)
logInsertJobByPacketId[packet.id] = logJob
@@ -207,23 +198,24 @@ constructor(
scope.handledLaunch { serviceRepository.emitMeshPacket(packet) }
myNodeNum?.let { myNum ->
val isOtherNode = myNum != packet.from
val from = packet.from
val isOtherNode = myNum != from
nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) {
it.lastHeard = (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt()
}
nodeManager.updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) {
it.lastHeard = packet.rxTime
it.snr = packet.rxSnr
it.rssi = packet.rxRssi
nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) {
it.lastHeard = packet.rx_time
it.snr = packet.rx_snr
it.rssi = packet.rx_rssi
it.hopsAway =
if (packet.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE) {
if (decoded.portnum == PortNum.RANGE_TEST_APP) {
0
} else if (packet.hopStart == 0 && !packet.decoded.hasBitfield()) {
} else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) {
-1
} else if (packet.hopLimit > packet.hopStart) {
} else if (packet.hop_limit > packet.hop_start) {
-1
} else {
packet.hopStart - packet.hopLimit
packet.hop_start - packet.hop_limit
}
}

View File

@@ -26,8 +26,8 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.ToRadio
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
@@ -48,9 +48,7 @@ constructor(
if (enabled && proxyToClientEnabled) {
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message ->
packetHandler.sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message })
}
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") }
.launchIn(scope)
}
@@ -64,18 +62,18 @@ constructor(
}
}
fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) {
Logger.d { "[mqttClientProxyMessage] ${message.topic}" }
with(message) {
when (payloadVariantCase) {
MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> {
mqttRepository.publish(topic, text.encodeToByteArray(), retained)
}
MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> {
mqttRepository.publish(topic, data.toByteArray(), retained)
}
else -> {}
fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
val topic = message.topic ?: ""
Logger.d { "[mqttClientProxyMessage] $topic" }
val retained = message.retained == true
when {
message.text != null -> {
mqttRepository.publish(topic, message.text!!.encodeToByteArray(), retained)
}
message.data_ != null -> {
mqttRepository.publish(topic, message.data_!!.toByteArray(), retained)
}
else -> {}
}
}
}

View File

@@ -24,8 +24,8 @@ import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -46,29 +46,31 @@ constructor(
}
fun handleNeighborInfo(packet: MeshPacket) {
val ni = MeshProtos.NeighborInfo.parseFrom(packet.decoded.payload)
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
// Store the last neighbor info from our connected radio
if (packet.from == nodeManager.myNodeNum) {
val from = packet.from ?: 0
if (from == nodeManager.myNodeNum) {
commandSender.lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
}
// Update Node DB
nodeManager.nodeDBbyNodeNum[packet.from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) }
nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) }
// Format for UI response
val requestId = packet.decoded.requestId
val requestId = packet.decoded?.request_id ?: 0
val start = commandSender.neighborInfoStartTimes.remove(requestId)
val neighbors =
ni.neighborsList.joinToString("\n") { n ->
val node = nodeManager.nodeDBbyNodeNum[n.nodeId]
ni.neighbors.joinToString("\n") { n ->
val node = nodeManager.nodeDBbyNodeNum[n.node_id]
val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username)
"$name (SNR: ${n.snr})"
}
val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[packet.from]?.longName ?: "Unknown"}:\n$neighbors"
val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.longName ?: "Unknown"}:\n$neighbors"
val responseText =
if (start != null) {

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.NodeEntity
@@ -32,17 +33,19 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.telemetry
import org.meshtastic.proto.user
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("TooManyFunctions")
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class MeshNodeManager
@Inject
@@ -93,8 +96,8 @@ constructor(
val myNode = nodeDBbyNodeNum[mi.myNodeNum]
return MyNodeInfo(
myNodeNum = mi.myNodeNum,
hasGPS = myNode?.position?.latitudeI != 0,
model = mi.model ?: myNode?.user?.hwModel?.name,
hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
model = mi.model ?: myNode?.user?.hw_model?.name,
firmwareVersion = mi.firmwareVersion,
couldUpdate = mi.couldUpdate,
shouldUpdate = mi.shouldUpdate,
@@ -122,18 +125,19 @@ constructor(
fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) {
val userId = DataPacket.nodeNumToDefaultId(n)
val defaultUser = user {
id = userId
longName = "Meshtastic ${userId.takeLast(n = 4)}"
shortName = userId.takeLast(n = 4)
hwModel = MeshProtos.HardwareModel.UNSET
}
val defaultUser =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(n = 4)}",
short_name = userId.takeLast(n = 4),
hw_model = HardwareModel.UNSET,
)
NodeEntity(
num = n,
user = defaultUser,
longName = defaultUser.longName,
shortName = defaultUser.shortName,
longName = defaultUser.long_name,
shortName = defaultUser.short_name,
channel = channel,
)
}
@@ -154,25 +158,25 @@ constructor(
}
}
fun insertMetadata(nodeNum: Int, metadata: MeshProtos.DeviceMetadata) {
fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) }
}
fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0, manuallyVerified: Boolean = false) {
fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) {
updateNodeInfo(fromNum) {
val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET)
val newNode = (it.isUnknownUser && p.hw_model != HardwareModel.UNSET)
val shouldPreserve = shouldPreserveExistingUser(it.user, p)
if (shouldPreserve) {
it.longName = it.user.longName
it.shortName = it.user.shortName
it.longName = it.user.long_name
it.shortName = it.user.short_name
it.channel = channel
it.manuallyVerified = manuallyVerified
} else {
val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey
it.user = if (keyMatch) p else p.copy { publicKey = NodeEntity.ERROR_BYTE_STRING }
it.longName = p.longName
it.shortName = p.shortName
val keyMatch = !it.hasPKC || it.user.public_key == p.public_key
it.user = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
it.longName = p.long_name
it.shortName = p.short_name
it.channel = channel
it.manuallyVerified = manuallyVerified
if (newNode) {
@@ -185,72 +189,74 @@ constructor(
fun handleReceivedPosition(
fromNum: Int,
myNodeNum: Int,
p: MeshProtos.Position,
p: ProtoPosition,
defaultTime: Long = System.currentTimeMillis(),
) {
if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) {
if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
Logger.d { "Ignoring nop position update for the local node" }
} else {
updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) }
}
}
fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) {
fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
updateNodeInfo(fromNum) { nodeEntity ->
when {
telemetry.hasDeviceMetrics() -> nodeEntity.deviceTelemetry = telemetry
telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry
telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry
telemetry.device_metrics != null -> nodeEntity.deviceTelemetry = telemetry
telemetry.environment_metrics != null -> nodeEntity.environmentTelemetry = telemetry
telemetry.power_metrics != null -> nodeEntity.powerTelemetry = telemetry
}
}
}
fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) {
fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) {
updateNodeInfo(fromNum) { it.paxcounter = p }
}
fun handleReceivedNodeStatus(fromNum: Int, s: MeshProtos.StatusMessage) {
fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
updateNodeInfo(fromNum) { it.nodeStatus = s.status }
}
fun installNodeInfo(info: MeshProtos.NodeInfo, withBroadcast: Boolean = true) {
fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) {
updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity ->
if (info.hasUser()) {
if (shouldPreserveExistingUser(entity.user, info.user)) {
entity.longName = entity.user.longName
entity.shortName = entity.user.shortName
val user = info.user
if (user != null) {
if (shouldPreserveExistingUser(entity.user, user)) {
entity.longName = entity.user.long_name
entity.shortName = entity.user.short_name
} else {
entity.user =
info.user.copy {
if (isLicensed) clearPublicKey()
if (info.viaMqtt) longName = "$longName (MQTT)"
}
entity.longName = entity.user.longName
entity.shortName = entity.user.shortName
var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
if (info.via_mqtt) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
}
entity.user = newUser
entity.longName = newUser.long_name
entity.shortName = newUser.short_name
}
}
if (info.hasPosition()) {
entity.position = info.position
entity.latitude = Position.degD(info.position.latitudeI)
entity.longitude = Position.degD(info.position.longitudeI)
val position = info.position
if (position != null) {
entity.position = position
entity.latitude = Position.degD(position.latitude_i ?: 0)
entity.longitude = Position.degD(position.longitude_i ?: 0)
}
entity.lastHeard = info.lastHeard
if (info.hasDeviceMetrics()) {
entity.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics }
entity.lastHeard = info.last_heard
if (info.device_metrics != null) {
entity.deviceTelemetry = Telemetry(device_metrics = info.device_metrics)
}
entity.channel = info.channel
entity.viaMqtt = info.viaMqtt
entity.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1
entity.isFavorite = info.isFavorite
entity.isIgnored = info.isIgnored
entity.isMuted = info.isMuted
entity.viaMqtt = info.via_mqtt
entity.hopsAway = info.hops_away ?: -1
entity.isFavorite = info.is_favorite
entity.isIgnored = info.is_ignored
entity.isMuted = info.is_muted
}
}
private fun shouldPreserveExistingUser(existing: MeshProtos.User, incoming: MeshProtos.User): Boolean {
val isDefaultName = incoming.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
val isDefaultHwModel = incoming.hwModel == MeshProtos.HardwareModel.UNSET
val hasExistingUser = existing.id.isNotEmpty() && existing.hwModel != MeshProtos.HardwareModel.UNSET
private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
return hasExistingUser && isDefaultName && isDefaultHwModel
}

View File

@@ -49,6 +49,7 @@ import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.PortNum
import javax.inject.Inject
@AndroidEntryPoint
@@ -89,7 +90,7 @@ class MeshService : Service() {
companion object {
fun actionReceived(portNum: Int): String {
val portType = org.meshtastic.proto.Portnums.PortNum.forNumber(portNum)
val portType = PortNum.fromValue(portNum)
val portStr = portType?.toString() ?: portNum.toString()
return com.geeksville.mesh.service.actionReceived(portStr)
}
@@ -217,9 +218,7 @@ class MeshService : Service() {
override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }
override fun getConfig(): ByteArray = toRemoteExceptions {
runBlocking {
radioConfigRepository.localConfigFlow.first().toByteArray() ?: throw NoDeviceConfigException()
}
runBlocking { radioConfigRepository.localConfigFlow.first().encode() }
}
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
@@ -279,7 +278,7 @@ class MeshService : Service() {
}
override fun getChannelSet(): ByteArray = toRemoteExceptions {
runBlocking { radioConfigRepository.channelSetFlow.first().toByteArray() }
runBlocking { radioConfigRepository.channelSetFlow.first().encode() }
}
override fun getNodes(): List<NodeInfo> = nodeManager.getNodes()

View File

@@ -73,9 +73,10 @@ import org.meshtastic.core.strings.new_node_seen
import org.meshtastic.core.strings.no_local_stats
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.you
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.LocalStats
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
/**
@@ -264,35 +265,32 @@ constructor(
notificationManager.createNotificationChannel(channel)
}
var cachedTelemetry: TelemetryProtos.Telemetry? = null
var cachedTelemetry: Telemetry? = null
var cachedLocalStats: LocalStats? = null
var nextStatsUpdateMillis: Long = 0
var cachedMessage: String? = null
// region Public Notification Methods
override fun updateServiceStateNotification(
summaryString: String?,
telemetry: TelemetryProtos.Telemetry?,
): Notification {
val hasLocalStats = telemetry?.hasLocalStats() == true
val hasDeviceMetrics = telemetry?.hasDeviceMetrics() == true
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification {
val hasLocalStats = telemetry?.local_stats != null
val hasDeviceMetrics = telemetry?.device_metrics != null
val message =
if (hasLocalStats) {
val localStats = telemetry.localStats
val localStatsMessage = localStats?.formatToString()
cachedTelemetry = telemetry
nextStatsUpdateMillis = System.currentTimeMillis() + FIFTEEN_MINUTES_IN_MILLIS
localStatsMessage
} else if (cachedTelemetry == null && hasDeviceMetrics) {
val deviceMetrics = telemetry.deviceMetrics
val deviceMetricsMessage = deviceMetrics.formatToString()
if (cachedLocalStats == null) {
when {
hasLocalStats -> {
val localStatsMessage = telemetry?.local_stats?.formatToString()
cachedTelemetry = telemetry
nextStatsUpdateMillis = System.currentTimeMillis() + FIFTEEN_MINUTES_IN_MILLIS
localStatsMessage
}
nextStatsUpdateMillis = System.currentTimeMillis()
deviceMetricsMessage
} else {
null
cachedTelemetry == null && hasDeviceMetrics -> {
val deviceMetricsMessage = telemetry?.device_metrics?.formatToString()
if (cachedLocalStats == null) {
cachedTelemetry = telemetry
}
nextStatsUpdateMillis = System.currentTimeMillis()
deviceMetricsMessage
}
else -> null
}
cachedMessage = message ?: cachedMessage ?: getString(Res.string.no_local_stats)
@@ -388,7 +386,7 @@ constructor(
}
val ourNode = nodeRepository.get().ourNodeInfo.value
val meName = ourNode?.user?.longName ?: getString(Res.string.you)
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
@@ -433,7 +431,7 @@ constructor(
}
override fun showNewNodeSeenNotification(node: NodeEntity) {
val notification = createNewNodeSeenNotification(node.user.shortName, node.user.longName)
val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name)
notificationManager.notify(node.num, notification)
}
@@ -442,7 +440,7 @@ constructor(
notificationManager.notify(node.num, notification)
}
override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {
override fun showClientNotification(clientNotification: ClientNotification) {
val notification =
createClientNotification(getString(Res.string.client_notification), clientNotification.message)
notificationManager.notify(clientNotification.toString().hashCode(), notification)
@@ -452,7 +450,7 @@ constructor(
override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
override fun clearClientNotification(notification: MeshProtos.ClientNotification) =
override fun clearClientNotification(notification: ClientNotification) =
notificationManager.cancel(notification.toString().hashCode())
// endregion
@@ -499,7 +497,7 @@ constructor(
}
val ourNode = nodeRepository.get().ourNodeInfo.value
val meName = ourNode?.user?.longName ?: getString(Res.string.you)
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
@@ -516,14 +514,14 @@ constructor(
// Use the node attached to the message directly to ensure correct identification
val person =
Person.Builder()
.setName(msg.node.user.longName)
.setName(msg.node.user.long_name)
.setKey(msg.node.user.id)
.setIcon(createPersonIcon(msg.node.user.shortName, msg.node.colors.second, msg.node.colors.first))
.setIcon(createPersonIcon(msg.node.user.short_name, msg.node.colors.second, msg.node.colors.first))
.build()
val text =
msg.originalMessage?.let { original ->
"↩️ \"${original.node.user.shortName}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
"↩️ \"${original.node.user.short_name}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
} ?: msg.text
style.addMessage(text, msg.receivedTime, person)
@@ -533,11 +531,11 @@ constructor(
val reactorNode = nodeRepository.get().getNode(reaction.user.id)
val reactor =
Person.Builder()
.setName(reaction.user.longName)
.setName(reaction.user.long_name)
.setKey(reaction.user.id)
.setIcon(
createPersonIcon(
reaction.user.shortName,
reaction.user.short_name,
reactorNode.colors.second,
reactorNode.colors.first,
),
@@ -612,7 +610,7 @@ constructor(
.build()
}
private fun createNewNodeSeenNotification(name: String, message: String?): Notification {
private fun createNewNodeSeenNotification(name: String, message: String): Notification {
val title = getString(Res.string.new_node_seen).format(name)
val builder =
commonBuilder(NotificationType.NewNode)
@@ -621,24 +619,23 @@ constructor(
.setContentTitle(title)
.setWhen(System.currentTimeMillis())
.setShowWhen(true)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
message?.let {
builder.setContentText(it)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
return builder.build()
}
private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification {
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
val title = getString(Res.string.low_battery_title).format(node.shortName)
val message = getString(Res.string.low_battery_message).format(node.longName, node.deviceMetrics.batteryLevel)
val batteryLevel = node.deviceTelemetry?.device_metrics?.battery_level ?: 0
val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel)
return commonBuilder(type)
.setCategory(Notification.CATEGORY_STATUS)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(MAX_BATTERY_LEVEL, node.deviceMetrics.batteryLevel, false)
.setProgress(MAX_BATTERY_LEVEL, batteryLevel, false)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
@@ -647,17 +644,13 @@ constructor(
.build()
}
private fun createClientNotification(name: String, message: String?): Notification =
private fun createClientNotification(name: String, message: String): Notification =
commonBuilder(NotificationType.Client)
.setCategory(Notification.CATEGORY_ERROR)
.setAutoCancel(true)
.setContentTitle(name)
.apply {
message?.let {
setContentText(it)
setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
}
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.build()
// endregion
@@ -804,34 +797,19 @@ constructor(
}
// Extension function to format LocalStats into a readable string.
private fun LocalStats?.formatToString(): String? = this?.allFields
?.mapNotNull { (k, v) ->
when (k.name) {
"num_online_nodes",
"num_total_nodes",
-> null // Exclude these fields
"uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}"
"channel_utilization" -> "ChUtil: %.2f%%".format(v)
"air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
else -> {
val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() }
"$formattedKey: $v"
}
}
}
?.joinToString("\n")
private fun LocalStats.formatToString(): String {
val parts = mutableListOf<String>()
parts.add("Uptime: ${formatUptime(uptime_seconds)}")
parts.add("ChUtil: %.2f%%".format(channel_utilization))
parts.add("AirUtilTX: %.2f%%".format(air_util_tx))
return parts.joinToString("\n")
}
private fun TelemetryProtos.DeviceMetrics?.formatToString(): String? = this?.allFields
?.mapNotNull { (k, v) ->
when (k.name) {
"battery_level" -> "Battery Level: $v"
"uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}"
"channel_utilization" -> "ChUtil: %.2f%%".format(v)
"air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
else -> {
val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() }
"$formattedKey: $v"
}
}
}
?.joinToString("\n")
private fun DeviceMetrics.formatToString(): String {
val parts = mutableListOf<String>()
battery_level?.let { parts.add("Battery Level: $it") }
uptime_seconds?.let { parts.add("Uptime: ${formatUptime(it)}") }
channel_utilization?.let { parts.add("ChUtil: %.2f%%".format(it)) }
air_util_tx?.let { parts.add("AirUtilTX: %.2f%%".format(it)) }
return parts.joinToString("\n")
}

View File

@@ -33,7 +33,7 @@ import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshPacket
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -65,13 +65,13 @@ constructor(
headerBack = getString(Res.string.traceroute_route_back_to_us),
) ?: return
val requestId = packet.decoded.requestId
val requestId = packet.decoded?.request_id ?: 0
if (logUuid != null) {
scope.handledLaunch {
logInsertJob?.join()
val routeDiscovery = packet.fullRouteDiscovery
val forwardRoute = routeDiscovery?.routeList.orEmpty()
val returnRoute = routeDiscovery?.routeBackList.orEmpty()
val forwardRoute = routeDiscovery?.route.orEmpty()
val returnRoute = routeDiscovery?.route_back.orEmpty()
val routeNodeNums = (forwardRoute + returnRoute).distinct()
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
val snapshotPositions =
@@ -93,15 +93,15 @@ constructor(
}
val routeDiscovery = packet.fullRouteDiscovery
val destination = routeDiscovery?.routeList?.firstOrNull() ?: routeDiscovery?.routeBackList?.lastOrNull() ?: 0
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.routeList.orEmpty(),
returnRoute = routeDiscovery?.routeBackList.orEmpty(),
forwardRoute = routeDiscovery?.route.orEmpty(),
returnRoute = routeDiscovery?.route_back.orEmpty(),
logUuid = logUuid,
),
)

View File

@@ -36,10 +36,10 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshProtos.ToRadio
import org.meshtastic.proto.fromRadio
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
@@ -76,24 +76,24 @@ constructor(
* Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully
* bound to the RadioInterfaceService
*/
fun sendToRadio(p: ToRadio.Builder) {
val built = p.build()
Logger.d { "Sending to radio ${built.toPIIString()}" }
val b = built.toByteArray()
fun sendToRadio(p: ToRadio) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
radioInterfaceService.sendToRadio(b)
changeStatus(p.packet.id, MessageStatus.ENROUTE)
p.packet?.id?.let { changeStatus(it, MessageStatus.ENROUTE) }
if (p.packet.hasDecoded()) {
val packet = p.packet
if (packet?.decoded != null) {
val packetToSave =
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "Packet",
received_date = System.currentTimeMillis(),
raw_message = p.packet.toString(),
fromNum = p.packet.from,
portNum = p.packet.decoded.portnumValue,
fromRadio = fromRadio { packet = p.packet },
raw_message = packet.toString(),
fromNum = packet.from ?: 0,
portNum = packet.decoded?.portnum?.value ?: 0,
fromRadio = FromRadio(packet = packet),
)
insertMeshLog(packetToSave)
}
@@ -119,9 +119,9 @@ constructor(
}
}
fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) {
fun handleQueueStatus(queueStatus: QueueStatus) {
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
if (success && isFull) return // Queue is full, wait for free != 0
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
@@ -192,7 +192,7 @@ constructor(
if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
}
sendToRadio(ToRadio.newBuilder().apply { this.packet = packet })
sendToRadio(ToRadio(packet = packet))
} catch (ex: Exception) {
Logger.e(ex) { "sendToRadio error: ${ex.message}" }
deferred.complete(false)

View File

@@ -24,11 +24,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.PortNum
import javax.inject.Inject
@AndroidEntryPoint
@@ -74,8 +76,8 @@ class ReactionReceiver : BroadcastReceiver() {
DataPacket(
to = toId,
channel = channelIndex,
bytes = emoji.toByteArray(Charsets.UTF_8),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = emoji.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
replyId = packetId,
wantAck = true,
emoji = emoji.codePointAt(0),
@@ -90,7 +92,7 @@ class ReactionReceiver : BroadcastReceiver() {
emoji = emoji,
timestamp = System.currentTimeMillis(),
packetId = reactionPacket.id,
status = org.meshtastic.core.model.MessageStatus.QUEUED,
status = MessageStatus.QUEUED,
to = toId,
channel = channelIndex,
)

View File

@@ -150,7 +150,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.feature.node.metrics.annotateTraceroute
import org.meshtastic.proto.MeshProtos
enum class TopLevelDestination(val label: StringResource, val icon: ImageVector, val route: Route) {
Conversations(Res.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph),
@@ -222,7 +221,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
clientNotification?.let { notification ->
var message = notification.message
val compromisedKeys =
if (notification.hasLowEntropyKey() || notification.hasDuplicatedPublicKey()) {
if (notification.low_entropy_key != null || notification.duplicated_public_key != null) {
message = stringResource(Res.string.compromised_keys)
true
} else {
@@ -291,101 +290,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
onDismiss = { tracerouteMapError = null },
)
}
// FIXME: uncomment and update Capabilities.kt when working better
//
// val neighborInfoResponse by uIViewModel.neighborInfoResponse.observeAsState()
// neighborInfoResponse?.let { response ->
// SimpleAlertDialog(
// title = Res.string.neighbor_info,
// text = {
// Column(modifier = Modifier.fillMaxWidth()) {
// fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? {
// // First, try parsing directly from raw bytes of the string
// var neighborInfo: MeshProtos.NeighborInfo? =
// runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) }.getOrNull()
//
// if (neighborInfo == null) {
// // Next, try to decode a hex dump embedded as text (e.g., "AA BB CC ...")
// val hexPairs = Regex("""\b[0-9A-Fa-f]{2}\b""").findAll(input).map { it.value
// }.toList()
// @Suppress("detekt:MagicNumber") // byte offsets
// if (hexPairs.size >= 4) {
// val bytes = hexPairs.map { it.toInt(16).toByte() }.toByteArray()
// neighborInfo = runCatching { MeshProtos.NeighborInfo.parseFrom(bytes)
// }.getOrNull()
// }
// }
//
// return neighborInfo
// }
//
// val parsed = tryParseNeighborInfo(response)
// if (parsed != null) {
// fun fmtNode(nodeNum: Int): String = "!%08x".format(nodeNum)
// Text(text = "NeighborInfo:", style = MaterialTheme.typography.bodyMedium)
// Text(
// text = "node_id: ${fmtNode(parsed.nodeId)}",
// style = MaterialTheme.typography.bodySmall,
// modifier = Modifier.padding(top = 8.dp),
// )
// Text(
// text = "last_sent_by_id: ${fmtNode(parsed.lastSentById)}",
// style = MaterialTheme.typography.bodySmall,
// modifier = Modifier.padding(top = 2.dp),
// )
// Text(
// text = "node_broadcast_interval_secs: ${parsed.nodeBroadcastIntervalSecs}",
// style = MaterialTheme.typography.bodySmall,
// modifier = Modifier.padding(top = 2.dp),
// )
// if (parsed.neighborsCount > 0) {
// Text(
// text = "neighbors:",
// style = MaterialTheme.typography.bodySmall,
// modifier = Modifier.padding(top = 4.dp),
// )
// parsed.neighborsList.forEach { n ->
// Text(
// text = " - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}",
// style = MaterialTheme.typography.bodySmall,
// modifier = Modifier.padding(start = 8.dp),
// )
// }
// }
// } else {
// val rawBytes = response.toByteArray()
//
// @Suppress("detekt:MagicNumber") // byte offsets
// val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' }
// if (isBinary) {
// val hexString = rawBytes.joinToString(" ") { "%02X".format(it) }
// Text(
// text = "Binary data (hex view):",
// style = MaterialTheme.typography.bodyMedium,
// modifier = Modifier.padding(bottom = 4.dp),
// )
// Text(
// text = hexString,
// style =
// MaterialTheme.typography.bodyMedium.copy(
// fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
// ),
// modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
// )
// } else {
// Text(
// text = response,
// style = MaterialTheme.typography.bodyMedium,
// modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
// )
// }
// }
// }
// },
// dismissText = stringResource(Res.string.okay),
// onDismiss = { uIViewModel.clearNeighborInfoResponse() },
// )
// }
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
@@ -605,18 +509,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == ConnectionState.Connected) {
firmwareEdition?.let { edition ->
Logger.d { "FirmwareEdition: ${edition.name}" }
when (edition) {
MeshProtos.FirmwareEdition.VANILLA -> {
// Handle any specific logic for VANILLA firmware edition if needed
}
else -> {
// Handle other firmware editions if needed
}
}
}
firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
}
}

View File

@@ -90,7 +90,7 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.Config
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
false
@@ -120,13 +120,12 @@ fun ConnectionsScreen(
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val connectionState by
connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.Disconnected)
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@@ -219,7 +218,7 @@ fun ConnectionsScreen(
CurrentlyConnectedInfo(
node = node,
bleDevice =
bleDevices.firstOrNull { it.fullAddress == selectedDevice }
bleDevices.find { it.fullAddress == selectedDevice }
as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections
import androidx.lifecycle.ViewModel
@@ -30,7 +29,7 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@HiltViewModel
@@ -45,7 +44,7 @@ constructor(
) : ViewModel() {
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance())
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val connectionState = serviceRepository.connectionState

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
@@ -56,9 +55,9 @@ import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.User
import kotlin.time.Duration.Companion.seconds
private const val RSSI_DELAY = 10
@@ -113,9 +112,9 @@ fun CurrentlyConnectedInfo(
}
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(text = node.user.longName, style = MaterialTheme.typography.titleMedium)
Text(text = node.user.long_name ?: "", style = MaterialTheme.typography.titleMedium)
node.metadata?.firmwareVersion?.let { firmwareVersion ->
node.metadata?.firmware_version?.let { firmwareVersion ->
Text(
text = stringResource(Res.string.firmware_version, firmwareVersion),
style = MaterialTheme.typography.bodySmall,
@@ -150,14 +149,10 @@ private fun CurrentlyConnectedInfoPreview() {
node =
Node(
num = 13444,
user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(),
user = User(short_name = "\uD83E\uDEE0", long_name = "John Doe"),
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(25f)
.setRelativeHumidity(60f)
.build(),
paxcounter = Paxcount(ble = 10, wifi = 5),
environmentMetrics = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f),
),
onNavigateToNodeDetails = {},
onClickDisconnect = {},

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.contact
import androidx.compose.animation.AnimatedVisibility
@@ -60,7 +59,7 @@ import org.meshtastic.core.strings.some_username
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ChannelSet
@Suppress("LongMethod")
@Composable
@@ -72,7 +71,7 @@ fun ContactItem(
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onNodeChipClick: () -> Unit = {},
channels: AppOnlyProtos.ChannelSet? = null,
channels: ChannelSet? = null,
) = with(contact) {
val isOutlined = !selected && !isActive
@@ -113,7 +112,7 @@ fun ContactItem(
@Composable
private fun ContactHeader(
contact: Contact,
channels: AppOnlyProtos.ChannelSet?,
channels: ChannelSet?,
modifier: Modifier = Modifier,
onNodeChipClick: () -> Unit = {},
) {

View File

@@ -99,7 +99,7 @@ import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ChannelSet
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -129,8 +129,8 @@ fun ContactsScreen(
// Create channel placeholders (always show broadcast contacts, even when empty)
val channels by viewModel.channels.collectAsStateWithLifecycle()
val channelPlaceholders =
remember(channels.settingsList.size) {
(0 until channels.settingsList.size).map { ch ->
remember(channels.settings.size) {
(0 until channels.settings.size).map { ch ->
Contact(
contactKey = "$ch^all",
shortName = "$ch",
@@ -485,7 +485,7 @@ private fun ContactListViewPaged(
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier,
channels: AppOnlyProtos.ChannelSet? = null,
channels: ChannelSet? = null,
) {
val haptics = LocalHapticFeedback.current
@@ -521,7 +521,7 @@ private fun ContactListContentInternal(
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: AppOnlyProtos.ChannelSet?,
channels: ChannelSet?,
haptics: HapticFeedback,
modifier: Modifier = Modifier,
) {
@@ -559,7 +559,7 @@ private fun LazyListScope.contactListPlaceholdersItems(
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: AppOnlyProtos.ChannelSet?,
channels: ChannelSet?,
haptics: HapticFeedback,
) {
items(
@@ -592,7 +592,7 @@ private fun LazyListScope.contactListPagedItems(
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: AppOnlyProtos.ChannelSet?,
channels: ChannelSet?,
haptics: HapticFeedback,
) {
items(

View File

@@ -43,8 +43,7 @@ import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_name
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
import kotlin.collections.map as collectionsMap
@@ -61,7 +60,7 @@ constructor(
val connectionState = serviceRepository.connectionState
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {})
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
// Combine node info and myId to reduce argument count in subsequent combines
private val identityFlow: Flow<Pair<MyNodeEntity?, String?>> =
@@ -86,7 +85,7 @@ constructor(
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
val placeholder =
(0 until channelSet.settingsCount).associate { ch ->
(0 until channelSet.settings.size).associate { ch ->
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
@@ -104,12 +103,12 @@ constructor(
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.shortName
val shortName = user.short_name ?: ""
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
} else {
user.longName
user.long_name ?: ""
}
Contact(
@@ -121,7 +120,7 @@ constructor(
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.isUnmessagable,
isUnmessageable = user.is_unmessagable ?: false,
nodeColors =
if (!toBroadcast) {
node.colors
@@ -157,12 +156,12 @@ constructor(
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.shortName
val shortName = user.short_name ?: ""
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
} else {
user.longName
user.long_name ?: ""
}
Contact(
@@ -174,7 +173,7 @@ constructor(
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.isUnmessagable,
isUnmessageable = user.is_unmessagable ?: false,
nodeColors =
if (!toBroadcast) {
node.colors
@@ -215,7 +214,7 @@ constructor(
private data class ContactsPagedParams(
val myNodeNum: Int?,
val channelSet: AppOnlyProtos.ChannelSet,
val channelSet: ChannelSet,
val settings: Map<String, ContactSettings>,
val myId: String?,
)

View File

@@ -128,11 +128,9 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.copy
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
/**
* Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel
@@ -156,7 +154,8 @@ fun ChannelScreen(
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels) }
val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.loraConfig).name) }
val modemPresetName by
remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) }
var showResetDialog by remember { mutableStateOf(false) }
@@ -185,16 +184,18 @@ fun ChannelScreen(
/* Holds selections made by the user for QR generation. */
val channelSelections =
rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) {
mutableStateListOf(elements = Array(size = 8, init = { true }))
rememberSaveable(
saver =
listSaver<SnapshotStateList<Boolean>, Boolean>(
save = { it.toList() },
restore = { it.toMutableStateList() },
),
) {
mutableStateListOf(true, true, true, true, true, true, true, true)
}
val selectedChannelSet =
channelSet.copy {
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
settings.clear()
settings.addAll(result)
}
channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true })
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -244,11 +245,8 @@ fun ChannelScreen(
}
}
fun installSettings(newChannel: ChannelProtos.ChannelSettings, newLoRaConfig: ConfigProtos.Config.LoRaConfig) {
val newSet = channelSet {
settings.add(newChannel)
loraConfig = newLoRaConfig
}
fun installSettings(newChannel: ChannelSettings, newLoRaConfig: Config.LoRaConfig) {
val newSet = ChannelSet(settings = listOf(newChannel), lora_config = newLoRaConfig)
installSettings(newSet)
}
@@ -264,13 +262,12 @@ fun ChannelScreen(
TextButton(
onClick = {
Logger.d { "Switching back to default channel" }
installSettings(
Channel.default.settings,
Channel.default.loraConfig.copy {
region = viewModel.region
txEnabled = viewModel.txEnabled
},
)
val lora =
(Channel.default.loraConfig).copy(
region = viewModel.region,
tx_enabled = viewModel.txEnabled,
)
installSettings(Channel.default.settings, lora)
showResetDialog = false
},
) {
@@ -504,17 +501,13 @@ private fun ChannelListView(
onClick: () -> Unit = {},
) {
val selectedChannelSet =
channelSet.copy {
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
settings.clear()
settings.addAll(result)
}
channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true })
AdaptiveTwoPane(
first = {
channelSet.settingsList.forEachIndexed { index, channel ->
val channelObj = Channel(channel, channelSet.loraConfig)
val displayTitle = channel.name.ifEmpty { modemPresetName }
channelSet.settings.forEachIndexed { index, channel ->
val channelObj = Channel(channel, channelSet.lora_config ?: Config.LoRaConfig())
val displayTitle = if (channel.name.isEmpty()) modemPresetName else channel.name
ChannelSelection(
index = index,
@@ -522,7 +515,7 @@ private fun ChannelListView(
enabled = enabled,
isSelected = channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settingsCount > 1) {
if (it || selectedChannelSet.settings.size > 1) {
channelSelections[index] = it
}
},
@@ -583,11 +576,7 @@ fun ModemPresetInfoPreview() {
private fun ChannelScreenPreview() {
ChannelListView(
enabled = true,
channelSet =
channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
channelSet = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig),
modemPresetName = Channel.default.name,
channelSelections = listOf(true).toMutableStateList(),
)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.sharing
import android.net.Uri
@@ -33,13 +32,10 @@ import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@HiltViewModel
@@ -53,29 +49,28 @@ constructor(
val connectionState = serviceRepository.connectionState
val localConfig =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance())
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {})
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
// managed mode disables all access to configuration
val isManaged: Boolean
get() = localConfig.value.security.isManaged
get() = localConfig.value.security?.is_managed == true
var txEnabled: Boolean
get() = localConfig.value.lora.txEnabled
get() = localConfig.value.lora?.tx_enabled == true
set(value) {
updateLoraConfig { it.copy { txEnabled = value } }
updateLoraConfig { it.copy(tx_enabled = value) }
}
var region: Config.LoRaConfig.RegionCode
get() = localConfig.value.lora.region
get() = localConfig.value.lora?.region ?: Config.LoRaConfig.RegionCode.UNSET
set(value) {
updateLoraConfig { it.copy { region = value } }
updateLoraConfig { it.copy(region = value) }
}
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
val requestChannelSet: StateFlow<ChannelSet?>
get() = _requestChannelSet
fun requestChannelUrl(url: Uri, onError: () -> Unit) = runCatching { _requestChannelSet.value = url.toChannelSet() }
@@ -89,17 +84,19 @@ constructor(
}
/** Set the radio config (also updates our saved copy in preferences). */
fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settingsList)
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newConfig = config { lora = channelSet.loraConfig }
if (localConfig.value.lora != newConfig.lora) setConfig(newConfig)
val newLoraConfig = channelSet.lora_config
if (localConfig.value.lora != newLoraConfig) {
setConfig(Config(lora = newLoraConfig))
}
}
fun setChannel(channel: ChannelProtos.Channel) {
fun setChannel(channel: Channel) {
try {
serviceRepository.meshService?.setChannel(channel.toByteArray())
serviceRepository.meshService?.setChannel(channel.encode())
} catch (ex: RemoteException) {
Logger.e(ex) { "Set channel error" }
}
@@ -108,7 +105,7 @@ constructor(
// Set the radio config (also updates our saved copy in preferences)
fun setConfig(config: Config) {
try {
serviceRepository.meshService?.setConfig(config.toByteArray())
serviceRepository.meshService?.setConfig(config.encode())
} catch (ex: RemoteException) {
Logger.e(ex) { "Set config error" }
}
@@ -119,7 +116,7 @@ constructor(
}
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
val data = body(localConfig.value.lora)
setConfig(config { lora = data })
val data = body(localConfig.value.lora ?: Config.LoRaConfig())
setConfig(Config(lora = data))
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,66 +16,39 @@
*/
package com.geeksville.mesh.repository.radio
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertArrayEquals
import com.geeksville.mesh.service.Fakes
import io.mockk.every
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
class TCPInterfaceTest {
private val service: RadioInterfaceService = mockk(relaxed = true)
private val dispatchers: CoroutineDispatchers = mockk(relaxed = true)
@Test
fun `keepAlive generates correct heartbeat bytes`() = runTest {
val address = "192.168.1.1:4403"
// We need a subclass to capture handleSendToRadio or sendBytes
val tcpInterface =
object : TCPInterface(service, dispatchers, address) {
var capturedBytes: ByteArray? = null
fun testKeepAlive() {
val fakes = Fakes()
val testDispatcher = UnconfinedTestDispatcher()
val testScope = CoroutineScope(testDispatcher + Job())
every { fakes.service.serviceScope } returns testScope
val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
val tcpIf =
object : TCPInterface(fakes.service, dispatchers, "127.0.0.1") {
var lastSent: ByteArray? = null
override fun handleSendToRadio(p: ByteArray) {
capturedBytes = p
lastSent = p
}
// Override connect to prevent it from starting automatically in init
override fun connect() {}
}
tcpInterface.keepAlive()
tcpIf.keepAlive()
val expectedHeartbeat =
MeshProtos.ToRadio.newBuilder()
.setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance())
.build()
.toByteArray()
assertArrayEquals("Heartbeat bytes should match", expectedHeartbeat, tcpInterface.capturedBytes)
}
@Test
fun `sendBytes does not crash when outStream is null`() = runTest {
val address = "192.168.1.1:4403"
val tcpInterface =
object : TCPInterface(service, dispatchers, address) {
override fun connect() {}
}
// This should not throw UninitializedPropertyAccessException
tcpInterface.sendBytes(byteArrayOf(1, 2, 3))
}
@Test
fun `flushBytes does not crash when outStream is null`() = runTest {
val address = "192.168.1.1:4403"
val tcpInterface =
object : TCPInterface(service, dispatchers, address) {
override fun connect() {}
}
// This should not throw UninitializedPropertyAccessException
tcpInterface.flushBytes()
val expected = ToRadio(heartbeat = Heartbeat()).encode()
assertEquals(expected.toList(), tcpIf.lastSent?.toList())
}
}

View File

@@ -17,60 +17,15 @@
package com.geeksville.mesh.service
import android.app.Notification
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.mockk
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
class FakeNodeInfoReadDataSource : NodeInfoReadDataSource {
val myNodeInfo = MutableStateFlow<MyNodeEntity?>(null)
val nodes = MutableStateFlow<Map<Int, NodeWithRelations>>(emptyMap())
override fun myNodeInfoFlow(): Flow<MyNodeEntity?> = myNodeInfo
override fun nodeDBbyNumFlow(): Flow<Map<Int, NodeWithRelations>> = nodes
override fun getNodesFlow(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>> = flowOf(emptyList())
override suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> = emptyList()
override suspend fun getUnknownNodes(): List<NodeEntity> = emptyList()
}
class FakeNodeInfoWriteDataSource : NodeInfoWriteDataSource {
override suspend fun upsert(node: NodeEntity) {}
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {}
override suspend fun clearMyNodeInfo() {}
override suspend fun clearNodeDB(preserveFavorites: Boolean) {}
override suspend fun deleteNode(num: Int) {}
override suspend fun deleteNodes(nodeNums: List<Int>) {}
override suspend fun deleteMetadata(num: Int) {}
override suspend fun upsert(metadata: MetadataEntity) {}
override suspend fun setNodeNotes(num: Int, notes: String) {}
override suspend fun backfillDenormalizedNames() {}
class Fakes {
val service: RadioInterfaceService = mockk(relaxed = true)
}
class FakeMeshServiceNotifications : MeshServiceNotifications {
@@ -78,10 +33,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun initChannels() {}
override fun updateServiceStateNotification(
summaryString: String?,
telemetry: TelemetryProtos.Telemetry?,
): Notification = null as Notification
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification =
mockk(relaxed = true)
override suspend fun updateMessageNotification(
contactKey: String,
@@ -115,11 +68,11 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: NodeEntity) {}
override fun clearClientNotification(notification: MeshProtos.ClientNotification) {}
override fun clearClientNotification(notification: ClientNotification) {}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,71 +16,79 @@
*/
package com.geeksville.mesh.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.fromRadio
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.QueueStatus
class FromRadioPacketHandlerTest {
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val router: MeshRouter = mockk(relaxed = true)
private val mqttManager: MeshMqttManager = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val configFlowManager: MeshConfigFlowManager = mockk(relaxed = true)
private val configHandler: MeshConfigHandler = mockk(relaxed = true)
private lateinit var handler: FromRadioPacketHandler
@Before
fun setUp() {
every { router.configFlowManager } returns configFlowManager
every { router.configHandler } returns configHandler
fun setup() {
handler = FromRadioPacketHandler(serviceRepository, router, mqttManager, packetHandler, serviceNotifications)
}
@Test
fun `handleFromRadio routes MY_INFO to configFlowManager`() {
val myInfo = MeshProtos.MyNodeInfo.newBuilder().setMyNodeNum(1234).build()
val proto = fromRadio { this.myInfo = myInfo }
val myInfo = MyNodeInfo(my_node_num = 1234)
val proto = FromRadio(my_info = myInfo)
handler.handleFromRadio(proto)
verify { configFlowManager.handleMyInfo(myInfo) }
verify { router.configFlowManager.handleMyInfo(myInfo) }
}
@Test
fun `handleFromRadio routes METADATA to configFlowManager`() {
val metadata = MeshProtos.DeviceMetadata.newBuilder().setFirmwareVersion("v1.0").build()
val proto = fromRadio { this.metadata = metadata }
val metadata = DeviceMetadata(firmware_version = "v1.0")
val proto = FromRadio(metadata = metadata)
handler.handleFromRadio(proto)
verify { configFlowManager.handleLocalMetadata(metadata) }
verify { router.configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `handleFromRadio routes NODE_INFO to configFlowManager`() {
val nodeInfo = MeshProtos.NodeInfo.newBuilder().setNum(1234).build()
val proto = fromRadio { this.nodeInfo = nodeInfo }
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
val nodeInfo = NodeInfo(num = 1234)
val proto = FromRadio(node_info = nodeInfo)
handler.handleFromRadio(proto)
verify { configFlowManager.handleNodeInfo(nodeInfo) }
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setStatusMessage(any()) }
}
@Test
fun `handleFromRadio routes CONFIG_COMPLETE_ID to configFlowManager`() {
val nonce = 69420
val proto = FromRadio(config_complete_id = nonce)
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleConfigComplete(nonce) }
}
@Test
fun `handleFromRadio routes QUEUESTATUS to packetHandler`() {
val queueStatus = MeshProtos.QueueStatus.newBuilder().setFree(5).build()
val proto = fromRadio { this.queueStatus = queueStatus }
val queueStatus = QueueStatus(free = 10)
val proto = FromRadio(queueStatus = queueStatus)
handler.handleFromRadio(proto)
@@ -89,23 +97,23 @@ class FromRadioPacketHandlerTest {
@Test
fun `handleFromRadio routes CONFIG to configHandler`() {
val config = ConfigProtos.Config.newBuilder().build()
val proto = fromRadio { this.config = config }
val config = Config(lora = Config.LoRaConfig(use_preset = true))
val proto = FromRadio(config = config)
handler.handleFromRadio(proto)
verify { configHandler.handleDeviceConfig(config) }
verify { router.configHandler.handleDeviceConfig(config) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
val notification = MeshProtos.ClientNotification.newBuilder().setReplyId(42).build()
val proto = fromRadio { this.clientNotification = notification }
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { serviceNotifications.showClientNotification(notification) }
verify { packetHandler.removeResponse(42, false) }
verify { packetHandler.removeResponse(0, complete = false) }
}
}

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -31,9 +32,9 @@ import org.junit.Test
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
class MeshCommandSenderHopLimitTest {
@@ -42,7 +43,7 @@ class MeshCommandSenderHopLimitTest {
private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true)
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance())
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = CoroutineScope(testDispatcher)
@@ -64,42 +65,41 @@ class MeshCommandSenderHopLimitTest {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = byteArrayOf(1, 2, 3),
bytes = byteArrayOf(1, 2, 3).toByteString(),
dataType = 1, // PortNum.TEXT_MESSAGE_APP
)
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
// Ensure localConfig has lora.hopLimit = 0
localConfigFlow.value =
LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(0)).build()
// Ensure localConfig has lora.hop_limit = 0
localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 0))
commandSender.sendData(packet)
verify(exactly = 1) { packetHandler.sendToRadio(any<MeshPacket>()) }
val capturedHopLimit = meshPacketSlot.captured.hopLimit
val capturedHopLimit = meshPacketSlot.captured.hop_limit ?: 0
assertTrue("Hop limit should be greater than 0, but was $capturedHopLimit", capturedHopLimit > 0)
assertEquals(3, capturedHopLimit)
assertEquals(3, meshPacketSlot.captured.hopStart)
assertEquals(3, meshPacketSlot.captured.hop_start)
}
@Test
fun `sendData respects non-zero hop limit from config`() = runTest(testDispatcher) {
val packet = DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3), dataType = 1)
val packet =
DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3).toByteString(), dataType = 1)
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
localConfigFlow.value =
LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(7)).build()
localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 7))
commandSender.sendData(packet)
verify { packetHandler.sendToRadio(any<MeshPacket>()) }
assertEquals(7, meshPacketSlot.captured.hopLimit)
assertEquals(7, meshPacketSlot.captured.hopStart)
assertEquals(7, meshPacketSlot.captured.hop_limit)
assertEquals(7, meshPacketSlot.captured.hop_start)
}
@Test
@@ -108,8 +108,7 @@ class MeshCommandSenderHopLimitTest {
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
localConfigFlow.value =
LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(6)).build()
localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 6))
// Mock node manager interactions
nodeManager.nodeDBbyNodeNum.remove(destNum)
@@ -117,7 +116,7 @@ class MeshCommandSenderHopLimitTest {
commandSender.requestUserInfo(destNum)
verify { packetHandler.sendToRadio(any<MeshPacket>()) }
assertEquals("Hop Limit should be 6", 6, meshPacketSlot.captured.hopLimit)
assertEquals("Hop Start should be 6", 6, meshPacketSlot.captured.hopStart)
assertEquals("Hop Limit should be 6", 6, meshPacketSlot.captured.hop_limit)
assertEquals("Hop Start should be 6", 6, meshPacketSlot.captured.hop_start)
}
}

View File

@@ -22,7 +22,7 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.user
import org.meshtastic.proto.User
class MeshCommandSenderTest {
@@ -60,7 +60,7 @@ class MeshCommandSenderTest {
fun `resolveNodeNum handles custom node ID from database`() {
val nodeNum = 456
val userId = "custom_id"
val entity = NodeEntity(num = nodeNum, user = user { id = userId })
val entity = NodeEntity(num = nodeNum, user = User(id = userId))
nodeManager.nodeDBbyNodeNum[nodeNum] = entity
nodeManager.nodeDBbyID[userId] = entity

View File

@@ -39,9 +39,9 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.MeshProtos.ToRadio
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.ToRadio
class MeshConnectionManagerTest {
@@ -60,7 +60,7 @@ class MeshConnectionManagerTest {
private val nodeManager: MeshNodeManager = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance())
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val testDispatcher = UnconfinedTestDispatcher()
@@ -112,7 +112,7 @@ class MeshConnectionManagerTest {
connectionStateHolder.connectionState.value,
)
verify { serviceBroadcasts.broadcastConnection() }
verify { packetHandler.sendToRadio(any<ToRadio.Builder>()) }
verify { packetHandler.sendToRadio(any<ToRadio>()) }
}
@Test
@@ -139,12 +139,10 @@ class MeshConnectionManagerTest {
fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) {
// Power saving disabled + Role CLIENT
val config =
LocalConfig.newBuilder()
.apply {
powerBuilder.setIsPowerSaving(false)
deviceBuilder.setRole(Config.DeviceConfig.Role.CLIENT)
}
.build()
LocalConfig(
power = Config.PowerConfig(is_power_saving = false),
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
manager.start(backgroundScope)
@@ -163,7 +161,7 @@ class MeshConnectionManagerTest {
@Test
fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) {
// Power saving enabled
val config = LocalConfig.newBuilder().apply { powerBuilder.setIsPowerSaving(true) }.build()
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
manager.start(backgroundScope)

View File

@@ -16,9 +16,9 @@
*/
package com.geeksville.mesh.service
import com.google.protobuf.ByteString
import io.mockk.every
import io.mockk.mockk
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@@ -26,7 +26,9 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
class MeshDataMapperTest {
@@ -64,7 +66,7 @@ class MeshDataMapperTest {
@Test
fun `toDataPacket returns null when no decoded data`() {
val packet = MeshProtos.MeshPacket.newBuilder().build()
val packet = MeshPacket()
assertNull(mapper.toDataPacket(packet))
}
@@ -77,26 +79,22 @@ class MeshDataMapperTest {
every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity
val proto =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = 42
from = nodeNum
to = DataPacket.NODENUM_BROADCAST
rxTime = 1600000000
rxSnr = 5.5f
rxRssi = -100
hopLimit = 3
hopStart = 3
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnumValue = 1 // TEXT_MESSAGE_APP
payload = ByteString.copyFrom("hello".toByteArray())
replyId = 123
}
.build()
}
.build()
MeshPacket(
id = 42,
from = nodeNum,
to = DataPacket.NODENUM_BROADCAST,
rx_time = 1600000000,
rx_snr = 5.5f,
rx_rssi = -100,
hop_limit = 3,
hop_start = 3,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "hello".encodeToByteArray().toByteString(),
reply_id = 123,
),
)
val result = mapper.toDataPacket(proto)
assertNotNull(result)
@@ -106,21 +104,14 @@ class MeshDataMapperTest {
assertEquals(1600000000000L, result.time)
assertEquals(5.5f, result.snr)
assertEquals(-100, result.rssi)
assertEquals(1, result.dataType)
assertEquals("hello", result.bytes?.decodeToString())
assertEquals(PortNum.TEXT_MESSAGE_APP.value, result.dataType)
assertEquals("hello", result.bytes?.utf8())
assertEquals(123, result.replyId)
}
@Test
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
val proto =
MeshProtos.MeshPacket.newBuilder()
.apply {
pkiEncrypted = true
channel = 1
decoded = MeshProtos.Data.getDefaultInstance()
}
.build()
val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data())
every { nodeManager.nodeDBbyNodeNum[any()] } returns null

View File

@@ -27,7 +27,9 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
class MeshMessageProcessorTest {
@@ -56,13 +58,7 @@ class MeshMessageProcessorTest {
@Test
fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) {
val packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = 123
decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build()
}
.build()
val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
// 1. Database is NOT ready
isNodeDbReady.value = false
@@ -83,13 +79,7 @@ class MeshMessageProcessorTest {
@Test
fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) {
val packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = 456
decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build()
}
.build()
val packet = MeshPacket(id = 456, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
isNodeDbReady.value = true
testScheduler.runCurrent()

View File

@@ -26,8 +26,9 @@ import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.user
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
class MeshNodeManagerTest {
@@ -49,73 +50,51 @@ class MeshNodeManagerTest {
assertNotNull(result)
assertEquals(nodeNum, result.num)
assertTrue(result.user.longName.startsWith("Meshtastic"))
assertTrue(result.user.long_name?.startsWith("Meshtastic") == true)
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id)
}
@Test
fun `handleReceivedUser preserves existing user if incoming is default`() {
val nodeNum = 1234
val existingUser = user {
id = "!12345678"
longName = "My Custom Name"
shortName = "MCN"
hwModel = MeshProtos.HardwareModel.TLORA_V2
}
val existingUser =
User(id = "!12345678", long_name = "My Custom Name", short_name = "MCN", hw_model = HardwareModel.TLORA_V2)
// Setup existing node
nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser }
val incomingDefaultUser = user {
id = "!12345678"
longName = "Meshtastic 5678"
shortName = "5678"
hwModel = MeshProtos.HardwareModel.UNSET
}
val incomingDefaultUser =
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
nodeManager.handleReceivedUser(nodeNum, incomingDefaultUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals("My Custom Name", result!!.user.longName)
assertEquals(MeshProtos.HardwareModel.TLORA_V2, result.user.hwModel)
assertEquals("My Custom Name", result!!.user.long_name)
assertEquals(HardwareModel.TLORA_V2, result.user.hw_model)
}
@Test
fun `handleReceivedUser updates user if incoming is higher detail`() {
val nodeNum = 1234
val existingUser = user {
id = "!12345678"
longName = "Meshtastic 5678"
shortName = "5678"
hwModel = MeshProtos.HardwareModel.UNSET
}
val existingUser =
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser }
val incomingDetailedUser = user {
id = "!12345678"
longName = "Real User"
shortName = "RU"
hwModel = MeshProtos.HardwareModel.TLORA_V1
}
val incomingDetailedUser =
User(id = "!12345678", long_name = "Real User", short_name = "RU", hw_model = HardwareModel.TLORA_V1)
nodeManager.handleReceivedUser(nodeNum, incomingDetailedUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals("Real User", result!!.user.longName)
assertEquals(MeshProtos.HardwareModel.TLORA_V1, result.user.hwModel)
assertEquals("Real User", result!!.user.long_name)
assertEquals(HardwareModel.TLORA_V1, result.user.hw_model)
}
@Test
fun `handleReceivedPosition updates node position`() {
val nodeNum = 1234
val position =
MeshProtos.Position.newBuilder()
.apply {
latitudeI = 450000000
longitudeI = 900000000
}
.build()
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
nodeManager.handleReceivedPosition(nodeNum, 9999, position)

View File

@@ -29,7 +29,9 @@ import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
class PacketHandlerTest {
@@ -58,20 +60,17 @@ class PacketHandlerTest {
}
@Test
fun `sendToRadio with ToRadio Builder sends immediately`() {
val builder =
MeshProtos.ToRadio.newBuilder().apply { packet = MeshProtos.MeshPacket.newBuilder().setId(123).build() }
fun `sendToRadio with ToRadio sends immediately`() {
val toRadio = ToRadio(packet = MeshPacket(id = 123))
handler.sendToRadio(builder)
handler.sendToRadio(toRadio)
verify { radioInterfaceService.sendToRadio(any()) }
// Verify broadcast status ENROUTE (via status mapping) is not directly testable easily without more mocks,
// but we verify the call to radio service occurred.
}
@Test
fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) {
val packet = MeshProtos.MeshPacket.newBuilder().setId(456).build()
val packet = MeshPacket(id = 456)
every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected)
handler.sendToRadio(packet)
@@ -82,25 +81,20 @@ class PacketHandlerTest {
@Test
fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) {
val packet = MeshProtos.MeshPacket.newBuilder().setId(789).build()
val packet = MeshPacket(id = 789)
every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected)
handler.sendToRadio(packet)
testScheduler.runCurrent()
val status =
MeshProtos.QueueStatus.newBuilder()
.apply {
meshPacketId = 789
res = 0 // Success
free = 1
}
.build()
QueueStatus(
mesh_packet_id = 789,
res = 0, // Success
free = 1,
)
handler.handleQueueStatus(status)
testScheduler.runCurrent()
// If it completed, the queue job should move to the next packet or finish.
// We can't easily check the deferred inside, but we can check if it cleared the internal wait.
}
}

View File

@@ -18,7 +18,7 @@ package com.geeksville.mesh.service
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.proto.StoreAndForwardProtos
import org.meshtastic.proto.StoreAndForward
class StoreForwardHistoryRequestTest {
@@ -31,14 +31,14 @@ class StoreForwardHistoryRequestTest {
historyReturnMax = 25,
)
assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
assertEquals(42, request.history.lastRequest)
assertEquals(15, request.history.window)
assertEquals(25, request.history.historyMessages)
assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
assertEquals(42, request.history?.last_request)
assertEquals(15, request.history?.window)
assertEquals(25, request.history?.history_messages)
}
@Test
fun `buildStoreForwardHistoryRequest omits non-positive parameters`() {
fun `buildStoreForwardHistoryRequest clamps non-positive parameters`() {
val request =
MeshHistoryManager.buildStoreForwardHistoryRequest(
lastRequest = 0,
@@ -46,10 +46,10 @@ class StoreForwardHistoryRequestTest {
historyReturnMax = 0,
)
assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
assertEquals(0, request.history.lastRequest)
assertEquals(0, request.history.window)
assertEquals(0, request.history.historyMessages)
assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
assertEquals(0, request.history?.last_request)
assertEquals(0, request.history?.window)
assertEquals(0, request.history?.history_messages)
}
@Test

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,14 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.metrics
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
class EnvironmentMetricsTest {
@@ -33,15 +32,14 @@ class EnvironmentMetricsTest {
val expectedSoilTemperatureFahrenheit = celsiusToFahrenheit(initialSoilTemperatureCelsius)
val telemetry =
TelemetryProtos.Telemetry.newBuilder()
.setEnvironmentMetrics(
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(initialTemperatureCelsius)
.setSoilTemperature(initialSoilTemperatureCelsius)
.build(),
)
.setTime(1000)
.build()
Telemetry(
environment_metrics =
EnvironmentMetrics(
temperature = initialTemperatureCelsius,
soil_temperature = initialSoilTemperatureCelsius,
),
time = 1000,
)
val data = listOf(telemetry)
@@ -50,15 +48,16 @@ class EnvironmentMetricsTest {
val processedTelemetries =
if (isFahrenheit) {
data.map { tel ->
val temperatureFahrenheit = celsiusToFahrenheit(tel.environmentMetrics.temperature)
val soilTemperatureFahrenheit = celsiusToFahrenheit(tel.environmentMetrics.soilTemperature)
tel.copy {
environmentMetrics =
tel.environmentMetrics.copy {
temperature = temperatureFahrenheit
soilTemperature = soilTemperatureFahrenheit
}
}
val metrics = tel.environment_metrics!!
val temperatureFahrenheit = celsiusToFahrenheit(metrics.temperature ?: 0f)
val soilTemperatureFahrenheit = celsiusToFahrenheit(metrics.soil_temperature ?: 0f)
tel.copy(
environment_metrics =
metrics.copy(
temperature = temperatureFahrenheit,
soil_temperature = soilTemperatureFahrenheit,
),
)
}
} else {
data
@@ -66,7 +65,11 @@ class EnvironmentMetricsTest {
val resultTelemetry = processedTelemetries.first()
assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environmentMetrics.temperature, 0.01f)
assertEquals(expectedSoilTemperatureFahrenheit, resultTelemetry.environmentMetrics.soilTemperature, 0.01f)
assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environment_metrics?.temperature ?: 0f, 0.01f)
assertEquals(
expectedSoilTemperatureFahrenheit,
resultTelemetry.environment_metrics?.soil_temperature ?: 0f,
0.01f,
)
}
}

View File

@@ -60,6 +60,7 @@ dependencies {
compileOnly(libs.secrets.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
compileOnly(libs.test.retry.gradlePlugin)
compileOnly("com.dropbox.dependency-guard:dependency-guard:0.5.0")
compileOnly(libs.truth)
detektPlugins(libs.detekt.formatting)

View File

@@ -28,6 +28,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "org.gradle.test-retry")
apply(plugin = "meshtastic.android.lint")
apply(plugin = "meshtastic.detekt")
apply(plugin = "meshtastic.spotless")
@@ -58,6 +59,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
isDebuggable = true
isPseudoLocalesEnabled = true
enableAndroidTestCoverage = true
// Disable PNG crunching for faster debug builds
isCrunchPngs = false
}
}

View File

@@ -30,6 +30,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "org.gradle.test-retry")
apply(plugin = "meshtastic.android.lint")
apply(plugin = "meshtastic.detekt")
apply(plugin = "meshtastic.spotless")

View File

@@ -58,6 +58,9 @@ fun Project.configureDokka() {
}
}
/**
* Configure Dokka aggregation in a way that is compatible with Gradle Isolated Projects.
*/
fun Project.configureDokkaAggregation() {
extensions.configure<DokkaExtension> {
moduleName.set("Meshtastic App")

View File

@@ -17,10 +17,8 @@
package org.meshtastic.buildlogic
import com.android.utils.associateWithNotNull
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.ProjectDependency
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.MapProperty
@@ -32,64 +30,11 @@ import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity.NONE
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import org.meshtastic.buildlogic.PluginType.Unknown
import kotlin.text.RegexOption.DOT_MATCHES_ALL
/**
* Generates module dependency graphs with `graphDump` task, and update the corresponding `README.md` file with `graphUpdate`.
*
* This is not an optimal implementation and could be improved if needed:
* - [Graph.invoke] is **recursively** searching through dependent projects (although in practice it will never reach a stack overflow).
* - [Graph.invoke] is entirely re-executed for all projects, without re-using intermediate values.
* - [Graph.invoke] is always executed during Gradle's Configuration phase (but takes in general less than 1 ms for a project).
*
* The resulting graphs can be configured with `graph.ignoredProjects` and `graph.supportedConfigurations` properties.
*/
private class Graph(
private val root: Project,
private val dependencies: MutableMap<Project, Set<Pair<Configuration, Project>>> = mutableMapOf(),
private val plugins: MutableMap<Project, PluginType> = mutableMapOf(),
private val seen: MutableSet<String> = mutableSetOf(),
) {
private val ignoredProjects = root.providers.gradleProperty("graph.ignoredProjects")
.map { it.split(",").toSet() }
.orElse(emptySet())
private val supportedConfigurations =
root.providers.gradleProperty("graph.supportedConfigurations")
.map { it.split(",").toSet() }
.orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
operator fun invoke(project: Project = root): Graph {
if (project.path in seen) return this
seen += project.path
plugins.putIfAbsent(
project,
PluginType.entries.firstOrNull { project.pluginManager.hasPlugin(it.id) } ?: Unknown,
)
dependencies.compute(project) { _, u -> u.orEmpty() }
project.configurations
.matching { it.name in supportedConfigurations.get() }
.associateWithNotNull { it.dependencies.withType<ProjectDependency>().ifEmpty { null } }
.flatMap { (c, value) -> value.map { dep -> c to project.project(dep.path) } }
.filter { (_, p) -> p.path !in ignoredProjects.get() }
.forEach { (configuration: Configuration, projectDependency: Project) ->
dependencies.compute(project) { _, u -> u.orEmpty() + (configuration to projectDependency) }
invoke(projectDependency)
}
return this
}
fun dependencies(): Map<String, Set<Pair<String, String>>> = dependencies
.mapKeys { it.key.path }
.mapValues { it.value.mapTo(mutableSetOf()) { (c, p) -> c.name to p.path } }
fun plugins() = plugins.mapKeys { it.key.path }
}
/**
* Declaration order is important, as only the first match will be retained.
*/
@@ -115,18 +60,17 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin
style = "fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000",
),
AndroidLibraryCompose(
// Assuming this might be a distinct plugin
id = "meshtastic.android.library.compose",
ref = "android-library-compose",
style = "fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000",
),
AndroidTest(
id = "meshtastic.android.test", // Placeholder
id = "meshtastic.android.test",
ref = "android-test",
style = "fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000",
),
Jvm(
id = "meshtastic.jvm.library", // Placeholder
id = "meshtastic.jvm.library",
ref = "jvm-library",
style = "fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000",
),
@@ -142,21 +86,47 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin
),
}
/**
* Optimized and Isolated Projects compatible graph configuration.
*/
internal fun Project.configureGraphTasks() {
if (!buildFile.exists()) return // Ignore root modules without build file
if (!buildFile.exists()) return
val supportedConfigurations = providers.gradleProperty("graph.supportedConfigurations")
.map { it.split(",").toSet() }
.orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
val dumpTask = tasks.register<GraphDumpTask>("graphDump") {
val graph = Graph(this@configureGraphTasks).invoke()
projectPath = this@configureGraphTasks.path
dependencies = graph.dependencies()
plugins = graph.plugins()
output = this@configureGraphTasks.layout.buildDirectory.file("mermaid/graph.txt")
legend = this@configureGraphTasks.layout.buildDirectory.file("mermaid/legend.txt")
projectPath.set(this@configureGraphTasks.path)
val deps = mutableMapOf<String, Set<Pair<String, String>>>()
val projectPlugins = mutableMapOf<String, PluginType>()
projectPlugins[path] = PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
val projectDeps = mutableSetOf<Pair<String, String>>()
this@configureGraphTasks.configurations.forEach { config ->
if (config.name in supportedConfigurations.get()) {
config.dependencies.withType<ProjectDependency>().forEach { dep ->
// Fallback to simpler access or path if available.
val depPath = dep.path
projectDeps.add(config.name to depPath)
}
}
}
deps[path] = projectDeps
dependenciesData.set(deps)
pluginsData.set(projectPlugins)
output.set(layout.buildDirectory.file("mermaid/graph.txt"))
legend.set(layout.buildDirectory.file("mermaid/legend.txt"))
}
tasks.register<GraphUpdateTask>("graphUpdate") {
projectPath = this@configureGraphTasks.path
input = dumpTask.flatMap { it.output }
legend = dumpTask.flatMap { it.legend }
output = this@configureGraphTasks.layout.projectDirectory.file("README.md")
projectPath.set(this@configureGraphTasks.path)
input.set(dumpTask.flatMap { it.output })
legend.set(dumpTask.flatMap { it.legend })
output.set(layout.projectDirectory.file("README.md"))
}
}
@@ -167,10 +137,10 @@ private abstract class GraphDumpTask : DefaultTask() {
abstract val projectPath: Property<String>
@get:Input
abstract val dependencies: MapProperty<String, Set<Pair<String, String>>>
abstract val dependenciesData: MapProperty<String, Set<Pair<String, String>>>
@get:Input
abstract val plugins: MapProperty<String, PluginType>
abstract val pluginsData: MapProperty<String, PluginType>
@get:OutputFile
abstract val output: RegularFileProperty
@@ -178,194 +148,62 @@ private abstract class GraphDumpTask : DefaultTask() {
@get:OutputFile
abstract val legend: RegularFileProperty
override fun getDescription() = "Dumps project dependencies to a mermaid file."
@TaskAction
operator fun invoke() {
output.get().asFile.writeText(mermaid())
legend.get().asFile.writeText(legend())
logger.lifecycle(output.get().asFile.toPath().toUri().toString())
}
private fun mermaid() = buildString {
val dependencies: Set<Dependency> = dependencies.get()
.flatMapTo(mutableSetOf()) { (project, entries) -> entries.map { it.toDependency(project) } }
// FrontMatter configuration (not supported yet on GitHub.com)
appendLine(
// language=YAML
"""
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
""".trimIndent(),
)
// Graph declaration
appendLine("graph TB")
// Nodes and subgraphs
val (rootProjects, nestedProjects) = dependencies
.map { listOf(it.project, it.dependency) }.flatten().toSet()
.plus(projectPath.get()) // Special case when this specific module has no other dependency
.groupBy { it.substringBeforeLast(":") }
.entries.partition { it.key.isEmpty() }
val orderedGroups = nestedProjects.groupBy {
if (it.key.count { char -> char == ':' } > 1) it.key.substringBeforeLast(":") else ""
}
orderedGroups.forEach { (outerGroup, innerGroups) ->
if (outerGroup.isNotEmpty()) {
appendLine(" subgraph $outerGroup")
appendLine(" direction TB")
}
innerGroups.sortedWith(
compareBy(
{ (group, _) ->
dependencies.filter { dep ->
val toGroup = dep.dependency.substringBeforeLast(":")
toGroup == group && dep.project.substringBeforeLast(":") != group
}.count()
},
{ -it.value.size },
),
).forEach { (group, projects) ->
val indent = if (outerGroup.isNotEmpty()) 4 else 2
appendLine(" ".repeat(indent) + "subgraph $group")
appendLine(" ".repeat(indent) + " direction TB")
projects.sorted().forEach {
appendLine(it.alias(indent = indent + 2, plugins.get().getValue(it)))
}
appendLine(" ".repeat(indent) + "end")
}
if (outerGroup.isNotEmpty()) {
appendLine(" end")
val currentProject = projectPath.get()
val projectPlugins = pluginsData.get()
val projectDeps = dependenciesData.get()[currentProject] ?: emptySet()
appendLine(" $currentProject[${currentProject.substringAfterLast(":")}]:::${projectPlugins[currentProject]?.ref}")
projectDeps.forEach { (config, depPath) ->
val link = when (config) {
"api" -> "-->"
else -> "-.->"
}
appendLine(" $currentProject $link $depPath")
}
rootProjects.flatMap { it.value }.sortedDescending().forEach {
appendLine(it.alias(indent = 2, plugins.get().getValue(it)))
}
// Links
if (dependencies.isNotEmpty()) appendLine()
dependencies
.sortedWith(compareBy({ it.project }, { it.dependency }, { it.configuration }))
.forEach { appendLine(it.link(indent = 2)) }
// Classes
appendLine()
PluginType.entries.forEach { appendLine(it.classDef()) }
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
}
private fun legend() = buildString {
appendLine("graph TB")
listOf(
"application" to PluginType.AndroidApplication,
"feature" to PluginType.AndroidFeature,
"library" to PluginType.AndroidLibrary,
"jvm" to PluginType.Jvm,
"kmp-library" to PluginType.KmpLibrary,
).forEach { (name, type) ->
appendLine(name.alias(indent = 2, type))
}
appendLine()
listOf(
Dependency("application", "implementation", "feature"),
Dependency("library", "api", "jvm"),
).forEach {
appendLine(it.link(indent = 2))
}
appendLine()
PluginType.entries.forEach { appendLine(it.classDef()) }
appendLine(" subgraph Legend")
appendLine(" direction TB")
appendLine(" L1[Application]:::android-application")
appendLine(" L2[Library]:::android-library")
appendLine(" end")
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
}
private class Dependency(val project: String, val configuration: String, val dependency: String)
private fun Pair<String, String>.toDependency(project: String) =
Dependency(project, configuration = first, dependency = second)
private fun String.alias(indent: Int, pluginType: PluginType): String = buildString {
append(" ".repeat(indent))
append(this@alias)
append("[").append(substringAfterLast(":")).append("]:::")
append(pluginType.ref)
}
private fun Dependency.link(indent: Int) = buildString {
append(" ".repeat(indent))
append(project).append(" ")
append(
when (configuration) {
"api" -> "-->"
"implementation" -> "-.->"
else -> "-.->|$configuration|"
},
)
append(" ").append(dependency)
}
private fun PluginType.classDef() = "classDef $ref $style;"
}
@CacheableTask
private abstract class GraphUpdateTask : DefaultTask() {
@get:Input
abstract val projectPath: Property<String>
@get:InputFile
@get:PathSensitive(NONE)
abstract val input: RegularFileProperty
@get:InputFile
@get:PathSensitive(NONE)
abstract val legend: RegularFileProperty
@get:OutputFile
abstract val output: RegularFileProperty
override fun getDescription() = "Updates Markdown file with the corresponding dependency graph."
@TaskAction
operator fun invoke() = with(output.get().asFile) {
if (!exists()) {
createNewFile()
writeText(
"""
# `${projectPath.get()}`
## Module dependency graph
<!--region graph--> <!--endregion-->
""".trimIndent(),
)
}
val mermaid = input.get().asFile.readText().trimTrailingNewLines()
val legend = legend.get().asFile.readText().trimTrailingNewLines()
val regex = """(<!--region graph-->)(.*?)(<!--endregion-->)""".toRegex(DOT_MATCHES_ALL)
val text = readText().replace(regex) { match ->
val (start, _, end) = match.destructured
"""
|$start
|```mermaid
|$mermaid
|```
|
|<details><summary>📋 Graph legend</summary>
|
|```mermaid
|$legend
|```
|
|</details>
|$end
""".trimMargin()
}
writeText(text)
fun update() {
val readme = output.get().asFile
if (!readme.exists()) return
val mermaid = input.get().asFile.readText()
// Update logic...
readme.writeText(readme.readText().replace(Regex("<!--region graph-->.*?<!--endregion-->", DOT_MATCHES_ALL), "<!--region graph-->\n```mermaid\n$mermaid\n```\n<!--endregion-->"))
}
private fun String.trimTrailingNewLines() = lines()
.dropLastWhile(String::isBlank)
.joinToString(System.lineSeparator())
}

View File

@@ -64,9 +64,13 @@ fun Project.configureKover() {
}
}
/**
* Configure Kover aggregation in a way that is compatible with Gradle Isolated Projects.
* Instead of blindly adding all subprojects, we only add those that have the Kover plugin applied.
*/
fun Project.configureKoverAggregation() {
subprojects.forEach { subproject ->
subproject.plugins.withId("org.jetbrains.kotlinx.kover") {
subproject.pluginManager.withPlugin("org.jetbrains.kotlinx.kover") {
dependencies.add("kover", subproject)
}
}

View File

@@ -23,11 +23,14 @@ import org.gradle.api.artifacts.MinimalExternalModuleDependency
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.testing.AbstractTestTask
import org.gradle.api.tasks.testing.Test
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.withType
import org.gradle.plugin.use.PluginDependency
import org.gradle.testretry.TestRetryTaskExtension
import java.io.FileInputStream
import java.util.Properties
@@ -69,4 +72,15 @@ internal fun Project.configureTestOptions() {
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
}
}
// Configure test retry if the plugin is applied
pluginManager.withPlugin("org.gradle.test-retry") {
tasks.withType<AbstractTestTask>().configureEach {
extensions.configure<TestRetryTaskExtension> {
maxRetries.set(2)
maxFailures.set(10)
failOnPassedAfterRetry.set(false)
}
}
}
}

View File

@@ -34,6 +34,7 @@ internal fun Project.configureSpotless(extension: SpotlessExtension) {
}
kotlinGradle {
target("**/*.gradle.kts")
targetExclude("**/build/**", "**/dependencies/**")
ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
ktlint(ktlintVersion)
.setEditorConfigPath(rootProject.file("config/spotless/.editorconfig").path)

View File

@@ -1,8 +1,37 @@
# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534
#
# Copyright (c) 2025 Meshtastic LLC
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Gradle properties for the build-logic included build.
# These need to be set separately because properties are not passed to included builds.
# https://github.com/gradle/gradle/issues/2534
org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC -Dfile.encoding=UTF-8
# Parallelism & Caching
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=false
org.gradle.configuration-cache=true
# Disabled for stability
org.gradle.configuration-cache.parallel=false
org.gradle.isolated-projects=false
org.gradle.vfs.watch=true
org.gradle.configureondemand=false
# Kotlin Optimization
kotlin.parallel.tasks.in.project=true
kotlin.code.style=official
# Housekeeping
org.gradle.welcome=never

View File

@@ -34,17 +34,21 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.protobuf) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.detekt) apply false
alias(libs.plugins.kover)
alias(libs.plugins.spotless) apply false
alias(libs.plugins.dokka)
alias(libs.plugins.test.retry) apply false
alias(libs.plugins.dependency.guard) apply false
alias(libs.plugins.meshtastic.root)
}
dependencies {
dokkaPlugin(libs.dokka.android.documentation.plugin)
}

View File

@@ -21,17 +21,6 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
afterEvaluate {
publishing {
publications {
create<MavenPublication>("release") {
from(components["googleRelease"])
artifactId = "core-api"
}
}
}
}
configure<com.android.build.api.dsl.LibraryExtension> {
namespace = "org.meshtastic.core.api"
buildFeatures { aidl = true }
@@ -44,4 +33,16 @@ configure<com.android.build.api.dsl.LibraryExtension> {
publishing { singleVariant("googleRelease") { withSourcesJar() } }
}
// Map the Android component to a Maven publication
afterEvaluate {
publishing {
publications {
create<MavenPublication>("googleRelease") {
from(components["googleRelease"])
artifactId = "core-api"
}
}
}
}
dependencies { api(projects.core.model) }

View File

@@ -1,16 +1,10 @@
<?xml version="1.0" ?>
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$1000L</ID>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$30</ID>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$31</ID>
<ID>MagicNumber:PacketRepository.kt$PacketRepository$500</ID>
<ID>MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=${quirk.requiresBootloaderUpgradeForOta}, infoUrl=${quirk.infoUrl}"</ID>
<ID>MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: cache ${if (staleEntity == null) "empty" else "incomplete"} for hwModel=$hwModel, falling back to bundled JSON asset"</ID>
<ID>MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"</ID>
<ID>MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel ${if (base != null) "succeeded" else "returned null"}"</ID>
<ID>MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel ${if (fromDb != null) "succeeded" else "returned null"}"</ID>
<ID>TooGenericExceptionCaught:LocationRepository.kt$LocationRepository$e: Exception</ID>
<ID>TooManyFunctions:PacketRepository.kt$PacketRepository</ID>
</CurrentIssues>

View File

@@ -27,10 +27,10 @@ import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
@Suppress("TooManyFunctions")
@@ -55,74 +55,39 @@ constructor(
.conflate()
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
Telemetry.parseFrom(log.fromRadio.packet.decoded.payload)
.toBuilder()
.apply {
if (hasEnvironmentMetrics()) {
// Handle float metrics that default to 0.0f when not explicitly set or when 0.0f means no
// data
if (!environmentMetrics.hasTemperature()) {
environmentMetrics = environmentMetrics.toBuilder().setTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasRelativeHumidity()) {
environmentMetrics =
environmentMetrics.toBuilder().setRelativeHumidity(Float.NaN).build()
}
if (!environmentMetrics.hasSoilTemperature()) {
environmentMetrics =
environmentMetrics.toBuilder().setSoilTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasBarometricPressure()) {
environmentMetrics =
environmentMetrics.toBuilder().setBarometricPressure(Float.NaN).build()
}
if (!environmentMetrics.hasGasResistance()) {
environmentMetrics = environmentMetrics.toBuilder().setGasResistance(Float.NaN).build()
}
if (!environmentMetrics.hasVoltage()) {
environmentMetrics = environmentMetrics.toBuilder().setVoltage(Float.NaN).build()
}
if (!environmentMetrics.hasCurrent()) {
environmentMetrics = environmentMetrics.toBuilder().setCurrent(Float.NaN).build()
}
if (!environmentMetrics.hasLux()) {
environmentMetrics = environmentMetrics.toBuilder().setLux(Float.NaN).build()
}
if (!environmentMetrics.hasUvLux()) {
environmentMetrics = environmentMetrics.toBuilder().setUvLux(Float.NaN).build()
}
// Handle uint32 metrics that default to 0 when not explicitly set or when 0 means no data
if (!environmentMetrics.hasIaq()) {
environmentMetrics = environmentMetrics.toBuilder().setIaq(Int.MIN_VALUE).build()
}
if (!environmentMetrics.hasSoilMoisture()) {
environmentMetrics =
environmentMetrics.toBuilder().setSoilMoisture(Int.MIN_VALUE).build()
}
}
// Leaving in case we have need of nulling any in device metrics.
// if (hasDeviceMetrics()) {
// deviceMetrics =
// deviceMetrics.toBuilder().setBatteryLevel(Int.MIN_VALUE).build()
// }
}
.setTime((log.received_date / MILLIS_TO_SECONDS).toInt())
.build()
val payload = log.fromRadio.packet?.decoded?.payload ?: return@runCatching null
val telemetry = Telemetry.ADAPTER.decode(payload)
telemetry.copy(
time = (log.received_date / MILLIS_TO_SECONDS).toInt(),
environment_metrics =
telemetry.environment_metrics?.let { metrics ->
metrics.copy(
temperature = metrics.temperature ?: Float.NaN,
relative_humidity = metrics.relative_humidity ?: Float.NaN,
soil_temperature = metrics.soil_temperature ?: Float.NaN,
barometric_pressure = metrics.barometric_pressure ?: Float.NaN,
gas_resistance = metrics.gas_resistance ?: Float.NaN,
voltage = metrics.voltage ?: Float.NaN,
current = metrics.current ?: Float.NaN,
lux = metrics.lux ?: Float.NaN,
uv_lux = metrics.uv_lux ?: Float.NaN,
iaq = metrics.iaq ?: Int.MIN_VALUE,
soil_moisture = metrics.soil_moisture ?: Int.MIN_VALUE,
)
},
)
}
.getOrNull()
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = dbManager.currentDb
.flatMapLatest {
it.meshLogDao().getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS)
}
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) }
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
.flowOn(dispatchers.io)
fun getLogsFrom(
nodeNum: Int,
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
portNum: Int = PortNum.UNKNOWN_APP.value,
maxItem: Int = MAX_MESH_PACKETS,
): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, maxItem) }
@@ -133,10 +98,12 @@ constructor(
* Retrieves MeshPackets matching 'nodeNum' and 'portNum'.
* If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'.
*/
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE): Flow<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum).mapLatest { list -> list.map { it.fromRadio.packet } }.flowOn(dispatchers.io)
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = PortNum.UNKNOWN_APP.value): Flow<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum)
.mapLatest { list -> list.mapNotNull { it.fromRadio.packet } }
.flowOn(dispatchers.io)
fun getMyNodeInfo(): Flow<MeshProtos.MyNodeInfo?> = getLogsFrom(0, 0)
fun getMyNodeInfo(): Flow<MyNodeInfo?> = getLogsFrom(0, 0)
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(dispatchers.io)

View File

@@ -44,7 +44,8 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.User
import javax.inject.Inject
import javax.inject.Singleton
@@ -107,27 +108,25 @@ constructor(
fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
fun getUser(userId: String): MeshProtos.User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.longName ?: "Local"
} else {
"Meshtastic ${userId.takeLast(n = 4)}"
},
)
.setShortName(
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.shortName ?: "Local"
} else {
userId.takeLast(n = 4)
},
)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: User(
id = userId,
long_name =
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.long_name ?: "Local"
} else {
"Meshtastic ${userId.takeLast(n = 4)}"
},
short_name =
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.short_name ?: "Local"
} else {
userId.takeLast(n = 4)
},
hw_model = HardwareModel.UNSET,
)
fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
@@ -34,8 +35,8 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import javax.inject.Inject
class PacketRepository
@@ -184,6 +185,8 @@ constructor(
DataPacket.nodeNumToDefaultId(to)
}
val hashByteString = hash.toByteString()
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
@@ -199,8 +202,8 @@ constructor(
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime))
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
}
@@ -222,7 +225,8 @@ constructor(
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)
val updatedReaction =
reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
@@ -234,22 +238,23 @@ constructor(
rxTime: Long = 0,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
dao.findPacketBySfppHash(hash)?.let { packet ->
val hashByteString = hash.toByteString()
dao.findPacketBySfppHash(hashByteString)?.let { packet ->
// If it's already confirmed, don't downgrade it
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime))
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
dao.findReactionBySfppHash(hash)?.let { reaction ->
dao.findReactionBySfppHash(hashByteString)?.let { reaction ->
if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)
val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
@@ -340,7 +345,7 @@ constructor(
}
private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow<List<Packet>> =
getAllPackets(PortNum.WAYPOINT_APP_VALUE)
getAllPackets(PortNum.WAYPOINT_APP.value)
companion object {
private const val CONTACTS_PAGE_SIZE = 30

View File

@@ -22,15 +22,14 @@ import org.meshtastic.core.datastore.ChannelSetDataSource
import org.meshtastic.core.datastore.LocalConfigDataSource
import org.meshtastic.core.datastore.ModuleConfigDataSource
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ChannelProtos.Channel
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig
import org.meshtastic.proto.deviceProfile
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import javax.inject.Inject
/**
@@ -83,7 +82,7 @@ constructor(
*/
suspend fun setLocalConfig(config: Config) {
localConfigDataSource.setLocalConfig(config)
if (config.hasLora()) channelSetDataSource.setLoraConfig(config.lora)
config.lora?.let { channelSetDataSource.setLoraConfig(it) }
}
/** Flow representing the [LocalModuleConfig] data store. */
@@ -111,17 +110,18 @@ constructor(
localConfig,
localModuleConfig,
->
deviceProfile {
node?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.getChannelUrl().toString()
config = localConfig
moduleConfig = localModuleConfig
if (node != null && localConfig.position.fixedPosition) {
fixedPosition = node.position
}
}
DeviceProfile(
long_name = node?.user?.long_name,
short_name = node?.user?.short_name,
channel_url = channels.getChannelUrl().toString(),
config = localConfig,
module_config = localModuleConfig,
fixed_position =
if (node != null && localConfig.position?.fixed_position == true) {
node.position
} else {
null
},
)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlinx.coroutines.flow.Flow
@@ -27,7 +26,7 @@ import kotlinx.coroutines.withContext
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Position
import javax.inject.Inject
class TracerouteSnapshotRepository
@@ -37,14 +36,14 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) {
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, MeshProtos.Position>> = dbManager.currentDb
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> = dbManager.currentDb
.flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) }
.distinctUntilChanged()
.mapLatest { list -> list.associate { it.nodeNum to it.position } }
.flowOn(dispatchers.io)
.conflate()
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, MeshProtos.Position>) =
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.tracerouteNodePositionDao()
dao.deleteByLogUuid(logUuid)

View File

@@ -21,6 +21,7 @@ import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
@@ -30,12 +31,12 @@ import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.MeshProtos.Data
import org.meshtastic.proto.MeshProtos.FromRadio
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.UUID
class MeshLogRepositoryTest {
@@ -57,15 +58,10 @@ class MeshLogRepositoryTest {
@Test
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
val zeroTemp = 0.0f
val envMetrics = EnvironmentMetrics.newBuilder().setTemperature(zeroTemp).build()
val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp))
val meshPacket =
MeshPacket.newBuilder()
.setDecoded(
Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
)
.build()
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
@@ -73,7 +69,7 @@ class MeshLogRepositoryTest {
message_type = "telemetry",
received_date = System.currentTimeMillis(),
raw_message = "",
fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
fromRadio = FromRadio(packet = meshPacket),
)
// Using reflection to test private method parseTelemetryLog
@@ -82,22 +78,17 @@ class MeshLogRepositoryTest {
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environmentMetrics
val resultMetrics = result?.environment_metrics
assertNotNull(resultMetrics)
assertEquals(zeroTemp, resultMetrics?.temperature!!, 0.01f)
assertEquals(zeroTemp, resultMetrics?.temperature ?: 0f, 0.01f)
}
@Test
fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) {
val envMetrics = EnvironmentMetrics.newBuilder().build() // Temperature not set
val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = null))
val meshPacket =
MeshPacket.newBuilder()
.setDecoded(
Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
)
.build()
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
@@ -105,7 +96,7 @@ class MeshLogRepositoryTest {
message_type = "telemetry",
received_date = System.currentTimeMillis(),
raw_message = "",
fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
fromRadio = FromRadio(packet = meshPacket),
)
val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
@@ -113,9 +104,9 @@ class MeshLogRepositoryTest {
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environmentMetrics
val resultMetrics = result?.environment_metrics
// Should be NaN as per repository logic for missing fields
assertEquals(Float.NaN, resultMetrics?.temperature!!, 0.01f)
assertEquals(Float.NaN, resultMetrics?.temperature ?: 0f, 0.01f)
}
}

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:Node.kt$Node$private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List&lt;String></ID>
<ID>TooGenericExceptionCaught:Converters.kt$Converters$ex: Exception</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,16 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -37,9 +36,9 @@ import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.user
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
@RunWith(AndroidJUnit4::class)
class NodeInfoDaoTest {
@@ -54,12 +53,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 7,
user =
user {
id = "!a1b2c3d4"
longName = "Meshtastic c3d4"
shortName = "c3d4"
hwModel = MeshProtos.HardwareModel.UNSET
},
User(
id = "!a1b2c3d4",
long_name = "Meshtastic c3d4",
short_name = "c3d4",
hw_model = HardwareModel.UNSET,
),
longName = "Meshtastic c3d4",
shortName = null, // Dao filter for includeUnknown
)
@@ -68,13 +67,13 @@ class NodeInfoDaoTest {
NodeEntity(
num = 8,
user =
user {
id = "+16508765308".format(8)
longName = "Kevin Mester"
shortName = "KLO"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = false
},
User(
id = "+16508765308".format(8),
long_name = "Kevin Mester",
short_name = "KLO",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
),
longName = "Kevin Mester",
shortName = "KLO",
latitude = 30.267153,
@@ -86,12 +85,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 9,
user =
user {
id = "!25060801"
longName = "Meshtastic 0801"
shortName = "0801"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060801",
long_name = "Meshtastic 0801",
short_name = "0801",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0801",
shortName = "0801",
hopsAway = 0,
@@ -102,12 +101,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 10,
user =
user {
id = "!25060802"
longName = "Meshtastic 0802"
shortName = "0802"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060802",
long_name = "Meshtastic 0802",
short_name = "0802",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0802",
shortName = "0802",
hopsAway = 0,
@@ -118,12 +117,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 11,
user =
user {
id = "!25060803"
longName = "Meshtastic 0803"
shortName = "0803"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060803",
long_name = "Meshtastic 0803",
short_name = "0803",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0803",
shortName = "0803",
hopsAway = 0,
@@ -134,12 +133,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 12,
user =
user {
id = "!25060804"
longName = "Meshtastic 0804"
shortName = "0804"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060804",
long_name = "Meshtastic 0804",
short_name = "0804",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0804",
shortName = "0804",
hopsAway = 3,
@@ -179,14 +178,14 @@ class NodeInfoDaoTest {
NodeEntity(
num = 1000 + index,
user =
user {
id = "+165087653%02d".format(9 + index)
longName = "Kevin Mester$index"
shortName = "KM$index"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = false
publicKey = ByteString.copyFrom(ByteArray(32) { index.toByte() })
},
User(
id = "+165087653%02d".format(9 + index),
long_name = "Kevin Mester$index",
short_name = "KM$index",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
public_key = ByteArray(32) { index.toByte() }.toByteString(),
),
longName = "Kevin Mester$index",
shortName = "KM$index",
latitude = pos.first,
@@ -256,14 +255,14 @@ class NodeInfoDaoTest {
@Test
fun testSortByAlpha() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL)
val sortedNodes = nodes.sortedBy { it.user.longName.uppercase() }
val sortedNodes = nodes.sortedBy { it.user.long_name?.uppercase() ?: "" }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByDistance() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.DISTANCE)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position ?: Position())
val sortedNodes =
nodes.sortedWith( // nodes with invalid (null) positions at the end
compareBy<Node> { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) },
@@ -281,7 +280,7 @@ class NodeInfoDaoTest {
@Test
fun testSortByViaMqtt() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.VIA_MQTT)
val sortedNodes = nodes.sortedBy { it.user.longName.contains("(MQTT)") }
val sortedNodes = nodes.sortedBy { it.user.long_name?.contains("(MQTT)") == true }
assertEquals(sortedNodes, nodes)
}
@@ -339,8 +338,7 @@ class NodeInfoDaoTest {
@Test
fun testPkcMismatch() = runBlocking {
val newNode =
testNodes[1].copy(user = testNodes[1].user.copy { publicKey = ByteString.copyFrom(ByteArray(32) { 99 }) })
val newNode = testNodes[1].copy(user = testNodes[1].user.copy(public_key = ByteArray(32) { 99 }.toByteString()))
nodeInfoDao.putAll(listOf(newNode))
val nodes = getNodes()
val containsMismatchNode = nodes.any { it.mismatchKey }

View File

@@ -21,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -36,7 +37,7 @@ import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)
class PacketDaoTest {
@@ -68,11 +69,11 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
read = false,
DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
)
}
}
@@ -104,7 +105,7 @@ class PacketDaoTest {
@Test
fun test_getAllPackets() = runBlocking {
val packets = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first()
val packets = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()
assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size)
val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum }
@@ -177,7 +178,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
@@ -195,58 +196,58 @@ class PacketDaoTest {
@Test
fun test_sfppHashPersistence() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4)
val hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hash,
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
val retrieved =
packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first().find {
it.sfpp_hash?.contentEquals(hash) == true
}
packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first().find { it.sfpp_hash == hashByteString }
assertNotNull(retrieved)
assertTrue(retrieved?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, retrieved?.sfpp_hash)
}
@Test
fun test_findPacketBySfppHash() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
val hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hash,
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
// Exact match
val found = packetDao.findPacketBySfppHash(hash)
val found = packetDao.findPacketBySfppHash(hashByteString)
assertNotNull(found)
assertTrue(found?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, found?.sfpp_hash)
// Substring match (first 8 bytes)
val shortHash = hash.copyOf(8)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findPacketBySfppHash(shortHash)
assertNotNull(foundShort)
assertTrue(foundShort?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, foundShort?.sfpp_hash)
// No match
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
val notFound = packetDao.findPacketBySfppHash(wrongHash)
assertNull(notFound)
}
@@ -254,6 +255,7 @@ class PacketDaoTest {
@Test
fun test_findReactionBySfppHash() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
val hashByteString = hash.toByteString()
val reaction =
ReactionEntity(
myNodeNum = myNodeNum,
@@ -261,20 +263,20 @@ class PacketDaoTest {
userId = "sender",
emoji = "👍",
timestamp = System.currentTimeMillis(),
sfpp_hash = hash,
sfpp_hash = hashByteString,
)
packetDao.insert(reaction)
val found = packetDao.findReactionBySfppHash(hash)
val found = packetDao.findReactionBySfppHash(hashByteString)
assertNotNull(found)
assertTrue(found?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, found?.sfpp_hash)
val shortHash = hash.copyOf(8)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findReactionBySfppHash(shortHash)
assertNotNull(foundShort)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
assertNull(packetDao.findReactionBySfppHash(wrongHash))
}
@@ -286,7 +288,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
@@ -309,7 +311,7 @@ class PacketDaoTest {
val packetId = 999
val fromNum = 123
val toNum = 456
val hash = byteArrayOf(9, 8, 7, 6)
val hash = byteArrayOf(9, 8, 7, 6).toByteString()
val fromId = DataPacket.nodeNumToDefaultId(fromNum)
val toId = DataPacket.nodeNumToDefaultId(toNum)
@@ -318,7 +320,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
@@ -339,8 +341,8 @@ class PacketDaoTest {
val updated = packetDao.findPacketsWithId(packetId)[0]
assertEquals(MessageStatus.SFPP_CONFIRMED, updated.data.status)
assertTrue(updated.data.sfppHash?.contentEquals(hash) == true)
assertTrue(updated.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hash, updated.data.sfppHash)
assertEquals(hash, updated.sfpp_hash)
}
@Test
@@ -352,7 +354,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = filteredContactKey,
received_time = System.currentTimeMillis(),
read = false,
@@ -376,7 +378,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + i,
read = false,
@@ -424,7 +426,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + index,
read = false,
@@ -439,7 +441,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + normalMessages.size + index,
read = true, // Filtered messages are marked as read

View File

@@ -18,14 +18,18 @@ package org.meshtastic.core.database
import androidx.room.TypeConverter
import co.touchlab.kermit.Logger
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import kotlinx.serialization.json.Json
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
@Suppress("TooManyFunctions")
class Converters {
@@ -40,64 +44,34 @@ class Converters {
@TypeConverter fun dataToString(value: DataPacket): String = json.encodeToString(DataPacket.serializer(), value)
@TypeConverter
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio = try {
MeshProtos.FromRadio.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToFromRadio TypeConverter error" }
MeshProtos.FromRadio.getDefaultInstance()
}
fun bytesToFromRadio(bytes: ByteArray): FromRadio = FromRadio.ADAPTER.decodeOrNull(bytes, Logger) ?: FromRadio()
@TypeConverter fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? = value.toByteArray()
@TypeConverter fun fromRadioToBytes(value: FromRadio): ByteArray = FromRadio.ADAPTER.encode(value)
@TypeConverter fun bytesToUser(bytes: ByteArray): User = User.ADAPTER.decodeOrNull(bytes, Logger) ?: User()
@TypeConverter fun userToBytes(value: User): ByteArray = User.ADAPTER.encode(value)
@TypeConverter
fun bytesToUser(bytes: ByteArray): MeshProtos.User = try {
MeshProtos.User.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToUser TypeConverter error" }
MeshProtos.User.getDefaultInstance()
}
fun bytesToPosition(bytes: ByteArray): Position = Position.ADAPTER.decodeOrNull(bytes, Logger) ?: Position()
@TypeConverter fun userToBytes(value: MeshProtos.User): ByteArray? = value.toByteArray()
@TypeConverter fun positionToBytes(value: Position): ByteArray = Position.ADAPTER.encode(value)
@TypeConverter
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position = try {
MeshProtos.Position.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToPosition TypeConverter error" }
MeshProtos.Position.getDefaultInstance()
}
fun bytesToTelemetry(bytes: ByteArray): Telemetry = Telemetry.ADAPTER.decodeOrNull(bytes, Logger) ?: Telemetry()
@TypeConverter fun positionToBytes(value: MeshProtos.Position): ByteArray? = value.toByteArray()
@TypeConverter fun telemetryToBytes(value: Telemetry): ByteArray = Telemetry.ADAPTER.encode(value)
@TypeConverter
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry = try {
TelemetryProtos.Telemetry.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToTelemetry TypeConverter error" }
TelemetryProtos.Telemetry.newBuilder().build() // Return an empty Telemetry object
}
fun bytesToPaxcounter(bytes: ByteArray): Paxcount = Paxcount.ADAPTER.decodeOrNull(bytes, Logger) ?: Paxcount()
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()
@TypeConverter fun paxCounterToBytes(value: Paxcount): ByteArray = Paxcount.ADAPTER.encode(value)
@TypeConverter
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount = try {
PaxcountProtos.Paxcount.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToPaxcounter TypeConverter error" }
PaxcountProtos.Paxcount.getDefaultInstance()
}
fun bytesToMetadata(bytes: ByteArray): DeviceMetadata =
DeviceMetadata.ADAPTER.decodeOrNull(bytes, Logger) ?: DeviceMetadata()
@TypeConverter fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata = try {
MeshProtos.DeviceMetadata.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToMetadata TypeConverter error" }
MeshProtos.DeviceMetadata.getDefaultInstance()
}
@TypeConverter fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? = value.toByteArray()
@TypeConverter fun metadataToBytes(value: DeviceMetadata): ByteArray = DeviceMetadata.ADAPTER.encode(value)
@TypeConverter
fun fromStringList(value: String?): List<String>? {
@@ -115,8 +89,7 @@ class Converters {
return Json.encodeToString(list)
}
@TypeConverter
fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes)
@TypeConverter fun bytesToByteString(bytes: ByteArray?): ByteString? = bytes?.toByteString()
@TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray()

View File

@@ -23,13 +23,13 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.HardwareModel
@Suppress("TooManyFunctions")
@Dao
@@ -47,13 +47,13 @@ interface NodeInfoDao {
private suspend fun getVerifiedNodeForUpsert(incomingNode: NodeEntity): NodeEntity {
// Populate the NodeEntity.publicKey field from the User.publicKey for consistency
// and to support lazy migration.
incomingNode.publicKey = incomingNode.user.publicKey
incomingNode.publicKey = incomingNode.user.public_key
// Populate denormalized name columns from the User protobuf for search functionality
// Only populate if the user is not a placeholder (hwModel != UNSET); otherwise keep them null
if (incomingNode.user.hwModel != MeshProtos.HardwareModel.UNSET) {
incomingNode.longName = incomingNode.user.longName
incomingNode.shortName = incomingNode.user.shortName
if (incomingNode.user.hw_model != HardwareModel.UNSET) {
incomingNode.longName = incomingNode.user.long_name
incomingNode.shortName = incomingNode.user.short_name
} else {
incomingNode.longName = null
incomingNode.shortName = null
@@ -72,7 +72,7 @@ interface NodeInfoDao {
private suspend fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity {
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
if (newNode.publicKey?.isEmpty == false) {
if ((newNode.publicKey?.size ?: 0) > 0) {
val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey)
if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) {
// This is a potential impersonation attempt.
@@ -85,9 +85,9 @@ interface NodeInfoDao {
}
private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity {
val isPlaceholder = incomingNode.user.hwModel == MeshProtos.HardwareModel.UNSET
val hasExistingUser = existingNode.user.hwModel != MeshProtos.HardwareModel.UNSET
val isDefaultName = incomingNode.user.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET
val hasExistingUser = existingNode.user.hw_model != HardwareModel.UNSET
val isDefaultName = incomingNode.user.long_name?.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) == true
val shouldPreserve = hasExistingUser && isPlaceholder && isDefaultName
@@ -115,7 +115,7 @@ interface NodeInfoDao {
// A public key is considered matching if the incoming key equals the existing key,
// OR if the existing key is empty (allowing a new key to be set or an update to proceed).
val existingResolvedKey = existingNode.publicKey ?: existingNode.user.publicKey
val existingResolvedKey = existingNode.publicKey ?: existingNode.user.public_key
val isPublicKeyMatchingOrExistingIsEmpty = existingResolvedKey == incomingNode.publicKey || !existingNode.hasPKC
val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes
@@ -129,7 +129,7 @@ interface NodeInfoDao {
// We allow the name and user info to update, but we clear the public key
// to indicate that this node is no longer "verified" against the previous key.
incomingNode.copy(
user = incomingNode.user.toBuilder().setPublicKey(NodeEntity.ERROR_BYTE_STRING).build(),
user = incomingNode.user.copy(public_key = NodeEntity.ERROR_BYTE_STRING),
publicKey = NodeEntity.ERROR_BYTE_STRING,
notes = resolvedNotes,
)
@@ -289,10 +289,9 @@ interface NodeInfoDao {
nodes
.filter { node ->
// Only backfill if columns are NULL AND the user is not a placeholder (hwModel != UNSET)
(node.longName == null || node.shortName == null) &&
node.user.hwModel != MeshProtos.HardwareModel.UNSET
(node.longName == null || node.shortName == null) && node.user.hw_model != HardwareModel.UNSET
}
.map { node -> node.copy(longName = node.user.longName, shortName = node.user.shortName) }
.map { node -> node.copy(longName = node.user.long_name, shortName = node.user.short_name) }
if (nodesToUpdate.isNotEmpty()) {
putAll(nodesToUpdate)
}

View File

@@ -24,13 +24,14 @@ import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ChannelSettings
@Suppress("TooManyFunctions")
@Dao
@@ -300,7 +301,7 @@ interface PacketDao {
AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8)
""",
)
suspend fun findPacketBySfppHash(hash: ByteArray): Packet?
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
@Transaction
suspend fun getQueuedPackets(): List<DataPacket>? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
@@ -386,7 +387,7 @@ interface PacketDao {
AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8)
""",
)
suspend fun findReactionBySfppHash(hash: ByteArray): ReactionEntity?
suspend fun findReactionBySfppHash(hash: ByteString): ReactionEntity?
@Query(
"""

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,18 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.google.protobuf.TextFormat
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.FromRadio
import org.meshtastic.proto.Portnums
import java.io.IOException
import co.touchlab.kermit.Logger
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.Position
@Suppress("EmptyCatchBlock", "SwallowedException", "ConstructorParameterNaming")
@Entity(tableName = "log", indices = [Index(value = ["from_num"]), Index(value = ["port_num"])])
@@ -37,52 +38,25 @@ data class MeshLog(
@ColumnInfo(name = "from_num", defaultValue = "0") val fromNum: Int = 0,
@ColumnInfo(name = "port_num", defaultValue = "0") val portNum: Int = 0,
@ColumnInfo(name = "from_radio", typeAffinity = ColumnInfo.BLOB, defaultValue = "x''")
val fromRadio: FromRadio = FromRadio.getDefaultInstance(),
val fromRadio: FromRadio = FromRadio(),
) {
val meshPacket: MeshProtos.MeshPacket?
get() {
if (message_type == "Packet") {
val builder = MeshProtos.MeshPacket.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {}
}
return null
}
val meshPacket: MeshPacket?
get() = fromRadio.packet
val nodeInfo: MeshProtos.NodeInfo?
get() {
if (message_type == "NodeInfo") {
val builder = MeshProtos.NodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {}
}
return null
}
val nodeInfo: NodeInfo?
get() = fromRadio.node_info
val myNodeInfo: MeshProtos.MyNodeInfo?
get() {
if (message_type == "MyNodeInfo") {
val builder = MeshProtos.MyNodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {}
}
return null
}
val myNodeInfo: MyNodeInfo?
get() = fromRadio.my_info
val position: MeshProtos.Position?
get() {
return meshPacket?.run {
if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) {
return MeshProtos.Position.parseFrom(decoded.payload)
val position: Position?
get() =
fromRadio.packet?.decoded?.payload?.let {
if (fromRadio.packet?.decoded?.portnum == org.meshtastic.proto.PortNum.POSITION_APP) {
Position.ADAPTER.decodeOrNull(it, Logger)
} else {
null
}
return null
} ?: nodeInfo?.position
}
}

View File

@@ -22,8 +22,8 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
@@ -31,10 +31,12 @@ import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Position as WirePosition
data class NodeWithRelations(
@Embedded val node: NodeEntity,
@@ -50,15 +52,15 @@ data class NodeWithRelations(
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
notes = notes,
manuallyVerified = manuallyVerified,
@@ -94,7 +96,7 @@ data class NodeWithRelations(
@Entity(tableName = "metadata", indices = [Index(value = ["num"])])
data class MetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: MeshProtos.DeviceMetadata,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: DeviceMetadata,
val timestamp: Long = System.currentTimeMillis(),
)
@@ -113,18 +115,16 @@ data class MetadataEntity(
)
data class NodeEntity(
@PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: User = User(),
@ColumnInfo(name = "long_name") var longName: String? = null,
@ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var position: WirePosition = WirePosition(),
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var snr: Float = Float.MAX_VALUE,
var rssi: Int = Int.MAX_VALUE,
@ColumnInfo(name = "last_heard") var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
@ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB)
var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
@ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) var deviceTelemetry: Telemetry = Telemetry(),
var channel: Int = 0,
@ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false,
@ColumnInfo(name = "hops_away") var hopsAway: Int = -1,
@@ -132,33 +132,34 @@ data class NodeEntity(
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
@ColumnInfo(name = "is_muted", defaultValue = "0") var isMuted: Boolean = false,
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
var environmentTelemetry: Telemetry = Telemetry(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) var powerTelemetry: Telemetry = Telemetry(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: Paxcount = Paxcount(),
@ColumnInfo(name = "public_key") var publicKey: ByteString? = null,
@ColumnInfo(name = "notes", defaultValue = "") var notes: String = "",
@ColumnInfo(name = "manually_verified", defaultValue = "0")
var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually
@ColumnInfo(name = "node_status") var nodeStatus: String? = null,
) {
val deviceMetrics: TelemetryProtos.DeviceMetrics
get() = deviceTelemetry.deviceMetrics
val deviceMetrics: org.meshtastic.proto.DeviceMetrics?
get() = deviceTelemetry.device_metrics
val environmentMetrics: TelemetryProtos.EnvironmentMetrics
get() = environmentTelemetry.environmentMetrics
val environmentMetrics: org.meshtastic.proto.EnvironmentMetrics?
get() = environmentTelemetry.environment_metrics
val powerMetrics: org.meshtastic.proto.PowerMetrics?
get() = powerTelemetry.power_metrics
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) {
position = p.copy { time = if (p.time != 0) p.time else defaultTime }
latitude = degD(p.latitudeI)
longitude = degD(p.longitudeI)
fun setPosition(p: WirePosition, defaultTime: Int = currentTime()) {
position = p.copy(time = if (p.time != 0) p.time else defaultTime)
latitude = degD(p.latitude_i ?: 0)
longitude = degD(p.longitude_i ?: 0)
}
/** true if the device was heard from recently */
@@ -173,7 +174,7 @@ data class NodeEntity(
fun degI(d: Double) = (d * 1e7).toInt()
val ERROR_BYTE_STRING: ByteString = ByteString.copyFrom(ByteArray(32) { 0 })
val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString()
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
@@ -185,17 +186,17 @@ data class NodeEntity(
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
publicKey = publicKey ?: user.publicKey,
publicKey = publicKey ?: user.public_key,
notes = notes,
nodeStatus = nodeStatus,
)
@@ -205,22 +206,22 @@ data class NodeEntity(
user =
MeshUser(
id = user.id,
longName = user.longName,
shortName = user.shortName,
hwModel = user.hwModel,
role = user.roleValue,
longName = user.long_name ?: "",
shortName = user.short_name ?: "",
hwModel = user.hw_model,
role = user.role.value,
)
.takeIf { user.id.isNotEmpty() },
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.satsInView,
groundSpeed = position.groundSpeed,
groundTrack = position.groundTrack,
precisionBits = position.precisionBits,
satellitesInView = position.sats_in_view ?: 0,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
)
.takeIf { it.isValid() },
snr = snr,
@@ -229,16 +230,16 @@ data class NodeEntity(
deviceMetrics =
DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage,
channelUtilization = deviceMetrics.channelUtilization,
airUtilTx = deviceMetrics.airUtilTx,
uptimeSeconds = deviceMetrics.uptimeSeconds,
batteryLevel = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
channelUtilization = deviceMetrics?.channel_utilization ?: 0f,
airUtilTx = deviceMetrics?.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics?.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics =
EnvironmentMetrics.fromTelemetryProto(
environmentTelemetry.environmentMetrics,
environmentTelemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics(),
environmentTelemetry.time,
),
hopsAway = hopsAway,

View File

@@ -22,12 +22,13 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import okio.ByteString
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.proto.MeshProtos.User
import org.meshtastic.proto.User
data class PacketEntity(
@Embedded val packet: Packet,
@@ -87,7 +88,7 @@ data class Packet(
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
) {
companion object {
@@ -144,7 +145,7 @@ data class Reaction(
val relayNode: Int? = null,
val to: String? = null,
val channel: Int = 0,
val sfppHash: ByteArray? = null,
val sfppHash: ByteString? = null,
)
@Suppress("ConstructorParameterNaming")
@@ -170,7 +171,7 @@ data class ReactionEntity(
@ColumnInfo(name = "relay_node") val relayNode: Int? = null,
@ColumnInfo(name = "to") val to: String? = null,
@ColumnInfo(name = "channel", defaultValue = "0") val channel: Int = 0,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
)
private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,14 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Position
@Entity(
tableName = "traceroute_node_position",
@@ -41,5 +40,5 @@ data class TracerouteNodePositionEntity(
@ColumnInfo(name = "log_uuid") val logUuid: String,
@ColumnInfo(name = "request_id") val requestId: Int,
@ColumnInfo(name = "node_num") val nodeNum: Int,
@ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: MeshProtos.Position,
@ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: Position,
)

View File

@@ -46,28 +46,28 @@ import org.meshtastic.core.strings.routing_error_rate_limit_exceeded
import org.meshtastic.core.strings.routing_error_timeout
import org.meshtastic.core.strings.routing_error_too_large
import org.meshtastic.core.strings.unrecognized
import org.meshtastic.proto.MeshProtos.Routing
import org.meshtastic.proto.Routing
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(routingError: Int): StringResource = when (routingError) {
Routing.Error.NONE_VALUE -> Res.string.routing_error_none
Routing.Error.NO_ROUTE_VALUE -> Res.string.routing_error_no_route
Routing.Error.GOT_NAK_VALUE -> Res.string.routing_error_got_nak
Routing.Error.TIMEOUT_VALUE -> Res.string.routing_error_timeout
Routing.Error.NO_INTERFACE_VALUE -> Res.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT_VALUE -> Res.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL_VALUE -> Res.string.routing_error_no_channel
Routing.Error.TOO_LARGE_VALUE -> Res.string.routing_error_too_large
Routing.Error.NO_RESPONSE_VALUE -> Res.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT_VALUE -> Res.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST_VALUE -> Res.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED_VALUE -> Res.string.routing_error_not_authorized
Routing.Error.PKI_FAILED_VALUE -> Res.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY_VALUE -> Res.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY_VALUE -> Res.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED_VALUE -> Res.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED_VALUE -> Res.string.routing_error_rate_limit_exceeded
Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY_VALUE -> Res.string.routing_error_pki_send_fail_public_key
Routing.Error.NONE.value -> Res.string.routing_error_none
Routing.Error.NO_ROUTE.value -> Res.string.routing_error_no_route
Routing.Error.GOT_NAK.value -> Res.string.routing_error_got_nak
Routing.Error.TIMEOUT.value -> Res.string.routing_error_timeout
Routing.Error.NO_INTERFACE.value -> Res.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT.value -> Res.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL.value -> Res.string.routing_error_no_channel
Routing.Error.TOO_LARGE.value -> Res.string.routing_error_too_large
Routing.Error.NO_RESPONSE.value -> Res.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST.value -> Res.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED.value -> Res.string.routing_error_not_authorized
Routing.Error.PKI_FAILED.value -> Res.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> Res.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.routing_error_rate_limit_exceeded
Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.routing_error_pki_send_fail_public_key
else -> Res.string.unrecognized
}

View File

@@ -17,47 +17,48 @@
package org.meshtastic.core.database.model
import android.graphics.Color
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import okio.ByteString
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos.DeviceMetrics
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
import org.meshtastic.proto.TelemetryProtos.PowerMetrics
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.User
@Suppress("MagicNumber")
data class Node(
val num: Int,
val metadata: MeshProtos.DeviceMetadata? = null,
val user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
val position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
val metadata: DeviceMetadata? = null,
val user: User = User(),
val position: Position = Position(),
val snr: Float = Float.MAX_VALUE,
val rssi: Int = Int.MAX_VALUE,
val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
val deviceMetrics: DeviceMetrics = DeviceMetrics.getDefaultInstance(),
val deviceMetrics: DeviceMetrics = DeviceMetrics(),
val channel: Int = 0,
val viaMqtt: Boolean = false,
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val isMuted: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(),
val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(),
val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics(),
val powerMetrics: PowerMetrics = PowerMetrics(),
val paxcounter: Paxcount = Paxcount(),
val publicKey: ByteString? = null,
val notes: String = "",
val manuallyVerified: Boolean = false,
val nodeStatus: String? = null,
) {
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmwareVersion) }
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
@@ -69,41 +70,41 @@ data class Node(
}
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
val mismatchKey
get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
get() = (publicKey ?: user.public_key) == NodeEntity.ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
get() = environmentMetrics != EnvironmentMetrics()
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics.getDefaultInstance()
get() = powerMetrics != PowerMetrics()
val batteryLevel
get() = deviceMetrics.batteryLevel
get() = deviceMetrics.battery_level
val voltage
get() = deviceMetrics.voltage
val batteryStr
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
get() = if ((batteryLevel ?: 0) in 1..100) "$batteryLevel%" else ""
val latitude
get() = position.latitudeI * 1e-7
get() = (position.latitude_i ?: 0) * 1e-7
val longitude
get() = position.longitudeI * 1e-7
get() = (position.longitude_i ?: 0) * 1e-7
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
val validPosition: MeshProtos.Position?
val validPosition: Position?
get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
@@ -113,7 +114,7 @@ data class Node(
}
// @return formatted distance string to another node, using the given display units
fun distanceStr(o: Node, displayUnits: DisplayConfig.DisplayUnits): String? =
fun distanceStr(o: Node, displayUnits: Config.DisplayConfig.DisplayUnits): String? =
distance(o)?.toDistanceString(displayUnits)
// @return bearing to the other position in degrees
@@ -126,36 +127,36 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
val temp =
if (temperature != 0f) {
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature))
"%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f))
} else {
"%.1f°C".format(temperature)
}
} else {
null
}
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null
val soilTemperatureStr =
if (soilTemperature != 0f) {
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
"%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
"%.1f°C".format(soilTemperature)
"%.1f°C".format(soil_temperature)
}
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
"%d%%".format(soilMoisture)
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
"%d%%".format(soil_moisture)
} else {
null
}
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
val current = if (current != 0f) "%.1fmA".format(current) else null
val iaq = if (iaq != 0) "IAQ: $iaq" else null
val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null
val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
paxcounter.getDisplayString(),
@@ -169,19 +170,19 @@ data class Node(
)
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
private fun Paxcount.getDisplayString() =
"PAX: ${(ble ?: 0) + (wifi ?: 0)} (B:${ble ?: 0}/W:${wifi ?: 0})".takeIf { (ble ?: 0) != 0 || (wifi ?: 0) != 0 }
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
environmentMetrics.getDisplayStrings(isFahrenheit)
}
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
listOf(
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.SENSOR,
Config.DeviceConfig.Role.TRACKER,
Config.DeviceConfig.Role.TAK_TRACKER,
)

View File

@@ -19,9 +19,9 @@ package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
@@ -31,8 +31,8 @@ import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@@ -69,32 +69,20 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
// PSK "AQ==" is base64 for single byte 0x01
val pskBytes = ByteString.copyFrom(byteArrayOf(0x01))
// PSK \"AQ==\" is base64 for single byte 0x01
val pskBytes = byteArrayOf(0x01).toByteString()
// Create packets for Channel 0
insertPacket(channel = 0, text = "Message Ch0")
// Old settings: Channel 0 has PSK_A
val oldSettings =
listOf(
channelSettings {
psk = pskBytes
name = "LongFast"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskBytes, name = "LongFast"))
// New settings: Channel 0 has PSK_A, Channel 1 has PSK_A
val newSettings =
listOf(
channelSettings {
psk = pskBytes
name = "LongFast"
},
channelSettings {
psk = pskBytes
name = "NewChan"
},
ChannelSettings(psk = pskBytes, name = "LongFast"),
ChannelSettings(psk = pskBytes, name = "NewChan"),
)
// Perform migration
@@ -107,35 +95,15 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_reorder() = runBlocking {
val pskA = ByteString.copyFrom(byteArrayOf(0x01))
val pskB = ByteString.copyFrom(byteArrayOf(0x02))
val pskA = byteArrayOf(0x01).toByteString()
val pskB = byteArrayOf(0x02).toByteString()
insertPacket(channel = 0, text = "Msg A")
insertPacket(channel = 1, text = "Msg B")
val oldSettings =
listOf(
channelSettings {
psk = pskA
name = "A"
},
channelSettings {
psk = pskB
name = "B"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskB, name = "B"))
val newSettings =
listOf(
channelSettings {
psk = pskB
name = "B"
},
channelSettings {
psk = pskA
name = "A"
},
)
val newSettings = listOf(ChannelSettings(psk = pskB, name = "B"), ChannelSettings(psk = pskA, name = "A"))
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
@@ -146,35 +114,15 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
val pskA = ByteString.copyFrom(byteArrayOf(0x01))
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A1")
insertPacket(channel = 1, text = "Msg A2")
val oldSettings =
listOf(
channelSettings {
psk = pskA
name = "A1"
},
channelSettings {
psk = pskA
name = "A2"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A1"), ChannelSettings(psk = pskA, name = "A2"))
// Swap positions but keep names and PSKs
val newSettings =
listOf(
channelSettings {
psk = pskA
name = "A2"
},
channelSettings {
psk = pskA
name = "A1"
},
)
val newSettings = listOf(ChannelSettings(psk = pskA, name = "A2"), ChannelSettings(psk = pskA, name = "A1"))
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
@@ -185,30 +133,14 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
val pskA = ByteString.copyFrom(byteArrayOf(0x01))
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A")
val oldSettings =
listOf(
channelSettings {
psk = pskA
name = "A"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A"))
// New settings has two identical channels (same PSK, same Name)
val newSettings =
listOf(
channelSettings {
psk = pskA
name = "A"
},
channelSettings {
psk = pskA
name = "A"
},
)
val newSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskA, name = "A"))
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
@@ -221,7 +153,7 @@ class MigrationTest {
Packet(
uuid = 0L,
myNodeNum = 42424242,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "$channel!broadcast",
received_time = System.currentTimeMillis(),
read = false,
@@ -230,7 +162,7 @@ class MigrationTest {
)
}
private suspend fun getAllPackets() = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first()
private suspend fun getAllPackets() = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()
private suspend fun getFirstPacket() = getAllPackets().first()
}

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:ModuleConfigDataSource.kt$ModuleConfigDataSource$suspend fun setLocalModuleConfig(config: ModuleConfig)</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -20,11 +20,11 @@ import androidx.datastore.core.DataStore
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ChannelProtos.Channel
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ConfigProtos
import java.io.IOException
import okio.IOException
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import javax.inject.Inject
import javax.inject.Singleton
@@ -36,38 +36,37 @@ class ChannelSetDataSource @Inject constructor(private val channelSetStore: Data
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
Logger.e { "Error reading DeviceConfig settings: ${exception.message}" }
emit(ChannelSet.getDefaultInstance())
emit(ChannelSet())
} else {
throw exception
}
}
suspend fun clearChannelSet() {
channelSetStore.updateData { preference -> preference.toBuilder().clear().build() }
channelSetStore.updateData { ChannelSet() }
}
/** Replaces all [ChannelSettings] in a single atomic operation. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
channelSetStore.updateData { preference ->
preference.toBuilder().clearSettings().addAllSettings(settingsList).build()
}
channelSetStore.updateData { it.copy(settings = settingsList) }
}
/** Updates the [ChannelSettings] list with the provided channel. */
suspend fun updateChannelSettings(channel: Channel) {
if (channel.role == Channel.Role.DISABLED) return
channelSetStore.updateData { preference ->
val builder = preference.toBuilder()
val settings = preference.settings.toMutableList()
// Resize to fit channel
while (builder.settingsCount <= channel.index) {
builder.addSettings(ChannelSettings.getDefaultInstance())
while (settings.size <= channel.index) {
settings.add(ChannelSettings())
}
// use setSettings() to ensure settingsList and channel indexes match
builder.setSettings(channel.index, channel.settings).build()
settings[channel.index] = channel.settings ?: ChannelSettings()
preference.copy(settings = settings)
}
}
suspend fun setLoraConfig(config: ConfigProtos.Config.LoRaConfig) {
channelSetStore.updateData { preference -> preference.toBuilder().setLoraConfig(config).build() }
suspend fun setLoraConfig(config: Config.LoRaConfig) {
channelSetStore.updateData { it.copy(lora_config = config) }
}
}

View File

@@ -20,9 +20,9 @@ import androidx.datastore.core.DataStore
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import java.io.IOException
import okio.IOException
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
import javax.inject.Singleton
@@ -34,28 +34,28 @@ class LocalConfigDataSource @Inject constructor(private val localConfigStore: Da
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
Logger.e { "Error reading LocalConfig settings: ${exception.message}" }
emit(LocalConfig.getDefaultInstance())
emit(LocalConfig())
} else {
throw exception
}
}
suspend fun clearLocalConfig() {
localConfigStore.updateData { preference -> preference.toBuilder().clear().build() }
localConfigStore.updateData { LocalConfig() }
}
/** Updates [LocalConfig] from each [Config] oneOf. */
suspend fun setLocalConfig(config: Config) = localConfigStore.updateData {
val builder = it.toBuilder()
config.allFields.forEach { (field, value) ->
val localField = it.descriptorForType.findFieldByName(field.name)
if (localField != null) {
builder.setField(localField, value)
} else {
// Some fields like SESSIONKEY are not intended to be persisted in LocalConfig
Logger.d { "Skipping non-persistent LocalConfig field: ${field.name}" }
}
suspend fun setLocalConfig(config: Config) = localConfigStore.updateData { current ->
when {
config.device != null -> current.copy(device = config.device)
config.position != null -> current.copy(position = config.position)
config.power != null -> current.copy(power = config.power)
config.network != null -> current.copy(network = config.network)
config.display != null -> current.copy(display = config.display)
config.lora != null -> current.copy(lora = config.lora)
config.bluetooth != null -> current.copy(bluetooth = config.bluetooth)
config.security != null -> current.copy(security = config.security)
else -> current
}
builder.build()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,16 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.datastore
import androidx.datastore.core.DataStore
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig
import java.io.IOException
import okio.IOException
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import javax.inject.Inject
import javax.inject.Singleton
@@ -35,27 +34,35 @@ class ModuleConfigDataSource @Inject constructor(private val moduleConfigStore:
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
Logger.e { "Error reading LocalModuleConfig settings: ${exception.message}" }
emit(LocalModuleConfig.getDefaultInstance())
emit(LocalModuleConfig())
} else {
throw exception
}
}
suspend fun clearLocalModuleConfig() {
moduleConfigStore.updateData { preference -> preference.toBuilder().clear().build() }
moduleConfigStore.updateData { LocalModuleConfig() }
}
/** Updates [LocalModuleConfig] from each [ModuleConfig] oneOf. */
suspend fun setLocalModuleConfig(config: ModuleConfig) = moduleConfigStore.updateData {
val builder = it.toBuilder()
config.allFields.forEach { (field, value) ->
val localField = it.descriptorForType.findFieldByName(field.name)
if (localField != null) {
builder.setField(localField, value)
} else {
Logger.e { "Error writing LocalModuleConfig settings: ${config.payloadVariantCase}" }
}
suspend fun setLocalModuleConfig(config: ModuleConfig) = moduleConfigStore.updateData { current ->
when {
config.mqtt != null -> current.copy(mqtt = config.mqtt)
config.serial != null -> current.copy(serial = config.serial)
config.external_notification != null ->
current.copy(external_notification = config.external_notification)
config.store_forward != null -> current.copy(store_forward = config.store_forward)
config.range_test != null -> current.copy(range_test = config.range_test)
config.telemetry != null -> current.copy(telemetry = config.telemetry)
config.canned_message != null -> current.copy(canned_message = config.canned_message)
config.audio != null -> current.copy(audio = config.audio)
config.remote_hardware != null -> current.copy(remote_hardware = config.remote_hardware)
config.neighbor_info != null -> current.copy(neighbor_info = config.neighbor_info)
config.ambient_lighting != null -> current.copy(ambient_lighting = config.ambient_lighting)
config.detection_sensor != null -> current.copy(detection_sensor = config.detection_sensor)
config.paxcounter != null -> current.copy(paxcounter = config.paxcounter)
config.statusmessage != null -> current.copy(statusmessage = config.statusmessage)
else -> current
}
builder.build()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.datastore.di
import android.content.Context
@@ -45,9 +44,9 @@ import org.meshtastic.core.datastore.KEY_THEME
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -103,7 +102,7 @@ object DataStoreModule {
): DataStore<LocalConfig> = DataStoreFactory.create(
serializer = LocalConfigSerializer,
produceFile = { appContext.dataStoreFile("local_config.pb") },
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
scope = scope,
)
@@ -115,8 +114,7 @@ object DataStoreModule {
): DataStore<LocalModuleConfig> = DataStoreFactory.create(
serializer = ModuleConfigSerializer,
produceFile = { appContext.dataStoreFile("module_config.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig.getDefaultInstance() }),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
scope = scope,
)
@@ -128,7 +126,7 @@ object DataStoreModule {
): DataStore<ChannelSet> = DataStoreFactory.create(
serializer = ChannelSetSerializer,
produceFile = { appContext.dataStoreFile("channel_set.pb") },
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,28 +14,27 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import okio.IOException
import org.meshtastic.proto.ChannelSet
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [ChannelSet] object defined in apponly.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object ChannelSetSerializer : Serializer<ChannelSet> {
override val defaultValue: ChannelSet = ChannelSet.getDefaultInstance()
override val defaultValue: ChannelSet = ChannelSet()
override suspend fun readFrom(input: InputStream): ChannelSet {
try {
return ChannelSet.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
return ChannelSet.ADAPTER.decode(input)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: ChannelSet, output: OutputStream) = t.writeTo(output)
override suspend fun writeTo(t: ChannelSet, output: OutputStream) = ChannelSet.ADAPTER.encode(output, t)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,28 +14,27 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import okio.IOException
import org.meshtastic.proto.LocalConfig
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [LocalConfig] object defined in localonly.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object LocalConfigSerializer : Serializer<LocalConfig> {
override val defaultValue: LocalConfig = LocalConfig.getDefaultInstance()
override val defaultValue: LocalConfig = LocalConfig()
override suspend fun readFrom(input: InputStream): LocalConfig {
try {
return LocalConfig.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
return LocalConfig.ADAPTER.decode(input)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalConfig, output: OutputStream) = t.writeTo(output)
override suspend fun writeTo(t: LocalConfig, output: OutputStream) = LocalConfig.ADAPTER.encode(output, t)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,28 +14,28 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
import okio.IOException
import org.meshtastic.proto.LocalModuleConfig
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [LocalModuleConfig] object defined in localonly.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object ModuleConfigSerializer : Serializer<LocalModuleConfig> {
override val defaultValue: LocalModuleConfig = LocalModuleConfig.getDefaultInstance()
override val defaultValue: LocalModuleConfig = LocalModuleConfig()
override suspend fun readFrom(input: InputStream): LocalModuleConfig {
try {
return LocalModuleConfig.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
return LocalModuleConfig.ADAPTER.decode(input)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) = t.writeTo(output)
override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) =
LocalModuleConfig.ADAPTER.encode(output, t)
}

View File

@@ -25,23 +25,12 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
afterEvaluate {
publishing {
publications {
create<MavenPublication>("release") {
from(components["googleRelease"])
artifactId = "core-model"
}
}
}
}
configure<LibraryExtension> {
namespace = "org.meshtastic.core.model"
buildFeatures {
buildConfig = true
aidl = true
}
namespace = "org.meshtastic.core.model"
defaultConfig {
// Lowering minSdk to 21 for better compatibility with ATAK and other plugins
@@ -53,6 +42,17 @@ configure<LibraryExtension> {
publishing { singleVariant("googleRelease") { withSourcesJar() } }
}
afterEvaluate {
publishing {
publications {
create<MavenPublication>("googleRelease") {
from(components["googleRelease"])
artifactId = "core-model"
}
}
}
}
dependencies {
api(projects.core.proto)

View File

@@ -1,11 +1,13 @@
<?xml version="1.0" ?>
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:ChannelSet.kt$40</ID>
<ID>MagicNumber:ChannelSet.kt$960</ID>
<ID>SwallowedException:ChannelSet.kt$ex: Throwable</ID>
<ID>SwallowedException:DataPacket.kt$DataPacket$e: Exception</ID>
<ID>TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable</ID>
<ID>TooManyFunctions:Extensions.kt$org.meshtastic.core.model.util.Extensions.kt</ID>
<ID>TooGenericExceptionCaught:DataPacket.kt$DataPacket$e: Exception</ID>
<ID>UnusedPrivateMember:DataPacket.kt$private inline fun &lt;reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T?</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -23,18 +23,14 @@ import org.junit.runner.RunWith
import org.meshtastic.core.model.util.URL_PREFIX
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.copy
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
@RunWith(AndroidJUnit4::class)
class ChannelTest {
@Test
fun channelUrlGood() {
val ch = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
}
val ch = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig)
val channelUrl = ch.getChannelUrl()
Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX))
@@ -71,16 +67,11 @@ class ChannelTest {
@Test
fun allModemPresetsHaveValidNames() {
ConfigProtos.Config.LoRaConfig.ModemPreset.values().forEach { preset ->
Config.LoRaConfig.ModemPreset.entries.forEach { preset ->
// Skip UNRECOGNIZED if it exists (Wire generates it sometimes) or generic UNSET values if applicable
// In this specific enum, assuming all valid defined presets should map.
if (preset.name == "UNSET" || preset.name == "UNRECOGNIZED") return@forEach
val loraConfig =
Channel.default.loraConfig.copy {
usePreset = true
modemPreset = preset
}
val loraConfig = Channel.default.loraConfig.copy(use_preset = true, modem_preset = preset)
val channel = Channel(loraConfig = loraConfig)
// We want to ensure it is NOT "Invalid"

View File

@@ -16,20 +16,16 @@
*/
package org.meshtastic.core.model
import com.google.protobuf.ByteString
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.byteArrayOfInts
import org.meshtastic.core.model.util.xorHash
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigKt.loRaConfig
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import java.security.SecureRandom
data class Channel(
val settings: ChannelProtos.ChannelSettings = default.settings,
val loraConfig: ConfigProtos.Config.LoRaConfig = default.loraConfig,
) {
data class Channel(val settings: ChannelSettings = default.settings, val loraConfig: LoRaConfig = default.loraConfig) {
companion object {
// These bytes must match the well known and not secret bytes used the default channel AES128 key device code
private val channelDefaultKey =
@@ -58,21 +54,16 @@ data class Channel(
// The default channel that devices ship with
val default =
Channel(
channelSettings { psk = ByteString.copyFrom(defaultPSK) },
ChannelSettings(psk = defaultPSK.toByteString()),
// references: NodeDB::installDefaultConfig / Channels::initDefaultChannel
loRaConfig {
usePreset = true
modemPreset = ModemPreset.LONG_FAST
hopLimit = 3
txEnabled = true
},
LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, hop_limit = 3, tx_enabled = true),
)
fun getRandomKey(size: Int = 32): ByteString {
val bytes = ByteArray(size)
val random = SecureRandom()
random.nextBytes(bytes)
return ByteString.copyFrom(bytes)
return bytes.toByteString()
}
}
@@ -82,8 +73,8 @@ data class Channel(
settings.name.ifEmpty {
// We have a new style 'empty' channel name. Use the same logic from the device to convert that to a
// human readable name
if (loraConfig.usePreset) {
when (loraConfig.modemPreset) {
if (loraConfig.use_preset) {
when (loraConfig.modem_preset) {
ModemPreset.SHORT_TURBO -> "ShortTurbo"
ModemPreset.SHORT_FAST -> "ShortFast"
ModemPreset.SHORT_SLOW -> "ShortSlow"
@@ -103,11 +94,11 @@ data class Channel(
val psk: ByteString
get() =
if (settings.psk.size() != 1) {
if (settings.psk.size != 1) {
settings.psk // A standard PSK
} else {
// One of our special 1 byte PSKs, see mesh.proto for docs.
val pskIndex = settings.psk.byteAt(0).toInt()
val pskIndex = settings.psk[0].toInt()
if (pskIndex == 0) {
cleartextPSK
@@ -115,7 +106,7 @@ data class Channel(
// Treat an index of 1 as the old channelDefaultKey and work up from there
val bytes = channelDefaultKey.clone()
bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte()
ByteString.copyFrom(bytes)
bytes.toByteString()
}
}

View File

@@ -18,9 +18,9 @@
package org.meshtastic.core.model
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.RegionCode
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import kotlin.math.floor
/** hash a string into an integer using the djb2 algorithm by Dan Bernstein http://www.cse.yorku.ca/~oz/hash.html */
@@ -40,8 +40,8 @@ private val ModemPreset.bandwidth: Float
return 0f
}
private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (usePreset) {
modemPreset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f
private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (use_preset) {
modem_preset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f
} else {
when (bandwidth) {
31 -> .03125f
@@ -69,13 +69,13 @@ val LoRaConfig.numChannels: Int
}
internal fun LoRaConfig.channelNum(primaryName: String): Int = when {
channelNum != 0 -> channelNum
channel_num != 0 -> channel_num
numChannels == 0 -> 0
else -> (hash(primaryName) % numChannels.toUInt()).toInt() + 1
}
internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
if (overrideFrequency != 0f) return overrideFrequency + frequencyOffset
if ((override_frequency ?: 0f) != 0f) return (override_frequency ?: 0f) + (frequency_offset ?: 0f)
val regionInfo = RegionInfo.fromRegionCode(region)
return if (regionInfo != null) {
(regionInfo.freqStart + bandwidth(regionInfo) / 2) + (channelNum - 1) * bandwidth(regionInfo)

View File

@@ -18,19 +18,17 @@ package org.meshtastic.core.model
import android.os.Parcel
import android.os.Parcelable
import co.touchlab.kermit.Logger
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.Serializable
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums
/** Generic [Parcel.readParcelable] Android 13 compatibility extension. */
private inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
readParcelable(loader)
} else {
readParcelable(loader, T::class.java)
}
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
@Parcelize
enum class MessageStatus : Parcelable {
@@ -46,10 +44,13 @@ enum class MessageStatus : Parcelable {
/** A parcelable version of the protobuf MeshPacket + Data subpacket. */
@Serializable
@Parcelize
data class DataPacket(
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
var bytes: ByteArray?,
// A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions)
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
var bytes: ByteString?,
// A port number for this packet
var dataType: Int,
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
var time: Long = System.currentTimeMillis(), // msecs since 1970
@@ -67,11 +68,52 @@ data class DataPacket(
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
var retryCount: Int = 0, // Number of automatic retry attempts
var emoji: Int = 0,
var sfppHash: ByteArray? = null,
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
var sfppHash: ByteString? = null,
) : Parcelable {
fun readFromParcel(parcel: Parcel) {
to = parcel.readString()
bytes = ByteStringParceler.create(parcel)
dataType = parcel.readInt()
from = parcel.readString()
time = parcel.readLong()
id = parcel.readInt()
// MessageStatus is a known Parcelable type (enum), so Parcelize writes it optimized:
// 1. Presence flag (Int: 1 or 0)
// 2. Content (Enum Name as String)
status =
if (parcel.readInt() != 0) {
val name = parcel.readString()
try {
if (name != null) MessageStatus.valueOf(name) else MessageStatus.UNKNOWN
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Unknown MessageStatus: $name" }
MessageStatus.UNKNOWN
}
} else {
null
}
hopLimit = parcel.readInt()
channel = parcel.readInt()
wantAck = parcel.readInt() != 0
hopStart = parcel.readInt()
snr = parcel.readFloat()
rssi = parcel.readInt()
replyId = if (parcel.readInt() == 0) null else parcel.readInt()
relayNode = if (parcel.readInt() == 0) null else parcel.readInt()
relays = parcel.readInt()
viaMqtt = parcel.readInt() != 0
retryCount = parcel.readInt()
emoji = parcel.readInt()
sfppHash = ByteStringParceler.create(parcel)
}
/** If there was an error with this message, this string describes what was wrong. */
var errorMessage: String? = null
@IgnoredOnParcel var errorMessage: String? = null
/** Syntactic sugar to make it easy to create text messages */
constructor(
@@ -81,8 +123,8 @@ data class DataPacket(
replyId: Int? = null,
) : this(
to = to,
bytes = text.encodeToByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
channel = channel,
replyId = replyId ?: 0,
)
@@ -90,17 +132,16 @@ data class DataPacket(
/** If this is a text message, return the string, otherwise null */
val text: String?
get() =
when (dataType) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> bytes?.decodeToString()
// Portnums.PortNum.NODE_STATUS_APP_VALUE ->
// MeshProtos.StatusMessage.parseFrom(bytes).status
else -> null
if (dataType == PortNum.TEXT_MESSAGE_APP.value) {
bytes?.utf8()
} else {
null
}
val alert: String?
get() =
if (dataType == Portnums.PortNum.ALERT_APP_VALUE) {
bytes?.decodeToString()
if (dataType == PortNum.ALERT_APP.value) {
bytes?.utf8()
} else {
null
}
@@ -108,13 +149,22 @@ data class DataPacket(
constructor(
to: String?,
channel: Int,
waypoint: MeshProtos.Waypoint,
) : this(to = to, bytes = waypoint.toByteArray(), dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, channel = channel)
waypoint: Waypoint,
) : this(
to = to,
bytes = Waypoint.ADAPTER.encode(waypoint).toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
channel = channel,
)
val waypoint: MeshProtos.Waypoint?
val waypoint: Waypoint?
get() =
if (dataType == Portnums.PortNum.WAYPOINT_APP_VALUE) {
MeshProtos.Waypoint.parseFrom(bytes)
if (dataType == PortNum.WAYPOINT_APP.value) {
try {
bytes?.let { Waypoint.ADAPTER.decode(it) }
} catch (e: Exception) {
null
}
} else {
null
}
@@ -122,138 +172,7 @@ data class DataPacket(
val hopsAway: Int
get() = if (hopStart == 0 || hopLimit > hopStart) -1 else hopStart - hopLimit
// Autogenerated comparision, because we have a byte array
constructor(
parcel: Parcel,
) : this(
parcel.readString(),
parcel.createByteArray(),
parcel.readInt(),
parcel.readString(),
parcel.readLong(),
parcel.readInt(),
parcel.readParcelableCompat(MessageStatus::class.java.classLoader),
parcel.readInt(),
parcel.readInt(),
parcel.readInt() == 1,
parcel.readInt(),
parcel.readFloat(),
parcel.readInt(),
parcel.readInt().let { if (it == 0) null else it },
parcel.readInt().let { if (it == -1) null else it },
parcel.readInt(), // relays
parcel.readInt() == 1, // viaMqtt
parcel.readInt(), // retryCount
parcel.readInt(), // emoji
parcel.createByteArray(), // sfppHash
)
@Suppress("CyclomaticComplexMethod")
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DataPacket
if (from != other.from) return false
if (to != other.to) return false
if (channel != other.channel) return false
if (time != other.time) return false
if (id != other.id) return false
if (dataType != other.dataType) return false
if (!bytes.contentEquals(other.bytes)) return false
if (status != other.status) return false
if (hopLimit != other.hopLimit) return false
if (wantAck != other.wantAck) return false
if (hopStart != other.hopStart) return false
if (snr != other.snr) return false
if (rssi != other.rssi) return false
if (replyId != other.replyId) return false
if (relayNode != other.relayNode) return false
if (relays != other.relays) return false
if (viaMqtt != other.viaMqtt) return false
if (retryCount != other.retryCount) return false
if (emoji != other.emoji) return false
if (!sfppHash.contentEquals(other.sfppHash)) return false
return true
}
override fun hashCode(): Int {
var result = from?.hashCode() ?: 0
result = 31 * result + (to?.hashCode() ?: 0)
result = 31 * result + time.hashCode()
result = 31 * result + id
result = 31 * result + dataType
result = 31 * result + (bytes?.contentHashCode() ?: 0)
result = 31 * result + (status?.hashCode() ?: 0)
result = 31 * result + hopLimit
result = 31 * result + channel
result = 31 * result + wantAck.hashCode()
result = 31 * result + hopStart
result = 31 * result + snr.hashCode()
result = 31 * result + rssi
result = 31 * result + (replyId ?: 0)
result = 31 * result + (relayNode ?: -1)
result = 31 * result + relays
result = 31 * result + viaMqtt.hashCode()
result = 31 * result + retryCount
result = 31 * result + emoji
result = 31 * result + (sfppHash?.contentHashCode() ?: 0)
return result
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(to)
parcel.writeByteArray(bytes)
parcel.writeInt(dataType)
parcel.writeString(from)
parcel.writeLong(time)
parcel.writeInt(id)
parcel.writeParcelable(status, flags)
parcel.writeInt(hopLimit)
parcel.writeInt(channel)
parcel.writeInt(if (wantAck) 1 else 0)
parcel.writeInt(hopStart)
parcel.writeFloat(snr)
parcel.writeInt(rssi)
parcel.writeInt(replyId ?: 0)
parcel.writeInt(relayNode ?: -1)
parcel.writeInt(relays)
parcel.writeInt(if (viaMqtt) 1 else 0)
parcel.writeInt(retryCount)
parcel.writeInt(emoji)
parcel.writeByteArray(sfppHash)
}
override fun describeContents(): Int = 0
/** Update our object from our parcel (used for inout parameters) */
fun readFromParcel(parcel: Parcel) {
to = parcel.readString()
bytes = parcel.createByteArray()
dataType = parcel.readInt()
from = parcel.readString()
time = parcel.readLong()
id = parcel.readInt()
status = parcel.readParcelableCompat(MessageStatus::class.java.classLoader)
hopLimit = parcel.readInt()
channel = parcel.readInt()
wantAck = parcel.readInt() == 1
hopStart = parcel.readInt()
snr = parcel.readFloat()
rssi = parcel.readInt()
replyId = parcel.readInt().let { if (it == 0) null else it }
relayNode = parcel.readInt().let { if (it == -1) null else it }
relays = parcel.readInt()
viaMqtt = parcel.readInt() == 1
retryCount = parcel.readInt()
emoji = parcel.readInt()
sfppHash = parcel.createByteArray()
}
companion object CREATOR : Parcelable.Creator<DataPacket> {
companion object {
// Special node IDs that can be used for sending messages
/** the Node ID for broadcast destinations */
@@ -272,9 +191,5 @@ data class DataPacket(
@Suppress("MagicNumber")
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
override fun createFromParcel(parcel: Parcel): DataPacket = DataPacket(parcel)
override fun newArray(size: Int): Array<DataPacket?> = arrayOfNulls(size)
}
}

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