mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Modularize more models/utils (#3182)
This commit is contained in:
parent
5bb3f73e0d
commit
4eba3e9daf
80 changed files with 656 additions and 629 deletions
|
|
@ -0,0 +1,3 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable DataPacket;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable MeshUser;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable MyNodeInfo;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable NodeInfo;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable Position;
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.Portnums
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Generic [Parcel.readParcelable] Android 13 compatibility extension. */
|
||||
private inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
@Suppress("DEPRECATION")
|
||||
readParcelable(loader)
|
||||
} else {
|
||||
readParcelable(loader, T::class.java)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
enum class MessageStatus : Parcelable {
|
||||
UNKNOWN, // Not set for this message
|
||||
RECEIVED, // Came in from the mesh
|
||||
QUEUED, // Waiting to send to the mesh as soon as we connect to the device
|
||||
ENROUTE, // Delivered to the radio, but no ACK or NAK received
|
||||
DELIVERED, // We received an ack
|
||||
ERROR, // We received back a nak, message not delivered
|
||||
}
|
||||
|
||||
/** A parcelable version of the protobuf MeshPacket + Data subpacket. */
|
||||
@Serializable
|
||||
data class DataPacket(
|
||||
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
|
||||
val bytes: ByteArray?,
|
||||
// A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions)
|
||||
val dataType: Int,
|
||||
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
|
||||
var time: Long = System.currentTimeMillis(), // msecs since 1970
|
||||
var id: Int = 0, // 0 means unassigned
|
||||
var status: MessageStatus? = MessageStatus.UNKNOWN,
|
||||
var hopLimit: Int = 0,
|
||||
var channel: Int = 0, // channel index
|
||||
var wantAck: Boolean = true, // If true, the receiver should send an ack back
|
||||
var hopStart: Int = 0,
|
||||
var snr: Float = 0f,
|
||||
var rssi: Int = 0,
|
||||
var replyId: Int? = null, // If this is a reply to a previous message, this is the ID of that message
|
||||
) : Parcelable {
|
||||
|
||||
/** If there was an error with this message, this string describes what was wrong. */
|
||||
var errorMessage: String? = null
|
||||
|
||||
/** Syntactic sugar to make it easy to create text messages */
|
||||
constructor(
|
||||
to: String?,
|
||||
channel: Int,
|
||||
text: String,
|
||||
replyId: Int? = null,
|
||||
) : this(
|
||||
to = to,
|
||||
bytes = text.encodeToByteArray(),
|
||||
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
|
||||
channel = channel,
|
||||
replyId = replyId ?: 0,
|
||||
)
|
||||
|
||||
/** If this is a text message, return the string, otherwise null */
|
||||
val text: String?
|
||||
get() =
|
||||
if (dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) {
|
||||
bytes?.decodeToString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val alert: String?
|
||||
get() =
|
||||
if (dataType == Portnums.PortNum.ALERT_APP_VALUE) {
|
||||
bytes?.decodeToString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
constructor(
|
||||
to: String?,
|
||||
channel: Int,
|
||||
waypoint: MeshProtos.Waypoint,
|
||||
) : this(to = to, bytes = waypoint.toByteArray(), dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, channel = channel)
|
||||
|
||||
val waypoint: MeshProtos.Waypoint?
|
||||
get() =
|
||||
if (dataType == Portnums.PortNum.WAYPOINT_APP_VALUE) {
|
||||
MeshProtos.Waypoint.parseFrom(bytes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val hopsAway: Int
|
||||
get() = if (hopStart == 0 || hopLimit > hopStart) -1 else hopStart - hopLimit
|
||||
|
||||
// Autogenerated comparision, because we have a byte array
|
||||
|
||||
constructor(
|
||||
parcel: Parcel,
|
||||
) : this(
|
||||
parcel.readString(),
|
||||
parcel.createByteArray(),
|
||||
parcel.readInt(),
|
||||
parcel.readString(),
|
||||
parcel.readLong(),
|
||||
parcel.readInt(),
|
||||
parcel.readParcelableCompat(MessageStatus::class.java.classLoader),
|
||||
parcel.readInt(),
|
||||
parcel.readInt(),
|
||||
parcel.readInt() == 1,
|
||||
parcel.readInt(),
|
||||
parcel.readFloat(),
|
||||
parcel.readInt(),
|
||||
parcel.readInt().let { if (it == 0) null else it },
|
||||
)
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DataPacket
|
||||
|
||||
if (from != other.from) return false
|
||||
if (to != other.to) return false
|
||||
if (channel != other.channel) return false
|
||||
if (time != other.time) return false
|
||||
if (id != other.id) return false
|
||||
if (dataType != other.dataType) return false
|
||||
if (!bytes!!.contentEquals(other.bytes!!)) return false
|
||||
if (status != other.status) return false
|
||||
if (hopLimit != other.hopLimit) return false
|
||||
if (wantAck != other.wantAck) return false
|
||||
if (hopStart != other.hopStart) return false
|
||||
if (snr != other.snr) return false
|
||||
if (rssi != other.rssi) return false
|
||||
if (replyId != other.replyId) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = from.hashCode()
|
||||
result = 31 * result + to.hashCode()
|
||||
result = 31 * result + time.hashCode()
|
||||
result = 31 * result + id
|
||||
result = 31 * result + dataType
|
||||
result = 31 * result + bytes!!.contentHashCode()
|
||||
result = 31 * result + status.hashCode()
|
||||
result = 31 * result + hopLimit
|
||||
result = 31 * result + channel
|
||||
result = 31 * result + wantAck.hashCode()
|
||||
result = 31 * result + hopStart
|
||||
result = 31 * result + snr.hashCode()
|
||||
result = 31 * result + rssi
|
||||
result = 31 * result + replyId.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(to)
|
||||
parcel.writeByteArray(bytes)
|
||||
parcel.writeInt(dataType)
|
||||
parcel.writeString(from)
|
||||
parcel.writeLong(time)
|
||||
parcel.writeInt(id)
|
||||
parcel.writeParcelable(status, flags)
|
||||
parcel.writeInt(hopLimit)
|
||||
parcel.writeInt(channel)
|
||||
parcel.writeInt(if (wantAck) 1 else 0)
|
||||
parcel.writeInt(hopStart)
|
||||
parcel.writeFloat(snr)
|
||||
parcel.writeInt(rssi)
|
||||
parcel.writeInt(replyId ?: 0)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
// Update our object from our parcel (used for inout parameters
|
||||
fun readFromParcel(parcel: Parcel) {
|
||||
to = parcel.readString()
|
||||
parcel.createByteArray()
|
||||
parcel.readInt()
|
||||
from = parcel.readString()
|
||||
time = parcel.readLong()
|
||||
id = parcel.readInt()
|
||||
status = parcel.readParcelableCompat(MessageStatus::class.java.classLoader)
|
||||
hopLimit = parcel.readInt()
|
||||
channel = parcel.readInt()
|
||||
wantAck = parcel.readInt() == 1
|
||||
hopStart = parcel.readInt()
|
||||
snr = parcel.readFloat()
|
||||
rssi = parcel.readInt()
|
||||
replyId = parcel.readInt().let { if (it == 0) null else it }
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<DataPacket> {
|
||||
// Special node IDs that can be used for sending messages
|
||||
|
||||
/** the Node ID for broadcast destinations */
|
||||
const val ID_BROADCAST = "^all"
|
||||
|
||||
/** The Node ID for the local node - used for from when sender doesn't know our local node ID */
|
||||
const val ID_LOCAL = "^local"
|
||||
|
||||
// special broadcast address
|
||||
const val NODENUM_BROADCAST = (0xffffffff).toInt()
|
||||
|
||||
// Public-key cryptography (PKC) channel index
|
||||
const val PKC_CHANNEL_INDEX = 8
|
||||
|
||||
fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
|
||||
|
||||
override fun createFromParcel(parcel: Parcel): DataPacket = DataPacket(parcel)
|
||||
|
||||
override fun newArray(size: Int): Array<DataPacket?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
/** Provide structured access to parse and compare device version strings */
|
||||
data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
val asInt
|
||||
get() =
|
||||
try {
|
||||
verStringToInt(asString)
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Exception while parsing version '$asString', assuming version 0")
|
||||
0
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
|
||||
*
|
||||
* Or throw an exception if the string can not be parsed
|
||||
*/
|
||||
@Suppress("TooGenericExceptionThrown", "MagicNumber")
|
||||
private fun verStringToInt(s: String): Int {
|
||||
// Allow 1 to two digits per match
|
||||
val match = Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(s) ?: throw Exception("Can't parse version $s")
|
||||
val (major, minor, build) = match.destructured
|
||||
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
|
||||
}
|
||||
|
||||
override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
// MyNodeInfo sent via special protobuf from radio
|
||||
@Parcelize
|
||||
data class MyNodeInfo(
|
||||
val myNodeNum: Int,
|
||||
val hasGPS: Boolean,
|
||||
val model: String?,
|
||||
val firmwareVersion: String?,
|
||||
val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want
|
||||
val shouldUpdate: Boolean, // this device has old firmware
|
||||
val currentPacketId: Long,
|
||||
val messageTimeoutMsec: Int,
|
||||
val minAppVersion: Int,
|
||||
val maxChannels: Int,
|
||||
val hasWifi: Boolean,
|
||||
val channelUtilization: Float,
|
||||
val airUtilTx: Float,
|
||||
val deviceId: String?,
|
||||
) : Parcelable {
|
||||
/** A human readable description of the software/hardware version */
|
||||
val firmwareString: String
|
||||
get() = "$model $firmwareVersion"
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NetworkDeviceHardware(
|
||||
@SerialName("activelySupported") val activelySupported: Boolean = false,
|
||||
@SerialName("architecture") val architecture: String = "",
|
||||
@SerialName("displayName") val displayName: String = "",
|
||||
@SerialName("hasInkHud") val hasInkHud: Boolean? = null,
|
||||
@SerialName("hasMui") val hasMui: Boolean? = null,
|
||||
@SerialName("hwModel") val hwModel: Int = 0,
|
||||
@SerialName("hwModelSlug") val hwModelSlug: String = "",
|
||||
@SerialName("images") val images: List<String>? = null,
|
||||
@SerialName("partitionScheme") val partitionScheme: String? = null,
|
||||
@SerialName("platformioTarget") val platformioTarget: String = "",
|
||||
@SerialName("requiresDfu") val requiresDfu: Boolean? = null,
|
||||
@SerialName("supportLevel") val supportLevel: Int? = null,
|
||||
@SerialName("tags") val tags: List<String>? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NetworkFirmwareRelease(
|
||||
@SerialName("id") val id: String = "",
|
||||
@SerialName("page_url") val pageUrl: String = "",
|
||||
@SerialName("release_notes") val releaseNotes: String = "",
|
||||
@SerialName("title") val title: String = "",
|
||||
@SerialName("zip_url") val zipUrl: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Releases(
|
||||
@SerialName("alpha") val alpha: List<NetworkFirmwareRelease> = listOf(),
|
||||
@SerialName("stable") val stable: List<NetworkFirmwareRelease> = listOf(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetworkFirmwareReleases(
|
||||
@SerialName("pullRequests") val pullRequests: List<NetworkFirmwareRelease> = listOf(),
|
||||
@SerialName("releases") val releases: Releases = Releases(),
|
||||
)
|
||||
261
core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt
Normal file
261
core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Parcelable
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.TelemetryProtos
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.model.util.bearing
|
||||
import org.meshtastic.core.model.util.latLongToMeter
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
|
||||
//
|
||||
// model objects that directly map to the corresponding protobufs
|
||||
//
|
||||
|
||||
@Parcelize
|
||||
data class MeshUser(
|
||||
val id: String,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val hwModel: MeshProtos.HardwareModel,
|
||||
val isLicensed: Boolean = false,
|
||||
val role: Int = 0,
|
||||
) : Parcelable {
|
||||
|
||||
override fun toString(): String = "MeshUser(id=${id.anonymize}, " +
|
||||
"longName=${longName.anonymize}, " +
|
||||
"shortName=${shortName.anonymize}, " +
|
||||
"hwModel=$hwModelString, " +
|
||||
"isLicensed=$isLicensed, " +
|
||||
"role=$role)"
|
||||
|
||||
/** Create our model object from a protobuf. */
|
||||
constructor(p: MeshProtos.User) : this(p.id, p.longName, p.shortName, p.hwModel, p.isLicensed, p.roleValue)
|
||||
|
||||
/**
|
||||
* a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null
|
||||
* if unset
|
||||
*/
|
||||
val hwModelString: String?
|
||||
get() =
|
||||
if (hwModel == MeshProtos.HardwareModel.UNSET) {
|
||||
null
|
||||
} else {
|
||||
hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Position(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitude: Int,
|
||||
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
|
||||
val satellitesInView: Int = 0,
|
||||
val groundSpeed: Int = 0,
|
||||
val groundTrack: Int = 0, // "heading"
|
||||
val precisionBits: Int = 0,
|
||||
) : Parcelable {
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
// / Convert to a double representation of degrees
|
||||
fun degD(i: Int) = i * 1e-7
|
||||
|
||||
fun degI(d: Double) = (d * 1e7).toInt()
|
||||
|
||||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will
|
||||
* be used.
|
||||
*/
|
||||
constructor(
|
||||
position: MeshProtos.Position,
|
||||
defaultTime: Int = currentTime(),
|
||||
) : this(
|
||||
// We prefer the int version of lat/lon but if not available use the depreciated legacy version
|
||||
degD(position.latitudeI),
|
||||
degD(position.longitudeI),
|
||||
position.altitude,
|
||||
if (position.time != 0) position.time else defaultTime,
|
||||
position.satsInView,
|
||||
position.groundSpeed,
|
||||
position.groundTrack,
|
||||
position.precisionBits,
|
||||
)
|
||||
|
||||
// / @return distance in meters to some other node (or null if unknown)
|
||||
fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude)
|
||||
|
||||
// / @return bearing to the other position in degrees
|
||||
fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude)
|
||||
|
||||
// If GPS gives a crap position don't crash our app
|
||||
@Suppress("MagicNumber")
|
||||
fun isValid(): Boolean = latitude != 0.0 &&
|
||||
longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
|
||||
override fun toString(): String =
|
||||
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class DeviceMetrics(
|
||||
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
|
||||
val batteryLevel: Int = 0,
|
||||
val voltage: Float,
|
||||
val channelUtilization: Float,
|
||||
val airUtilTx: Float,
|
||||
val uptimeSeconds: Int,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
@Suppress("MagicNumber")
|
||||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
}
|
||||
|
||||
/** Create our model object from a protobuf. */
|
||||
constructor(
|
||||
p: TelemetryProtos.DeviceMetrics,
|
||||
telemetryTime: Int = currentTime(),
|
||||
) : this(telemetryTime, p.batteryLevel, p.voltage, p.channelUtilization, p.airUtilTx, p.uptimeSeconds)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class EnvironmentMetrics(
|
||||
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
|
||||
val temperature: Float?,
|
||||
val relativeHumidity: Float?,
|
||||
val soilTemperature: Float?,
|
||||
val soilMoisture: Int?,
|
||||
val barometricPressure: Float?,
|
||||
val gasResistance: Float?,
|
||||
val voltage: Float?,
|
||||
val current: Float?,
|
||||
val iaq: Int?,
|
||||
val lux: Float? = null,
|
||||
val uvLux: Float? = null,
|
||||
) : Parcelable {
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
|
||||
fun fromTelemetryProto(proto: TelemetryProtos.EnvironmentMetrics, time: Int): EnvironmentMetrics =
|
||||
EnvironmentMetrics(
|
||||
temperature = proto.temperature.takeIf { proto.hasTemperature() && !it.isNaN() },
|
||||
relativeHumidity =
|
||||
proto.relativeHumidity.takeIf { proto.hasRelativeHumidity() && !it.isNaN() && it != 0.0f },
|
||||
soilTemperature = proto.soilTemperature.takeIf { proto.hasSoilTemperature() && !it.isNaN() },
|
||||
soilMoisture = proto.soilMoisture.takeIf { proto.hasSoilMoisture() && it != Int.MIN_VALUE },
|
||||
barometricPressure = proto.barometricPressure.takeIf { proto.hasBarometricPressure() && !it.isNaN() },
|
||||
gasResistance = proto.gasResistance.takeIf { proto.hasGasResistance() && !it.isNaN() },
|
||||
voltage = proto.voltage.takeIf { proto.hasVoltage() && !it.isNaN() },
|
||||
current = proto.current.takeIf { proto.hasCurrent() && !it.isNaN() },
|
||||
iaq = proto.iaq.takeIf { proto.hasIaq() && it != Int.MIN_VALUE },
|
||||
lux = proto.lux.takeIf { proto.hasLux() && !it.isNaN() },
|
||||
uvLux = proto.uvLux.takeIf { proto.hasUvLux() && !it.isNaN() },
|
||||
time = time,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class NodeInfo(
|
||||
val num: Int, // This is immutable, and used as a key
|
||||
var user: MeshUser? = null,
|
||||
var position: Position? = null,
|
||||
var snr: Float = Float.MAX_VALUE,
|
||||
var rssi: Int = Int.MAX_VALUE,
|
||||
var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
|
||||
var deviceMetrics: DeviceMetrics? = null,
|
||||
var channel: Int = 0,
|
||||
var environmentMetrics: EnvironmentMetrics? = null,
|
||||
var hopsAway: Int = 0,
|
||||
) : Parcelable {
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
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
|
||||
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
|
||||
}
|
||||
|
||||
val batteryLevel
|
||||
get() = deviceMetrics?.batteryLevel
|
||||
|
||||
val voltage
|
||||
get() = deviceMetrics?.voltage
|
||||
|
||||
@Suppress("ImplicitDefaultLocale")
|
||||
val batteryStr
|
||||
get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
|
||||
|
||||
/** true if the device was heard from recently */
|
||||
val isOnline: Boolean
|
||||
get() {
|
||||
return lastHeard > onlineTimeThreshold()
|
||||
}
|
||||
|
||||
// / return the position if it is valid, else null
|
||||
val validPosition: Position?
|
||||
get() {
|
||||
return position?.takeIf { it.isValid() }
|
||||
}
|
||||
|
||||
// / @return distance in meters to some other node (or null if unknown)
|
||||
fun distance(o: NodeInfo?): Int? {
|
||||
val p = validPosition
|
||||
val op = o?.validPosition
|
||||
return if (p != null && op != null) p.distance(op).toInt() else null
|
||||
}
|
||||
|
||||
// / @return bearing to the other position in degrees
|
||||
fun bearing(o: NodeInfo?): Int? {
|
||||
val p = validPosition
|
||||
val op = o?.validPosition
|
||||
return if (p != null && op != null) p.bearing(op).toInt() else null
|
||||
}
|
||||
|
||||
// / @return a nice human readable string for the distance, or null for unknown
|
||||
@Suppress("MagicNumber")
|
||||
fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist ->
|
||||
when {
|
||||
dist == 0 -> null // same point
|
||||
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 ->
|
||||
"%.0f m".format(dist.toDouble())
|
||||
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 ->
|
||||
"%.1f km".format(dist / 1000.0)
|
||||
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 ->
|
||||
"%.0f ft".format(dist.toDouble() * 3.281)
|
||||
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 ->
|
||||
"%.1f mi".format(dist / 1609.34)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model.util
|
||||
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// return time if within 24 hours, otherwise date
|
||||
fun getShortDate(time: Long): String? {
|
||||
val date = if (time != 0L) Date(time) else return null
|
||||
val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1)
|
||||
|
||||
return if (isWithin24Hours) {
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
|
||||
} else {
|
||||
DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
// return time if within 24 hours, otherwise date/time
|
||||
fun getShortDateTime(time: Long): String {
|
||||
val date = Date(time)
|
||||
val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1)
|
||||
|
||||
return if (isWithin24Hours) {
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
|
||||
} else {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatUptime(seconds: Int): String = formatUptime(seconds.toLong())
|
||||
|
||||
private fun formatUptime(seconds: Long): String {
|
||||
val days = TimeUnit.SECONDS.toDays(seconds)
|
||||
val hours = TimeUnit.SECONDS.toHours(seconds) % TimeUnit.DAYS.toHours(1)
|
||||
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % TimeUnit.HOURS.toMinutes(1)
|
||||
val secs = seconds % TimeUnit.MINUTES.toSeconds(1)
|
||||
|
||||
return listOfNotNull(
|
||||
"${days}d".takeIf { days > 0 },
|
||||
"${hours}h".takeIf { hours > 0 },
|
||||
"${minutes}m".takeIf { minutes > 0 },
|
||||
"${secs}s".takeIf { secs > 0 },
|
||||
)
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun onlineTimeThreshold() = (System.currentTimeMillis() / 1000 - 2 * 60 * 60).toInt()
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model.util
|
||||
|
||||
import android.widget.EditText
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import org.meshtastic.core.model.BuildConfig
|
||||
|
||||
/**
|
||||
* When printing strings to logs sometimes we want to print useful debugging information about users or positions. But
|
||||
* we don't want to leak things like usernames or locations. So this function if given a string, will return a string
|
||||
* which is a maximum of three characters long, taken from the tail of the string. Which should effectively hide real
|
||||
* usernames and locations, but still let us see if values were zero, empty or different.
|
||||
*/
|
||||
val Any?.anonymize: String
|
||||
get() = this.anonymize()
|
||||
|
||||
/** A version of anonymize that allows passing in a custom minimum length */
|
||||
fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null"
|
||||
|
||||
// A toString that makes sure all newlines are removed (for nice logging).
|
||||
fun Any.toOneLineString() = this.toString().replace('\n', ' ')
|
||||
|
||||
fun ConfigProtos.Config.toOneLineString(): String {
|
||||
val redactedFields = """(wifi_psk:|public_key:|private_key:|admin_key:)\s*".*"""
|
||||
return this.toString()
|
||||
.replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" }
|
||||
.replace('\n', ' ')
|
||||
}
|
||||
|
||||
fun MeshProtos.toOneLineString(): String {
|
||||
val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys
|
||||
return this.toString()
|
||||
.replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" }
|
||||
.replace('\n', ' ')
|
||||
}
|
||||
|
||||
// Return a one line string version of an object (but if a release build, just say 'might be PII)
|
||||
fun Any.toPIIString() = if (!BuildConfig.DEBUG) {
|
||||
"<PII?>"
|
||||
} else {
|
||||
this.toOneLineString()
|
||||
}
|
||||
|
||||
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMillis()): String {
|
||||
val currentTime = (currentTimeMillis / 1000).toInt()
|
||||
val diffMin = (currentTime - lastSeenUnix) / 60
|
||||
return when {
|
||||
diffMin < 1 -> "now"
|
||||
diffMin < 60 -> diffMin.toString() + " min"
|
||||
diffMin < 2880 -> (diffMin / 60).toString() + " h"
|
||||
diffMin < 1440000 -> (diffMin / (60 * 24)).toString() + " d"
|
||||
else -> "?"
|
||||
}
|
||||
}
|
||||
|
||||
private const val MPS_TO_KMPH = 3.6f
|
||||
private const val KM_TO_MILES = 0.621371f
|
||||
|
||||
fun Int.mpsToKmph(): Float {
|
||||
// Convert meters per second to kilometers per hour
|
||||
val kmph = this * MPS_TO_KMPH
|
||||
return kmph
|
||||
}
|
||||
|
||||
fun Int.mpsToMph(): Float {
|
||||
// Convert meters per second to miles per hour
|
||||
val mph = this * MPS_TO_KMPH * KM_TO_MILES
|
||||
return mph
|
||||
}
|
||||
|
||||
// Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() })
|
||||
fun EditText.onEditorAction(actionId: Int, func: () -> Unit) {
|
||||
setOnEditorActionListener { _, receivedActionId, _ ->
|
||||
if (actionId == receivedActionId) {
|
||||
func()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import org.meshtastic.core.model.Position
|
||||
import java.util.Locale
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@SuppressLint("PropertyNaming")
|
||||
object GPSFormat {
|
||||
fun toDec(latitude: Double, longitude: Double): String =
|
||||
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
|
||||
}
|
||||
|
||||
private const val EARTH_RADIUS_METERS = 6371e3
|
||||
|
||||
/** @return distance in meters along the surface of the earth (ish) */
|
||||
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
|
||||
val lat1 = Math.toRadians(latitudeA)
|
||||
val lon1 = Math.toRadians(longitudeA)
|
||||
val lat2 = Math.toRadians(latitudeB)
|
||||
val lon2 = Math.toRadians(longitudeB)
|
||||
|
||||
val dLat = lat2 - lat1
|
||||
val dLon = lon2 - lon1
|
||||
|
||||
val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
|
||||
val c = 2 * asin(sqrt(a))
|
||||
|
||||
return EARTH_RADIUS_METERS * c
|
||||
}
|
||||
|
||||
// Same as above, but takes Mesh Position proto.
|
||||
@Suppress("MagicNumber")
|
||||
fun positionToMeter(a: Position, b: Position): Double =
|
||||
latLongToMeter(a.latitude * 1e-7, a.longitude * 1e-7, b.latitude * 1e-7, b.longitude * 1e-7)
|
||||
|
||||
/**
|
||||
* Computes the bearing in degrees between two points on Earth.
|
||||
*
|
||||
* @param lat1 Latitude of the first point
|
||||
* @param lon1 Longitude of the first point
|
||||
* @param lat2 Latitude of the second point
|
||||
* @param lon2 Longitude of the second point
|
||||
* @return Bearing between the two points in degrees. A value of 0 means due north.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val lat1Rad = Math.toRadians(lat1)
|
||||
val lon1Rad = Math.toRadians(lon1)
|
||||
val lat2Rad = Math.toRadians(lat2)
|
||||
val lon2Rad = Math.toRadians(lon2)
|
||||
|
||||
val dLon = lon2Rad - lon1Rad
|
||||
|
||||
val y = sin(dLon) * cos(lat2Rad)
|
||||
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
|
||||
val bearing = Math.toDegrees(atan2(y, x))
|
||||
|
||||
return (bearing + 360) % 360
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.model.util
|
||||
|
||||
import kotlin.math.ln
|
||||
|
||||
object UnitConversions {
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun celsiusToFahrenheit(celsius: Float): Float = (celsius * 1.8F) + 32
|
||||
|
||||
fun Float.toTempString(isFahrenheit: Boolean) = if (isFahrenheit) {
|
||||
val fahrenheit = celsiusToFahrenheit(this)
|
||||
"%.0f°F".format(fahrenheit)
|
||||
} else {
|
||||
"%.0f°C".format(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculated the dew point based on the Magnus-Tetens approximation which is a widely used formula for calculating
|
||||
* dew point temperature.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun calculateDewPoint(tempCelsius: Float, humidity: Float): Float {
|
||||
val (a, b) = 17.27f to 237.7f
|
||||
val alpha = (a * tempCelsius) / (b + tempCelsius) + ln(humidity / 100f)
|
||||
return (b * alpha) / (a - alpha)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue