feat/decoupling (#4685)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-03 07:15:28 -06:00 committed by GitHub
parent 40244f8337
commit 2c49db8041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
254 changed files with 5132 additions and 2666 deletions

View file

@ -32,6 +32,7 @@ configure<LibraryExtension> {
}
dependencies {
implementation(projects.core.repository)
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)

View file

@ -34,8 +34,8 @@ import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.User

View file

@ -41,6 +41,7 @@ import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.repository.DatabaseManager as SharedDatabaseManager
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
@ -51,21 +52,21 @@ open class DatabaseManager
constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
) {
) : SharedDatabaseManager {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val mutex = Mutex()
// Expose the DB cache limit as a reactive stream so UI can observe changes.
private val _cacheLimit = MutableStateFlow(getCacheLimit())
open val cacheLimit: StateFlow<Int> = _cacheLimit
private val _cacheLimit = MutableStateFlow(getCurrentCacheLimit())
override val cacheLimit: StateFlow<Int> = _cacheLimit
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
private val prefsListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == DatabaseConstants.CACHE_LIMIT_KEY) {
_cacheLimit.value = getCacheLimit()
_cacheLimit.value = getCurrentCacheLimit()
}
}
@ -88,7 +89,7 @@ constructor(
}
/** Switch active database to the one associated with [address]. Serialized via mutex. */
suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
val dbName = buildDbName(address)
// Remember the previously active DB name (any) so we can record its last-used time as well.
@ -159,7 +160,7 @@ constructor(
}
private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock {
val limit = getCacheLimit()
val limit = getCurrentCacheLimit()
val all = listExistingDbNames()
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
val deviceDbs =
@ -189,13 +190,13 @@ constructor(
}
}
fun getCacheLimit(): Int = prefs
override fun getCurrentCacheLimit(): Int = prefs
.getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT)
.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
fun setCacheLimit(limit: Int) {
override fun setCacheLimit(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
if (clamped == getCacheLimit()) return
if (clamped == getCurrentCacheLimit()) return
prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply()
_cacheLimit.value = clamped
// Enforce asynchronously with current active DB protected

View file

@ -241,17 +241,19 @@ interface PacketDao {
@Transaction
suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
val new = data.copy(status = m)
// Find by packet ID first for better performance and reliability
findPacketsWithId(data.id).find { it.data == data }?.let { update(it.copy(data = new)) }
?: findDataPacket(data)?.let { update(it.copy(data = new)) }
// Match on key fields that identify the packet, rather than the entire data object
findPacketsWithId(data.id)
.find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to }
?.let { update(it.copy(data = new)) }
}
@Transaction
suspend fun updateMessageId(data: DataPacket, id: Int) {
val new = data.copy(id = id)
// Find by packet ID first for better performance and reliability
findPacketsWithId(data.id).find { it.data == data }?.let { update(it.copy(data = new, packetId = id)) }
?: findDataPacket(data)?.let { update(it.copy(data = new, packetId = id)) }
// Match on key fields that identify the packet
findPacketsWithId(data.id)
.find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to }
?.let { update(it.copy(data = new, packetId = id)) }
}
@Query(

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,14 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.di
import android.app.Application
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
@ -34,26 +35,34 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {
@Provides @Singleton
fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app)
abstract class DatabaseModule {
@Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao()
@Binds
@Singleton
abstract fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager
@Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao()
companion object {
@Provides
@Singleton
fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app)
@Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao()
@Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao()
@Provides
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao()
@Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao()
@Provides
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao()
@Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao()
@Provides
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao()
@Provides
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao()
@Provides
fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao =
database.tracerouteNodePositionDao()
@Provides
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao()
@Provides
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao()
@Provides
fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao =
database.tracerouteNodePositionDao()
}
}

View file

@ -26,10 +26,10 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
@ -65,6 +65,7 @@ data class NodeWithRelations(
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
publicKey = publicKey ?: user.public_key,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
@ -90,6 +91,7 @@ data class NodeWithRelations(
environmentTelemetry = environmentTelemetry,
powerTelemetry = powerTelemetry,
paxcounter = paxcounter,
publicKey = publicKey ?: user.public_key,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,

View file

@ -24,12 +24,12 @@ import androidx.room.PrimaryKey
import androidx.room.Relation
import okio.ByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.proto.User
data class PacketEntity(
@Embedded val packet: Packet,
@ -130,24 +130,6 @@ data class ContactSettings(
get() = nowMillis <= muteUntil
}
data class Reaction(
val replyId: Int,
val user: User,
val emoji: String,
val timestamp: Long,
val snr: Float,
val rssi: Int,
val hopsAway: Int,
val packetId: Int = 0,
val status: MessageStatus = MessageStatus.UNKNOWN,
val routingError: Int = 0,
val relays: Int = 0,
val relayNode: Int? = null,
val to: String? = null,
val channel: Int = 0,
val sfppHash: ByteString? = null,
)
@Suppress("ConstructorParameterNaming")
@Entity(
tableName = "reactions",
@ -173,11 +155,11 @@ data class ReactionEntity(
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
)
private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction {
val node = getNode(userId)
suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node?): Reaction {
val user = getNode(userId)?.user ?: org.meshtastic.proto.User(id = userId)
return Reaction(
replyId = replyId,
user = node.user,
user = user,
emoji = emoji,
timestamp = timestamp,
snr = snr,
@ -194,5 +176,5 @@ private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?)
)
}
private suspend fun List<ReactionEntity>.toReaction(getNode: suspend (userId: String?) -> Node) =
suspend fun List<ReactionEntity>.toReaction(getNode: suspend (userId: String?) -> Node?) =
this.map { it.toReaction(getNode) }

View file

@ -1,112 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delivery_confirmed
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.message_status_enroute
import org.meshtastic.core.resources.message_status_queued
import org.meshtastic.core.resources.message_status_sfpp_confirmed
import org.meshtastic.core.resources.message_status_sfpp_routing
import org.meshtastic.core.resources.routing_error_admin_bad_session_key
import org.meshtastic.core.resources.routing_error_admin_public_key_unauthorized
import org.meshtastic.core.resources.routing_error_bad_request
import org.meshtastic.core.resources.routing_error_duty_cycle_limit
import org.meshtastic.core.resources.routing_error_got_nak
import org.meshtastic.core.resources.routing_error_max_retransmit
import org.meshtastic.core.resources.routing_error_no_channel
import org.meshtastic.core.resources.routing_error_no_interface
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.routing_error_no_route
import org.meshtastic.core.resources.routing_error_none
import org.meshtastic.core.resources.routing_error_not_authorized
import org.meshtastic.core.resources.routing_error_pki_failed
import org.meshtastic.core.resources.routing_error_pki_send_fail_public_key
import org.meshtastic.core.resources.routing_error_pki_unknown_pubkey
import org.meshtastic.core.resources.routing_error_rate_limit_exceeded
import org.meshtastic.core.resources.routing_error_timeout
import org.meshtastic.core.resources.routing_error_too_large
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Routing
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(routingError: Int): StringResource = when (routingError) {
Routing.Error.NONE.value -> Res.string.routing_error_none
Routing.Error.NO_ROUTE.value -> Res.string.routing_error_no_route
Routing.Error.GOT_NAK.value -> Res.string.routing_error_got_nak
Routing.Error.TIMEOUT.value -> Res.string.routing_error_timeout
Routing.Error.NO_INTERFACE.value -> Res.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT.value -> Res.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL.value -> Res.string.routing_error_no_channel
Routing.Error.TOO_LARGE.value -> Res.string.routing_error_too_large
Routing.Error.NO_RESPONSE.value -> Res.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST.value -> Res.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED.value -> Res.string.routing_error_not_authorized
Routing.Error.PKI_FAILED.value -> Res.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> Res.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.routing_error_rate_limit_exceeded
Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.routing_error_pki_send_fail_public_key
else -> Res.string.unrecognized
}
data class Message(
val uuid: Long,
val receivedTime: Long,
val node: Node,
val text: String,
val fromLocal: Boolean,
val time: String,
val read: Boolean,
val status: MessageStatus?,
val routingError: Int,
val packetId: Int,
val emojis: List<Reaction>,
val snr: Float,
val rssi: Int,
val hopsAway: Int,
val replyId: Int?,
val originalMessage: Message? = null,
val viaMqtt: Boolean = false,
val relayNode: Int? = null,
val relays: Int = 0,
val filtered: Boolean = false,
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
val transportMechanism: Int = 0,
) {
fun getStatusStringRes(): Pair<StringResource, StringResource> {
val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status
val text =
when (status) {
MessageStatus.RECEIVED -> Res.string.delivery_confirmed
MessageStatus.QUEUED -> Res.string.message_status_queued
MessageStatus.ENROUTE -> Res.string.message_status_enroute
MessageStatus.SFPP_ROUTING -> Res.string.message_status_sfpp_routing
MessageStatus.SFPP_CONFIRMED -> Res.string.message_status_sfpp_confirmed
else -> getStringResFrom(routingError)
}
return title to text
}
}

View file

@ -1,240 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.model
import okio.ByteString
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
/**
* Domain model representing a node in the mesh network.
*
* This class aggregates user information, position data, and hardware metrics.
*/
@Suppress("MagicNumber")
data class Node(
val num: Int,
val metadata: DeviceMetadata? = null,
val user: User = User(),
val position: Position = Position(),
val snr: Float = Float.MAX_VALUE,
val rssi: Int = Int.MAX_VALUE,
val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
val deviceMetrics: DeviceMetrics = DeviceMetrics(),
val channel: Int = 0,
val viaMqtt: Boolean = false,
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val isMuted: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics(),
val powerMetrics: PowerMetrics = PowerMetrics(),
val paxcounter: Paxcount = Paxcount(),
val publicKey: ByteString? = null,
val notes: String = "",
val manuallyVerified: Boolean = false,
val nodeStatus: String? = null,
/** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */
val lastTransport: Int = 0,
) {
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
return foreground to background
}
val isUnknownUser
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
val mismatchKey
get() = (publicKey ?: user.public_key) == NodeEntity.ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics()
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics()
val batteryLevel
get() = deviceMetrics.battery_level
val voltage
get() = deviceMetrics.voltage
val batteryStr
get() = if ((batteryLevel ?: 0) in 1..100) "$batteryLevel%" else ""
val latitude
get() = (position.latitude_i ?: 0) * 1e-7
val longitude
get() = (position.longitude_i ?: 0) * 1e-7
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
val validPosition: Position?
get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
fun distance(o: Node): Int? = when {
validPosition == null || o.validPosition == null -> null
else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt()
}
// @return formatted distance string to another node, using the given display units
fun distanceStr(o: Node, displayUnits: Config.DisplayConfig.DisplayUnits): String? =
distance(o)?.toDistanceString(displayUnits)
// @return bearing to the other position in degrees
fun bearing(o: Node?): Int? = when {
validPosition == null || o?.validPosition == null -> null
else -> bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
val temp =
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f))
} else {
"%.1f°C".format(temperature)
}
} else {
null
}
val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
"%.1f°C".format(soil_temperature)
}
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
"%d%%".format(soil_moisture)
} else {
null
}
val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null
val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
paxcounter.getDisplayString(),
temp,
humidity,
soilTemperatureStr,
soilMoisture,
voltage,
current,
iaq,
)
}
private fun Paxcount.getDisplayString() =
"PAX: ${(ble ?: 0) + (wifi ?: 0)} (B:${ble ?: 0}/W:${wifi ?: 0})".takeIf { (ble ?: 0) != 0 || (wifi ?: 0) != 0 }
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
environmentMetrics.getDisplayStrings(isFahrenheit)
fun toEntity() = NodeEntity(
num = num,
user = user,
position = position,
latitude = latitude,
longitude = longitude,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceTelemetry = Telemetry(device_metrics = deviceMetrics),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentTelemetry = Telemetry(environment_metrics = environmentMetrics),
powerTelemetry = Telemetry(power_metrics = powerMetrics),
paxcounter = paxcounter,
publicKey = publicKey ?: user.public_key,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
companion object {
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
/** Creates a fallback [Node] when the node is not found in the database. */
fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
val longName = "$fallbackNamePrefix $safeUserId"
val defaultUser =
User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET)
return Node(num = nodeNum, user = defaultUser)
}
}
}
fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
listOf(
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.SENSOR,
Config.DeviceConfig.Role.TRACKER,
Config.DeviceConfig.Role.TAK_TRACKER,
)

View file

@ -1,37 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_sort_alpha
import org.meshtastic.core.resources.node_sort_channel
import org.meshtastic.core.resources.node_sort_distance
import org.meshtastic.core.resources.node_sort_hops_away
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.resources.node_sort_via_favorite
import org.meshtastic.core.resources.node_sort_via_mqtt
enum class NodeSortOption(val sqlValue: String, val stringRes: StringResource) {
LAST_HEARD("last_heard", Res.string.node_sort_last_heard),
ALPHABETICAL("alpha", Res.string.node_sort_alpha),
DISTANCE("distance", Res.string.node_sort_distance),
HOPS_AWAY("hops_away", Res.string.node_sort_hops_away),
CHANNEL("channel", Res.string.node_sort_channel),
VIA_MQTT("via_mqtt", Res.string.node_sort_via_mqtt),
VIA_FAVORITE("via_favorite", Res.string.node_sort_via_favorite),
}

View file

@ -1,96 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.tak_role_forwardobserver
import org.meshtastic.core.resources.tak_role_hq
import org.meshtastic.core.resources.tak_role_k9
import org.meshtastic.core.resources.tak_role_medic
import org.meshtastic.core.resources.tak_role_rto
import org.meshtastic.core.resources.tak_role_sniper
import org.meshtastic.core.resources.tak_role_teamlead
import org.meshtastic.core.resources.tak_role_teammember
import org.meshtastic.core.resources.tak_role_unspecified
import org.meshtastic.core.resources.tak_team_blue
import org.meshtastic.core.resources.tak_team_brown
import org.meshtastic.core.resources.tak_team_cyan
import org.meshtastic.core.resources.tak_team_dark_blue
import org.meshtastic.core.resources.tak_team_dark_green
import org.meshtastic.core.resources.tak_team_green
import org.meshtastic.core.resources.tak_team_magenta
import org.meshtastic.core.resources.tak_team_maroon
import org.meshtastic.core.resources.tak_team_orange
import org.meshtastic.core.resources.tak_team_purple
import org.meshtastic.core.resources.tak_team_red
import org.meshtastic.core.resources.tak_team_teal
import org.meshtastic.core.resources.tak_team_unspecified_color
import org.meshtastic.core.resources.tak_team_white
import org.meshtastic.core.resources.tak_team_yellow
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(team: Team): StringResource = when (team) {
Team.Unspecifed_Color -> Res.string.tak_team_unspecified_color
Team.White -> Res.string.tak_team_white
Team.Yellow -> Res.string.tak_team_yellow
Team.Orange -> Res.string.tak_team_orange
Team.Magenta -> Res.string.tak_team_magenta
Team.Red -> Res.string.tak_team_red
Team.Maroon -> Res.string.tak_team_maroon
Team.Purple -> Res.string.tak_team_purple
Team.Dark_Blue -> Res.string.tak_team_dark_blue
Team.Blue -> Res.string.tak_team_blue
Team.Cyan -> Res.string.tak_team_cyan
Team.Teal -> Res.string.tak_team_teal
Team.Green -> Res.string.tak_team_green
Team.Dark_Green -> Res.string.tak_team_dark_green
Team.Brown -> Res.string.tak_team_brown
}
fun getStringResFrom(role: MemberRole): StringResource = when (role) {
MemberRole.Unspecifed -> Res.string.tak_role_unspecified
MemberRole.TeamMember -> Res.string.tak_role_teammember
MemberRole.TeamLead -> Res.string.tak_role_teamlead
MemberRole.HQ -> Res.string.tak_role_hq
MemberRole.Sniper -> Res.string.tak_role_sniper
MemberRole.Medic -> Res.string.tak_role_medic
MemberRole.ForwardObserver -> Res.string.tak_role_forwardobserver
MemberRole.RTO -> Res.string.tak_role_rto
MemberRole.K9 -> Res.string.tak_role_k9
}
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun getColorFrom(team: Team): Long = when (team) {
Team.Unspecifed_Color -> 0xFF00FFFF // Default to Cyan
Team.White -> 0xFFFFFFFF
Team.Yellow -> 0xFFFFFF00
Team.Orange -> 0xFFFFA500
Team.Magenta -> 0xFFFF00FF
Team.Red -> 0xFFFF0000
Team.Maroon -> 0xFF800000
Team.Purple -> 0xFF800080
Team.Dark_Blue -> 0xFF00008B
Team.Blue -> 0xFF0000FF
Team.Cyan -> 0xFF00FFFF
Team.Teal -> 0xFF008080
Team.Green -> 0xFF00FF00
Team.Dark_Green -> 0xFF006400
Team.Brown -> 0xFFA52A2A
}

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.model
package org.meshtastic.core.model
import org.junit.Assert.assertEquals
import org.junit.Test