Merge branch 'main' into features/lockdown-v2

This commit is contained in:
Nick
2026-06-03 12:06:56 -04:00
committed by GitHub
16 changed files with 247 additions and 308 deletions

View File

@@ -2,6 +2,13 @@
# Older handover entries rotated out of session_context.md. Not read by default.
# Consult only if you need historical detail on a specific past change.
## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
- Fixed a 404/Unknown inference error by updating `GeminiNanoDocAssistant.kt`'s `MODEL_NAME` from `"gemini-3.1-flash-lite"` to the correct Firebase AI Logic preview name `"gemini-3.1-flash-lite-preview"`.
- Overhauled multi-turn hybrid chat seeding: eliminated the redundant background `chat.sendMessage` call on the first turn; if the first turn is answered on-device, the session caches the Q&A locally and seeds the subsequent cloud-chat session via `startChat(history = ...)`.
- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
## 2026-05-20 — Decoupled and Isolated Flatpak manifest generation logic to build-logic/flatpak
- Isolated the optimized `GenerateFlatpakSourcesTask` from monolithic `build-logic/convention` into its own specialized, lightweight `:flatpak` subproject under `build-logic`.
- Created `:flatpak` configuration and registered the formal plugin ID `"meshtastic.flatpak"` implemented by `FlatpakConventionPlugin` inside the default package namespace.

View File

@@ -6,6 +6,12 @@
# the oldest entries to `session_context.archive.md` (not read by default). The
# "Golden Context" block at the bottom is stable across sessions; keep it here.
## 2026-06-03 — Cluster-marker FATAL: revert shipped map series + in-scope rememberComposeBitmapDescriptor fix
- Reverted ALL google-flavor map changes to before #5684 (per user): restored MapView.kt, NodeClusterMarkers.kt, WaypointMarkers.kt, InlineMap.kt to parent commit bc9f1637; deleted MarkerBitmapRenderer.kt; re-pinned `play-services-maps = 20.0.0` in libs.versions.toml. The shipped #5702#5719 series (Canvas markers + ViewTree-owner band-aids) had lost the info-window popups + interactions.
- Root cause (verified against maps-compose 8.3.0 + android-maps-utils 4.1.1 SOURCE in gradle cache): ONLY `Clustering(clusterItemContent=…)` crashes — its `ComposeUiClusterRenderer` builds a *detached* `InvalidatingComposeView` with a fake lifecycle owner and NO SavedStateRegistryOwner. `MarkerComposable` already bakes its icon via the safe in-scope `rememberComposeBitmapDescriptor`; info windows render with the live marker compositionContext. So InlineMap/NodeTrack/Traceroute were left untouched.
- Fix (NodeClusterMarkers.kt ONLY): icons baked in-scope via `rememberComposeBitmapDescriptor(node){ PulsingNodeChip }` into a snapshot stateMap; custom `private class NodeClusterRenderer : DefaultClusterRenderer` assigns them in onBeforeClusterItemRendered/onClusterItemUpdated (bg thread, READ-only — never composes, so the crash class is gone). Native info windows (super sets title/snippet) + onClusterItemInfoWindowClick→navigateToNodeDetails; precision circles drawn from the renderer's own `unclusteredItems` MutableState (clusterItemDecoration can't fire — `ClusterRendererItemState` is lib-internal). Strictly better than the elegant-euler Canvas branch — keeps the REAL Compose chip.
- `compileGoogleDebugKotlin` + `spotlessCheck` + `detekt` PASS. NOT committed, NOT device-verified. Next: device-test (clusters show chips + info-window popups + no FATAL), then commit/push.
## 2026-05-28 — Stabilized DatabaseManager withDb retry host test
- Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`.
- Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping.
@@ -33,13 +39,6 @@
- Refactored `GeminiNanoDocAssistant.answer` to reuse `answerStream` flow under the hood, eliminating duplicate prompting code.
- Verified that all unit tests (`:feature:docs:allTests`) and static analysis checks (`spotlessApply spotlessCheck detekt`) pass 100% green.
## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
- Fixed a 404/Unknown inference error by updating `GeminiNanoDocAssistant.kt`'s `MODEL_NAME` from `"gemini-3.1-flash-lite"` to the correct Firebase AI Logic preview name `"gemini-3.1-flash-lite-preview"`.
- Overhauled multi-turn hybrid chat seeding: eliminated the redundant background `chat.sendMessage` call on the first turn; if the first turn is answered on-device, the session caches the Q&A locally and seeds the subsequent cloud-chat session via `startChat(history = ...)`.
- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
## Golden Context (stable across sessions)
- Always check `.skills/compose-ui/strings-index.txt` before reading `strings.xml`.
- Run `python3 scripts/sort-strings.py` after adding strings to keep the index organized.

View File

@@ -9,10 +9,9 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases)
### Unreleased (not yet in any build)
* chore(agents): prune governance cruft and add Claude token guards (#5706) by James Rich (@jamesarich) in [`813acee71`](https://github.com/meshtastic/Meshtastic-Android/commit/813acee71604686dc4c98b43cf692fe4586cb5c5)
* fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view (#5708) by James Rich (@jamesarich) in [`1b661739e`](https://github.com/meshtastic/Meshtastic-Android/commit/1b661739e346f6d64ff06731ee2ac0bc80501d19)
* fix(map): apply kotlinx-serialization compiler plugin to androidApp (#5726) by James Rich (@jamesarich) in [`0e4f12ec0`](https://github.com/meshtastic/Meshtastic-Android/commit/0e4f12ec0dbf2d303d09574698e3ef3c73341cf6)
### Open Beta (v2.7.14-open.18)
### Open Beta (v2.7.14-open.21)
Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13):
#### 🏗️ Features
@@ -239,6 +238,11 @@ Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/relea
* fix(firmware): surface error state when BLE OTA connection attempts are exhausted by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5700
* fix(map): replace MarkerComposable with Canvas-rendered bitmaps by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5702
* fix(map): remove manual ViewTree lifecycle owner workarounds by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5704
* fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5708
* fix(map): initialize Maps SDK before building marker bitmap descriptors by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5709
* fix(map): eliminate cluster-renderer FATAL and harden black-map paths by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5715
* fix(map): revert app-side Maps SDK init to library-idiomatic, fix inline-map crash by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5719
* fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5723
#### 📝 Other Changes
* refactor(ui): compose resources, domain layer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4628
* Add per-message transport method icons for new message format by @Kealper in https://github.com/meshtastic/Meshtastic-Android/pull/4643

View File

@@ -26,6 +26,7 @@ plugins {
alias(libs.plugins.meshtastic.android.application)
alias(libs.plugins.meshtastic.android.application.flavors)
alias(libs.plugins.meshtastic.android.application.compose)
alias(libs.plugins.meshtastic.kotlinx.serialization)
id("meshtastic.koin")
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.secrets)

View File

@@ -78,7 +78,7 @@ import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
@@ -102,7 +102,6 @@ import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -126,6 +125,7 @@ import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -864,17 +864,17 @@ private fun NodeTrackOverlay(
}
if (index == sortedPositions.lastIndex) {
val chipIcon = rememberNodeChipDescriptor(focusedNode)
Marker(
MarkerComposable(
state = markerState,
icon = chipIcon,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
)
) {
NodeChip(node = focusedNode)
}
} else {
MarkerInfoWindowComposable(
state = markerState,
@@ -969,6 +969,7 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
// region --- Traceroute Map Content ---
@OptIn(MapsComposeExperimentalApi::class)
@Composable
private fun TracerouteMapContent(
forwardOffsetPoints: List<LatLng>,
@@ -997,8 +998,7 @@ private fun TracerouteMapContent(
}
displayNodes.forEach { node ->
val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
val chipIcon = rememberNodeChipDescriptor(node)
Marker(state = markerState, icon = chipIcon, zIndex = 4f)
MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
}
}

View File

@@ -1,144 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.text.TextPaint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.core.graphics.createBitmap
import com.google.android.gms.maps.MapsInitializer
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import org.meshtastic.core.model.Node
private const val CHIP_CORNER_RADIUS_DP = 4f
private const val CHIP_PADDING_HORIZONTAL_DP = 8f
private const val CHIP_MIN_WIDTH_DP = 64f
private const val CHIP_MIN_HEIGHT_DP = 28f
private const val CHIP_TEXT_SIZE_SP = 14f
private const val EMOJI_TEXT_SIZE_SP = 32f
private const val EMOJI_PADDING_DP = 2f
/**
* Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in
* maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was
* measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875).
*/
@Composable
fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
val context = LocalContext.current
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
ensureMapsInitialized(context)
renderNodeChipBitmap(node, density, fontScale)
}
}
/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
@Composable
fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
val context = LocalContext.current
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(codePoint) {
ensureMapsInitialized(context)
renderEmojiBitmap(codePoint, density, fontScale)
}
}
/**
* [BitmapDescriptorFactory] only works after the Maps SDK has been initialized, which normally happens when a
* GoogleMap/MapView is created. These descriptors are built during composition, and on the node-detail inline map the
* icon is computed before that screen's GoogleMap has loaded the SDK — so [BitmapDescriptorFactory.fromBitmap] crashes
* with "IBitmapDescriptorFactory is not initialized". Initialize explicitly first; [MapsInitializer.initialize] is
* synchronous and idempotent, so it is a no-op once the SDK is already up.
*/
@Suppress("DEPRECATION")
private fun ensureMapsInitialized(context: Context) {
MapsInitializer.initialize(context)
}
private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
val (textColorInt, nodeColorInt) = node.colors
val scaledDensity = density * fontScale
val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = CHIP_TEXT_SIZE_SP * scaledDensity
typeface = Typeface.DEFAULT_BOLD
color = textColorInt
textAlign = Paint.Align.CENTER
isStrikeThruText = node.isIgnored
}
val label = node.user.short_name.ifEmpty { "???" }
val textWidth = textPaint.measureText(label)
val paddingH = CHIP_PADDING_HORIZONTAL_DP * density
val minWidth = CHIP_MIN_WIDTH_DP * density
val minHeight = CHIP_MIN_HEIGHT_DP * density
val width = maxOf(minWidth, textWidth + paddingH * 2).toInt()
val height = minHeight.toInt()
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt }
val cornerRadius = CHIP_CORNER_RADIUS_DP * density
canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint)
val textX = width / 2f
val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
canvas.drawText(label, textX, textY, textPaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor {
val scaledDensity = density * fontScale
val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = EMOJI_TEXT_SIZE_SP * scaledDensity
textAlign = Paint.Align.CENTER
}
val emoji = String(Character.toChars(codePoint))
val padding = EMOJI_PADDING_DP * density
val textWidth = textPaint.measureText(emoji)
val metrics = textPaint.fontMetrics
val textHeight = metrics.descent - metrics.ascent
val width = (textWidth + padding * 2).toInt().coerceAtLeast(1)
val height = (textHeight + padding * 2).toInt().coerceAtLeast(1)
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val textX = width / 2f
val textY = padding - metrics.ascent
canvas.drawText(emoji, textX, textY, textPaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}

View File

@@ -16,28 +16,49 @@
*/
package org.meshtastic.app.map.component
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.currentStateAsState
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import androidx.compose.ui.platform.LocalContext
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.Marker
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import com.google.maps.android.compose.clustering.rememberClusterManager
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
private const val MIN_CLUSTER_SIZE = 10
/**
* Renders node markers with clustering.
*
* Marker bitmaps are generated **in the maps compose scope** via [rememberComposeBitmapDescriptor], which composes each
* chip in a `ComposeView` parented to the live host view (a real, attached view that has valid `ViewTreeLifecycleOwner`
* / `SavedStateRegistryOwner`) and renders it synchronously to a [BitmapDescriptor].
*
* This deliberately avoids the clustering library's `clusterItemContent` path: that renderer
* ([com.google.maps.android.compose.clustering.ComposeUiClusterRenderer]) composes each item in a *detached*
* `ComposeView` that only carries a fake lifecycle owner and no `SavedStateRegistryOwner`, so it crashes when the
* surrounding Navigation 3 / popup hierarchy lacks those owners (the top Crashlytics FATAL). A custom
* [DefaultClusterRenderer] subclass assigns the pre-baked bitmaps instead, keeping native info windows (title/snippet
* from [NodeClusterItem]) and click interactions intact.
*/
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")
@Composable
@@ -47,68 +68,99 @@ fun NodeClusterMarkers(
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
val view = LocalView.current
val lifecycleOwner = LocalLifecycleOwner.current
val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
val context = LocalContext.current
val clusterManager = rememberClusterManager<NodeClusterItem>()
// maps-compose renders each non-clustered item to a bitmap through an off-screen ComposeView that
// it attaches under the MapView (see ComposeUiClusterRenderer + NoDrawContainerView in
// MapComposeViewRender). That ComposeView walks up the view tree for a ViewTreeLifecycleOwner and,
// when it finds none, crashes with "Composed into the View which doesn't propagate
// ViewTreeLifecycleOwner!" (googlemaps/android-maps-compose#875 / #325) — a FATAL on the map screen.
//
// Propagate the owners onto this map screen's host view (LocalView.current), which is an ancestor
// of the internally-created MapView, so the renderer's ComposeView can resolve them. We deliberately
// do NOT touch view.rootView (the activity root): attaching a transient NavEntry lifecycle there is
// what caused the node-list popup regression (#5684), which is why #5704 removed the prior, broader
// workaround entirely. Scoping to the map host view and restoring the previous owners on dispose
// keeps the fix local to the map and leaves Popups/DropdownMenus untouched.
DisposableEffect(view, lifecycleOwner, savedStateRegistryOwner) {
val prevLifecycleOwner = view.findViewTreeLifecycleOwner()
val prevSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner()
view.setViewTreeLifecycleOwner(lifecycleOwner)
view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
onDispose {
view.setViewTreeLifecycleOwner(prevLifecycleOwner)
view.setViewTreeSavedStateRegistryOwner(prevSavedStateRegistryOwner)
// Bake each node's marker icon in-scope. Keyed by node so a bitmap is only re-rendered when that node
// actually changes. The descriptors are stashed in a snapshot map the renderer reads at render time.
val iconDescriptors = remember { mutableStateMapOf<Int, BitmapDescriptor>() }
nodeClusterItems.forEach { item ->
key(item.node.num) {
val descriptor = rememberComposeBitmapDescriptor(item.node) { PulsingNodeChip(node = item.node) }
DisposableEffect(descriptor) {
iconDescriptors[item.node.num] = descriptor
onDispose { iconDescriptors.remove(item.node.num) }
}
}
}
// The cluster renderer drives marker rendering from an async Handler (DefaultClusterRenderer's
// MarkerModifier), which can fire after this screen has stopped and the internal ComposeView is
// detached — at which point no owner is reachable regardless of the above. Skip rendering once the
// lifecycle is no longer at least STARTED to close most of that race.
if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return
if (clusterManager != null) {
val rendererState = remember { mutableStateOf<NodeClusterRenderer?>(null) }
Clustering(
items = nodeClusterItems,
onClusterClick = onClusterClick,
onClusterItemInfoWindowClick = { item ->
navigateToNodeDetails(item.node.num)
false
},
clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
onClusterManager = { clusterManager ->
(clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
},
clusterItemDecoration = { clusterItem ->
if (mapFilterState.showPrecisionCircle) {
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
// The renderer needs the GoogleMap instance, only available inside the map scope.
MapEffect(clusterManager) { map ->
val renderer = NodeClusterRenderer(context, map, clusterManager) { iconDescriptors[it] }
clusterManager.renderer = renderer
rendererState.value = renderer
}
// Keep listeners current — the lambdas can change across recompositions.
SideEffect {
clusterManager.setOnClusterClickListener { cluster -> onClusterClick(cluster) }
clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
}
// Re-cluster once the renderer is attached and freshly-baked icons arrive, so markers pick up the bitmaps
// even when the cluster manager became available after the icons were rendered.
val renderer = rendererState.value
LaunchedEffect(renderer, iconDescriptors.size) { if (renderer != null) clusterManager.cluster() }
Clustering(items = nodeClusterItems, clusterManager = clusterManager)
// Precision circles for the currently-unclustered items (the renderer tracks them as the zoom changes).
if (mapFilterState.showPrecisionCircle) {
renderer?.unclusteredItems?.value?.forEach { item ->
item.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
center = clusterItem.position,
center = item.position,
radius = precisionMeters,
fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(clusterItem.node.colors.second),
fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(item.node.colors.second),
strokeWidth = 2f,
zIndex = 0f,
)
}
}
}
// Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
},
)
}
}
}
/**
* [DefaultClusterRenderer] that assigns the pre-baked [BitmapDescriptor]s (rendered in the maps compose scope) to
* non-clustered item markers, and exposes the set of currently-unclustered items so the caller can decorate them (e.g.
* precision circles). Cluster bubbles keep the library's default rendering.
*/
private class NodeClusterRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager<NodeClusterItem>,
private val iconProvider: (Int) -> BitmapDescriptor?,
) : DefaultClusterRenderer<NodeClusterItem>(context, map, clusterManager) {
val unclusteredItems = mutableStateOf<Set<NodeClusterItem>>(emptySet())
init {
minClusterSize = MIN_CLUSTER_SIZE
}
override fun onClustersChanged(clusters: Set<Cluster<NodeClusterItem>>) {
super.onClustersChanged(clusters)
unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet()
}
override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
// super sets title/snippet from the ClusterItem, which drives the native info window.
super.onBeforeClusterItemRendered(item, markerOptions)
iconProvider(item.node.num)?.let { markerOptions.icon(it) }
markerOptions.zIndex(item.getZIndex())
}
override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
// super keeps title/snippet (and the open info window) in sync.
super.onClusterItemUpdated(item, marker)
iconProvider(item.node.num)?.let { marker.setIcon(it) }
marker.zIndex = item.getZIndex()
}
}

View File

@@ -16,14 +16,22 @@
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
@@ -31,6 +39,7 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
@@ -56,7 +65,11 @@ fun WaypointMarkers(
}
val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
val icon = rememberEmojiMarkerDescriptor(iconCodePoint)
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
Marker(
state = markerState,

View File

@@ -27,15 +27,17 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
@@ -49,7 +51,6 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val cameraState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
}
val markerIcon = rememberNodeChipDescriptor(node)
GoogleMap(
mapColorScheme = mapColorScheme,
@@ -78,7 +79,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
strokeWidth = 2f,
)
}
Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon)
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}
}

View File

@@ -188,6 +188,12 @@
]
},
"pullRequests": [
{
"id": "10604",
"title": "Add XIAO ESP32C6 + Wio SX1262 variant with hardware SPI support",
"page_url": "https://github.com/meshtastic/firmware/pull/10604",
"zip_url": "https://discord.com/invite/meshtastic"
},
{
"id": "10587",
"title": "Add SHT2x CRC check to fix i2c scan hang",

View File

@@ -559,7 +559,7 @@
<string name="history_return_max">Verlauf Rückgabewert maximal</string>
<string name="history_return_window">Zeitraum Rückgabewert</string>
<string name="hop_limit">Anzahl der Weiterleitungen</string>
<string name="hops_away">Zwischenschritte entfernt</string>
<string name="hops_away">Sprünge entfernt</string>
<string name="host">Host</string>
<string name="host_metrics_log">Host Kennzahlen</string>
<string name="humidity">Feuchtigkeit</string>
@@ -843,7 +843,7 @@
<string name="node_layout_help_signal_good">Das Signal ist stark, das Signal-Rauschverhältnis ist über 7 dB und die Signalstärke ist über 115 dBm.</string>
<string name="node_layout_help_signal_indicator">Kombiniert SNR und RSSI zu einem Qualitätsniveau, das als farbiges Symbol mit einer Beschreibung angezeigt wird.</string>
<string name="node_layout_help_signal_none">Kein nutzbares Signal erkannt. Unterhalb aller Qualitätsgrenzen.</string>
<string name="node_layout_hops_away">Hops Entfernt</string>
<string name="node_layout_hops_away">Sprünge entfernt</string>
<string name="node_layout_last_heard_time">Zuletzt gesehen</string>
<string name="node_layout_log_icons">Umweltdaten</string>
<string name="node_layout_no_preview_available">Keine Knoten für die Vorschau vorhanden.</string>
@@ -862,7 +862,7 @@
<string name="node_sort_button">Sortieroptionen</string>
<string name="node_sort_channel">Kanal</string>
<string name="node_sort_distance">Entfernung</string>
<string name="node_sort_hops_away">Zwischenschritte entfernt</string>
<string name="node_sort_hops_away">Sprünge entfernt</string>
<string name="node_sort_last_heard">Zuletzt gehört</string>
<string name="node_sort_title">Sortieren nach</string>
<string name="node_sort_via_favorite">über Favorit</string>

View File

@@ -56,7 +56,7 @@ kotlin {
jvmMain.dependencies {
// Desktop JVM: standard JAR bundles native libs for desktop archs.
implementation("com.github.luben:zstd-jni:1.5.7-9")
implementation("com.github.luben:zstd-jni:1.5.7-10")
// xpp3 is excluded from jvmAndroidMain (Android ships it as a
// platform class), but Desktop JVM still needs it for XmlPullParser.
implementation("org.ogce:xpp3:1.1.6")
@@ -66,7 +66,7 @@ kotlin {
// Android: @aar variant ships .so files for arm/arm64/x86/x86_64.
// Without this, zstd-jni's ZstdDictCompress.<clinit> throws
// UnsatisfiedLinkError and poisons TakV2Compressor permanently.
implementation("com.github.luben:zstd-jni:1.5.7-9@aar")
implementation("com.github.luben:zstd-jni:1.5.7-10@aar")
}
commonTest.dependencies {
@@ -81,7 +81,7 @@ kotlin {
dependencies {
// Host-JVM tests need the platform JAR (not AAR) for zstd native
// libs — the @aar from androidMain only ships ARM/x86 .so files.
implementation("com.github.luben:zstd-jni:1.5.7-9")
implementation("com.github.luben:zstd-jni:1.5.7-10")
}
}
}

View File

@@ -113,13 +113,13 @@ aliases:
### Настройка питания
| Настройка | Описание |
| --------------------------------------- | ------------------------------------------------- |
| Энергосбережение | Включить спящий режим с низким энергопотреблением |
| Завершение работы через | Таймер автоотключения при простое |
| Множитель ADC | Коэффициент калибровки напряжения батареи |
| Wait Bluetooth (s) | Time to wait for BLE connection at boot |
| Mesh SDS Timeout (s) | Super-deep-sleep timeout |
| Настройка | Описание |
| ----------------------- | ------------------------------------------------- |
| Энергосбережение | Включить спящий режим с низким энергопотреблением |
| Завершение работы через | Таймер автоотключения при простое |
| Множитель ADC | Коэффициент калибровки напряжения батареи |
| Ожидание Bluetooth | Время ожидания подключения BLE при загрузке |
| Тайм-аут Mesh SDS | Тайм-аут сверхглубокого сна |
### Настройка сети
@@ -131,44 +131,44 @@ aliases:
| NTP-сервер | Сервер синхронизации времени |
| Syslog-сервер | Удалённый сервер логирования |
![IP address field](../../assets/screenshots/settings_ipv4_field.png)
![Поле IP-адреса](../../assets/screenshots/settings_ipv4_field.png)
### Настройка Bluetooth
| Настройка | Описание |
| --------------------- | ------------------------------------------------------------------------- |
| Bluetooth Enabled | Enable/disable BLE radio |
| Pairing Mode | Fixed PIN, Random PIN, or No PIN |
| Фиксированный PIN-код | PIN code for pairing (default: 123456) |
| Настройка | Описание |
| --------------------- | -------------------------------------------------------------------------------- |
| Bluetooth включен | Включение/отключение BLE радиостанции |
| Режим сопряжения | Фиксированный PIN, случайный PIN или без PIN |
| Фиксированный PIN-код | PIN-код для сопряжения (по умолчанию: 123456) |
### Настройки безопасности
| Настройка | Описание |
| ------------------------- | -------------------------------------------------------------------------- |
| Публичный ключ | Your node's public key (read-only) |
| Ключ администратора | Key for remote administration |
| Приватный ключ | Your node's private key (handle securely) |
| ~~Admin Channel Enabled~~ | ⚠️ Removed — now configured automatically when an admin key is set |
| Журнал отладки | Output live debug logging over serial/bluetooth |
| Serial Enabled | Enable serial console access (moved from Device Config) |
| Управляемый режим | Restrict non-admin channel changes |
| Настройка | Описание |
| -------------------------------- | ------------------------------------------------------------------------------------------------- |
| Публичный ключ | Публичный ключ твоей ноды (только для чтения) |
| Ключ администратора | Ключ для удалённого администрирования |
| Приватный ключ | Приватный ключ твоей ноды (обращайся с ним осторожно) |
| ~~Канал администратора включен~~ | ⚠️ Удалено — теперь настраивается автоматически при установке ключа администратора |
| Журнал отладки | Выводить живой отладочный лог через последовательный порт/Bluetooth |
| Последовательная включена | Включить доступ к последовательной консоли (перемещено из настроек устройства) |
| Управляемый режим | Ограничить изменения канала для неадминистраторов |
![Password field](../../assets/screenshots/settings_password_field.png)
![Поле пароля](../../assets/screenshots/settings_password_field.png)
Settings use standard preference controls — dropdowns, toggles, and sliders:
Настройки используют стандартные элементы управления предпочтениями — выпадающие списки, переключатели и ползунки:
| Control | Screenshot |
| -------- | ----------------------------------------------------------- |
| Dropdown | ![Dropdown](../../assets/screenshots/settings_dropdown.png) |
| Toggle | ![Toggle](../../assets/screenshots/settings_switch.png) |
| Slider | ![Slider](../../assets/screenshots/settings_slider.png) |
| Управление | Снимок экрана |
| ----------------- | -------------------------------------------------------------------- |
| Выпадающий список | ![Выпадающий список](../../assets/screenshots/settings_dropdown.png) |
| Переключатель | ![Переключатель](../../assets/screenshots/settings_switch.png) |
| Ползунок | ![Ползунок](../../assets/screenshots/settings_slider.png) |
## Связанные темы
- [Settings — Modules & Admin](settings-module-admin) — optional feature modules and device administration
- [Signal Meter](signal-meter) — how modem presets affect signal quality thresholds
- [LoRa configuration](https://meshtastic.org/docs/configuration/radio/lora) — detailed LoRa settings reference on meshtastic.org
- [Initial configuration](https://meshtastic.org/docs/getting-started/initial-config) — region setup guide on meshtastic.org
- [Настройки — Модули и администрирование](settings-module-admin) — дополнительные модули и управление устройством
- [Измеритель сигнала](signal-meter) — как предустановки модема влияют на пороги качества сигнала
- [Конфигурация LoRa](https://meshtastic.org/docs/configuration/radio/lora) — подробная справка по настройкам LoRa на meshtastic.org
- [Начальная конфигурация](https://meshtastic.org/docs/getting-started/initial-config) — руководство по настройке региона на meshtastic.org
---

View File

@@ -1,9 +1,9 @@
---
title: How the Meshtastic Signal Meter Works
title: Как работает измеритель сигнала Meshtastic
parent: Руководство пользователя
nav_order: 15
last_updated: 2026-05-13
description: How the signal meter calculates quality from RSSI and SNR — LoRa spread spectrum, presets, and what the bars really mean.
description: Как измеритель сигнала вычисляет качество по RSSI и SNR — спектр с расширением LoRa, предустановки и что на самом деле означают эти полосы.
aliases:
- signal
- signal-meter
@@ -11,72 +11,72 @@ aliases:
- rssi
---
# How the Meshtastic Signal Meter Works
# Как работает измеритель сигнала Meshtastic
The Meshtastic signal meter — the familiar bars or status color in the app — is calculated very differently than the "bars" on a traditional cell phone or Wi-Fi router.
Измеритель сигнала Meshtastic — знакомые полосы или цвет состояния в приложении — рассчитывается совсем иначе, чем "полоски" на традиционном мобильном телефоне или Wi-Fi роутере.
Most consumer devices simply measure how "loud" a signal is. However, because Meshtastic uses **LoRa (Long Range)** technology, its signal meter measures how **clear** the signal is, relative to the specific settings your mesh is using.
Большинство потребительских устройств просто измеряют, насколько "громкий" сигнал. Однако, поскольку Meshtastic использует технологию **LoRa (Long Range)**, его измеритель сигнала оценивает, насколько **чистый** сигнал, относительно конкретных настроек вашей mesh-сети.
---
## 1. The Two Metrics: "Loudness" vs. "Clarity"
## 1. Две метрики: "Громкость" против "Чёткости"
Every time the LoRa radio chip receives a message, it reports two measurements:
Каждый раз, когда радиочип LoRa получает сообщение, он сообщает два измерения:
- **RSSI (Received Signal Strength Indicator):** The **loudness** of the raw power hitting your antenna.
- **SNR (Signal-to-Noise Ratio):** The **clarity** of the signal compared to the background static.
- **RSSI (Индикатор уровня принятого сигнала):** **громкость** необработанного сигнала, поступающего на твою антенну.
- **SNR (Соотношение сигнал/шум):** **Чёткость** сигнала по сравнению с фоновым шумом.
> **Tip — The Analogy:** Imagine you are trying to hear a friend talking to you.
> **Совет — аналогия:** Представь, что ты пытаешься услышать, как друг разговаривает с тобой.
>
> - **RSSI** is how loud their voice is.
> - **The Noise Floor** is the background noise in the room (air conditioning, other people talking, traffic).
> - **SNR** is how easily you can distinguish your friend's voice from the background noise.
> - **RSSI** — это громкость его голоса.
> - **Минимальный уровень шума** — это фоновый шум в комнате (кондиционер, разговоры других людей, движение транспорта).
> - **SNR** — это то, насколько легко ты можешь различить голос твоего друга на фоне шума.
If your friend shouts at you at a deafening rock concert, the signal is incredibly loud (High RSSI), but you still can't understand them because the background noise is louder (Bad SNR). Conversely, if your friend whispers to you in a dead-silent library, the signal is very weak (Low RSSI), but you can understand them perfectly (Great SNR).
Если твой друг кричит тебе на оглушительном рок-концерте, сигнал невероятно громкий (высокий RSSI), но ты всё равно не можешь его понять, потому что фоновый шум громче (плохое SNR). И наоборот, если твой друг шепчет тебе в абсолютно тихой библиотеке, сигнал очень слабый (низкий RSSI), но ты можешь его прекрасно понять (отличный SNR).
---
## 2. The Magic of LoRa: Hearing "Below the Noise Floor"
## 2. Магия LoRa: слышит "ниже уровня шума"
For standard radios (like FM or Wi-Fi), if the background noise is louder than the signal (a negative SNR), the receiver just hears static.
Для стандартных радиостанций (таких как FM или Wi-Fi), если фоновый шум громче сигнала (отрицательное отношение сигнал/шум), приемник просто слышит помехи.
LoRa is special. It uses **"Spread Spectrum"** modulation, which allows the radio to mathematically pull a signal out of the air even when it is buried deep _underneath_ the background noise. This is why you will frequently see **negative SNR numbers** in Meshtastic (e.g., -10 dB, which means the signal is 10 decibels weaker than the background static).
LoRa особенная. Она использует модуляцию **"широкополосного спектра"**, которая позволяет радио математически извлекать сигнал из воздуха даже когда он глубоко _под_ фоновым шумом. Вот почему ты часто будете видеть **отрицательные значения SNR** в Meshtastic (например, -10 дБ, что означает, что сигнал на 10 децибел слабее фонового шума).
Depending on which Meshtastic preset you are using (e.g., `LongFast` vs. `ShortFast`), the radio has a specific **SNR Limit** — the absolute maximum amount of noise it can tolerate before the message is completely lost to the static.
В зависимости от того, какой пресет Meshtastic ты используешь (например, `LongFast` или `ShortFast`), у радиоприемника есть определённый **предел SNR** — абсолютное максимальное количество шума, которое он может выдержать, прежде чем сообщение полностью потеряется в помехах.
---
## 3. How the Signal Meter Calculates Quality
## 3. Как измеритель сигнала рассчитывает качество
The Meshtastic apps take both RSSI and SNR and run them through a specific formula to assign your signal a quality rating (None, Bad, Fair, or Good). It specifically scales these values based on the physical limits of the radio preset you are using.
Приложения Meshtastic используют как RSSI, так и SNR и пропускают их через определённую формулу для присвоения сигналу оценки качества (Нет, Плохой, Удовлетворительный или Хороший). Оно специально масштабирует эти значения на основе физических ограничений используемого тобой пресета радиоприемника.
Here is exactly how the app decides how many bars (or what color) to show you:
Вот точно как приложение решает, сколько полосок (или какого цвета) показывать тебе:
| Level | Bars | Criteria | Meaning |
| ----------- | ---- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Хороший | 3 | RSSI better than `-115 dBm` **AND** SNR better than `-7 dB` | Signal is both loud and clear — healthy connection. |
| Средний | 2 | RSSI better than `-126 dBm` with good SNR, **OR** SNR better than `-15 dB` with good RSSI | Signal getting quieter or noisier, but still decodable. |
| Плохой | 1 | Falls between Fair and None thresholds | At the edge of range or experiencing interference. |
| Отсутствует | 0 | RSSI worse than `-126 dBm` **AND** SNR worse than `-15 dB` | Transmission completely buried in noise. |
| Уровень | Деления | Критерии | Значение |
| ----------- | ------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| Хороший | 3 | RSSI лучше `-115 dBm` **И** SNR лучше `-7 dB` | Сигнал как громкий, так и четкий — здоровое соединение. |
| Средний | 2 | RSSI лучше `-126 dBm` с хорошим SNR, **ИЛИ** SNR лучше `-15 dB` с хорошим RSSI | Сигнал становится тише или громче, но всё ещё читается. |
| Плохой | 1 | Попадает между порогами Fair и None | На границе диапазона или при наличии помех. |
| Отсутствует | 0 | RSSI хуже `-126 dBm` **И** SNR хуже `-15 dB` | Передача полностью погребена в шуме. |
---
## 4. What This Means for You
## 4. Что это значит для тебя
Because Meshtastic's meter acts as a **"Clarity Meter"**, it behaves differently than what most people expect:
Поскольку измеритель Meshtastic действует как **"Измеритель чёткости"**, он ведёт себя иначе, чем ожидает большинство людей:
> **Tip — Don't panic over low RSSI:** You might see a seemingly terrible RSSI value like `-118 dBm`. On a cell phone, you would have zero bars. But if you have an SNR of `+2 dB`, Meshtastic will still show a strong signal! _The library is quiet, so the whisper is heard perfectly._
> **Совет — не паникуй из-за низкого RSSI:** ты можешь увидеть кажущееся ужасным значение RSSI, например `-118 dBm`. На сотовом телефоне у тебя было бы ноль делений. Но если отношение сигнал/шум (SNR) `+2 дБ`, Meshtastic все равно покажет сильный сигнал! _Библиотека тихая, поэтому шепот слышен идеально._
> **Warning — Watch out for local noise:** If you hook up a massive antenna and see a great RSSI (e.g., `-90 dBm`) but your signal meter is only showing **1 Bar (Bad)**, you have a problem. It means you have local interference — perhaps a cheap power supply, a noisy computer, or a nearby radio tower — creating so much static that it is drowning out your mesh.
> **Внимание — остерегайся местного шума:** если ты подключишь массивную антенну и увидишь отличный RSSI (например, `-90 dBm`), но индикатор сигнала показывает только **1 полоску (плохо)**, у тебя есть проблема. Это означает, что у тебя есть местные помехи — возможно, дешевый источник питания, шумный компьютер или расположенная рядом радиовышка — создающие столько статического шума, что он заглушает сеть.
## Where Signal Information Appears
## Где появляется информация о сигнале
In the app, signal data is shown in several places:
В приложении данные сигнала отображаются в нескольких местах:
- **Node list** — signal bars icon next to each node
- **Node detail** — SNR, RSSI, and signal quality in the device metrics section
- **Traceroute** — per-hop signal quality for each relay node
- **Signal metrics** — historical SNR and RSSI data in the metrics charts
- **Список нод** — значок полос сигнала рядом с каждой нодой
- **Детали ноды** — SNR, RSSI и качество сигнала в разделе метрик устройства
- **Трассировка** — качество сигнала на каждой промежуточной ноде
- **Метрики сигнала** — история данных SNR и RSSI на графиках метрик
![Node entry showing SNR, RSSI values and colored signal bars](../../assets/screenshots/nodes_signal_info.png)
![Запись ноды, показывающая значения SNR, RSSI и цветные индикаторы сигнала](../../assets/screenshots/nodes_signal_info.png)

View File

@@ -36,7 +36,7 @@ turbine = "1.2.1"
compose-screenshot = "0.0.1-alpha15"
# Compose Multiplatform
compose-multiplatform = "1.11.0"
compose-multiplatform = "1.11.1"
compose-multiplatform-material3 = "1.11.0-alpha07"
# `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui,foundation,animation}
# artifacts that ship in lockstep with CMP. Kept as a separate version ref so Renovate
@@ -281,7 +281,7 @@ test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", vers
aboutlibraries-gradlePlugin = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutlibraries" }
jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" }
jna = { module = "net.java.dev.jna:jna", version = "5.18.1" }
jna = { module = "net.java.dev.jna:jna", version = "5.19.0" }
# TAK
takpacket-sdk-kmp = { module = "org.meshtastic:takpacket-sdk", version.ref = "takpacket-sdk" }