Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ adb logcat -s MeshAnd MeshAnd/SDK
- Preview: `pip install -r site/requirements.txt && ./site/build.sh`, then `python3 -m http.server -d build/site`.
- A link between guides on GitHub (`../CHANGELOG.md`) is rewritten by `build.sh`; a new kind of cross-folder link needs the same, or `--strict` fails.
- The download button asks the GitHub API for `releases/latest`, so it needs no change per release.
- Features not yet in a release carry a "Next version" badge on the landing page (currently Chat and Phone GPS). **When releasing, remove those badges and the note below the feature list.** Keep the text in step with `README.md` and `CHANGELOG.md`.
- Features not yet in a release carry a "Next version" badge on the landing page (currently Chat, Phone GPS and Radio details). **When releasing, remove those badges and the note below the feature list.** Keep the text in step with `README.md` and `CHANGELOG.md`.

## Architecture (`app/src/main/java/com/meshand/app/`)
- `MeshAndApp.kt`: the `Application` class. Owns `AppGraph` (settings, client, repository, OsmAnd bridge) for the whole process, wired by hand.
Expand All @@ -105,6 +105,7 @@ adb logcat -s MeshAnd MeshAnd/SDK
- Shares one `InMemoryStorageProvider` across sessions.
- `data/meshtastic/MeshtasticMapper.kt`: protobuf `NodeInfo` / `MeshPacket` → `MeshNode` / `LiveUpdate`, and the merge of the two. Pure functions, unit-tested.
- `data/meshtastic/InMemoryStorageProvider.kt`: the SDK requires a `StorageProvider`. This is an in-memory one, so there is no database.
- `data/repository/RadioInfoRepository.kt`: the connected radio's details for the "This radio" card on the Radio page (`domain/model/RadioInfo`, built by `data/meshtastic/RadioInfoMapper`, pure and tested). Identity, firmware, role and LoRa region/preset come from `ownNode` and `configBundle`; nothing else from the bundle is read, and it is never logged. Battery, voltage, channel use, airtime and uptime come from device telemetry: `telemetry.observe(ownNode)` plus `telemetry.requestDevice()` every 2 min. That request goes to our own radio only, never over LoRa. Note that `telemetry.observe(NodeId.LOCAL)` matches every node's telemetry, so always pass the real own node number.
- `data/repository/NodeRepository.kt`: combines SDK `nodes` (the NodeDB) with live `packets` into `StateFlow<List<MeshNode>>`.
- Nodes survive reconnects; they're cleared only when switching to a different radio.
- Empty SDK snapshots at session start are ignored.
Expand All @@ -118,7 +119,7 @@ adb logcat -s MeshAnd MeshAnd/SDK
- UI: Material 3 with one app theme (`ui/theme/Theme.kt`). It has a pine primary (the logo's `#1F5A47`), light and dark schemes (Settings → Appearance: System/Light/Dark, `AppSettings.themeMode`; `MeshAndTheme` also sets the system-bar icon colours and window background), and semantic `colorScheme.good`/`warning`. **Don't hard-code colours in screens.** Shared pieces are in `ui/common/Components.kt` (`SectionCard`, `HintText`, `StatusDot`, `ChoiceRow` segmented buttons, `SwitchRow`).
- `ui/connection/` is the screen shown when not connected.
- The main screen has a bottom bar with 5 pages (`Tab` in `MainActivity`, a plain `rememberSaveable` state, no navigation library):
- **Radio:** `StatusPage` (link, own GPS, OsmAnd switch, Team list button, overview), or `ConnectionScreen` when not connected
- **Radio:** `StatusPage` (link, own GPS, OsmAnd switch, Team list button, overview, "This radio" details), or `ConnectionScreen` when not connected
- **Nodes:** `NodesPage`, compact cards that expand on tap, with a search box above 6 nodes
- **Pins:** `PinsPage`, one card per pin, with a count badge on the tab
- **Chat:** `ChatListPage` (every channel, then direct conversations, unread badges) and `ConversationPage` (bubbles, delivery state, compose box with a byte counter). The open conversation is `chatKey` in `MainActivity`; the bottom bar hides inside a conversation. "Message" on node cards and team member cards opens a direct conversation.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ using the app, not for developers.
was delivered (direct) or relayed by a neighbour (channel), and get a notification for new
messages. Messages are saved on the phone. A message can be up to 233 bytes: about 230 Latin
letters, or about 77 Georgian letters.
- **Radio details.** The Radio page shows a "This radio" card with your radio's battery (percent
and voltage, or "Plugged in"), how busy the channel is, your own airtime, how long it has been
running, its hardware, firmware, role and LoRa region/preset. Battery and airtime refresh every
couple of minutes.

### Changed

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/meshand/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class MainActivity : ComponentActivity() {
MeshAndTheme {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val update by applicationContext.graph.updates.available.collectAsStateWithLifecycle()
val radioInfo by applicationContext.graph.radioInfo.info.collectAsStateWithLifecycle()
val chatMessages by viewModel.chatMessages.collectAsStateWithLifecycle()
val chatChannels by viewModel.chatChannels.collectAsStateWithLifecycle()
val chatUnread by viewModel.chatUnread.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -213,6 +214,7 @@ class MainActivity : ComponentActivity() {
osmAnd = state.osmAnd,
pinCount = state.pins.size,
phoneGps = state.phoneGps,
radioInfo = radioInfo,
onUsePhoneGpsChange = ::setUsePhoneGps,
onDisconnect = viewModel::disconnect,
onOsmAndEnabledChange = viewModel::setOsmAndEnabled,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/meshand/app/MeshAndApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.meshand.app.data.phonegps.PhoneGpsFeeder
import com.meshand.app.data.repository.ChatRepository
import com.meshand.app.data.repository.NodeRepository
import com.meshand.app.data.repository.PinRepository
import com.meshand.app.data.repository.RadioInfoRepository
import com.meshand.app.data.repository.TrailRecorder
import com.meshand.app.data.settings.AppSettings
import com.meshand.app.data.storage.LocalStore
Expand Down Expand Up @@ -48,6 +49,7 @@ class AppGraph(app: Application) {
val pins = PinRepository(app, client, repository.nodes, scope, store, osmAnd::findOsmAndPackage)
val phoneGps = PhoneGpsFeeder(app, settings, client, scope)
val chat = ChatRepository(app, client, repository.nodes, scope, store)
val radioInfo = RadioInfoRepository(client, scope)
private val silenceAlerts = SilenceAlertMonitor(app, settings, repository.activeNodes, client.status)

init {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.meshand.app.data.meshtastic

import com.meshand.app.domain.model.RadioInfo
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.NodeInfo
import org.meshtastic.sdk.ConfigBundle
import java.time.Instant
import java.util.Locale

/** Own node + config bundle + latest device telemetry → [RadioInfo]. Pure, unit-tested. */
object RadioInfoMapper {
/**
* [metrics] is the latest device telemetry (fresher than the NodeDB copy in [own]).
* Only reads identity, firmware, role and LoRa region/preset from [bundle]; never keys.
*/
fun toRadioInfo(own: NodeInfo?, bundle: ConfigBundle?, metrics: DeviceMetrics?, metricsAt: Instant?): RadioInfo {
val user = own?.user
val metadata = bundle?.metadata
val deviceConfig = bundle?.configs?.firstNotNullOfOrNull { it.device }
val lora = bundle?.configs?.firstNotNullOfOrNull { it.lora }
val m = metrics ?: own?.device_metrics
val battery = m?.battery_level?.takeIf { it >= 0 }
val hardware = metadata?.hw_model?.name?.takeUnless { it == "UNSET" }
?: user?.hw_model?.name?.takeUnless { it == "UNSET" }
return RadioInfo(
longName = user?.long_name?.takeIf { it.isNotBlank() },
shortName = user?.short_name?.takeIf { it.isNotBlank() },
nodeIdHex = own?.let { "!%08x".format(MeshtasticMapper.nodeNumToLong(it.num)) },
hardware = hardware?.let(::label),
firmware = metadata?.firmware_version?.takeIf { it.isNotBlank() },
role = (deviceConfig?.role ?: metadata?.role)?.name?.let(::label),
region = lora?.region?.name?.takeUnless { it == "UNSET" }?.replace('_', ' '),
modemPreset = lora?.takeIf { it.use_preset }?.modem_preset?.name?.let(::label),
batteryPercent = battery?.coerceAtMost(100),
pluggedIn = battery != null && battery > 100,
voltage = m?.voltage?.takeIf { it > 0f },
channelUtilization = m?.channel_utilization,
airUtilTx = m?.air_util_tx,
uptimeSeconds = m?.uptime_seconds?.toLong()?.and(0xFFFF_FFFFL),
metricsAt = metricsAt.takeIf { metrics != null },
)
}

/** `T_BEAM_S3_CORE` → "T Beam S3 Core", `CLIENT_MUTE` → "Client Mute". */
fun label(enumName: String): String =
enumName.split('_').filter { it.isNotEmpty() }.joinToString(" ") { word ->
if (word.any(Char::isDigit)) word else word.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) }
}

/** "3 d 4 h", "5 h 12 min", "8 min". */
fun formatUptime(seconds: Long): String = when {
seconds < 3600 -> "${seconds / 60} min"
seconds < 86_400 -> "${seconds / 3600} h ${seconds % 3600 / 60} min"
else -> "${seconds / 86_400} d ${seconds % 86_400 / 3600} h"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.meshand.app.data.repository

import android.util.Log
import com.meshand.app.data.meshtastic.MeshtasticClient
import com.meshand.app.data.meshtastic.RadioInfoMapper
import com.meshand.app.domain.model.RadioInfo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.sdk.AdminResult
import org.meshtastic.sdk.ConnectionState
import org.meshtastic.sdk.NodeId
import org.meshtastic.sdk.RadioClient
import java.time.Instant

private const val TAG = "MeshAnd"

/**
* The connected radio's own details for the Radio page: identity and firmware from the session,
* battery and airtime from device telemetry. The radio volunteers its telemetry rarely, so it is
* also asked every [POLL_MS]; that request is addressed to our own radio and never goes over LoRa.
*/
class RadioInfoRepository(client: MeshtasticClient, scope: CoroutineScope) {
private val _info = MutableStateFlow<RadioInfo?>(null)

/** Null until connected; kept through reconnects, cleared on Disconnect. */
val info: StateFlow<RadioInfo?> = _info.asStateFlow()

init {
scope.launch {
client.activeRadio.collect { if (it == null) _info.value = null }
}
scope.launch {
client.session.collectLatest { session -> if (session != null) observe(session) }
}
}

private suspend fun observe(session: RadioClient) = coroutineScope {
val metrics = MutableStateFlow<Pair<DeviceMetrics, Instant>?>(null)
// Telemetry our radio sends to the phone on its own (and the replies to our requests).
launch {
session.ownNode.filterNotNull().map { it.num }.distinctUntilChanged().collectLatest { num ->
session.telemetry.observe(NodeId(num)).collect { telemetry ->
telemetry.device_metrics?.let { metrics.value = it to Instant.now() }
}
}
}
launch {
session.connection.first { it is ConnectionState.Connected }
while (true) {
try {
when (val result = session.telemetry.requestDevice()) {
is AdminResult.Success -> metrics.value = result.value to Instant.now()
else -> Log.d(TAG, "Radio telemetry request: $result")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Radio telemetry request failed", e)
}
delay(POLL_MS)
}
}
combine(session.ownNode, session.configBundle, metrics) { own, bundle, latest ->
RadioInfoMapper.toRadioInfo(own, bundle, latest?.first, latest?.second)
}.collect { info -> if (info.nodeIdHex != null) _info.value = info }
}

private companion object {
const val POLL_MS = 2 * 60_000L
}
}
32 changes: 32 additions & 0 deletions app/src/main/java/com/meshand/app/domain/model/RadioInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.meshand.app.domain.model

import java.time.Instant

/** The connected radio itself: identity, firmware, battery and airtime. All fields optional. */
data class RadioInfo(
val longName: String? = null,
val shortName: String? = null,
/** e.g. `!a1b2c3d4`. */
val nodeIdHex: String? = null,
/** Readable hardware model, e.g. "T Beam S3 Core". */
val hardware: String? = null,
val firmware: String? = null,
/** Readable device role, e.g. "Client". */
val role: String? = null,
/** e.g. "EU 868". */
val region: String? = null,
/** e.g. "Long Fast"; null when the radio uses custom LoRa settings. */
val modemPreset: String? = null,
/** 0–100. */
val batteryPercent: Int? = null,
/** Running from USB/external power. */
val pluggedIn: Boolean = false,
val voltage: Float? = null,
/** How busy the channel is (everyone's traffic), in percent. */
val channelUtilization: Float? = null,
/** This radio's own share of transmit airtime in the last hour, in percent. */
val airUtilTx: Float? = null,
val uptimeSeconds: Long? = null,
/** When the battery/airtime numbers were last received from the radio. */
val metricsAt: Instant? = null,
)
44 changes: 44 additions & 0 deletions app/src/main/java/com/meshand/app/ui/nodes/MainPages.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,22 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.meshand.app.R
import com.meshand.app.data.meshtastic.RadioInfoMapper
import com.meshand.app.domain.Geo
import com.meshand.app.domain.GpsHealth
import com.meshand.app.domain.PositionAge
import com.meshand.app.domain.model.ConnectionStatus
import com.meshand.app.domain.model.MeshNode
import com.meshand.app.domain.model.OsmAndStatus
import com.meshand.app.domain.model.PhoneGpsStatus
import com.meshand.app.domain.model.RadioInfo
import com.meshand.app.domain.model.Pin
import com.meshand.app.ui.common.ColorDot
import com.meshand.app.ui.common.DistanceBadge
Expand Down Expand Up @@ -83,6 +86,7 @@ fun StatusPage(
osmAnd: OsmAndStatus,
pinCount: Int,
phoneGps: PhoneGpsStatus,
radioInfo: RadioInfo?,
onUsePhoneGpsChange: (Boolean) -> Unit,
onDisconnect: () -> Unit,
onOsmAndEnabledChange: (Boolean) -> Unit,
Expand Down Expand Up @@ -115,9 +119,49 @@ fun StatusPage(
OverviewRow("Pins", if (pinCount == 0) "none shared yet" else "$pinCount shared in the last 24 h", onOpenPins)
}
}
if (radioInfo != null) item { RadioInfoCard(radioInfo, now) }
}
}

/** The connected radio itself: battery, airtime, uptime, and what it is. */
@Composable
private fun RadioInfoCard(info: RadioInfo, now: Instant) {
val colors = MaterialTheme.colorScheme
SectionCard(title = "This radio") {
val battery = when {
info.pluggedIn -> "Plugged in"
info.batteryPercent != null -> "${info.batteryPercent} %"
else -> null
}
val volts = info.voltage?.let { String.format(Locale.US, "%.2f V", it) }
val low = !info.pluggedIn && (info.batteryPercent ?: 100) < 20
InfoRow("Battery", listOfNotNull(battery, volts).joinToString(" · ").ifEmpty { "unknown" }, if (low) colors.warning else null)
info.channelUtilization?.let { InfoRow("Channel busy", percent(it)) }
info.airUtilTx?.let { InfoRow("Your airtime", "${percent(it)} of the last hour") }
info.uptimeSeconds?.let { InfoRow("Running for", RadioInfoMapper.formatUptime(it)) }
info.hardware?.let { InfoRow("Hardware", it) }
info.firmware?.let { InfoRow("Firmware", it) }
info.role?.let { InfoRow("Role", it) }
listOfNotNull(info.region, info.modemPreset).takeIf { it.isNotEmpty() }?.let { InfoRow("LoRa", it.joinToString(" · ")) }
val name = listOfNotNull(info.longName, info.shortName?.let { "($it)" }).joinToString(" ")
InfoRow("Node", listOf(name, info.nodeIdHex.orEmpty()).filter { it.isNotBlank() }.joinToString(" · "))
HintText(
info.metricsAt?.let { "Battery and airtime updated ${formatAgo(Duration.between(it, now).seconds)}." }
?: "Waiting for the radio's battery and airtime report…",
)
}
}

@Composable
private fun InfoRow(label: String, value: String, valueColor: Color? = null) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.4f))
Text(value, style = MaterialTheme.typography.bodyMedium, color = valueColor ?: MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.6f))
}
}

private fun percent(value: Float): String = String.format(Locale.US, if (value < 10) "%.1f %%" else "%.0f %%", value)

@Composable
private fun OverviewRow(title: String, detail: String, onClick: () -> Unit) {
Row(
Expand Down
Loading
Loading