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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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