mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat/decoupling (#4685)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
40244f8337
commit
2c49db8041
254 changed files with 5132 additions and 2666 deletions
|
|
@ -32,6 +32,7 @@ configure<LibraryExtension> {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.repository)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue