diff --git a/.github/renovate.json b/.github/renovate.json
index dda9390c3..1faa1a4ad 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -56,6 +56,15 @@
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
"automerge": true
},
+ {
+ "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
+ "groupName": "compose-multiplatform",
+ "matchPackageNames": [
+ "/^org\\.jetbrains\\.compose/",
+ "androidx.compose.runtime:runtime-tracing",
+ "androidx.compose.ui:ui-test-manifest"
+ ]
+ },
{
"description": "Restrict sensitive infrastructure to manual minor updates",
"matchUpdateTypes": [
diff --git a/.gitignore b/.gitignore
index 8447bc7f7..447d8a28e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,4 @@ wireless-install.sh
firebase-debug.log
.agent_plans/
.agent_refs/
+.agent_artifacts/
diff --git a/.pr5167.diff b/.pr5167.diff
new file mode 100644
index 000000000..d0a809449
--- /dev/null
+++ b/.pr5167.diff
@@ -0,0 +1,295 @@
+diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
+new file mode 100644
+index 0000000000..2a27b96906
+--- /dev/null
++++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
+@@ -0,0 +1,39 @@
++/*
++ * Copyright (c) 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
++ * 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 .
++ */
++package org.meshtastic.core.common.di
++
++import kotlinx.coroutines.CoroutineScope
++import kotlinx.coroutines.SupervisorJob
++import org.koin.core.annotation.Single
++import org.meshtastic.core.common.util.ioDispatcher
++
++/**
++ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
++ *
++ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
++ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
++ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
++ *
++ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
++ * and should be used sparingly.
++ */
++interface ApplicationCoroutineScope : CoroutineScope
++
++@Single(binds = [ApplicationCoroutineScope::class])
++internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
++ override val coroutineContext = SupervisorJob() + ioDispatcher
++}
+diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+index 231c84d401..5365ab95e2 100644
+--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
++++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
+ import co.touchlab.kermit.Logger
+ import com.eygraber.uri.toAndroidUri
+ import com.eygraber.uri.toKmpUri
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.withContext
+ import org.jetbrains.compose.resources.StringResource
+ import org.jetbrains.compose.resources.getString
+ import org.meshtastic.core.common.gpsDisabled
+ import org.meshtastic.core.common.util.CommonUri
++import org.meshtastic.core.common.util.ioDispatcher
+ import java.net.URLEncoder
+
+ @Composable
+@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
+ val context = LocalContext.current
+ return remember(context) {
+ { uri, maxChars ->
+- withContext(Dispatchers.IO) {
++ withContext(ioDispatcher) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val androidUri = uri.toAndroidUri()
+diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+index 031e1fe35d..a938f92ea6 100644
+--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
++++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
+
+ import androidx.compose.runtime.Composable
+ import co.touchlab.kermit.Logger
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.withContext
+ import org.jetbrains.compose.resources.StringResource
+ import org.meshtastic.core.common.util.CommonUri
++import org.meshtastic.core.common.util.ioDispatcher
+ import java.awt.Desktop
+ import java.awt.FileDialog
+ import java.awt.Frame
+@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
+ /** JVM — Reads text from a file URI. */
+ @Composable
+ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
+- withContext(Dispatchers.IO) {
++ withContext(ioDispatcher) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val file = File(URI(uri.toString()))
+diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+index dc1c459716..f8ff9fcac8 100644
+--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
++++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withTimeoutOrNull
+ import org.jetbrains.compose.resources.StringResource
+ import org.koin.core.annotation.KoinViewModel
++import org.meshtastic.core.common.di.ApplicationCoroutineScope
+ import org.meshtastic.core.common.util.CommonUri
+ import org.meshtastic.core.common.util.safeCatching
+ import org.meshtastic.core.database.entity.FirmwareRelease
+@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
+ private val firmwareUpdateManager: FirmwareUpdateManager,
+ private val usbManager: FirmwareUsbManager,
+ private val fileHandler: FirmwareFileHandler,
++ private val applicationScope: ApplicationCoroutineScope,
+ ) : ViewModel() {
+
+ private val _state = MutableStateFlow(FirmwareUpdateState.Idle)
+@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
+
+ override fun onCleared() {
+ super.onCleared()
+- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
+- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
+- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
+- // is cancelled concurrently.
+- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
+- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
++ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
++ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
++ // running even if something tries to cancel it mid-flight.
++ applicationScope.launch(NonCancellable) {
+ tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
+ }
+ }
+diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+index 4c48a1ced5..030d84effd 100644
+--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
+ firmwareUpdateManager,
+ usbManager,
+ fileHandler,
++ TestApplicationCoroutineScope(testDispatcher),
+ )
+
+ @Test
+diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+index 7032ed4088..a8eddff838 100644
+--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
+ firmwareUpdateManager,
+ usbManager,
+ fileHandler,
++ TestApplicationCoroutineScope(testDispatcher),
+ )
+
+ @Test
+diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
+new file mode 100644
+index 0000000000..3ef5c44ef4
+--- /dev/null
++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
+@@ -0,0 +1,26 @@
++/*
++ * Copyright (c) 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
++ * 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 .
++ */
++package org.meshtastic.feature.firmware
++
++import kotlinx.coroutines.CoroutineDispatcher
++import kotlinx.coroutines.CoroutineScope
++import kotlinx.coroutines.SupervisorJob
++import org.meshtastic.core.common.di.ApplicationCoroutineScope
++
++internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
++ ApplicationCoroutineScope,
++ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
+diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+index acb1545bdd..23a0d03ab2 100644
+--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
++++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
+ firmwareUpdateManager,
+ usbManager,
+ fileHandler,
++ TestApplicationCoroutineScope(testDispatcher),
+ )
+
+ // -----------------------------------------------------------------------
+diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+index c251b4d5ef..315ad1da85 100644
+--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
+ import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withContext
++import org.meshtastic.core.common.util.ioDispatcher
+ import org.meshtastic.core.resources.Res
+ import org.meshtastic.core.resources.debug_export_failed
+ import org.meshtastic.core.resources.debug_export_success
+@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) =
+- withContext(Dispatchers.IO) {
++ withContext(ioDispatcher) {
+ try {
+ if (logs.isEmpty()) {
+ withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
+diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+index 9afde85e5f..a28a576788 100644
+--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
+ import androidx.compose.runtime.rememberCoroutineScope
+ import androidx.compose.ui.platform.LocalContext
+ import co.touchlab.kermit.Logger
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withContext
++import org.meshtastic.core.common.util.ioDispatcher
+
+ @Composable
+ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
+@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
+ return { fileName -> exportLauncher.launch(fileName) }
+ }
+
+-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
++private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
+ try {
+ context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
+ Logger.i { "TAK data package exported successfully to $targetUri" }
+diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+index 5b63cc90a3..a9a7285593 100644
+--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
++++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
+ import androidx.compose.runtime.Composable
+ import androidx.compose.runtime.rememberCoroutineScope
+ import co.touchlab.kermit.Logger
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withContext
++import org.meshtastic.core.common.util.ioDispatcher
+ import java.awt.FileDialog
+ import java.awt.Frame
+ import java.io.File
+@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr
+ if (directory != null && file != null) {
+ val targetFile = File(directory, file)
+ val data = dataPackageProvider()
+- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
++ withContext(ioDispatcher) { targetFile.writeBytes(data) }
+ Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
+ }
+ }
diff --git a/Gemfile.lock b/Gemfile.lock
index de497cc4a..cf6a1b9c0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
- addressable (2.8.8)
+ addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
- aws-partitions (1.1213.0)
- aws-sdk-core (3.242.0)
+ aws-partitions (1.1240.0)
+ aws-sdk-core (3.245.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
- aws-sdk-kms (1.121.0)
- aws-sdk-core (~> 3, >= 3.241.4)
+ aws-sdk-kms (1.123.0)
+ aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.213.0)
- aws-sdk-core (~> 3, >= 3.241.4)
+ aws-sdk-s3 (1.219.0)
+ aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -29,7 +29,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
- faraday-retry (1.0.3)
+ faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
- fastimage (2.4.0)
- fastlane (2.232.2)
+ fastimage (2.4.1)
+ fastlane (2.233.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
- fastlane-sirp (>= 1.0.0)
+ fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,10 +122,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
- fastlane-sirp (1.0.0)
- sysrandom (~> 1.0)
+ fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.95.0)
+ google-apis-androidpublisher_v3 (0.99.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -139,15 +138,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
- google-apis-storage_v1 (0.59.0)
+ google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
- google-cloud-errors (1.5.0)
- google-cloud-storage (1.58.0)
+ google-cloud-errors (1.6.0)
+ google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -169,13 +168,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
- json (2.18.1)
+ json (2.19.4)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
- multi_json (1.19.1)
+ multi_json (1.20.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -185,13 +184,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
- public_suffix (7.0.2)
- rake (13.3.1)
+ public_suffix (7.0.5)
+ rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
- retriable (3.1.2)
+ retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -205,7 +204,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
- sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 14df5580d..de2b3144c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -40,15 +40,6 @@
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-# ---- Compose Runtime & Animation --------------------------------------------
-
-# Defence-in-depth: prevent R8 tree-shaking of Compose infrastructure classes
-# that are referenced indirectly through compiler-generated state machines.
-# With -dontoptimize above these are largely redundant, but they provide a
-# safety net against future toolchain changes.
--keep class androidx.compose.runtime.** { *; }
--keep class androidx.compose.ui.** { *; }
--keep class androidx.compose.animation.core.** { *; }
--keep class androidx.compose.animation.** { *; }
--keep class androidx.compose.foundation.** { *; }
--keep class androidx.compose.material3.** { *; }
+# Compose runtime/ui/animation/foundation/material3 keep rules now live in
+# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard)
+# get the same defence-in-depth coverage against CMP 1.11 optimizer folding.
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 0864e55cd..628865010 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -59,6 +59,7 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
@@ -91,6 +92,8 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
+ private val usbRepository: UsbRepository by inject()
+
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
@@ -166,6 +169,16 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
+ override fun onResume() {
+ super.onResume()
+ // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
+ // resumed while a USB device is already attached (e.g. process restart, returning
+ // from another app), the manifest-declared attach intent may have already fired
+ // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
+ // reality without requiring the user to physically replug.
+ usbRepository.refreshState()
+ }
+
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
@@ -257,6 +270,11 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" }
+ // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
+ // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
+ // never sees this event. Forward it explicitly so the serialDevices StateFlow
+ // refreshes and the device shows up in the Connect → Serial tab.
+ usbRepository.refreshState()
showSettingsPage()
}
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index 2fa797c74..91b8ebce2 100644
--- a/build-logic/settings.gradle.kts
+++ b/build-logic/settings.gradle.kts
@@ -30,7 +30,7 @@ pluginManagement {
}
plugins {
- id("com.gradle.develocity") version("4.4.0")
+ id("com.gradle.develocity") version("4.4.1")
}
dependencyResolutionManagement {
diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro
index 902636dbf..8d0d8efde 100644
--- a/config/proguard/shared-rules.pro
+++ b/config/proguard/shared-rules.pro
@@ -20,12 +20,10 @@
-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations
# ---- Kotlin / Coroutines ----------------------------------------------------
-
--keep class kotlin.Metadata { *; }
--keep class kotlin.reflect.** { *; }
--keep class kotlin.coroutines.Continuation { *; }
--keep class kotlinx.coroutines.** { *; }
--dontwarn kotlinx.coroutines.**
+# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules
+# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep
+# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No
+# explicit wildcards needed here.
# ---- Koin DI (reflection-based injection) -----------------------------------
@@ -41,9 +39,7 @@
-keep @org.koin.core.annotation.ComponentScan class * { *; }
-keep @org.koin.core.annotation.Single class * { *; }
-keep @org.koin.core.annotation.Factory class * { *; }
-
-# Generated Koin module extensions (Koin Annotations plugin output)
--keep class org.meshtastic.**.di.** { *; }
+-keep @org.koin.core.annotation.KoinViewModel class * { *; }
# ---- kotlinx-serialization --------------------------------------------------
@@ -63,13 +59,14 @@
# ---- Wire Protobuf ----------------------------------------------------------
-# Wire generates ADAPTER companion objects accessed via reflection
--keep class com.squareup.wire.** { *; }
--dontwarn com.squareup.wire.**
-
-# Generated proto message classes (both meshtastic protos and internal package)
--keep class org.meshtastic.proto.** { *; }
--keep class meshtastic.** { *; }
+# Wire generates an ADAPTER static field on every Message subclass accessed
+# reflectively during encoding/decoding. Keep those fields and the
+# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve
+# the runtime itself.
+-keepclassmembers class * extends com.squareup.wire.Message {
+ public static *** ADAPTER;
+}
+-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; }
# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs
# when compiling for non-Android JVM targets; harmless on Android).
@@ -86,40 +83,24 @@
-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; }
-keep class org.meshtastic.core.database.MeshtasticDatabase { *; }
-# Room DAOs — Room generates implementations at compile time; keep interfaces
--keep class org.meshtastic.core.database.dao.** { *; }
-
-# Room Entities — accessed via reflection for column mapping
--keep class org.meshtastic.core.database.entity.** { *; }
-
-# Room TypeConverters — invoked reflectively
--keep class org.meshtastic.core.database.Converters { *; }
-
-# Room generated _Impl classes
--keep class **_Impl { *; }
+# Room's own consumer rules (from androidx.room3) keep DAOs, entities,
+# generated _Impl classes, and TypeConverters referenced from the database.
# ---- SQLite bundled --------------------------------------------------------
-
--keep class androidx.sqlite.** { *; }
--dontwarn androidx.sqlite.**
+# androidx.sqlite ships consumer rules.
# ---- Ktor (ServiceLoader + plugin discovery) --------------------------------
--keep class io.ktor.** { *; }
--dontwarn io.ktor.**
-
-# Keep ServiceLoader metadata files
+# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory
+# implementations reflectively via ServiceLoader).
-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; }
# ---- Coil 3 (image loading) -------------------------------------------------
-
--keep class coil3.** { *; }
--dontwarn coil3.**
+# coil3 ships consumer rules.
# ---- Kable BLE --------------------------------------------------------------
-
--keep class com.juul.kable.** { *; }
--dontwarn com.juul.kable.**
+# com.juul.kable ships consumer rules; if release builds fail with missing
+# Kable classes, restore a narrow keep for the specific reflection-loaded type.
# ---- Compose Multiplatform resources ----------------------------------------
@@ -127,17 +108,14 @@
# Without these the fdroid flavor has crashed at startup with a misleading
# URLDecodeException due to R8 exception-class merging.
-keep class org.jetbrains.compose.resources.** { *; }
--keep class org.meshtastic.core.resources.** { *; }
+-keep class org.meshtastic.core.resources.Res { *; }
+-keepclassmembers class org.meshtastic.core.resources.Res$* { *; }
# ---- AboutLibraries ---------------------------------------------------------
-
--keep class com.mikepenz.aboutlibraries.** { *; }
--dontwarn com.mikepenz.aboutlibraries.**
+# com.mikepenz.aboutlibraries ships consumer rules.
# ---- Multiplatform Markdown Renderer ----------------------------------------
-
--keep class com.mikepenz.markdown.** { *; }
--dontwarn com.mikepenz.markdown.**
+# com.mikepenz.markdown ships consumer rules.
# ---- QR Code Kotlin ---------------------------------------------------------
@@ -147,33 +125,42 @@
-dontwarn qrcode.**
# ---- Kermit logging ---------------------------------------------------------
-
--keep class co.touchlab.kermit.** { *; }
--dontwarn co.touchlab.kermit.**
+# co.touchlab.kermit ships consumer rules.
# ---- Okio -------------------------------------------------------------------
-
--keep class okio.** { *; }
--dontwarn okio.**
+# okio ships consumer rules.
# ---- DataStore --------------------------------------------------------------
-
--keep class androidx.datastore.** { *; }
--dontwarn androidx.datastore.**
+# androidx.datastore ships consumer rules.
# ---- Paging -----------------------------------------------------------------
-
--keep class androidx.paging.** { *; }
--dontwarn androidx.paging.**
+# androidx.paging ships consumer rules.
# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) -----------------
-
--keep class androidx.lifecycle.** { *; }
--keep class androidx.navigation3.** { *; }
--dontwarn androidx.lifecycle.**
--dontwarn androidx.navigation3.**
+# androidx.lifecycle and androidx.navigation3 ship consumer rules.
# ---- Meshtastic shared model ------------------------------------------------
+# core.model types are reached via static references from Koin-wired graphs,
+# Room entities, and kotlinx-serialization @Serializable companions — all of
+# which have their own keep rules above.
-# Core model classes (used in serialization, Room, and Koin injection)
--keep class org.meshtastic.core.model.** { *; }
+# ---- Compose Runtime & Animation --------------------------------------------
+
+# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that
+# are referenced indirectly through compiler-generated state machines. Applies
+# to BOTH R8 (Android app) and ProGuard (desktop distribution).
+#
+# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on
+# Composer.() / ComposerImpl.() and -assumevalues on
+# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full
+# mode on Android, ProGuard with optimize.set(true) on desktop) these call
+# sites can be rewritten even when the target classes are kept, causing the
+# recomposer / frame-clock / animation state machines to silently freeze on
+# the first frame. -dontoptimize (set per-host) is the primary defence; these
+# keep rules are a safety net against future toolchain changes. See #5146.
+-keep class androidx.compose.runtime.** { *; }
+-keep class androidx.compose.ui.** { *; }
+-keep class androidx.compose.animation.core.** { *; }
+-keep class androidx.compose.animation.** { *; }
+-keep class androidx.compose.foundation.** { *; }
+-keep class androidx.compose.material3.** { *; }
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
index 6f5180b60..d273a0b90 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
@@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
/**
* Classification of a BLE-layer exception for the transport layer to act on.
*
- * @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
+ * @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
+ * Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission
+ * grants, transient GATT errors). Reserved for future use.
* @property gattStatus the platform GATT status code when available (Android-specific).
* @property message a human-readable description of the failure.
*/
@@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
is GattRequestRejectedException ->
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
is UnmetRequirementException ->
- BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
+ // Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
+ // device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
+ // retrying; UI can show a hint based on the message.
+ BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
else -> null
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
new file mode 100644
index 000000000..2a27b9690
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.common.di
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.ioDispatcher
+
+/**
+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
+ *
+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
+ *
+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
+ * and should be used sparingly.
+ */
+interface ApplicationCoroutineScope : CoroutineScope
+
+@Single(binds = [ApplicationCoroutineScope::class])
+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
+ override val coroutineContext = SupervisorJob() + ioDispatcher
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
index 8e57b4dbb..51905ff41 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
@@ -23,6 +23,7 @@ package org.meshtastic.core.common.util
* All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional
* for a mesh networking app where consistency matters.
*/
+@Suppress("TooManyFunctions")
object MetricFormatter {
fun temperature(celsius: Float, isFahrenheit: Boolean): String {
@@ -47,6 +48,12 @@ object MetricFormatter {
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
fun rssi(value: Int): String = "$value dBm"
+
+ fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s"
+
+ fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(millimeters, decimalPlaces)} mm"
}
private const val FAHRENHEIT_SCALE = 1.8f
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
index b602a4a62..94781fca3 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
@@ -120,4 +120,24 @@ class MetricFormatterTest {
fun snrNegative() {
assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))
}
+
+ @Test
+ fun windSpeed() {
+ assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f))
+ }
+
+ @Test
+ fun windSpeedZero() {
+ assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f))
+ }
+
+ @Test
+ fun rainfall() {
+ assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f))
+ }
+
+ @Test
+ fun rainfallZero() {
+ assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f))
+ }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index a60dc85c5..022f3548d 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -60,6 +60,7 @@ import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@@ -211,11 +212,11 @@ class MeshConnectionManagerImpl(
}
}
- private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
+ private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
- delay(HANDSHAKE_TIMEOUT)
+ delay(timeout)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
@@ -291,13 +292,13 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
- startHandshakeStallGuard(1, action)
+ startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
- startHandshakeStallGuard(2, action)
+ startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
action()
}
@@ -404,7 +405,14 @@ class MeshConnectionManagerImpl(
*/
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
- private val HANDSHAKE_TIMEOUT = 30.seconds
+ private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
+
+ /**
+ * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
+ * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
+ * nodes.
+ */
+ private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
// Shorter window for the retry attempt: if the device genuinely didn't receive the
// first want_config_id the retry completes within a few seconds. Waiting another 30s
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 7a6ec3320..d9d21ad8b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -32,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
+import org.meshtastic.core.model.util.toOneLineString
+import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
@@ -125,11 +127,11 @@ class MeshMessageProcessorImpl(
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.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString()
+ proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString()
+ proto.config != null -> "Config" to proto.config!!.toOneLineString()
+ proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString()
+ proto.channel != null -> "Channel" to proto.channel!!.toOneLineString()
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
else -> return
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index b928e8505..5693d343b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -20,15 +20,28 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.model.MqttConnectionState
+import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.network.repository.resolveEndpoint
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.mqtt.ConnectionState
+import org.meshtastic.mqtt.MqttClient
+import org.meshtastic.mqtt.MqttException
+import org.meshtastic.mqtt.ProbeResult
+import org.meshtastic.mqtt.probe
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.ToRadio
@@ -40,18 +53,30 @@ class MqttManagerImpl(
@Named("ServiceScope") private val scope: CoroutineScope,
) : MqttManager {
private var mqttMessageFlow: Job? = null
+ private val proxyActive = MutableStateFlow(false)
+
+ override val mqttConnectionState: StateFlow =
+ combine(proxyActive, mqttRepository.connectionState) { active, libState ->
+ if (!active) MqttConnectionState.Inactive else libState.toAppState()
+ }
+ .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive)
override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) {
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
+ proxyActive.value = true
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.catch { throwable ->
- serviceRepository.setErrorMessage(
- text = "MqttClientProxy failed: $throwable",
- severity = Severity.Warn,
- )
+ proxyActive.value = false
+ val message =
+ when (throwable) {
+ is MqttException.ConnectionRejected -> "MQTT: connection rejected (check credentials)"
+ is MqttException.ConnectionLost -> "MQTT: connection lost"
+ else -> "MQTT proxy failed: ${throwable.message}"
+ }
+ serviceRepository.setErrorMessage(text = message, severity = Severity.Warn)
}
.launchIn(scope)
}
@@ -63,6 +88,7 @@ class MqttManagerImpl(
mqttMessageFlow?.cancel()
mqttMessageFlow = null
}
+ proxyActive.value = false
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
@@ -79,4 +105,57 @@ class MqttManagerImpl(
else -> {}
}
}
+
+ private fun ConnectionState.toAppState(): MqttConnectionState = when (this) {
+ is ConnectionState.Connecting -> MqttConnectionState.Connecting
+ is ConnectionState.Connected -> MqttConnectionState.Connected
+ is ConnectionState.Reconnecting ->
+ MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message)
+ is ConnectionState.Disconnected ->
+ reason?.let { MqttConnectionState.Disconnected(reason = it.message) }
+ ?: MqttConnectionState.Disconnected.Idle
+ }
+
+ override suspend fun probe(
+ address: String,
+ tlsEnabled: Boolean,
+ username: String?,
+ password: String?,
+ ): MqttProbeStatus {
+ val endpoint = resolveEndpoint(address, tlsEnabled)
+ val result =
+ MqttClient.probe(endpoint = endpoint) {
+ val user = username?.takeUnless { it.isEmpty() }
+ val pass = password?.takeUnless { it.isEmpty() }
+ if (user != null) this.username = user
+ if (pass != null) password(pass)
+ }
+ return result.toAppStatus()
+ }
+
+ private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) {
+ is ProbeResult.Success -> {
+ val info = serverInfo
+ val summary =
+ buildList {
+ info.assignedClientIdentifier?.let { add("client=$it") }
+ info.maximumQosOrdinal?.let { add("maxQoS=$it") }
+ info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") }
+ }
+ .joinToString(", ")
+ .ifEmpty { null }
+ MqttProbeStatus.Success(serverInfo = summary)
+ }
+ is ProbeResult.Rejected ->
+ MqttProbeStatus.Rejected(
+ reasonCode = reasonCode.value,
+ reason = message,
+ serverReference = serverReference,
+ )
+ is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message)
+ is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message)
+ is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message)
+ is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs)
+ is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message)
+ }
}
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
index 8062afa76..451a62174 100644
--- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
@@ -20,7 +20,7 @@ import androidx.room3.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
@@ -59,7 +59,7 @@ class MigrationTest {
)
@Before
- fun createDb(): Unit = runBlocking {
+ fun createDb(): Unit = runTest {
val context = ApplicationProvider.getApplicationContext()
database =
Room.inMemoryDatabaseBuilder(
@@ -77,7 +77,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
+ fun testMigrateChannelsByPSK_duplicatePSK() = runTest {
// PSK \"AQ==\" is base64 for single byte 0x01
val pskBytes = byteArrayOf(0x01).toByteString()
@@ -103,7 +103,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_reorder() = runBlocking {
+ fun testMigrateChannelsByPSK_reorder() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
val pskB = byteArrayOf(0x02).toByteString()
@@ -122,7 +122,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
+ fun testMigrateChannelsByPSK_disambiguateByName() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A1")
@@ -141,7 +141,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
+ fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A")
diff --git a/core/model/consumer-rules.pro b/core/model/consumer-rules.pro
deleted file mode 100644
index 5f75d687d..000000000
--- a/core/model/consumer-rules.pro
+++ /dev/null
@@ -1,2 +0,0 @@
--keep class org.meshtastic.core.model.DataPacket
--keep class org.meshtastic.core.model.DataPacket$CREATOR
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt
new file mode 100644
index 000000000..4d3bfca10
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt
@@ -0,0 +1,56 @@
+/*
+ * 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
+ * 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 .
+ */
+package org.meshtastic.core.model
+
+/**
+ * App-level MQTT proxy connection state, decoupled from the MQTT library's internal type.
+ *
+ * Modeled as a sealed class so disconnect / reconnect events can carry diagnostic context — the user-facing reason for
+ * an unexpected disconnect, or the most recent reconnect attempt failure — without requiring downstream consumers to
+ * depend on the MQTT library's exception types.
+ */
+sealed class MqttConnectionState {
+ /** The MQTT proxy has not been started (disabled or not yet initialized). */
+ data object Inactive : MqttConnectionState()
+
+ /** The MQTT client is actively connecting to the broker. */
+ data object Connecting : MqttConnectionState()
+
+ /** The MQTT client is connected and subscribed to topics. */
+ data object Connected : MqttConnectionState()
+
+ /**
+ * The MQTT client lost connection and is attempting to reconnect.
+ *
+ * @property attempt 1-based attempt counter for the current reconnect loop.
+ * @property lastError Localized message from the most recent reconnect failure, if any.
+ */
+ data class Reconnecting(val attempt: Int = 0, val lastError: String? = null) : MqttConnectionState()
+
+ /**
+ * The MQTT client is not connected to the broker.
+ *
+ * @property reason Localized failure message for an unexpected disconnect, or `null` for the idle / initial /
+ * intentional-close case (use [Idle]).
+ */
+ data class Disconnected(val reason: String? = null) : MqttConnectionState() {
+ companion object {
+ /** Singleton for the idle / no-reason disconnected state. */
+ val Idle: Disconnected = Disconnected(reason = null)
+ }
+ }
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt
new file mode 100644
index 000000000..e3cb7c77a
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.model
+
+/**
+ * UI-friendly outcome of a one-shot MQTT broker reachability probe.
+ *
+ * Mirrors the failure shapes of `org.meshtastic.mqtt.ProbeResult` but stays in the model module so feature/UI code can
+ * consume the result without depending on the MQTT library.
+ */
+sealed class MqttProbeStatus {
+ /** Probe is currently in flight. */
+ data object Probing : MqttProbeStatus()
+
+ /**
+ * Broker accepted the connection. [serverInfo] is a short human-readable summary of any CONNACK properties that are
+ * useful to surface to the user.
+ */
+ data class Success(val serverInfo: String?) : MqttProbeStatus()
+
+ /** Broker rejected the connection (CONNACK with non-zero reason code). */
+ data class Rejected(val reasonCode: Int, val reason: String?, val serverReference: String?) : MqttProbeStatus()
+
+ /** DNS lookup failed. */
+ data class DnsFailure(val message: String?) : MqttProbeStatus()
+
+ /** TCP socket could not be opened. */
+ data class TcpFailure(val message: String?) : MqttProbeStatus()
+
+ /** TLS handshake failed. */
+ data class TlsFailure(val message: String?) : MqttProbeStatus()
+
+ /** Probe exceeded its timeout. */
+ data class Timeout(val timeoutMs: Long) : MqttProbeStatus()
+
+ /** Any other / unclassified failure. */
+ data class Other(val message: String?) : MqttProbeStatus()
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 47d812f68..dfe70fd92 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -18,8 +18,11 @@
package org.meshtastic.core.model.util
+import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.Telemetry
/**
@@ -48,6 +51,24 @@ fun MeshPacket.toOneLineString(): String {
return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
}
+fun Channel.toOneLineString(): String {
+ // Redact the channel preshared key (psk) from logs.
+ val redactedFields = """(psk)=[^,}]+"""
+ return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
+}
+
+fun ModuleConfig.toOneLineString(): String {
+ // Redact MQTT credentials from logs.
+ val redactedFields = """(password|username)=[^,}]+"""
+ return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
+}
+
+fun MyNodeInfo.toOneLineString(): String {
+ // Redact the hardware unique identifier from logs.
+ val redactedFields = """(device_id)=[^,}]+"""
+ return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
+}
+
fun Any.toPIIString() = if (!isDebug) {
""
} else {
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index c3dc2ffd5..f2fb85d7f 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -40,8 +40,7 @@ kotlin {
implementation(projects.core.ble)
implementation(libs.okio)
- implementation(libs.kmqtt.client)
- implementation(libs.kmqtt.common)
+ api(libs.meshtastic.mqtt.client)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
index bc3558800..0f7985276 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
@@ -108,7 +108,10 @@ class SerialRadioTransport(
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
- onDeviceDisconnect(false)
+ // USB unplug / cable error is transient — the transport will reconnect when
+ // the device is replugged or the OS re-enumerates the port. Only an explicit
+ // close() (user disconnects) should signal a permanent disconnect.
+ onDeviceDisconnect(waitForStopped = false, isPermanent = false)
}
},
)
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
index b2ccf6545..d8b14be03 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
@@ -87,6 +87,11 @@ internal class SerialConnectionImpl(
port.open(usbDeviceConnection)
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
+
+ // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as
+ // present and starts its serial-side Meshtastic protocol. Empirically, omitting these
+ // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at
+ // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion.
port.dtr = true
port.rts = true
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
index b4773dff3..c5080ec14 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
@@ -54,9 +54,7 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
- buildMap {
- serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
- }
+ buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -83,6 +81,8 @@ class UsbRepository(
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
- private suspend fun refreshStateInternal() =
- withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
+ private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
+ val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
+ _serialDevices.emit(devices)
+ }
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
index 77114ff55..f2ba25804 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -133,7 +133,11 @@ class BleRadioTransport(
@Volatile private var isFullyConnected = false
private var connectionJob: Job? = null
- private val reconnectPolicy = BleReconnectPolicy()
+
+ // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
+ // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
+ // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
+ private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
private val heartbeatSender =
HeartbeatSender(
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
index cef746af0..e4d250796 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
@@ -26,10 +26,11 @@ import kotlin.time.Duration.Companion.seconds
/**
* Encapsulates the BLE reconnection policy with exponential backoff.
*
- * The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or
- * give up permanently.
+ * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep).
+ * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns;
+ * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely.
*
- * @param maxFailures maximum consecutive failures before giving up permanently
+ * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
* @param failureThreshold after this many consecutive failures, signal a transient disconnect
* @param settleDelay delay before each connection attempt to let the BLE stack settle
* @param minStableConnection minimum time a connection must stay up to be considered "stable"
@@ -148,7 +149,18 @@ class BleReconnectPolicy(
companion object {
const val DEFAULT_MAX_FAILURES = 10
const val DEFAULT_FAILURE_THRESHOLD = 3
- val DEFAULT_SETTLE_DELAY = 1.seconds
+
+ /**
+ * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side
+ * GATT session have time to settle.
+ *
+ * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between
+ * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
+ * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
+ * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
+ * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
+ */
+ val DEFAULT_SETTLE_DELAY = 3.seconds
val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
internal val RECONNECT_BASE_DELAY = 5.seconds
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
index ac912346a..8c689dbcb 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
@@ -37,18 +37,20 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p
override suspend fun close() {
Logger.d { "Closing stream for good" }
- onDeviceDisconnect(true)
+ onDeviceDisconnect(waitForStopped = true, isPermanent = true)
}
/**
- * Notify the transport callback that our device has gone away, but wait for it to come back.
+ * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop.
*
* @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
* transport callbacks
- * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g.
- * TCP transient disconnect). Defaults to true for serial — subclasses may override with false.
+ * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O
+ * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS
+ * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to
+ * signal a user-initiated terminal disconnect.
*/
- protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) {
+ protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) {
callback.onDisconnect(isPermanent = isPermanent)
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt
index fe092fd7c..9efb9150b 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt
@@ -17,6 +17,8 @@
package org.meshtastic.core.network.repository
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.mqtt.ConnectionState
import org.meshtastic.proto.MqttClientProxyMessage
/** Interface defining the MQTT interactions used for proxying messages to and from the mesh. */
@@ -38,4 +40,7 @@ interface MQTTRepository {
* @param retained Whether the message should be retained by the broker.
*/
fun publish(topic: String, data: ByteArray, retained: Boolean)
+
+ /** Observable MQTT connection lifecycle state (DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING). */
+ val connectionState: StateFlow
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt
index 5e4ffa91d..47cfb6f7a 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt
@@ -17,22 +17,15 @@
package org.meshtastic.core.network.repository
import co.touchlab.kermit.Logger
-import io.github.davidepianca98.MQTTClient
-import io.github.davidepianca98.mqtt.MQTTException
-import io.github.davidepianca98.mqtt.MQTTVersion
-import io.github.davidepianca98.mqtt.Subscription
-import io.github.davidepianca98.mqtt.packets.Qos
-import io.github.davidepianca98.mqtt.packets.mqttv5.ReasonCode
-import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions
-import io.github.davidepianca98.socket.IOException
-import io.github.davidepianca98.socket.tls.TLSClientSettings
-import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -44,11 +37,19 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecodingException
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MqttJsonPayload
import org.meshtastic.core.model.util.subscribeList
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.mqtt.ConnectionState
+import org.meshtastic.mqtt.MqttClient
+import org.meshtastic.mqtt.MqttEndpoint
+import org.meshtastic.mqtt.MqttException
+import org.meshtastic.mqtt.MqttMessage
+import org.meshtastic.mqtt.QoS
+import org.meshtastic.mqtt.packet.Subscription
import org.meshtastic.proto.MqttClientProxyMessage
import kotlin.concurrent.Volatile
@@ -64,12 +65,16 @@ class MQTTRepositoryImpl(
private const val DEFAULT_TOPIC_LEVEL = "/2/e/"
private const val JSON_TOPIC_LEVEL = "/2/json/"
private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org"
+ private const val KEEPALIVE_SECONDS = 30
private const val INITIAL_RECONNECT_DELAY_MS = 1000L
private const val MAX_RECONNECT_DELAY_MS = 30_000L
private const val RECONNECT_BACKOFF_MULTIPLIER = 2
}
- @Volatile private var client: MQTTClient? = null
+ @Volatile private var client: MqttClient? = null
+
+ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected.Idle)
+ override val connectionState: StateFlow = _connectionState.asStateFlow()
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
@@ -77,25 +82,17 @@ class MQTTRepositoryImpl(
exceptionsWithDebugInfo = false
}
private val scope = CoroutineScope(dispatchers.default + SupervisorJob())
-
- @Volatile private var clientJob: Job? = null
private val publishSemaphore = Semaphore(20)
- @Suppress("TooGenericExceptionCaught")
override fun disconnect() {
Logger.i { "MQTT Disconnecting" }
val c = client
- client = null // Null first to prevent re-entrant disconnect
- try {
- c?.disconnect(ReasonCode.SUCCESS)
- } catch (e: Exception) {
- Logger.w(e) { "MQTT clean disconnect failed" }
- }
- clientJob?.cancel()
- clientJob = null
+ client = null
+ _connectionState.value = ConnectionState.Disconnected.Idle
+ scope.launch { safeCatching { c?.close() }.onFailure { e -> Logger.w(e) { "MQTT clean disconnect failed" } } }
}
- @OptIn(ExperimentalUnsignedTypes::class)
+ @OptIn(ExperimentalSerializationApi::class)
override val proxyMessageFlow: Flow = callbackFlow {
val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: "unknown"}"
val channelSet = radioConfigRepository.channelSetFlow.first()
@@ -103,108 +100,105 @@ class MQTTRepositoryImpl(
val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } ?: DEFAULT_TOPIC_ROOT
- val (host, port) =
- (mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let {
- it[0] to (it.getOrNull(1)?.toIntOrNull() ?: if (mqttConfig?.tls_enabled == true) 8883 else 1883)
- }
+ val rawAddress = mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS
+ val endpoint = resolveEndpoint(rawAddress, mqttConfig?.tls_enabled == true)
val newClient =
- MQTTClient(
- mqttVersion = MQTTVersion.MQTT5,
- address = host,
- port = port,
- tls = if (mqttConfig?.tls_enabled == true) TLSClientSettings() else null,
- userName = mqttConfig?.username,
- password = mqttConfig?.password?.encodeToByteArray()?.toUByteArray(),
- clientId = ownerId,
- publishReceived = { packet ->
- val topic = packet.topicName
- val payload = packet.payload?.toByteArray()
- Logger.d { "MQTT received message on topic $topic (size: ${payload?.size ?: 0} bytes)" }
-
- if (topic.contains("/json/")) {
- try {
- val jsonStr = payload?.decodeToString() ?: ""
- // Validate JSON by parsing it
- json.decodeFromString(jsonStr)
- Logger.d { "MQTT parsed JSON payload successfully" }
-
- trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = packet.retain))
- } catch (e: JsonDecodingException) {
- @OptIn(ExperimentalSerializationApi::class)
- Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" }
- } catch (e: SerializationException) {
- Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" }
- } catch (e: IllegalArgumentException) {
- Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" }
- }
- } else {
- trySend(
- MqttClientProxyMessage(
- topic = topic,
- data_ = payload?.toByteString() ?: okio.ByteString.EMPTY,
- retained = packet.retain,
- ),
- )
- }
- },
- )
-
+ MqttClient(ownerId) {
+ keepAliveSeconds = KEEPALIVE_SECONDS
+ autoReconnect = true
+ username = mqttConfig?.username
+ mqttConfig?.password?.let { password(it) }
+ }
client = newClient
- // Subscribe before starting the event loop. KMQTT's subscribe() calls send(),
- // which queues the SUBSCRIBE packet in pendingSendMessages while connackReceived
- // is false. Once the event loop receives CONNACK, it flushes the queue — so
- // subscriptions are guaranteed to be sent immediately after the connection is
- // established, with no timing races. This replaces a previous yield()-based
- // approach that was unreliable on lightly loaded dispatchers.
- val subscriptions = mutableListOf()
- channelSet.subscribeList.forEach { globalId ->
- subscriptions.add(
- Subscription("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)),
- )
- if (mqttConfig?.json_enabled == true) {
- subscriptions.add(
- Subscription("$rootTopic$JSON_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)),
+ val subscriptions: List = buildList {
+ channelSet.subscribeList.forEach { globalId ->
+ add(
+ Subscription(
+ "$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+",
+ maxQos = QoS.AT_LEAST_ONCE,
+ noLocal = true,
+ ),
)
- }
- }
- subscriptions.add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)))
-
- if (subscriptions.isNotEmpty()) {
- Logger.d { "MQTT subscribing to ${subscriptions.size} topics" }
- newClient.subscribe(subscriptions)
- }
-
- clientJob =
- scope.launch {
- var reconnectDelay = INITIAL_RECONNECT_DELAY_MS
- while (true) {
- try {
- Logger.i { "MQTT Starting client loop for $host:$port" }
- newClient.runSuspend()
- // runSuspend returned normally — broker closed connection cleanly.
- // Reset backoff so the next reconnect starts with the minimum delay.
- reconnectDelay = INITIAL_RECONNECT_DELAY_MS
- Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" }
- } catch (e: MQTTException) {
- Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" }
- } catch (e: IOException) {
- Logger.e(e) { "MQTT Client loop error (IO), reconnecting in ${reconnectDelay}ms" }
- } catch (e: CancellationException) {
- Logger.i { "MQTT Client loop cancelled" }
- throw e
- }
- delay(reconnectDelay)
- reconnectDelay =
- (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS)
+ if (mqttConfig?.json_enabled == true) {
+ add(
+ Subscription(
+ "$rootTopic$JSON_TOPIC_LEVEL$globalId/+",
+ maxQos = QoS.AT_LEAST_ONCE,
+ noLocal = true,
+ ),
+ )
}
}
+ add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", maxQos = QoS.AT_LEAST_ONCE, noLocal = true))
+ }
+
+ // Collect from the SharedFlow before connecting to avoid missing retained messages
+ // that arrive immediately after SUBSCRIBE.
+ launch { newClient.messages.collect { msg -> processMessage(msg) } }
+
+ // Forward the client's connection state to the repo-level StateFlow for UI observation.
+ launch { newClient.connectionState.collect { _connectionState.value = it } }
+
+ // Retry the initial connect with exponential backoff. Once established,
+ // autoReconnect handles subsequent drops and re-subscribes internally.
+ launch {
+ var reconnectDelay = INITIAL_RECONNECT_DELAY_MS
+ while (true) {
+ val result = safeCatching {
+ Logger.i { "MQTT Connecting to $endpoint" }
+ newClient.connect(endpoint)
+ if (subscriptions.isNotEmpty()) {
+ Logger.d { "MQTT subscribing to ${subscriptions.size} topics" }
+ newClient.subscribe(subscriptions)
+ }
+ Logger.i { "MQTT connected and subscribed" }
+ }
+ when {
+ result.isSuccess -> return@launch
+ result.exceptionOrNull() is MqttException.ConnectionRejected -> {
+ Logger.e(result.exceptionOrNull()) { "MQTT connection rejected (unrecoverable), stopping" }
+ close(result.exceptionOrNull()!!)
+ return@launch
+ }
+ else -> {
+ Logger.e(result.exceptionOrNull()) { "MQTT connect failed, retrying in ${reconnectDelay}ms" }
+ delay(reconnectDelay)
+ reconnectDelay =
+ (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS)
+ }
+ }
+ }
+ }
awaitClose { disconnect() }
}
- @OptIn(ExperimentalUnsignedTypes::class)
+ @OptIn(ExperimentalSerializationApi::class)
+ private fun ProducerScope.processMessage(msg: MqttMessage) {
+ val topic = msg.topic
+ val payload = msg.payload.toByteArray()
+ Logger.d { "MQTT received message on topic $topic (size: ${payload.size} bytes)" }
+
+ if (topic.contains("/json/")) {
+ try {
+ val jsonStr = payload.decodeToString()
+ json.decodeFromString(jsonStr)
+ Logger.d { "MQTT parsed JSON payload successfully" }
+ trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = msg.retain))
+ } catch (e: JsonDecodingException) {
+ Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" }
+ } catch (e: SerializationException) {
+ Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" }
+ } catch (e: IllegalArgumentException) {
+ Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" }
+ }
+ } else {
+ trySend(MqttClientProxyMessage(topic = topic, data_ = payload.toByteString(), retained = msg.retain))
+ }
+ }
+
override fun publish(topic: String, data: ByteArray, retained: Boolean) {
val currentClient = client
if (currentClient == null) {
@@ -214,18 +208,36 @@ class MQTTRepositoryImpl(
Logger.d { "MQTT publishing message to topic $topic (size: ${data.size} bytes, retained: $retained)" }
scope.launch {
publishSemaphore.withPermit {
- @Suppress("TooGenericExceptionCaught")
- try {
+ safeCatching {
currentClient.publish(
- retain = retained,
- qos = Qos.AT_LEAST_ONCE,
- topic = topic,
- payload = data.toUByteArray(),
+ MqttMessage(topic = topic, payload = data, qos = QoS.AT_LEAST_ONCE, retain = retained),
)
- } catch (e: Exception) {
- Logger.w(e) { "MQTT publish to $topic failed" }
}
+ .onFailure { e -> Logger.w(e) { "MQTT publish to $topic failed" } }
}
}
}
}
+
+/**
+ * Resolve a user-supplied broker address into an [MqttEndpoint].
+ *
+ * Address resolution rules:
+ * - If [rawAddress] already contains a URI scheme (`scheme://…`), parse it directly via [MqttEndpoint.parse] and
+ * respect whatever transport / port the user encoded.
+ * - Otherwise wrap it as a WebSocket endpoint (`ws[s]://host${WEBSOCKET_PATH}`) so the proxy works over CDNs and
+ * firewall-restricted networks where raw 1883/8883 may be blocked. The scheme is `wss` when [tlsEnabled] is `true`,
+ * `ws` otherwise.
+ *
+ * Extracted as a top-level function so [MQTTRepositoryImplTest] can exercise every branch without spinning up the full
+ * repository, and so `MqttManagerImpl` (in `:core:data`) can reuse the same parsing rules for the probe API. Visibility
+ * is `public` because Kotlin's `internal` is scoped per Gradle module.
+ */
+fun resolveEndpoint(rawAddress: String, tlsEnabled: Boolean): MqttEndpoint = if (rawAddress.contains("://")) {
+ MqttEndpoint.parse(rawAddress)
+} else {
+ val scheme = if (tlsEnabled) "wss" else "ws"
+ MqttEndpoint.parse("$scheme://$rawAddress$WEBSOCKET_PATH")
+}
+
+private const val WEBSOCKET_PATH = "/mqtt"
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
index f1049f897..840dc214a 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
@@ -22,6 +22,7 @@ import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
@@ -95,10 +96,10 @@ class BleRadioTransportTest {
* [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep
* timeout in [MeshConnectionManagerImpl]).
*
- * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses,
- * connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay
- * elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3
- * settle delay elapses, connectAndAwait throws → onDisconnect called
+ * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1
+ * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms —
+ * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24
+ * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called
*/
@Test
fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest {
@@ -119,10 +120,10 @@ class BleRadioTransportTest {
)
bleTransport.start()
- // Advance through exactly 3 failure iterations (≈18 001 ms virtual time).
+ // Advance through exactly 3 failure iterations (≈24 001 ms virtual time).
// The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended
// and advanceTimeBy returns cleanly.
- advanceTimeBy(18_001L)
+ advanceTimeBy(24_001L)
verify { service.onDisconnect(any(), any()) }
@@ -131,16 +132,17 @@ class BleRadioTransportTest {
}
/**
- * After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and
- * signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline.
+ * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected
+ * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm —
+ * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must
+ * never call `onDisconnect(isPermanent = true)` from the give-up path.
*
- * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw +
- * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s
- * settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing
- * variance.
+ * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw +
+ * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s
+ * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance.
*/
@Test
- fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest {
+ fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest {
val device = FakeBleDevice(address = address, name = "Test Device")
bluetoothRepository.bond(device)
@@ -158,11 +160,13 @@ class BleRadioTransportTest {
)
bleTransport.start()
- // Advance enough time for all 10 failures to occur.
- advanceTimeBy(400_001L)
+ // Run well past where the legacy policy (maxFailures = 10) would have given up.
+ advanceTimeBy(800_001L)
- // Should have been called with isPermanent=true at least once (the final call).
- verify { service.onDisconnect(isPermanent = true, errorMessage = any()) }
+ // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit;
+ // the policy must NEVER signal a permanent disconnect on its own. Only explicit close()
+ // (verified separately by the service layer) may emit isPermanent = true.
+ verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) }
bleTransport.close()
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt
index 73e096da9..26b83a420 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt
@@ -18,25 +18,82 @@ package org.meshtastic.core.network.repository
import kotlinx.serialization.json.Json
import org.meshtastic.core.model.MqttJsonPayload
+import org.meshtastic.mqtt.MqttEndpoint
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertIs
import kotlin.test.assertTrue
class MQTTRepositoryImplTest {
- @Test
- fun `test address parsing logic`() {
- val address1 = "mqtt.example.com:1883"
- val (host1, port1) = address1.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) }
- assertEquals("mqtt.example.com", host1)
- assertEquals(1883, port1)
+ // region resolveEndpoint — every behavioral branch of address parsing.
- val address2 = "mqtt.example.com"
- val (host2, port2) = address2.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) }
- assertEquals("mqtt.example.com", host2)
- assertEquals(1883, port2)
+ @Test
+ fun `bare host without scheme is wrapped as ws WebSocket on the standard port`() {
+ val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = false)
+
+ val ws = assertIs(endpoint)
+ assertEquals("ws://broker.example.com/mqtt", ws.url)
}
+ @Test
+ fun `bare host with TLS enabled is upgraded to wss`() {
+ val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = true)
+
+ val ws = assertIs(endpoint)
+ assertEquals("wss://broker.example.com/mqtt", ws.url)
+ }
+
+ @Test
+ fun `host with explicit port is preserved when wrapped`() {
+ val endpoint = resolveEndpoint(rawAddress = "broker.example.com:9001", tlsEnabled = false)
+
+ val ws = assertIs(endpoint)
+ assertEquals("ws://broker.example.com:9001/mqtt", ws.url)
+ }
+
+ @Test
+ fun `address with ws scheme is parsed as-is and tls flag is ignored`() {
+ // tlsEnabled is intentionally true here — when the user supplies a full URL we
+ // must honor whatever scheme they provided, not silently upgrade it.
+ val endpoint = resolveEndpoint(rawAddress = "ws://broker.example.com:8080/custom-path", tlsEnabled = true)
+
+ val ws = assertIs(endpoint)
+ assertEquals("ws://broker.example.com:8080/custom-path", ws.url)
+ }
+
+ @Test
+ fun `address with wss scheme is parsed as-is`() {
+ val endpoint = resolveEndpoint(rawAddress = "wss://broker.example.com/secure-mqtt", tlsEnabled = false)
+
+ val ws = assertIs(endpoint)
+ assertEquals("wss://broker.example.com/secure-mqtt", ws.url)
+ }
+
+ @Test
+ fun `address with mqtt tcp scheme is parsed as Tcp endpoint`() {
+ val endpoint = resolveEndpoint(rawAddress = "mqtt://broker.example.com:1883", tlsEnabled = false)
+
+ val tcp = assertIs(endpoint)
+ assertEquals("broker.example.com", tcp.host)
+ assertEquals(1883, tcp.port)
+ assertEquals(false, tcp.tls)
+ }
+
+ @Test
+ fun `address with mqtts tcp scheme is parsed as Tcp endpoint with tls true`() {
+ val endpoint = resolveEndpoint(rawAddress = "mqtts://broker.example.com:8883", tlsEnabled = false)
+
+ val tcp = assertIs(endpoint)
+ assertEquals("broker.example.com", tcp.host)
+ assertEquals(8883, tcp.port)
+ assertEquals(true, tcp.tls)
+ }
+
+ // endregion
+
+ // region MqttJsonPayload — keep the existing JSON contract tests.
+
@Test
fun `test json payload parsing`() {
val jsonStr =
@@ -72,4 +129,6 @@ class MQTTRepositoryImplTest {
assertTrue(jsonStr.contains("\"from\":12345678"))
assertTrue(jsonStr.contains("\"payload\":\"Hello World\""))
}
+
+ // endregion
}
diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
index 354c4cd30..202d8de57 100644
--- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
+++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
@@ -78,7 +78,11 @@ open class TcpRadioTransport(
Logger.d { "[$address] Closing TCP transport" }
closing = true
transport.stop()
- callback.onDisconnect(isPermanent = true)
+ // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the
+ // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting
+ // it from close() caused a double-disconnect and prevented the auto-reconnect loop from
+ // owning its own lifecycle. The `closing` guard above suppresses the listener's transient
+ // disconnect during teardown.
}
override fun keepAlive() {
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
index a3f34d67e..45ba70eb7 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
@@ -129,7 +129,10 @@ private constructor(
// Ignore errors during port close
}
if (isActive) {
- onDeviceDisconnect(true)
+ // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as
+ // transient — the user did not explicitly disconnect, and the port may come
+ // back when the device is replugged or the OS re-enumerates it.
+ onDeviceDisconnect(waitForStopped = true, isPermanent = false)
}
}
}
@@ -169,8 +172,10 @@ private constructor(
private const val READ_TIMEOUT_MS = 100
/**
- * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
- * disconnect to the [callback] and returns the (non-connected) instance.
+ * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient
+ * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as
+ * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the
+ * user grants permission); only an explicit close should signal a permanent disconnect.
*/
fun open(
portName: String,
@@ -183,7 +188,7 @@ private constructor(
if (!transport.startConnection()) {
val errorMessage = diagnoseOpenFailure(portName)
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
- callback.onDisconnect(isPermanent = true, errorMessage = errorMessage)
+ callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
}
return transport
}
diff --git a/core/proto/consumer-rules.pro b/core/proto/consumer-rules.pro
deleted file mode 100644
index e9dc3751a..000000000
--- a/core/proto/consumer-rules.pro
+++ /dev/null
@@ -1,43 +0,0 @@
-# Core proto classes required for packet handling and serialization
-# FromRadio and related message types (primary packet container)
--keep class org.meshtastic.proto.FromRadio
--keep class org.meshtastic.proto.Data
--keep class org.meshtastic.proto.MeshPacket
--keep class org.meshtastic.proto.LogRecord
-
-# Message type payloads (handled in packet routing)
--keep class org.meshtastic.proto.AdminMessage
--keep class org.meshtastic.proto.StoreAndForward
--keep class org.meshtastic.proto.StoreForwardPlusPlus
--keep class org.meshtastic.proto.Routing
-
-# User and Node information
--keep class org.meshtastic.proto.User
--keep class org.meshtastic.proto.NeighborInfo
--keep class org.meshtastic.proto.Neighbor
-
-# Location and environment data
--keep class org.meshtastic.proto.Position
--keep class org.meshtastic.proto.Waypoint
--keep class org.meshtastic.proto.StatusMessage
-
-# Telemetry data types
--keep class org.meshtastic.proto.Telemetry
--keep class org.meshtastic.proto.DeviceMetrics
--keep class org.meshtastic.proto.EnvironmentMetrics
--keep class org.meshtastic.proto.AirQualityMetrics
--keep class org.meshtastic.proto.PowerMetrics
--keep class org.meshtastic.proto.LocalStats
--keep class org.meshtastic.proto.HostMetrics
-
-# Other data
--keep class org.meshtastic.proto.Paxcount
--keep class org.meshtastic.proto.DeviceMetadata
-
-# Configuration classes
--keep class org.meshtastic.proto.ChannelSet
--keep class org.meshtastic.proto.LocalConfig
--keep class org.meshtastic.proto.Config
--keep class org.meshtastic.proto.ModuleConfig
--keep class org.meshtastic.proto.Channel
--keep class org.meshtastic.proto.ClientNotification
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt
index 7ebfa0521..6701514f8 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt
@@ -16,10 +16,16 @@
*/
package org.meshtastic.core.repository
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.model.MqttConnectionState
+import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.proto.MqttClientProxyMessage
/** Interface for managing MQTT proxy communication. */
interface MqttManager {
+ /** Observable MQTT proxy connection state for UI consumption. */
+ val mqttConnectionState: StateFlow
+
/** Starts the MQTT proxy with the given settings. */
fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean)
@@ -28,4 +34,15 @@ interface MqttManager {
/** Handles an MQTT proxy message from the radio. */
fun handleMqttProxyMessage(message: MqttClientProxyMessage)
+
+ /**
+ * Probe an MQTT broker to verify connectivity and credentials without joining the proxy lifecycle. Intended for UI
+ * "Test Connection" affordances.
+ *
+ * @param address Raw broker address as the user would type it (host, host:port, or full URL).
+ * @param tlsEnabled `true` to upgrade bare addresses to `wss://` (ignored when [address] already has a scheme).
+ * @param username Optional MQTT username.
+ * @param password Optional MQTT password.
+ */
+ suspend fun probe(address: String, tlsEnabled: Boolean, username: String?, password: String?): MqttProbeStatus
}
diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml
index fc61e78d4..2e4eaf53c 100644
--- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml
@@ -154,6 +154,7 @@
الرسائل
إعدادات لورا
الجهة
+ انقطع الاتصال
استغرق وقت طويل
المسافة
الإعدادات
@@ -173,4 +174,5 @@
إعدادات بلوتوث
+ عربي
diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml
index 301ff3bb4..cb615de37 100644
--- a/core/resources/src/commonMain/composeResources/values-be/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml
@@ -167,6 +167,8 @@
Граць
LoRa
Рэгіён
+ Адлучана
+ Злучаны
Імя карыстальніка
Пароль
Уключана
@@ -220,4 +222,5 @@
Сіні
Зялёны
Meshtastic
+ Фільтраваць
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index fe1520458..f69e137d9 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -201,10 +201,15 @@
Възстановяване на настройките по подразбиране
Приложи
Тема
+ Контраст
Светла
Тъмна
По подразбиране на системата
Избор на тема
+ Ниво на контраста
+ Стандартен
+ Среден
+ Висок
Изпращане на местоположение в мрежата
Компактно кодиране за Кирилица
@@ -301,6 +306,8 @@
Батерия
Използване на канала
Използване на ефира
+ %1$s: %2$s%%
+ %1$s: %2$s V
%1$s
%1$s: %2$s
записа
@@ -317,12 +324,9 @@
Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие.
Известия за нови възли
SNR
- Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни.
RSSI
- Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка.
(Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500.
Метрики на устройството
- Карта на възела
Позиция
Последна актуализация на позицията
Показатели на околната среда
@@ -362,7 +366,6 @@
1М
Макс
Мин
- Ср
Разгъване на диаграмата
Свиване на диаграмата
Неизвестна възраст
@@ -483,6 +486,17 @@
Честотен слот
Игнориране на MQTT
Конфигуриране на MQTT
+ Неактивен
+ Прекъсната връзка
+ Свързване…
+ Свързано
+ Повторно свързване…
+ Повторно свързване (опит %1$d) — %2$s
+ Тестване на връзката
+ Достъпен. Брокерът е приел идентификационните данни.
+ Достъпен (%1$s)
+ Хостът не е намерен
+ Връзката е неуспешна
MQTT е активиран
Адрес
Потребителско име
@@ -960,5 +974,9 @@
Въведете или изберете мрежа
WiFi е конфигуриран успешно!
Прилагането на конфигурацията за WiFi не е успешно
+ Изход
Meshtastic
+ Филтър
+ Изберете устройство
+ Изберете мрежа
diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml
index 485e1c9e1..22b52e28e 100644
--- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml
@@ -182,6 +182,7 @@
Sempre
Traçar ruta
Regió
+ Desconnectat
Temps esgotat
Distància
Meshtastic
@@ -200,4 +201,5 @@
Meshtastic
+ Filtre
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index 6220218ac..d3e0566ac 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -331,12 +331,9 @@
Informace o uživateli
Oznámení o nových uzlech
SNR
- Poměr signálu k šumu (SNR) je veličina používaná k vyjádření poměru mezi úrovní požadovaného signálu a úrovní šumu na pozadí. V Meshtastic a dalších bezdrátových systémech vyšší hodnota SNR značí čistší signál, což může zvýšit spolehlivost a kvalitu přenosu dat.
RSSI
- Indikátor síly přijímaného signálu, měření, které se používá k určení hladiny výkonu přijímané anténou. Vyšší hodnota RSSI obvykle znamená silnější a stabilnější spojení.
(Vnitřní kvalita ovzduší) relativní hodnota IAQ měřená Bosch BME680. Hodnota rozsahu 0–500.
Metriky zařízení
- Mapa uzlu
Pozice
Poslední aktualizace pozice
Metriky prostředí
@@ -525,6 +522,8 @@
Ignorovat MQTT
OK do MQTT
Nastavení MQTT
+ Odpojeno
+ Připojeno
MQTT povoleno
Adresa
Uživatelské jméno
@@ -969,4 +968,5 @@
Připojit
Hotovo
Meshtastic
+ Filtr
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 161feaa3e..4755515ad 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -383,12 +383,9 @@
Benutzerinfo
Benachrichtigung neue Knoten
SNR
- Signal-Rausch-Verhältnis, ein in der Kommunikation verwendetes Maß, um den Pegel eines gewünschten Signals im Verhältnis zum Pegel des Hintergrundrauschens zu quantifizieren. Bei Meshtastic und anderen drahtlosen Systemen weist ein höheres SNR auf ein klareres Signal hin, das die Zuverlässigkeit und Qualität der Datenübertragung verbessern kann.
RSSI
- Indikator für die empfangene Signalstärke, eine Messung zur Bestimmung der von der Antenne empfangenen Leistungsstärke. Ein höherer RSSI-Wert weist im Allgemeinen auf eine stärkere und stabilere Verbindung hin.
(Innenluftqualität) relativer IAQ-Wert gemessen von Bosch BME680.
Gerätedaten
- Standortkarte Knoten
Standort
Letzte Standortaktualisierung
Umweltdaten
@@ -435,7 +432,6 @@
1 Monat
Maximal
Minimum
- Durchschnitt
Diagramm einblenden
Diagramm ausblenden
Alter unbekannt
@@ -613,6 +609,23 @@
MQTT ignorieren
OK für MQTT
MQTT Einstellungen
+ Inaktiv
+ Verbindung getrennt
+ Verbindung getrennt - %1$s
+ Wird verbunden
+ Verbunden
+ Erneut verbinden
+ Erneut verbinden (Versuch %1$d) - %2$s
+ Verbindung testen
+ Broker prüfen.
+ Erreichbar. Broker akzeptierte Anmeldedaten.
+ Erreichbar (%1$s)
+ Broker abgelehnt: %1$s
+ Host nicht gefunden
+ Broker (TCP) nicht erreichbar
+ TLS Handshake fehlgeschlagen
+ Zeitüberschreitung nach %1$d ms
+ Verbindung fehlgeschlagen
MQTT aktiviert
Adresse
Benutzername
@@ -1208,5 +1221,21 @@
Netzwerk eingeben oder auswählen
WLAN erfolgreich konfiguriert!
WLAN Konfiguration konnte nicht angewendet werden
+ Meshtastic Desktop
+ Meshtastic anzeigen
+ Beenden
Meshtastic
+ TAK Datenpaket exportieren
+ Zeitzone löschen
+ Filter
+ Filter entfernen
+ Legende für Luftqualität anzeigen
+ Nachrichtenstatus anzeigen
+ Antwort senden
+ Nachricht kopieren
+ Nachricht auswählen
+ Nachricht löschen
+ Mit Emoji reagieren
+ Gerät auswählen
+ Wählen Sie ein Netzwerk
diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml
index 88feab55e..8386ac2ea 100644
--- a/core/resources/src/commonMain/composeResources/values-el/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml
@@ -164,6 +164,7 @@
Μηνύματα
LoRa
Περιφέρεια
+ Αποσυνδεδεμένο
Διεύθυνση
Όνομα χρήστη
Κωδικός πρόσβασης
@@ -200,4 +201,5 @@
Κόκκινο
Μπλε
Πράσινο
+ Φίλτρο
diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml
index 3470f7bed..4c59aa547 100644
--- a/core/resources/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml
@@ -300,13 +300,10 @@
Clave pública no coincide
Notificaciones de nuevo nodo
SNR
- SNR: Ratio de señal a ruido, una medida utilizada en las comunicaciones para cuantificar el nivel de una señal deseada respecto al nivel del ruido de fondo. En Meshtastic y otros sistemas inalámbricos, un mayor SNR indica una señal más clara que puede mejorar la fiabilidad y la calidad de la transmisión de datos.
RSSI
- Indicador de Fuerza de Señal Recibida (RSSI en inglés), una medida utilizada para determinar el nivel de potencia que está siendo recibido por la antena. Un valor de RSSI más alto generalmente indica una conexión más fuerte y estable.
(Calidad de Aire interior) escala relativa del valor IAQ como mediciones del sensor Bosch BME680.
Rango de Valores 0 - 500.
Métricas de Dispositivo
- Mapa de Nodos
Posición
Última actualización
Métricas de Entorno
@@ -492,6 +489,8 @@ Rango de Valores 0 - 500.
Ignorar Paquetes MQTT
Permitir MQTT
Configuración MQTT
+ Desconectado
+ Conectado
Activar el MQTT
Dirección del Servidor MQTT
Usuario
@@ -837,4 +836,5 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Conectar
Hecho
Meshtastic
+ Filtro
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 650c69122..c2e327629 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -383,12 +383,9 @@
Kasutaja teave
Uue sõlme teade
SNR
- Signaali ja müra suhe (SNR) on mõõdik, mida kasutatakse soovitud signaali taseme ja taustamüra taseme vahelise suhte määramisel. Meshtastic ja teistes traadita süsteemides näitab kõrgem signaali ja müra suhe selgemat signaali, mis võib parandada andmeedastuse usaldusväärsust ja kvaliteeti.
RSSI
- Vastuvõetud signaali tugevuse indikaator (RSSI), mõõt mida kasutatakse antenni poolt vastuvõetava võimsustaseme määramiseks. Kõrgem RSSI väärtus näitab üldiselt tugevamat ja stabiilsemat ühendust.
Siseõhu kvaliteet (IAQ) on suhtelise skaala väärtus, näiteks mõõtes Bosch BME680 abil. Väärtuste vahemik 0–500.
Seadme mõõdikud
- Sõlmede kaart
Asukoht
Viimase asukoha värskendus
Keskkonnamõõdikud
@@ -435,7 +432,6 @@
1k
Maksimaalselt
Min
- Keskm
Laienda diagrammi
Ahenda diagrammi
Tundmatu vanus
@@ -613,6 +609,23 @@
Keela MQTT
Ok MQTTi
MQTT sätted
+ Mitteaktiivne
+ Ühendus katkenud
+ Ühendus katkenud — %1$s
+ Ühendan…
+ Ühendatud
+ Taas ühendan…
+ Ühendan uuesti (katse %1$d) — %2$s
+ Test ühendus
+ Kontrollin vahendajat…
+ Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave.
+ Kättesaadav (%1$s)
+ Vahendaja lükkas tagasi: %1$s
+ Hosti ei leitud
+ Vahendajaga ei saa ühendust (TCP)
+ TLS ühendus ebaõnnestus
+ Ajaline katkestus peale %1$d ms
+ Ühendus ebaõnnestus
MQTT lubatud
Aadress
Kasutajatunnus
@@ -1208,5 +1221,21 @@
Sisestage või valige võrk
WiFi edukalt seadistatud!
WiFi sätete rakendamine ebaõnnestus
+ Meshtastic töölaud
+ Näita Meshtastic
+ Sule
Kärgvõrgustik
+ Ekspordi TAK andmepakett
+ Eemalda ajatsoon
+ Filtreeri
+ Eemalda filter
+ Näita õhukvaliteedi ajalugu
+ Kuva sõnumi olek
+ Saada vastus
+ Kopeeri sõnum
+ Vali sõnum
+ Kustuta sõnum
+ Vasta emotikoniga
+ Vali seade
+ Vali võrk
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 3d9e28e91..f9da71dea 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -383,12 +383,9 @@
Käyttäjätiedot
Uuden laitteen ilmoitukset
SNR
- Signaali-kohinasuhde (SNR) on mittari, jota käytetään viestinnässä halutun signaalin tason ja taustahälyn tason määrittämisessä. Meshtasticissa ja muissa langattomissa järjestelmissä korkeampi SNR tarkoittaa selkeämpää signaalia, joka voi parantaa tiedonsiirron luotettavuutta ja laatua.
RSSI
- Vastaanotetun signaalin voimakkuusindikaattori (RSSI) on mittari, jota käytetään määrittämään antennilla vastaanotetun signaalin voimakkuus. Korkeampi RSSI-arvo yleensä osoittaa vahvemman ja vakaamman yhteyden.
Sisäilman laatu (IAQ) on suhteellinen asteikko, jota voidaan mitata mm. Bosch BME680 anturilla ja sen arvoväli on 0–500.
Laitteen mittausloki
- Laitekartta
Sijainti
Viimeisin sijainnin päivitys
Ympäristöarvot
@@ -435,7 +432,6 @@
1 kk
Kaikki
Minimi
- Keskiarvo
Laajenna kaavio
Pienennä kaavio
Tuntematon ikä
@@ -613,6 +609,23 @@
Ohita MQTT
MQTT päällä
MQTT asetukset
+ Passiivinen
+ Ei yhdistetty
+ Yhteys katkaistu — %1$s
+ Yhdistetään…
+ Yhdistetty
+ Yhdistetään uudelleen…
+ Yhdistetään uudelleen (yritys %1$d) — %2$s
+ Testaa yhteys
+ Tarkistetaan välityspalvelinta…
+ Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot.
+ Yhteys onnistui (%1$s)
+ Välityspalvelin ei hyväksynyt: %1$s
+ Palvelinta ei löytynyt
+ Yhteyttä välityspalvelimeen ei saada (TCP)
+ TLS-yhteyden muodostus epäonnistui
+ Aikakatkaistu %1$d ms jälkeen
+ Yhdistäminen epäonnistui
MQTT käytössä
Osoite
Käyttäjänimi
@@ -1209,5 +1222,21 @@
Syötä tai valitse verkko
WiFi määritetty onnistuneesti!
WiFi-asetusten käyttöönotto epäonnistui
+ Meshtastic työpöytä
+ Näytä Meshtastic
+ Lopeta
Meshtastic
+ Vie TAK-datapaketti
+ Tyhjennä aikavyöhyke
+ Suodatus
+ Poista suodatin
+ Näytä ilmanlaadun selite
+ Näytä viestin tila
+ Lähetä vastaus
+ Kopioi viesti
+ Valitse viesti
+ Poista viesti
+ Reaktio emojin kanssa
+ Valitse laite
+ Valitse verkko
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 0ef821cb6..f4afeef5c 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -18,6 +18,7 @@
Meshtastic
+ Meshtastic %1$s
Filtre
Effacer le filtre de nœud
Filtrer par
@@ -40,9 +41,11 @@
Interne
par Favoris
Afficher uniquement les nœuds ignorés
+ Exclure MQTT
Non reconnu
En attente d'accusé de réception
En file d'attente pour l'envoi
+ Délivré au nœud
Inconnu
Routage via chaîne SF++…
Confirmé via chaîne SF++
@@ -119,7 +122,8 @@
Distance minimale en mètres pour considérer une diffusion de position intelligente.
À quelle fréquence devrions-nous essayer d'obtenir une position GPS (<10sec le GPS est maintenu allumé).
Champs optionnels à inclure dans les messages de position. Plus il y en a, plus le message est grand, plus cela augmentant le temps d'occupation du réseau et le risque de perte.
- Sera en veille profonde autant que possible, pour les rôles traqueur et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur.
+ Sera en veille profonde autant que possible, pour les rôles traceurs et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur.
+ Généré à partir de votre clé publique et envoyé à d'autres nœuds sur le maillage pour leur permettre de calculer une clé secrète partagée.
Utilisée pour créer une clé partagée avec un appareil distant.
Clé publique autorisée à envoyer des messages d’administration à ce nœud.
L'appareil est géré par un administrateur de maillage, l'utilisateur ne peut accéder à aucun des paramètres de l'appareil.
@@ -163,18 +167,25 @@
Port :
Connecté
Connexions actuelles :
- IP WiFi :
+ IP du Wifi :
IP Ethernet :
Connexion en cours
Non connecté
Aucun appareil sélectionné
Périphérique inconnu
+ Aucun périphérique réseau trouvé
+ Pas de périphérique USB trouvé
+ USB
+ Mode Démo
Connecté à la radio, mais en mode veille
Mise à jour de l’application requise
Vous devez mettre à jour cette application sur l'app store (ou Github). Il est trop vieux pour dialoguer avec le micrologiciel de la radio. Veuillez lire nos docs sur ce sujet.
Aucun (désactivé)
Notifications de service
Remerciements
+ Bibliothèques Open Source
+ Meshtastic est construit avec les bibliothèques open source suivantes. Appuyez sur n'importe quelle bibliothèque pour voir sa licence.
+ %1$d Bibliothèques
Cette URL de canal est invalide et ne peut pas être utilisée
Panneau de débogage
Contenu décodé :
@@ -207,7 +218,21 @@
Correspondre à tout | N'importe quel
Cela supprimera tous les paquets de journaux et les entrées de la base de données de votre appareil - c'est une réinitialisation complète, et est permanent.
Effacer
+ Rechercher des émojis...
+ Plus d'actions
Canal
+ %1$s: %2$s
+ Message de %1$s: %2$s
+ Entête
+ Élément %1$d
+ Pied de page
+ Exporter le paquet de données TAK
+ Point
+ Texte
+ Jauge
+ Dégradé
+ Ceci est un composable personnalisé
+ Avec plusieurs lignes et styles
Statut d'envoi du message
Nouveaux messages au-dessous
Notifications de message
@@ -228,10 +253,15 @@
Rétablir les valeurs par défaut
Appliquer
Thème
+ Contraste
Clair
Sombre
Valeur par défaut du système
Choisir un thème
+ Niveau de contraste
+ Standard
+ Milieu
+ Haut
Fournir l'emplacement au maillage
Encodage compact pour Cyrillique
@@ -275,6 +305,7 @@
Message direct
Reconfiguration de NodeDB
Réception confirmée par le destinataire
+ Votre appareil peut se déconnecter et redémarrer lorsque les paramètres sont appliqués.
Erreur
Une erreur inconnue s'est produite
Ignorer
@@ -317,6 +348,8 @@
Actuellement :
Toujours muet
Non muet
+ Muet pour %1$d jours, %2$s heures
+ Muet pour %1$s heures
Désactiver les notifications pour '%1$s' ?
Réactiver les notifications pour '%1$s' ?
Remplacer
@@ -326,7 +359,10 @@
Batterie
UtilCanal
UtilAir
+ %1$s / %2$s%%
+ %1$s: %2$s V
%1$s
+ %1$s: %2$s
Temp
Hum
Temp sol
@@ -347,12 +383,9 @@
Infos utilisateur
Notifikasyon nouvo nœud
SNR
- Signal-to-Noise Ratio, une mesure utilisée dans les communications pour quantifier le niveau du signal par rapport au niveau du bruit de fond. Dans les systèmes Meshtastic et autres systèmes sans fil, un SNR plus élevé indique un signal plus clair qui peut améliorer la fiabilité et la qualité de la transmission de données.
RSSI
- Indicateur de force du signal reçu, une mesure utilisée pour déterminer le niveau de puissance reçu par l'antenne. Une valeur RSSI plus élevée indique généralement une connexion plus forte et plus stable.
(Qualité de l'air intérieur) valeur de l'échelle relative IAQ mesurée par Bosch BME680. Plage de valeur 0–500.
Métriques de l’appareil
- Carte historique des positions
Position
Dernière mise à jour de position
Métriques d'environnement
@@ -381,13 +414,26 @@
Durée : %1$s s
Route aller :\n\n
Route retour :\n\n
+ Saut vers l'avant
+ Saut vers l'arrière
+ Aller/Retour
Pas de réponse
+ Charge 1 m
+ Charge 5m
+ Charge 15 m
+ Moyenne de charge du système d'une minute
+ Moyenne de charge du système de cinq minutes
+ Moyenne de charge du système de 15 minutes
+ Mémoire système disponible en octets
1H
24H
1S
2S
1M
Max
+ Min
+ Agrandir le graphique
+ Réduire le graphique
Age inconnu
Copier
Caractère d'appel !
@@ -401,11 +447,17 @@
Canal 1
Canal 2
Canal 3
+ Canal 4
+ Canal 5
+ Canal 6
+ Canal 7
+ Canal 8
Actif
Tension
Êtes-vous sûr ?
Documentation du rôle de l'appareil et le billet de blog sur comment Choisir le rôle de l'appareil approprié.]]>
Je sais ce que je fais.
+ La batterie du nœud %1$s est faible (%2$d%)
Notifications de batterie faible
Batterie faible : %1$s
Notifications de batterie faible (nœuds favoris)
@@ -531,6 +583,9 @@
Durée de sortie (en millisecondes)
Durée de répétition de la sortie (secondes)
Sonnerie
+ Sonnerie importée
+ Le fichier est vide
+ Erreur d'importation : %1$s
Lancer
Utiliser l'I2S comme buzzer
LoRa
@@ -554,6 +609,13 @@
Ignorer MQTT
Transmission des paquets vers MQTT
Configuration MQTT
+ Inactif
+ Déconnecté
+ Connexion…
+ Connecté
+ Reconnexion…
+ Test de la connexion
+ Échec de la connexion
MQTT activé
Adresse
Nom d'utilisateur
@@ -625,6 +687,8 @@
Série activée
Écho activé
Vitesse de transmission série
+ RX
+ Tx
Délai d'expiration
Mode série
Outrepasser le port série de la console
@@ -659,8 +723,15 @@
Distance
Lux
Vent
+ Vitesse du vent
+ Rafales de vent
+ Vent à la traîne
+ Direction du vent
+ Pluie (1h)
+ Pluie (24h)
Poids
Radiation
+ Températeur 1-Wire
Qualité de l'air intérieur (IAQ)
URL
@@ -677,6 +748,7 @@
Horodatage
En-tête
Vitesse
+ %1$d Km/h
Sats
Alt
Fréq
@@ -742,6 +814,11 @@
Afficher les points de repère
Afficher les cercles de précision
Notification client
+ Vérification de la clé
+ Requête de vérification de clé
+ Vérification de la clé terminée
+ Clé publique dupliquée détectée
+ Clé de chiffrement faible détectée
Clés compromises détectées, sélectionnez OK pour régénérer.
Régénérer la clé privée
Êtes-vous sûr de vouloir régénérer votre clé privée ?\n\nLes nœuds qui peuvent avoir précédemment échangé des clés avec ce nœud devront supprimer ce nœud et ré-échanger des clés afin de reprendre une communication sécurisée.
@@ -794,7 +871,14 @@
Composer un message
Métriques de PAX
PAX
+ PAX : %1$d
+ B:%1$d
+ W :%1$d
+ PAX : %1$s
+ BLE: %1$s
+ Wi-Fi : %1$s
Aucune métrique PAX disponible.
+ Approvisionnement Wi-Fi pour mPWRD-OS
Appareils Bluetooth
Périphérique connecté
Limite de débit dépassée. Veuillez réessayer plus tard.
@@ -849,6 +933,8 @@
Terrain
Hybride
Gérer les calques de la carte
+ Les calques personnalisés prennent en charge les fichiers .kml, .kmz ou GeoJSON.
+ Aucun calque personnalisé chargé.
Ajouter un calque
Afficher le calque
Supprimer le calque
@@ -856,6 +942,10 @@
Nœuds à cet emplacement
Type de carte sélectionné
Gérer les sources de tuiles personnalisées
+ Ajouter un réseau de tuile personnalisée
+ Aucune source de tuiles personnalisées trouvée.
+ Modifier le réseau de tuile personnalisée
+ Supprimer le réseau de tuile personnalisée
Le nom ne peut pas être vide.
Le nom du fournisseur existe déjà.
URL ne peut être vide.
@@ -949,6 +1039,7 @@
Notes de Version
Une erreur inconnue s'est produite
Les informations de l'utilisateur du nœud sont manquantes.
+ Batterie trop faible (%1$d%). Veuillez charger votre appareil avant de mettre à jour.
Impossible de récupérer le fichier firmware.
Échec de la mise à jour USB
Intégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB.
@@ -1025,8 +1116,10 @@
Configuration
Gérer à distance sans fil les paramètres et les canaux de votre appareil.
Sélection du style de carte
+ Batterie : %1$d%
Nœuds : %1$d en ligne / %2$d au total
Temps de disponibilité : %1$s
+ ChUtil: %1$s% | AirTX: %2$s%
Trafic : TX %1$d / RX %2$d (D: %3$d)
Relais : %1$d (annulé: %2$d)
Diagnostiques : %1$s
@@ -1040,11 +1133,94 @@
Actualiser
Mis à jour
+ Ajouter une couche de réseau
+ Fichier local MBTiles
+ Ajouter un fichier local MBTiles
+ TAK (ATAK)
+ Configuration TAK
+ Activer le serveur TAK local
+ Démarre un serveur TCP sur le port 8089 pour les connexions ATAK
+ Couleur de l'équipe
+ Rôle Membre
+ Non spécifié
+ Blanc
+ Jaune
+ Orange
+ Magenta
Rouge
+ Marron
+ Pourpre
+ Bleu foncé
Bleu
+ Cyan
+ Turquoise
Vert
+ Vert Foncé
+ Marron
+ Non spécifié
+ Membre de l'équipe
+ Chef d'équipe
+ Quartier général
+ Tireur d'élite
+ Medic
+ Observateur de transfert
+ Opérateur de radio téléphonie
+ Doggo (K9)
+ Gestion du trafic
+ Configuration de la gestion du trafic
Module activé
+ Déduplication de Position
+ Précision de position (octets)
+ Intervalle de position min (secs)
+ Réponse directe de NodeInfo
+ Max de saut pour une réponse directe
+ Limitation de débit
+ Fenêtre de limitation de taux (secs)
+ Paquets maximum dans la fenêtre
+ Ignorer les paquets inconnus
+ Seuil de paquets inconnu
+ Télémétrie locale uniquement (Relays)
+ Position locale uniquement (Relays)
+ Conserver les sauts du Routeur
+ Note
+ Stockage de l'appareil & UI (lecture seule)
+ Thème %1$s, Langue %2$s
+ Fichiers disponibles (%1$d ) :
+ - %1$s (%2$d octets)
+ Aucun fichier affiché.
Connecter
Terminé
+ Approvisionnement Wi-Fi pour mPWRD-OS
+ Fournissez les identifiants Wi-Fi à votre appareil mPWRD-OS via Bluetooth.
+ En savoir plus sur le projet mPWRD-OS\nhttps://github.com/mPWRD-OS
+ Recherche de l'appareil
+ Appareil détecté
+ Prêt à rechercher des réseaux WiFi.
+ Rechercher des réseaux
+ Recherche…
+ Application de la configuration WiFi…
+ Aucun réseau trouvé
+ Impossible de se connecter : %1$s
+ Échec de la recherche des réseaux WiFi : %1$s
+ %1$d%
+ Réseaux disponibles
+ Nom du réseau (SSID)
+ Saisir ou sélectionnez un réseau
+ WiFi configuré avec succès !
+ Impossible d'appliquer la configuration WiFi
+ Meshtastic application de bureau
+ Afficher Meshtastic
+ Quitter
Meshtastic
+ Exporter le paquet de données TAK
+ Filtre
+ Supprimer le filtre
+ Afficher le statut du message
+ Envoyer une réponse
+ Copier le message
+ Sélectionner le message
+ Supprimer le message
+ Réagir avec un emoji
+ Sélectionner l'appareil
+ Sélectionner le réseau
diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml
index a081daff2..baabf41d0 100644
--- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml
@@ -190,10 +190,7 @@
Cóid Poiblí Eochair
Mícomhoiriúnacht na heochrach phoiblí
Fógartha faoi na nodes nua
- Ráta Sigineal go Torann, tomhas a úsáidtear i gcomhfhreagras chun an leibhéal de shígnéil inmhianaithe agus torann cúlra a mheas. I Meshtastic agus i gcórais gan sreang eile, ciallaíonn SNR níos airde go bhfuil sígneál níos soiléire ann agus ábalta méadú ar chreideamh agus cáilíocht an tarchur sonraí.
- Táscaire Cumhachta Athnuachana Aithint an Aoise, tomhas a úsáidtear chun leibhéal cumhachta atá faighte ag an antsnáithe a mheas. Léiríonn RSSI níos airde gnóthachtáil níos laige atá i gceangal seasmhach agus níos láidre.
(Cáilíocht Aeir Inmheánach) scála ábhartha den luach QAÍ a thomhas ag Bosch BME680. Scála Luach 0–500.
- Léarscáil an Node
Rialachas
Rialú iargúlta
Go dona
@@ -213,6 +210,7 @@
Céimeanna i dtreo %1$d Céimeanna ar ais %2$d
Réigiún
+ Na ceangailte
Am tráth
Sáth
@@ -229,4 +227,5 @@
+ Scagaire
diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml
index cc3c02597..dc751d2e9 100644
--- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml
@@ -149,6 +149,7 @@
Sempre
Traza-ruta
Rexión
+ Desconectado
Distancia
@@ -164,4 +165,5 @@
+ Filtro
diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml
index 3afe39071..502d64056 100644
--- a/core/resources/src/commonMain/composeResources/values-he/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml
@@ -133,6 +133,7 @@
בדיקת מסלול
הודעות
אזור
+ מנותק
מרחק
הגדרות
@@ -147,4 +148,5 @@
+ פילטר
diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
index aae7d6690..114c3ed9a 100644
--- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
@@ -150,6 +150,7 @@
Detalji
Crveno
Regija
+ Odspojeno
Udaljenost
Meshtastic
@@ -168,4 +169,5 @@
Crveno
Meshtastic
+ Filtriraj
diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml
index 7c4fc0f24..60e00d491 100644
--- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml
@@ -186,10 +186,7 @@
Chifreman Kle Piblik
Pa matche kle piblik
Notifikasyon nouvo nœud
- Rapò Siynal sou Bri, yon mezi ki itilize nan kominikasyon pou mezire nivo siynal vle a kont nivo bri ki nan anviwònman an. Nan Meshtastic ak lòt sistèm san fil, yon SNR pi wo endike yon siynal pi klè ki ka amelyore fyab ak kalite transmisyon done.
- Endikatè Fòs Siynal Resevwa, yon mezi ki itilize pou detèmine nivo pouvwa siynal ki resevwa pa antèn nan. Yon RSSI pi wo jeneralman endike yon koneksyon pi fò ak plis estab.
(Kalite Lèy Entèryè) echèl relatif valè IAQ jan li mezire pa Bosch BME680. Ranje valè 0–500.
- Kat Nœud
Administrasyon
Administrasyon Remote
Move
@@ -201,6 +198,7 @@
Direk
Hops vèsus %1$d Hops tounen %2$d
Rejyon
+ Dekonekte
Tan pase
Distans
@@ -217,4 +215,5 @@
+ Filtre
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index f553a6a32..33b795a7f 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -316,12 +316,9 @@
Publikus kulcs nem egyezik
Új állomás értesítések
SNR
- Jel–zaj arány (SNR): a kommunikációban a kívánt jel szintjének és a háttérzaj szintjének aránya. A Meshtastic és más vezeték nélküli rendszerek esetében a magasabb SNR tisztább jelet jelent, ami javítja az adatátvitel megbízhatóságát és minőségét.
RSSI
- Vett jelerősség-mutató (RSSI): az antenna által vett jel teljesítményszintjének mérése. A magasabb RSSI általában erősebb, stabilabb kapcsolatot jelez.
(Beltéri levegőminőség) relatív IAQ érték a Bosch BME680 szenzor alapján. Értéktartomány: 0–500.
Eszközmetrikák
- Állomás Térkép
Pozíció
Utolsó pozíciófrissítés
Környezeti metrikák
@@ -515,6 +512,8 @@
MQTT figyelmen kívül hagyása
MQTT-re továbbítható
MQTT beállítások
+ Szétkapcsolva
+ Csatlakoztatva
MQTT engedélyezve
Cím
Felhasználónév
@@ -851,4 +850,5 @@
Zöld
Csatlakozás
Meshtastic
+ Filter
diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml
index 4e07e1c2a..ce8853250 100644
--- a/core/resources/src/commonMain/composeResources/values-is/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml
@@ -119,6 +119,7 @@
Hámarsksendingartíma náð. Ekki hægt að senda skilaboð, vinsamlegast reynið aftur síðar.
Ferilkönnun
Svæði
+ Aftengd
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 744741047..baa0e0947 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -355,12 +355,9 @@
Informazioni Utente
Notifiche di nuovi nodi
SNR
- Rapporto segnale-rumore (Signal-to-Noise Ratio), una misura utilizzata nelle comunicazioni per quantificare il livello di un segnale desiderato rispetto al livello di rumore di fondo. In Meshtastic e in altri sistemi wireless, un SNR più elevato indica un segnale più chiaro che può migliorare l'affidabilità e la qualità della trasmissione dei dati.
RSSI
- Indicatore di forza del segnale ricevuto (Received Signal Strength Indicator), una misura utilizzata per determinare il livello di potenza ricevuto dall'antenna. Un valore RSSI più elevato indica generalmente una connessione più forte e più stabile.
(Qualità dell'aria interna) scala relativa del valore della qualità dell'aria indoor, misurato da Bosch BME680. Valore Intervallo 0–500.
Metriche Dispositivo
- Mappa Dei Nodi
Posizione
Aggiornamento ultima posizione
Metriche Ambientali
@@ -560,6 +557,8 @@
Ignora MQTT
OK per MQTT
Configurazione MQTT
+ Disconnesso
+ Connesso
MQTT abilitato
Indirizzo
Username
@@ -961,4 +960,5 @@
Connetti
Fatto
Meshtastic
+ Filtro
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 59b54d2f5..64aa0fe05 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -275,11 +275,8 @@
公開キーが一致しません
新しいノードの通知
SN比
- 信号対ノイズ比(SN比)は、通信において、目的の信号のレベルを背景ノイズのレベルに対して定量化するために使用される尺度です。Meshtasticや他の無線システムでは、SN比が高いほど信号が鮮明であることを示し、データ伝送の信頼性と品質を向上させることができます。
RSSI
- 受信信号強度インジケーター(RSSI)は、アンテナで受信している電力レベルを測定するための指標です。一般的にRSSI値が高いほど、より強力で安定した接続を示します。
(屋内空気品質) 相対スケールIAQ値は、ボッシュBME680によって測定されます。 値の範囲は 0-500。
- ノードマップ
位置
管理
リモート管理
@@ -424,6 +421,8 @@
PAファン無効
MQTT を無視
MQTT設定
+ 切断
+ 接続済
MQTTを有効化
アドレス
ユーザー名
@@ -653,4 +652,5 @@
モジュール有効
接続
Meshtastic
+ 絞り込み
diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
index 0a5bc4031..914446a60 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -213,11 +213,8 @@
공개 키가 일치하지 않습니다
새로운 노드 알림
SNR
- 통신에서 원하는 신호의 수준을 배경 잡음의 수준과 비교하여 정량화하는 데 사용되는 신호 대 잡음비 Signal-to-Noise Ratio, SNR는 Meshtastic와 같은 무선 시스템에서 SNR이 높을수록 더 선명한 신호를 나타내어 데이터 전송의 안정성과 품질을 향상시킬 수 있습니다.
RSSI
- 수신 신호 강도 지표 Received Signal Strength Indicator, RSSI는 안테나가 수신하는 신호의 전력 수준을 측정하는 데 사용되는 지표입니다. RSSI 값이 높을수록 일반적으로 더 강력하고 안정적인 연결을 나타냅니다.
(실내공기질) Bosch BME680으로 측정한 상대적 척도 IAQ 값. 범위 0–500.
- 노드 지도
위치
최근 위치 업데이트
관리
@@ -373,6 +370,8 @@
PA fan 비활성화됨
MQTT로 부터 수신 무시
MQTT 설정
+ 연결 끊김
+ 연결됨
MQTT 활성화
서버 주소
사용자명
@@ -540,4 +539,5 @@
초록
연결
Meshtastic
+ 필터
diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
index 9592d8b14..33f5e4d59 100644
--- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
@@ -190,7 +190,6 @@
Naujo įtaiso pranešimas
SNR
RSSI
- Įtaisų žemėlapis
Administravimas
Nuotolinis administravimas
Silpnas
@@ -217,6 +216,7 @@
Skambučio simbolis!
Raudona
Regionas
+ Atsijungta
Viešasis raktas
Privatus raktas
Baigėsi laikas
@@ -237,4 +237,5 @@
Raudona
+ Filtras
diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
index ee07fb52b..b6972b6ec 100644
--- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
@@ -201,11 +201,8 @@
Publieke sleutel komt niet overeen
Nieuwe node meldingen
SNR
- Signal-to-Noise Ratio, een meeting die wordt gebruikt in de communicatie om het niveau van een gewenst signaal tegenover achtergrondlawaai te kwantificeren. In Meshtastische en andere draadloze systemen geeft een hoger SNR een zuiverder signaal aan dat de betrouwbaarheid en kwaliteit van de gegevensoverdracht kan verbeteren.
RSSI
- Ontvangen Signal Sterkte Indicator, een meting gebruikt om het stroomniveau te bepalen dat de antenne ontvangt. Een hogere RSSI-waarde geeft een sterkere en stabielere verbinding aan.
(Binnenluchtkwaliteit) relatieve schaal IAQ waarde gemeten door Bosch BME680. Waarde tussen 0 en 500.
- Node Kaart
Positie
Beheer
Extern beheer
@@ -312,6 +309,8 @@
Inkomende negeren
Negeer MQTT
MQTT Configuratie
+ Niet verbonden
+ Verbonden
MQTT ingeschakeld
Adres
Gebruikersnaam
@@ -416,4 +415,5 @@
Blauw
Groen
Verbinding maken
+ Filter
diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml
index 2ecd2a425..cd00c43e2 100644
--- a/core/resources/src/commonMain/composeResources/values-no/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml
@@ -194,11 +194,8 @@
Direktemeldinger bruker den nye offentlige nøkkelinfrastrukturen for kryptering. Krever firmware versjon 2.5 eller høyere.
Varsel om nye noder
SNR
- Signal-to-Noise Ratio, et mål som brukes i kommunikasjon for å sette nivået av et ønsket signal til bakgrunnstrøynivået. I Meshtastic og andre trådløse systemer tyder et høyere SNR på et klarere signal som kan forbedre påliteligheten og kvaliteten på dataoverføringen.
RSSI
- \"Received Signal Strength Indicator\", en måling som brukes til å bestemme strømnivået som mottas av antennen. Høyere RSSI verdi indikerer generelt en sterkere og mer stabil forbindelse.
(Innendørs luftkvalitet) relativ skala IAQ-verdi målt ved Bosch BME680. Verdi 0–500.
- Nodekart
Administrasjon
Fjernadministrasjon
Dårlig
@@ -222,6 +219,7 @@
Kopier
Varsel, bjellekarakter!
Region
+ Frakoblet
Offentlig nøkkel
Privat nøkkel
Tidsavbrudd
@@ -240,4 +238,5 @@
+ Filter
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index 272f515f8..7c9b3433b 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -333,12 +333,9 @@
Informacje o użytkowniku
Powiadomienia o nowych węzłach
SNR:
- Współczynnik sygnału do szumu (Signal-to-Noise Ratio) - miara stosowana w komunikacji do określania poziomu pożądanego sygnału w stosunku do poziomu szumu tła. W Meshtastic i innych systemach bezprzewodowych wyższy współczynnik SNR oznacza czystszy sygnał, który może zwiększyć niezawodność i jakość transmisji danych.
RSSI:
- Received Signal Strength Indicator - miara używana do określenia poziomu mocy odbieranej przez antenę. Wyższa wartość RSSI zazwyczaj oznacza silniejsze i bardziej stabilne połączenie.
Jakość powietrza w pomieszczeniach (Indoor Air Quality) - wartość względna w skali IAQ mierzona czujnikiem BME680. Zakres wartości: 0–500.
Metryka urządzenia
- Ślad na mapie
Pozycjonowanie
Ostatnia aktualizacja lokalizacji
Metryki środowiskowe
@@ -499,6 +496,8 @@
Zignoruj MQTT
Ok dla MQTT
Konfiguracja MQTT
+ Rozłączono
+ Połączony
Włącz MQTT
Adres
Nazwa użytkownika
@@ -750,4 +749,5 @@
Połącz
Wykonano
Meshtastic
+ Filtr
diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
index 521a84b48..ac97b091c 100644
--- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
@@ -230,11 +230,8 @@
Chave pública não confere
Novas notificações de nó
SNR
- Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado para o nível de ruído de fundo. Na Meshtastic e outros sistemas sem fios, uma SNR maior indica um sinal mais claro que pode melhorar a confiabilidade e a qualidade da transmissão de dados.
RSSI
- Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de potência que está sendo recebida pela antena. Um valor maior de RSSI geralmente indica uma conexão mais forte e mais estável.
(Qualidade do ar interior) valor relativo da escala IAQ medido pelo Bosch BME680. Intervalo de Valor de 0–500.
- Mapa do nó
Posição
Atualização da última posição
Administração
@@ -382,6 +379,8 @@
Ventilador do PA desativado
Ignorar MQTT
Configurações MQTT
+ Desconectado
+ Conectado
MQTT habilitado
Endereço
Nome de usuário
@@ -666,4 +665,5 @@
Verde
Concluído
Meshtastic
+ Filtro
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 545bd4e6f..a00bce554 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -219,11 +219,8 @@
Incompatibilidade de chave pública
Notificações de novos nodes
SNR
- Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado com o nível de ruído de fundo. Em Meshtastic e outros sistemas sem fio. Quanto mais alta for a relação sinal-ruído, menor é o efeito do ruído de fundo sobre a deteção ou medição do sinal.
RSSI
- Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de energia que está a ser recebido pela antena. Um valor mais elevado de RSSI geralmente indica uma conexão mais forte e mais estável.
(Qualidade do ar interior) valor relativo da escala IAQ conforme medida por Bosch BME680. Entre 0–500.
- Mapa de nodes
Posição
Administração
Administração Remota
@@ -366,6 +363,8 @@
Ignorar entrada
Ignorar MQTT
Configuração MQTT
+ Desconectado
+ Ligado
MQTT ativo
Endereço
Utilizador
@@ -516,4 +515,5 @@
Verde
Ligar
Nome do nó de alternativo
+ Filtrar
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 984a939d8..f9787ba93 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -376,12 +376,9 @@
Info utilizator
Notificări noduri noi
SNR
- Raportul semnal-zgomot (Signal-to-Noise Ratio), o măsură utilizată în comunicații pentru a cuantifica nivelul unui semnal dorit în raport cu nivelul zgomotului de fond. În Meshtastic și în alte sisteme wireless, un SNR mai mare indică un semnal mai clar, care poate îmbunătăți fiabilitatea și calitatea transmiterii datelor.
RSSI
- Indicatorul intensității semnalului recepționat (Received Signal Strength Indicator), o măsurătoare utilizată pentru a determina nivelul de putere recepționat de antenă. O valoare RSSI mai mare indică, în general, o conexiune mai puternică și mai stabilă.
(Calitatea aerului interior) valoarea IAQ pe o scară relativă, măsurată cu Bosch BME680. Intervalul valorilor: 0–500.
Valori dispozitiv
- Harta nodurilor
Poziție
Ultima actualizare a poziției
Indicatori de mediu
@@ -424,7 +421,6 @@
1W
2W
Maxim
- Medie
Extindeți graficul
Restrânge graficul
Vârstă necunoscută
@@ -599,6 +595,8 @@
Ignoră MQTT
Acceptă MQTT
Configurare MQTT
+ Deconectat
+ Conectat
MQTT activat
Adresă
Nume de utilizator
@@ -1169,4 +1167,5 @@
WiFi configurat cu succes!
Nu s-a reușit aplicarea configurației Wi-Fi
Meshtastic
+ Filtru
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index dd0d4a53f..8d4590e82 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -389,12 +389,9 @@
Пользовательская информация
Уведомления о новых нодах
Сигнал/шум
- Соотношение сигнал/шум, мера, используемая в коммуникациях для количественной оценки уровня желаемого сигнала по отношению к уровню фонового шума. В Meshtastic и других беспроводных системах более высокий SNR указывает на более четкий сигнал, который может повысить надежность и качество передачи данных.
RSSI
- Индикатор уровня принимаемого сигнала, измерение, используемое для определения уровня мощности, принимаемой антенной. Более высокое значение RSSI обычно указывает на более сильное и стабильное соединение.
(Качество воздуха в помещении) Относительная шкала IAQ, измеренная Bosch BME680. Диапазон значений 0–500.
Интервал передачи
- Карта нод
Местоположение
Обновление последнего местоположения
Метрики окружения
@@ -443,7 +440,6 @@
1М
Макс
Мин
- Сред
Развернуть диаграмму
Свернуть диаграмму
Неизвестный возраст
@@ -621,6 +617,23 @@
Игнорировать MQTT
ОК в MQTT
Настройка MQTT
+ Неактивно
+ Отключено
+ Отключено — %1$s
+ Подключение...
+ Подключено
+ Переподключение...
+ Переподключение (попытка %1$d) — %2$s
+ Проверить соединение
+ Проверяем брокер…
+ Доступно. Брокер принял учетные данные.
+ Доступно (%1$s)
+ Брокер отклонен: %1$s
+ Узел не найден
+ Не удается подключиться к брокеру (TCP)
+ Сбой TLS-рукопожатия
+ Тайм-аут после %1$d мс
+ Соединение не удалось
MQTT включен
Адрес
Имя пользователя
@@ -1224,5 +1237,21 @@
Введите или выберите сеть
Wi-Fi успешно настроен!
Не удалось применить настройку Wi-Fi
+ Meshtastic Desktop
+ Показать Meshtastic
+ Выход
Meshtastic
+ Экспорт пакета данных TAK
+ Очистить часовой пояс
+ Фильтр
+ Удалить фильтр
+ Показать легенду качества воздуха
+ Показать статус сообщения
+ Отправить ответ
+ Скопировать сообщение
+ Выбрать сообщение
+ Удалить сообщение
+ Отреагировать эмодзи
+ Выберите устройство
+ Выбрать сеть
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index e51ef506d..6beec1a74 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -257,11 +257,8 @@
Nezhoda verejného kľúča
Notifikácie nových uzlov
SNR
- Pomer signálu od šumu (SNR), miera používaná v komunikácii na kvantifikáciu úrovne požadovaného signálu k úrovni hluku pozadia. V Meshtastic a iných bezdrôtových systémoch znamená vyšší SNR jasnejší signál, ktorý môže zvýšiť spoľahlivosť a kvalitu prenosu údajov.
RSSI
- Indikátor sily prijímaného signálu (RSSI), meranie používané na určenie úrovne výkonu prijatého skrz anténu. Vyššia hodnota RSSI vo všeobecnosti znamená silnejšie a stabilnejšie pripojenie.
(Kvalita vzduchu v interiéri) relatívna hodnota IAQ meraná prístrojom Bosch BME680. Rozsah hodnôt 0–500.
- Mapa uzlov
Pozícia
Administrácia
Administrácia na diaľku
@@ -362,6 +359,8 @@
LoRa
Šírka pásma
Región
+ Odpojené
+ Pripojený
Adresa
Používateľské meno
Heslo
@@ -428,4 +427,5 @@
Modrá
Zelená
Meshtastic
+ Filter
diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml
index 8025c4751..bff8e6150 100644
--- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml
@@ -196,11 +196,8 @@
Neujemanje javnega ključa
Obvestila novih vozlišč
SNR
- Razmerje med signalom in šumom je merilo, ki se uporablja v komunikacijah za količinsko opredelitev ravni želenega signala glede na raven hrupa v ozadju. V Meshtastic in drugih brezžičnih sistemih višji SNR pomeni jasnejši signal, ki lahko poveča zanesljivost in kakovost prenosa podatkov.
RSSI
- Indikator moči sprejetega signala je meritev, ki se uporablja za določanje ravni moči, ki jo sprejema antena. Višja vrednost RSSI na splošno pomeni močnejšo in stabilnejšo povezavo.
(Kakovost zraka v zaprtih prostorih) relativna vrednost IAQ na lestvici, izmerjena z Bosch BME680. Razpon vrednosti 0–500.
- Zemljevid vozlišč
Administracija
Administracija na daljavo
Slab
@@ -226,6 +223,7 @@
Kopiraj
Znak opozorilnega zvonca!
Regija
+ Prekinjeno
Javni ključ
Zasebni ključ
Časovna omejitev
@@ -244,4 +242,5 @@
+ Filter
diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml
index e70391f4d..edfac59b0 100644
--- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml
@@ -186,10 +186,7 @@
Kriptimi me Çelës Publik
Përputhje e Gabuar e Çelësit Publik
Njoftimet për nyje të reja
- Raporti i Sinjalit në Zhurmë, një masë e përdorur në komunikime për të kuantifikuar nivelin e një sinjali të dëshiruar ndaj nivelit të zhurmës në background. Në Meshtastic dhe sisteme të tjera pa tel, një SNR më i lartë tregon një sinjal më të pastër që mund të rrisë besueshmërinë dhe cilësinë e transmetimit të të dhënave.
- Indikatori i Fuqisë së Sinjalit të Marrë, një matje e përdorur për të përcaktuar nivelin e energjisë që po merret nga antena. Një vlerë më e lartë RSSI zakonisht tregon një lidhje më të fortë dhe më të qëndrueshme.
(Cilësia e Ajrit të Brendshëm) shkalla relative e vlerës IAQ siç matet nga Bosch BME680. Intervali i Vlerave 0–500.
- Harta e Nyjës
Administratë
Administratë e Largët
I Keq
@@ -202,6 +199,7 @@
Hops drejt %1$d Hops prapa %2$d
訊息
Rajon
+ I shkëputur
Koha e skaduar
Distanca
@@ -218,4 +216,5 @@
+ Filtrimi
diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
index 21a9c14c1..a365fc888 100644
--- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
@@ -241,12 +241,9 @@
Неусаглашеност јавних кључева
Обавештење о новом чвору
SNR
- Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података.
RSSI
- Indikator jačine primljenog signala RSSI, merenje koje se koristi za određivanje nivoa snage koji antena prima. Viša vrednost RSSI generalno ukazuje na jaču i stabilniju vezu.
(Kvalitet vazduha u zatvorenom prostoru) relativna skala vrednosti IAQ merena Bosch BME680. Raspon vrednosti 0–500.
Метрика уређаја
- Mapa čvorova
Позиција
Метрике сензора
Administracija
@@ -349,6 +346,8 @@
Игнориши MQTT
Позитиван за MQTT
MQTT подешавања
+ Raskačeno
+ Блутут повезан
Адреса
Корисничко име
Лозинка
@@ -430,4 +429,5 @@
Блутут
Напајано
+ Filter
diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
index 516a963f4..5bfbb0a84 100644
--- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
@@ -241,12 +241,9 @@
Неусаглашеност јавних кључева
Обавештења о новим чворовима
SNR
- Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података.
RSSI
- Индикатор јачине примљеног сигнала RSSI је мера која се користи за одређивање нивоа снаге која се прима преко антене. Виша вредност RSSI генерално указује на јачу и стабилнију везу.
Индекс квалитета ваздуха (IAQ) као мера за одређивање квалитета ваздуха унутрашњости, мерен са Bosch BME680. Вредности се крећу у распону од 0 до 500.
Метрика уређаја
- Мапа чворова
Позиција
Метрике сензора
Администрација
@@ -349,6 +346,8 @@
Игнориши MQTT
Позитиван за MQTT
MQTT подешавања
+ Раскачено
+ Блутут повезан
Адреса
Корисничко име
Лозинка
@@ -430,4 +429,5 @@
Блутут
Напајано
+ Филтер
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 27f368d7e..59e19f1e5 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -43,6 +43,7 @@
Okänd
Inväntar kvittens
Kvittens köad
+ Levererad till nät
Okänd
Kvitterad
Ingen rutt
@@ -339,12 +340,9 @@
Användarinfo
Ny nod avisering
SNR
- Signal-to-Noise Ratio, är ett mått som används inom kommunikation för att kvantifiera nivån av en önskad signal mot nivån av bakgrundsbrus. I Meshtastic och andra trådlösa system indikerar en högre SNR en tydligare signal som kan förbättra tillförlitligheten och kvaliteten på dataöverföringen.
RSSI
- Received Signal Strength Indicator, ett mått som används för att avgöra effektnivån som togs emot av antennen. Ett högre RSSI-värde indikerar generellt en starkare och stabilare anslutning.
(Indoor Air Quality) relativ skala IAQ värdet mätt med Bosch BME600. Värdeintervall 0-500.
Enhetens mätvärden
- Nod karta
Plats
Senaste positionsuppdatering
Miljövärden
@@ -373,6 +371,7 @@
Varaktighet: %1$s s
Rutt spårad mot destination:\n\n
Rutten spårad tillbaka till oss:\n\n
+ Inget svar
1h
24T
1V
@@ -529,6 +528,10 @@
Ignorera MQTT
Ok till MQTT
MQTT-konfiguration
+ Frånkopplad
+ Ansluten
+ Testa anslutningen
+ Anslutningen misslyckades
MQTT är aktiverat
Adress
Användarnamn
@@ -946,4 +949,6 @@
Anslut
Klart
Meshtastic
+ Filter
+ Välj enhet
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index a3ae53c8c..75a9e3a5d 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -213,11 +213,8 @@
Genel Anahtar Uyuşmazlığı
Yeni düğüm bildirimleri
SNR
- Sinyal-Gürültü Oranı, iletişimde istenen bir sinyalin seviyesini arka plan gürültüsü seviyesine mukayese ölçmek için kullanılan bir ölçüdür. Meshtastic ve diğer kablosuz sistemlerde, daha yüksek bir SNR, veri iletiminin güvenilirliğini ve kalitesini artırabilecek daha net bir sinyale işaret eder.
RSSI
- Alınan Sinyal Gücü Göstergesi, anten tarafından alınan güç seviyesini belirlemek için kullanılan bir ölçüdür. Daha yüksek bir RSSI değeri genellikle daha güçlü ve daha istikrarlı bir bağlantıya işaret eder.
(İç Hava Kalitesi) Bosch BME680 tarafından ölçülen bağıl ölçekli IAQ değeri. Değer Aralığı 0–500.
- Düğüm Haritası
Konum
Yönetim
Uzaktan Yönetim
@@ -366,6 +363,8 @@
PA fanı devre dışı
MQTT'yi Yoksay
MQTT Yapılandırması
+ Bağlantı kesildi
+ Bağlandı
MQTT etkin
Adres
Kullanıcı adı
@@ -547,4 +546,5 @@
Yeşil
Bağlan
Meshtastic
+ Filtre
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 3e96490dc..c9a86af43 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -277,9 +277,7 @@
Сповіщення про нові вузли
SNR
RSSI
- Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення RSSI зазвичай вказує на міцніше та стабільніше з'єднання.
Показники пристрою
- Мапа вузлів
Місцезнаходження
Показники довкілля
Адміністрування
@@ -401,6 +399,9 @@
Перевизначити частоту
Ігнорувати MQTT
Налаштування MQTT
+ Відключено
+ Під’єднано
+ Перевірка зʼєднання
MQTT увімкнений
Адреса
Ім'я користувача
@@ -722,4 +723,6 @@
Під’єднатися
Готово
Meshtastic
+ Фільтри
+ Оберіть пристрій
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index 87feeb9e2..7fff0db20 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -362,12 +362,9 @@
用户信息
新节点通知
SNR
- 信噪比(Signal-to-Noise Ratio, SNR)是一种用于通信领域的测量指标,用于量化目标信号与背景噪声的比例。在 Meshtastic 及其他无线系统中,较高的信噪比表示信号更加清晰,从而能够提升数据传输的可靠性和质量。
RSSI
- 接收信号强度指示(Received Signal Strength Indicator, RSSI)是一种用于测量天线接收到的信号功率的指标。较高的 RSSI 值通常表示更强、更稳定的连接。
室内空气质量(Indoor Air Quality, IAQ):由 Bosch BME680 传感器测量的相对标尺 IAQ 值,取值范围为 0–500。
设备指标
- 节点地图
定位
最后位置更新
传感器指标
@@ -568,6 +565,9 @@
忽略 MQTT
使用MQTT
MQTT设置
+ 已断开连接
+ 已连接
+ 连接测试
启用MQTT
地址
用户名
@@ -1115,4 +1115,6 @@
连接
完成
Meshtastic
+ 搜索节点
+ 选择设备
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 354415089..20ee6c639 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -45,6 +45,7 @@
無法識別
正在等待確認
發送佇列中
+ 已傳送至 Mesh
不明
透過 SF++ 鏈路由…
已在 SF++ 鏈上確認
@@ -122,6 +123,7 @@
嘗試獲取 GPS 位置的頻率(< 10 秒將保持 GPS 模組開啟)。
位置訊息可選的附加欄位。包含的欄位越多,訊息越大,將造成空中時間拉長和封包遺失的風險增加。
將盡可能使所有元件進入睡眠狀態。對於 tracker 和 sensor 角色,此模式將包含 LoRa 無線電。如果您想搭配手機應用程式使用設備,或正在使用沒有使用者按鈕的設備,請勿啟用此設定。
+ 從您的私鑰生成並傳送給網狀網路中的其他節點,以供它們計算出共享密鑰。
用於與遠端設備交換密鑰。
被授權可對此節點發送管理訊息的公鑰。
設備處於受管理狀態,使用者無法變更任何設備設定。
@@ -220,6 +222,11 @@
%1$s: %2$s
來自 %1$s 的訊息:%2$s
標頭
+ 標尾
+ 點形
+ 文字
+ 儀表板
+ 梯度
這是一個一個一個可客製化的組合元件
還支援多行文字與多種樣式
訊息傳遞狀態
@@ -371,12 +378,9 @@
使用者資訊
新節點通知
SNR
- 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。
RSSI
- 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。
(室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。
裝置計量資料
- 節點地圖
位置
最後位置更新
環境計量資料
@@ -408,6 +412,12 @@
回程跳數
來回跳數
無回應
+ 1分鐘負載
+ 5分鐘負載
+ 15分鐘負載
+ 1分鐘系統負載平均值
+ 5分鐘系統負載平均值
+ 15分鐘系統負載平均值
可用系統記憶體(位元組)
1小時
二十四小時
@@ -416,7 +426,6 @@
1個月
最大值
最小
- 平均
展開圖表
收起圖表
未知年齡
@@ -594,6 +603,23 @@
無視MQTT
允許轉發至 MQTT
MQTT配置
+ 已停用
+ 已中斷連線
+ 已斷線 — %1$s
+ 正在連接…
+ 已連線
+ 重新連接中…
+ 重新連接中(第 %1$d 次嘗試) — %2$s
+ 測試連線
+ 正在查詢 Broker…
+ 可供連線,Broker 已驗證並接受憑證。
+ 可供連線(%1$s)
+ Broker 遭拒:%1$s
+ 找不到伺服器
+ 無法連線至 Broker 中繼伺服器(TCP)
+ TLS 握手失敗
+ 經過 %1$d 毫秒後逾時
+ 測試失敗
啟用MQTT服務器
地址
用戶名
@@ -792,6 +818,9 @@
顯示路徑
顯示定位精準度
客户端通知
+ 金鑰驗證
+ 金鑰驗證請求
+ 金鑰驗證已完成
偵測到重複的公鑰
偵測到加密金鑰強度不足
偵測到金鑰已洩漏,點選確定後重新產生金鑰。
@@ -853,6 +882,7 @@
BLE: %1$s
WiFi: %1$s
無可用的 PAX 人流計量資料。
+ mPWRD-OS 的 Wi-Fi 設定
藍牙裝置
連接裝置
超過速率限制,請稍後再嘗試。
@@ -1158,6 +1188,9 @@
保留路由跳數
注意
裝置儲存空間與使用者介面(唯讀)
+ 主題 %1$s,語言 %2$s
+ 可使用檔案(%1$d):
+ - %1$s(%2$d 位元)
未發現任何檔案。
連線
完成
@@ -1166,10 +1199,34 @@
進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS
正在搜尋裝置…
找到裝置
+ 準備好掃描 Wi-Fi 網路了。
+ 搜尋網路
正在搜尋…
+ 正在套用 Wi-Fi 設定…
找不到網路
+ 無法連接:%1$s
+ 無法搜尋到 Wi-Fi 網路:%1$s
%1$d%
可用的網路
網路名稱(SSID)
+ 手動輸入或選擇一個網路
+ Wi-Fi 已設定完成!
+ 無法套用 Wi-Fi 設定
+ Meshtastic Desktop
+ 顯示 Meshtastic
+ 離開
Meshtastic
+ 匯出 TAK 資料封包
+ 清除時區
+ 過濾器
+ 移除篩選條件
+ 顯示空氣品質圖例
+ 顯示訊息狀態
+ 傳送回覆
+ 複製訊息
+ 選擇訊息
+ 刪除訊息
+ 使用表情符號回應
+ 選擇裝置
+ 選擇網路
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4748844c6..505d80821 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -408,12 +408,9 @@
User Info
New node notifications
SNR
- Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission.
RSSI
- Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.
(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500.
Device Metrics
- Node Map
Position
Last position update
Environment Metrics
@@ -460,7 +457,6 @@
1M
Max
Min
- Avg
Expand chart
Collapse chart
Unknown Age
@@ -638,6 +634,23 @@
Ignore MQTT
Ok to MQTT
MQTT Config
+ Inactive
+ Disconnected
+ Disconnected — %1$s
+ Connecting…
+ Connected
+ Reconnecting…
+ Reconnecting (attempt %1$d) — %2$s
+ Test connection
+ Probing broker…
+ Reachable. Broker accepted credentials.
+ Reachable (%1$s)
+ Broker rejected: %1$s
+ Host not found
+ Cannot reach broker (TCP)
+ TLS handshake failed
+ Timed out after %1$d ms
+ Connection failed
MQTT enabled
Address
Username
@@ -1265,4 +1278,18 @@
Show Meshtastic
Quit
Meshtastic
+ Export TAK Data Package
+ mPWRD-OS
+ Clear time zone
+ Filter
+ Remove filter
+ Show air quality legend
+ Show message status
+ Send reply
+ Copy message
+ Select message
+ Delete message
+ React with emoji
+ Select device
+ Select network
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt
new file mode 100644
index 000000000..ef8cac0ba
--- /dev/null
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.testing
+
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.repository.DeviceHardwareRepository
+
+/**
+ * A test double for [DeviceHardwareRepository] backed by an in-memory map keyed by `(hwModel, target)`.
+ *
+ * Call [setHardware] (or [setHardwareForModel]) to seed results, or [setResult] to control the exact [Result] returned
+ * for a given lookup. By default, lookups return `Result.success(null)`.
+ */
+class FakeDeviceHardwareRepository :
+ BaseFake(),
+ DeviceHardwareRepository {
+
+ private val hardware = mutableMapOf, Result>()
+ private val calls = mutableListOf>()
+
+ init {
+ registerResetAction {
+ hardware.clear()
+ calls.clear()
+ }
+ }
+
+ /** Records every [getDeviceHardwareByModel] invocation for assertion. */
+ val recordedCalls: List>
+ get() = calls.toList()
+
+ override suspend fun getDeviceHardwareByModel(
+ hwModel: Int,
+ target: String?,
+ forceRefresh: Boolean,
+ ): Result {
+ calls.add(Triple(hwModel, target, forceRefresh))
+ return hardware[hwModel to target] ?: hardware[hwModel to null] ?: Result.success(null)
+ }
+
+ /** Seeds a successful lookup for the given model/target pair. */
+ fun setHardware(hwModel: Int, target: String? = null, device: DeviceHardware?) {
+ hardware[hwModel to target] = Result.success(device)
+ }
+
+ /** Seeds a successful lookup for any target of the given model. */
+ fun setHardwareForModel(hwModel: Int, device: DeviceHardware?) {
+ hardware[hwModel to null] = Result.success(device)
+ }
+
+ /** Seeds an arbitrary [Result] for the given lookup (use to test failure paths). */
+ fun setResult(hwModel: Int, target: String? = null, result: Result) {
+ hardware[hwModel to target] = result
+ }
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt
new file mode 100644
index 000000000..166256764
--- /dev/null
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.testing
+
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.database.entity.FirmwareRelease
+import org.meshtastic.core.repository.FirmwareReleaseRepository
+
+/**
+ * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as
+ * [kotlinx.coroutines.flow.MutableStateFlow]s.
+ *
+ * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values.
+ */
+class FakeFirmwareReleaseRepository :
+ BaseFake(),
+ FirmwareReleaseRepository {
+
+ private val _stableRelease = mutableStateFlow(null)
+ private val _alphaRelease = mutableStateFlow(null)
+
+ override val stableRelease: Flow = _stableRelease
+ override val alphaRelease: Flow = _alphaRelease
+
+ var invalidateCacheCalls: Int = 0
+ private set
+
+ init {
+ registerResetAction { invalidateCacheCalls = 0 }
+ }
+
+ override suspend fun invalidateCache() {
+ invalidateCacheCalls++
+ }
+
+ fun setStableRelease(release: FirmwareRelease?) {
+ _stableRelease.value = release
+ }
+
+ fun setAlphaRelease(release: FirmwareRelease?) {
+ _alphaRelease.value = release
+ }
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt
new file mode 100644
index 000000000..215542485
--- /dev/null
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.testing
+
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.database.entity.QuickChatAction
+import org.meshtastic.core.repository.QuickChatActionRepository
+
+/**
+ * A test double for [QuickChatActionRepository] that keeps actions in an in-memory list (sorted by `position`).
+ *
+ * The in-memory list is exposed reactively through [getAllActions].
+ */
+class FakeQuickChatActionRepository :
+ BaseFake(),
+ QuickChatActionRepository {
+
+ private val actionsFlow = mutableStateFlow>(emptyList())
+
+ override fun getAllActions(): Flow> = actionsFlow
+
+ override suspend fun upsert(action: QuickChatAction) {
+ val existingIndex = actionsFlow.value.indexOfFirst { it.uuid == action.uuid }
+ actionsFlow.value =
+ if (existingIndex >= 0) {
+ actionsFlow.value.toMutableList().also { it[existingIndex] = action }
+ } else {
+ actionsFlow.value + action
+ }
+ .sortedBy { it.position }
+ }
+
+ override suspend fun deleteAll() {
+ actionsFlow.value = emptyList()
+ }
+
+ override suspend fun delete(action: QuickChatAction) {
+ actionsFlow.value =
+ actionsFlow.value
+ .filterNot { it.uuid == action.uuid }
+ .map { if (it.position > action.position) it.copy(position = it.position - 1) else it }
+ }
+
+ override suspend fun setItemPosition(uuid: Long, newPos: Int) {
+ actionsFlow.value =
+ actionsFlow.value.map { if (it.uuid == uuid) it.copy(position = newPos) else it }.sortedBy { it.position }
+ }
+
+ /** Seeds the current list of actions (useful for test setup). */
+ fun setActions(actions: List) {
+ actionsFlow.value = actions.sortedBy { it.position }
+ }
+
+ /** Returns the current in-memory snapshot. */
+ val currentActions: List
+ get() = actionsFlow.value
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt
new file mode 100644
index 000000000..aa68e9b21
--- /dev/null
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.testing
+
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.repository.RadioConfigRepository
+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.DeviceUIConfig
+import org.meshtastic.proto.FileInfo
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfig
+
+/**
+ * A test double for [RadioConfigRepository] backed by in-memory [kotlinx.coroutines.flow.MutableStateFlow]s.
+ *
+ * All mutator methods update the underlying state flows synchronously so tests can observe changes immediately.
+ * [deviceProfileFlow] is derived from [localConfigFlow], [moduleConfigFlow], and the current channel set.
+ */
+@Suppress("TooManyFunctions")
+class FakeRadioConfigRepository :
+ BaseFake(),
+ RadioConfigRepository {
+
+ private val channelSetBacking = mutableStateFlow(ChannelSet())
+ override val channelSetFlow: Flow = channelSetBacking
+
+ private val localConfigBacking = mutableStateFlow(LocalConfig())
+ override val localConfigFlow: Flow = localConfigBacking
+
+ private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig())
+ override val moduleConfigFlow: Flow = moduleConfigBacking
+
+ private val deviceProfileBacking = mutableStateFlow(DeviceProfile())
+ override val deviceProfileFlow: Flow = deviceProfileBacking
+ val currentDeviceProfile: DeviceProfile
+ get() = deviceProfileBacking.value
+
+ private val deviceUIConfigBacking = mutableStateFlow(null)
+ override val deviceUIConfigFlow: Flow = deviceUIConfigBacking
+
+ private val fileManifestBacking = mutableStateFlow>(emptyList())
+ override val fileManifestFlow: Flow> = fileManifestBacking
+
+ val currentChannelSet: ChannelSet
+ get() = channelSetBacking.value
+
+ val currentLocalConfig: LocalConfig
+ get() = localConfigBacking.value
+
+ val currentModuleConfig: LocalModuleConfig
+ get() = moduleConfigBacking.value
+
+ val currentDeviceUIConfig: DeviceUIConfig?
+ get() = deviceUIConfigBacking.value
+
+ val currentFileManifest: List
+ get() = fileManifestBacking.value
+
+ /**
+ * Last [Config] passed to [setLocalConfig] (null until called). Tests should use [setLocalConfigDirect] to drive
+ * state.
+ */
+ var lastSetLocalConfig: Config? = null
+ private set
+
+ /** Last [ModuleConfig] passed to [setLocalModuleConfig] (null until called). */
+ var lastSetModuleConfig: ModuleConfig? = null
+ private set
+
+ init {
+ registerResetAction {
+ lastSetLocalConfig = null
+ lastSetModuleConfig = null
+ }
+ }
+
+ override suspend fun clearChannelSet() {
+ channelSetBacking.value = ChannelSet()
+ }
+
+ override suspend fun replaceAllSettings(settingsList: List) {
+ channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList)
+ }
+
+ override suspend fun updateChannelSettings(channel: Channel) {
+ val current = channelSetBacking.value.settings.toMutableList()
+ while (current.size <= channel.index) current.add(ChannelSettings())
+ current[channel.index] = channel.settings ?: ChannelSettings()
+ channelSetBacking.value = channelSetBacking.value.copy(settings = current)
+ }
+
+ override suspend fun clearLocalConfig() {
+ localConfigBacking.value = LocalConfig()
+ }
+
+ override suspend fun setLocalConfig(config: Config) {
+ lastSetLocalConfig = config
+ }
+
+ override suspend fun clearLocalModuleConfig() {
+ moduleConfigBacking.value = LocalModuleConfig()
+ }
+
+ override suspend fun setLocalModuleConfig(config: ModuleConfig) {
+ lastSetModuleConfig = config
+ }
+
+ override suspend fun setDeviceUIConfig(config: DeviceUIConfig) {
+ deviceUIConfigBacking.value = config
+ }
+
+ override suspend fun clearDeviceUIConfig() {
+ deviceUIConfigBacking.value = null
+ }
+
+ override suspend fun addFileInfo(info: FileInfo) {
+ fileManifestBacking.value = fileManifestBacking.value + info
+ }
+
+ override suspend fun clearFileManifest() {
+ fileManifestBacking.value = emptyList()
+ }
+
+ /** Directly sets the [LocalConfig] without merging (preferred for test setup). */
+ fun setLocalConfigDirect(config: LocalConfig) {
+ localConfigBacking.value = config
+ }
+
+ /** Directly sets the [LocalModuleConfig] without merging (preferred for test setup). */
+ fun setLocalModuleConfigDirect(config: LocalModuleConfig) {
+ moduleConfigBacking.value = config
+ }
+
+ /** Directly sets the combined [DeviceProfile] emitted by [deviceProfileFlow]. */
+ fun setDeviceProfile(profile: DeviceProfile) {
+ deviceProfileBacking.value = profile
+ }
+
+ /** Directly sets the [ChannelSet] (bypasses [updateChannelSettings]/[replaceAllSettings]). */
+ fun setChannelSet(channelSet: ChannelSet) {
+ channelSetBacking.value = channelSet
+ }
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt
new file mode 100644
index 000000000..a52b86bd0
--- /dev/null
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 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
+ * 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 .
+ */
+package org.meshtastic.core.testing
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.meshtastic.core.repository.TracerouteSnapshotRepository
+import org.meshtastic.proto.Position
+
+/**
+ * A test double for [TracerouteSnapshotRepository] keyed by `logUuid`.
+ *
+ * Use [upsertSnapshotPositions] as you would in production, or [seedSnapshot] to directly inject state for a log.
+ */
+class FakeTracerouteSnapshotRepository :
+ BaseFake(),
+ TracerouteSnapshotRepository {
+
+ private val snapshots = mutableStateFlow