mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
feat(wire): migrate from protobuf -> wire (#4401)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
42
.github/workflows/merge-queue.yml
vendored
42
.github/workflows/merge-queue.yml
vendored
@@ -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"
|
||||
|
||||
53
.github/workflows/pull-request.yml
vendored
53
.github/workflows/pull-request.yml
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
144
.github/workflows/reusable-android-build.yml
vendored
144
.github/workflows/reusable-android-build.yml
vendored
@@ -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
|
||||
169
.github/workflows/reusable-android-test.yml
vendored
169
.github/workflows/reusable-android-test.yml
vendored
@@ -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
196
.github/workflows/reusable-check.yml
vendored
Normal 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
|
||||
54
.github/workflows/reusable-lint.yml
vendored
54
.github/workflows/reusable-lint.yml
vendored
@@ -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
|
||||
@@ -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)
|
||||
|
||||
415
app/dependencies/googleReleaseRuntimeClasspath.txt
Normal file
415
app/dependencies/googleReleaseRuntimeClasspath.txt
Normal 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
|
||||
@@ -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.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${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.<no name provided>$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.<no name provided>$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$<no name provided> : 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>
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}" } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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 = {},
|
||||
) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
8
core/database/detekt-baseline.xml
Normal file
8
core/database/detekt-baseline.xml
Normal 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<String></ID>
|
||||
<ID>TooGenericExceptionCaught:Converters.kt$Converters$ex: Exception</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
7
core/datastore/detekt-baseline.xml
Normal file
7
core/datastore/detekt-baseline.xml
Normal 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>
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T?</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user