mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Run spotless ahead of 2523 to make the diff easier (#2571)
This commit is contained in:
parent
64ead16d83
commit
d336f23486
6 changed files with 282 additions and 483 deletions
|
|
@ -20,9 +20,9 @@ package com.geeksville.mesh
|
|||
import android.graphics.Color
|
||||
import android.os.Parcelable
|
||||
import com.geeksville.mesh.util.GPSFormat
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import com.geeksville.mesh.util.bearing
|
||||
import com.geeksville.mesh.util.latLongToMeter
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import com.geeksville.mesh.util.onlineTimeThreshold
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
|
@ -40,33 +40,27 @@ data class MeshUser(
|
|||
val role: Int = 0,
|
||||
) : Parcelable {
|
||||
|
||||
override fun toString(): String {
|
||||
return "MeshUser(id=${id.anonymize}, " +
|
||||
"longName=${longName.anonymize}, " +
|
||||
"shortName=${shortName.anonymize}, " +
|
||||
"hwModel=$hwModelString, " +
|
||||
"isLicensed=$isLicensed, " +
|
||||
"role=$role)"
|
||||
}
|
||||
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.
|
||||
/** 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
|
||||
*/
|
||||
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()
|
||||
if (hwModel == MeshProtos.HardwareModel.UNSET) {
|
||||
null
|
||||
} else {
|
||||
hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -82,16 +76,22 @@ data class Position(
|
|||
) : Parcelable {
|
||||
|
||||
companion object {
|
||||
/// Convert to a double representation of degrees
|
||||
// / 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.
|
||||
/**
|
||||
* 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(
|
||||
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),
|
||||
|
|
@ -100,21 +100,20 @@ data class Position(
|
|||
position.satsInView,
|
||||
position.groundSpeed,
|
||||
position.groundTrack,
|
||||
position.precisionBits
|
||||
position.precisionBits,
|
||||
)
|
||||
|
||||
/// @return distance in meters to some other node (or null if unknown)
|
||||
// / @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
|
||||
// / @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
|
||||
fun isValid(): Boolean {
|
||||
return latitude != 0.0 && longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
}
|
||||
fun isValid(): Boolean = latitude != 0.0 &&
|
||||
longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
|
||||
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this)
|
||||
|
|
@ -124,12 +123,10 @@ data class Position(
|
|||
else -> GPSFormat.DEC(this)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})"
|
||||
}
|
||||
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!)
|
||||
|
|
@ -143,16 +140,11 @@ data class DeviceMetrics(
|
|||
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,
|
||||
)
|
||||
/** 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
|
||||
|
|
@ -184,7 +176,7 @@ data class NodeInfo(
|
|||
var deviceMetrics: DeviceMetrics? = null,
|
||||
var channel: Int = 0,
|
||||
var environmentMetrics: EnvironmentMetrics? = null,
|
||||
var hopsAway: Int = 0
|
||||
var hopsAway: Int = 0,
|
||||
) : Parcelable {
|
||||
|
||||
val colors: Pair<Int, Int>
|
||||
|
|
@ -196,46 +188,53 @@ data class NodeInfo(
|
|||
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
|
||||
val batteryStr get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
|
||||
val batteryLevel
|
||||
get() = deviceMetrics?.batteryLevel
|
||||
|
||||
/**
|
||||
* true if the device was heard from recently
|
||||
*/
|
||||
val voltage
|
||||
get() = deviceMetrics?.voltage
|
||||
|
||||
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
|
||||
// / 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)
|
||||
// / @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
|
||||
// / @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
|
||||
// / @return a nice human readable string for the distance, or null for unknown
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,94 +42,64 @@ class Converters : Logging {
|
|||
}
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio {
|
||||
return try {
|
||||
MeshProtos.FromRadio.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToFromRadio TypeConverter error:", ex)
|
||||
MeshProtos.FromRadio.getDefaultInstance()
|
||||
}
|
||||
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio = try {
|
||||
MeshProtos.FromRadio.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToFromRadio TypeConverter error:", ex)
|
||||
MeshProtos.FromRadio.getDefaultInstance()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? {
|
||||
return value.toByteArray()
|
||||
}
|
||||
@TypeConverter fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? = value.toByteArray()
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToUser(bytes: ByteArray): MeshProtos.User {
|
||||
return try {
|
||||
MeshProtos.User.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToUser TypeConverter error:", ex)
|
||||
MeshProtos.User.getDefaultInstance()
|
||||
}
|
||||
fun bytesToUser(bytes: ByteArray): MeshProtos.User = try {
|
||||
MeshProtos.User.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToUser TypeConverter error:", ex)
|
||||
MeshProtos.User.getDefaultInstance()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun userToBytes(value: MeshProtos.User): ByteArray? {
|
||||
return value.toByteArray()
|
||||
}
|
||||
@TypeConverter fun userToBytes(value: MeshProtos.User): ByteArray? = value.toByteArray()
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position {
|
||||
return try {
|
||||
MeshProtos.Position.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToPosition TypeConverter error:", ex)
|
||||
MeshProtos.Position.getDefaultInstance()
|
||||
}
|
||||
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position = try {
|
||||
MeshProtos.Position.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToPosition TypeConverter error:", ex)
|
||||
MeshProtos.Position.getDefaultInstance()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun positionToBytes(value: MeshProtos.Position): ByteArray? {
|
||||
return value.toByteArray()
|
||||
}
|
||||
@TypeConverter fun positionToBytes(value: MeshProtos.Position): ByteArray? = value.toByteArray()
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry {
|
||||
return try {
|
||||
TelemetryProtos.Telemetry.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToTelemetry TypeConverter error:", ex)
|
||||
TelemetryProtos.Telemetry.getDefaultInstance()
|
||||
}
|
||||
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry = try {
|
||||
TelemetryProtos.Telemetry.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToTelemetry TypeConverter error:", ex)
|
||||
TelemetryProtos.Telemetry.getDefaultInstance()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? {
|
||||
return value.toByteArray()
|
||||
}
|
||||
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount {
|
||||
return try {
|
||||
PaxcountProtos.Paxcount.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToPaxcounter TypeConverter error:", ex)
|
||||
PaxcountProtos.Paxcount.getDefaultInstance()
|
||||
}
|
||||
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount = try {
|
||||
PaxcountProtos.Paxcount.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToPaxcounter TypeConverter error:", ex)
|
||||
PaxcountProtos.Paxcount.getDefaultInstance()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? {
|
||||
return value.toByteArray()
|
||||
}
|
||||
@TypeConverter fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? = value.toByteArray()
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata {
|
||||
return try {
|
||||
MeshProtos.DeviceMetadata.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToMetadata TypeConverter error:", ex)
|
||||
MeshProtos.DeviceMetadata.getDefaultInstance()
|
||||
}
|
||||
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata = try {
|
||||
MeshProtos.DeviceMetadata.parseFrom(bytes)
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
errormsg("bytesToMetadata TypeConverter error:", ex)
|
||||
MeshProtos.DeviceMetadata.getDefaultInstance()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? {
|
||||
return value.toByteArray()
|
||||
}
|
||||
@TypeConverter fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? = value.toByteArray()
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(value: String?): List<String>? {
|
||||
|
|
@ -148,12 +118,7 @@ class Converters : Logging {
|
|||
}
|
||||
|
||||
@TypeConverter
|
||||
fun bytesToByteString(bytes: ByteArray?): ByteString? {
|
||||
return if (bytes == null) null else ByteString.copyFrom(bytes)
|
||||
}
|
||||
fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes)
|
||||
|
||||
@TypeConverter
|
||||
fun byteStringToBytes(value: ByteString?): ByteArray? {
|
||||
return value?.toByteArray()
|
||||
}
|
||||
@TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,69 +84,41 @@ data class NodeWithRelations(
|
|||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "metadata",
|
||||
indices = [
|
||||
Index(value = ["num"]),
|
||||
],
|
||||
)
|
||||
@Entity(tableName = "metadata", indices = [Index(value = ["num"])])
|
||||
data class MetadataEntity(
|
||||
@PrimaryKey val num: Int,
|
||||
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB)
|
||||
val proto: MeshProtos.DeviceMetadata,
|
||||
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: MeshProtos.DeviceMetadata,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Entity(tableName = "nodes")
|
||||
data class NodeEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
val num: Int, // This is immutable, and used as a key
|
||||
|
||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||
var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
|
||||
@PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key
|
||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
|
||||
@ColumnInfo(name = "long_name") var longName: String? = null,
|
||||
@ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter
|
||||
|
||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||
var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
|
||||
var latitude: Double = 0.0,
|
||||
var longitude: Double = 0.0,
|
||||
|
||||
var snr: Float = Float.MAX_VALUE,
|
||||
var rssi: Int = Int.MAX_VALUE,
|
||||
|
||||
@ColumnInfo(name = "last_heard")
|
||||
var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
|
||||
|
||||
@ColumnInfo(name = "last_heard") var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
|
||||
@ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB)
|
||||
var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
|
||||
|
||||
var channel: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "via_mqtt")
|
||||
var viaMqtt: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "hops_away")
|
||||
var hopsAway: Int = -1,
|
||||
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
var isFavorite: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "is_ignored", defaultValue = "0")
|
||||
var isIgnored: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false,
|
||||
@ColumnInfo(name = "hops_away") var hopsAway: Int = -1,
|
||||
@ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false,
|
||||
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
|
||||
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
|
||||
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
|
||||
|
||||
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
|
||||
var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
|
||||
|
||||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
|
||||
var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
|
||||
|
||||
@ColumnInfo(name = "public_key")
|
||||
var publicKey: ByteString? = null,
|
||||
@ColumnInfo(name = "public_key") var publicKey: ByteString? = null,
|
||||
) {
|
||||
val deviceMetrics: TelemetryProtos.DeviceMetrics
|
||||
get() = deviceTelemetry.deviceMetrics
|
||||
|
|
@ -154,8 +126,11 @@ data class NodeEntity(
|
|||
val environmentMetrics: TelemetryProtos.EnvironmentMetrics
|
||||
get() = environmentTelemetry.environmentMetrics
|
||||
|
||||
val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
|
||||
val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty()
|
||||
val isUnknownUser
|
||||
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
|
||||
|
||||
val hasPKC
|
||||
get() = (publicKey ?: user.publicKey).isNotEmpty()
|
||||
|
||||
fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) {
|
||||
position = p.copy { time = if (p.time != 0) p.time else defaultTime }
|
||||
|
|
@ -163,9 +138,7 @@ data class NodeEntity(
|
|||
longitude = degD(p.longitudeI)
|
||||
}
|
||||
|
||||
/**
|
||||
* true if the device was heard from recently
|
||||
*/
|
||||
/** true if the device was heard from recently */
|
||||
val isOnline: Boolean
|
||||
get() {
|
||||
return lastHeard > onlineTimeThreshold()
|
||||
|
|
@ -174,22 +147,27 @@ data class NodeEntity(
|
|||
companion object {
|
||||
// Convert to a double representation of degrees
|
||||
fun degD(i: Int) = i * 1e-7
|
||||
|
||||
fun degI(d: Double) = (d * 1e7).toInt()
|
||||
|
||||
val ERROR_BYTE_STRING: ByteString = ByteString.copyFrom(ByteArray(32) { 0 })
|
||||
|
||||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
}
|
||||
|
||||
fun toNodeInfo() = NodeInfo(
|
||||
num = num,
|
||||
user = MeshUser(
|
||||
user =
|
||||
MeshUser(
|
||||
id = user.id,
|
||||
longName = user.longName,
|
||||
shortName = user.shortName,
|
||||
hwModel = user.hwModel,
|
||||
role = user.roleValue,
|
||||
).takeIf { user.id.isNotEmpty() },
|
||||
position = Position(
|
||||
)
|
||||
.takeIf { user.id.isNotEmpty() },
|
||||
position =
|
||||
Position(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
altitude = position.altitude,
|
||||
|
|
@ -198,11 +176,13 @@ data class NodeEntity(
|
|||
groundSpeed = position.groundSpeed,
|
||||
groundTrack = position.groundTrack,
|
||||
precisionBits = position.precisionBits,
|
||||
).takeIf { it.isValid() },
|
||||
)
|
||||
.takeIf { it.isValid() },
|
||||
snr = snr,
|
||||
rssi = rssi,
|
||||
lastHeard = lastHeard,
|
||||
deviceMetrics = DeviceMetrics(
|
||||
deviceMetrics =
|
||||
DeviceMetrics(
|
||||
time = deviceTelemetry.time,
|
||||
batteryLevel = deviceMetrics.batteryLevel,
|
||||
voltage = deviceMetrics.voltage,
|
||||
|
|
@ -211,7 +191,8 @@ data class NodeEntity(
|
|||
uptimeSeconds = deviceMetrics.uptimeSeconds,
|
||||
),
|
||||
channel = channel,
|
||||
environmentMetrics = EnvironmentMetrics(
|
||||
environmentMetrics =
|
||||
EnvironmentMetrics(
|
||||
time = environmentTelemetry.time,
|
||||
temperature = environmentMetrics.temperature,
|
||||
relativeHumidity = environmentMetrics.relativeHumidity,
|
||||
|
|
|
|||
|
|
@ -57,14 +57,13 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
|
||||
import java.text.DateFormat
|
||||
|
||||
object CommonCharts {
|
||||
val DATE_TIME_FORMAT: DateFormat =
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
const val MS_PER_SEC = 1000L
|
||||
|
|
@ -86,13 +85,13 @@ fun ChartHeader(amount: Int) {
|
|||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "$amount ${stringResource(R.string.logs)}",
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,14 +102,10 @@ fun ChartHeader(amount: Int) {
|
|||
* @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart.
|
||||
*/
|
||||
@Composable
|
||||
fun HorizontalLinesOverlay(
|
||||
modifier: Modifier,
|
||||
lineColors: List<Color>,
|
||||
) {
|
||||
fun HorizontalLinesOverlay(modifier: Modifier, lineColors: List<Color>) {
|
||||
/* 100 is a good number to divide into quarters */
|
||||
val verticalSpacing = MAX_PERCENT_VALUE / LINE_LIMIT
|
||||
Canvas(modifier = modifier) {
|
||||
|
||||
val lineStart = 0f
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
|
@ -125,72 +120,51 @@ fun HorizontalLinesOverlay(
|
|||
color = lineColors[i],
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
|
||||
)
|
||||
lineY += verticalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`).
|
||||
*/
|
||||
/** Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`). */
|
||||
@Composable
|
||||
fun YAxisLabels(
|
||||
modifier: Modifier,
|
||||
labelColor: Color,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
) {
|
||||
fun YAxisLabels(modifier: Modifier, labelColor: Color, minValue: Float, maxValue: Float) {
|
||||
val range = maxValue - minValue
|
||||
val verticalSpacing = range / LINE_LIMIT
|
||||
val density = LocalDensity.current
|
||||
Canvas(modifier = modifier) {
|
||||
|
||||
val height = size.height
|
||||
|
||||
/* Y Labels */
|
||||
val textPaint = Paint().apply {
|
||||
color = labelColor.toArgb()
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = density.run { 12.dp.toPx() }
|
||||
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
|
||||
alpha = TEXT_PAINT_ALPHA
|
||||
}
|
||||
val textPaint =
|
||||
Paint().apply {
|
||||
color = labelColor.toArgb()
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = density.run { 12.dp.toPx() }
|
||||
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
|
||||
alpha = TEXT_PAINT_ALPHA
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
var label = minValue
|
||||
repeat(LINE_LIMIT + 1) {
|
||||
val ratio = (label - minValue) / range
|
||||
val y = height - (ratio * height)
|
||||
drawText(
|
||||
"${label.toInt()}",
|
||||
0f,
|
||||
y + 4.dp.toPx(),
|
||||
textPaint
|
||||
)
|
||||
drawText("${label.toInt()}", 0f, y + 4.dp.toPx(), textPaint)
|
||||
label += verticalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the vertical lines to help the user relate the plotted data within a time frame.
|
||||
*/
|
||||
/** Draws the vertical lines to help the user relate the plotted data within a time frame. */
|
||||
@Composable
|
||||
fun TimeAxisOverlay(
|
||||
modifier: Modifier,
|
||||
oldest: Int,
|
||||
newest: Int,
|
||||
timeInterval: Long
|
||||
) {
|
||||
|
||||
fun TimeAxisOverlay(modifier: Modifier, oldest: Int, newest: Int, timeInterval: Long) {
|
||||
val range = newest - oldest
|
||||
val density = LocalDensity.current
|
||||
val lineColor = MaterialTheme.colorScheme.onSurface
|
||||
Canvas(modifier = modifier) {
|
||||
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
|
|
@ -200,13 +174,14 @@ fun TimeAxisOverlay(
|
|||
current -= timeRemaining
|
||||
current += timeInterval
|
||||
|
||||
val textPaint = Paint().apply {
|
||||
color = lineColor.toArgb()
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = density.run { 12.dp.toPx() }
|
||||
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
|
||||
alpha = TEXT_PAINT_ALPHA
|
||||
}
|
||||
val textPaint =
|
||||
Paint().apply {
|
||||
color = lineColor.toArgb()
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = density.run { 12.dp.toPx() }
|
||||
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
|
||||
alpha = TEXT_PAINT_ALPHA
|
||||
}
|
||||
|
||||
/* Vertical Lines with labels */
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
|
|
@ -219,39 +194,22 @@ fun TimeAxisOverlay(
|
|||
color = lineColor,
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
|
||||
)
|
||||
|
||||
/* Time */
|
||||
drawText(
|
||||
TIME_FORMAT.format(current * MS_PER_SEC),
|
||||
x,
|
||||
0f,
|
||||
textPaint
|
||||
)
|
||||
drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint)
|
||||
/* Date */
|
||||
drawText(
|
||||
DATE_FORMAT.format(current * MS_PER_SEC),
|
||||
x,
|
||||
DATE_Y,
|
||||
textPaint
|
||||
)
|
||||
drawText(DATE_FORMAT.format(current * MS_PER_SEC), x, DATE_Y, textPaint)
|
||||
current += timeInterval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the `oldest` and `newest` times for the respective telemetry data.
|
||||
* Expects time in seconds.
|
||||
*/
|
||||
/** Draws the `oldest` and `newest` times for the respective telemetry data. Expects time in seconds. */
|
||||
@Composable
|
||||
fun TimeLabels(
|
||||
oldest: Int,
|
||||
newest: Int,
|
||||
) {
|
||||
|
||||
fun TimeLabels(oldest: Int, newest: Int) {
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
|
||||
|
|
@ -264,7 +222,7 @@ fun TimeLabels(
|
|||
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = 12.sp
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -276,22 +234,11 @@ fun TimeLabels(
|
|||
* @param promptInfoDialog Executes when the user presses the info icon.
|
||||
*/
|
||||
@Composable
|
||||
fun Legend(
|
||||
legendData: List<LegendData>,
|
||||
displayInfoIcon: Boolean = true,
|
||||
promptInfoDialog: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
fun Legend(legendData: List<LegendData>, displayInfoIcon: Boolean = true, promptInfoDialog: () -> Unit = {}) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
legendData.forEachIndexed { index, data ->
|
||||
LegendLabel(
|
||||
text = stringResource(data.nameRes),
|
||||
color = data.color,
|
||||
isLine = data.isLine
|
||||
)
|
||||
LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine)
|
||||
|
||||
if (index != legendData.lastIndex) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
|
@ -302,7 +249,7 @@ fun Legend(
|
|||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
modifier = Modifier.clickable { promptInfoDialog() },
|
||||
contentDescription = stringResource(R.string.info)
|
||||
contentDescription = stringResource(R.string.info),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -320,11 +267,7 @@ fun Legend(
|
|||
fun LegendInfoDialog(pairedRes: List<Pair<Int, Int>>, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.info),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(text = stringResource(R.string.info), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
|
|
@ -332,32 +275,23 @@ fun LegendInfoDialog(pairedRes: List<Pair<Int, Int>>, onDismiss: () -> Unit) {
|
|||
Text(
|
||||
text = stringResource(pair.first),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
Text(
|
||||
text = stringResource(pair.second),
|
||||
style = TextStyle.Default,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
Text(text = stringResource(pair.second), style = TextStyle.Default)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
|
||||
Canvas(
|
||||
modifier = Modifier.size(4.dp)
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(4.dp)) {
|
||||
if (isLine) {
|
||||
drawLine(
|
||||
color = color,
|
||||
|
|
@ -367,9 +301,7 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
|
|||
cap = StrokeCap.Round,
|
||||
)
|
||||
} else {
|
||||
drawCircle(
|
||||
color = color
|
||||
)
|
||||
drawCircle(color = color)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
|
@ -383,9 +315,10 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
|
|||
@Preview
|
||||
@Composable
|
||||
private fun LegendPreview() {
|
||||
val data = listOf(
|
||||
LegendData(nameRes = R.string.rssi, color = Color.Red),
|
||||
LegendData(nameRes = R.string.snr, color = Color.Green)
|
||||
)
|
||||
val data =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.rssi, color = Color.Red),
|
||||
LegendData(nameRes = R.string.snr, color = Color.Green),
|
||||
)
|
||||
Legend(legendData = data, promptInfoDialog = {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,19 +18,35 @@
|
|||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -38,32 +54,16 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.PaxcountProtos
|
||||
import com.geeksville.mesh.Portnums.PortNum
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.database.entity.MeshLog
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.util.formatUptime
|
||||
import com.geeksville.mesh.Portnums.PortNum
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.remember
|
||||
import com.geeksville.mesh.model.TimeFrame
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.geeksville.mesh.ui.common.components.OptionLabel
|
||||
import com.geeksville.mesh.ui.common.components.SlidingSelector
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import com.geeksville.mesh.util.formatUptime
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
|
|
@ -72,7 +72,7 @@ private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIG
|
|||
private enum class PaxSeries(val color: Color, val legendRes: Int) {
|
||||
PAX(Color.Black, R.string.pax),
|
||||
BLE(Color.Cyan, R.string.ble_devices),
|
||||
WIFI(Color.Green, R.string.wifi_devices)
|
||||
WIFI(Color.Green, R.string.wifi_devices),
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -93,9 +93,7 @@ private fun PaxMetricsChart(
|
|||
val minTime = times.minOrNull() ?: 0
|
||||
val maxTime = times.maxOrNull() ?: 1
|
||||
val timeDiff = maxTime - minTime
|
||||
val dp = remember(timeFrame, screenWidth, timeDiff) {
|
||||
timeFrame.dp(screenWidth, time = timeDiff.toLong())
|
||||
}
|
||||
val dp = remember(timeFrame, screenWidth, timeDiff) { timeFrame.dp(screenWidth, time = timeDiff.toLong()) }
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
|
|
@ -107,42 +105,21 @@ private fun PaxMetricsChart(
|
|||
val visibleNewest = minTime + (timeDiff * rightRatio).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
TimeLabels(
|
||||
oldest = visibleTimeRange.first,
|
||||
newest = visibleTimeRange.second
|
||||
)
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.33f)
|
||||
) {
|
||||
Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) {
|
||||
YAxisLabels(
|
||||
modifier = Modifier
|
||||
.weight(Y_AXIS_WEIGHT)
|
||||
.fillMaxHeight()
|
||||
.padding(start = 8.dp),
|
||||
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue
|
||||
maxValue = maxValue,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(state = scrollState, reverseScrolling = true)
|
||||
.weight(CHART_WEIGHT)
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(CHART_WEIGHT),
|
||||
) {
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = List(size = 5) { Color.LightGray },
|
||||
)
|
||||
TimeAxisOverlay(
|
||||
modifier.width(dp),
|
||||
oldest = minTime,
|
||||
newest = maxTime,
|
||||
timeFrame.lineInterval()
|
||||
)
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { Color.LightGray })
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = minTime, newest = maxTime, timeFrame.lineInterval())
|
||||
Canvas(modifier = Modifier.width(dp).fillMaxHeight()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
|
@ -155,7 +132,7 @@ private fun PaxMetricsChart(
|
|||
color = color,
|
||||
start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)),
|
||||
end = Offset(xForTime(series[i].first), yForValue(series[i].second)),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -165,13 +142,10 @@ private fun PaxMetricsChart(
|
|||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = Modifier
|
||||
.weight(Y_AXIS_WEIGHT)
|
||||
.fillMaxHeight()
|
||||
.padding(end = 8.dp),
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue
|
||||
maxValue = maxValue,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -179,46 +153,48 @@ private fun PaxMetricsChart(
|
|||
|
||||
@Composable
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
fun PaxMetricsScreen(
|
||||
metricsViewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
val dateFormat = DateFormat.getDateTimeInstance()
|
||||
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
|
||||
// Only show logs that can be decoded as PaxcountProtos.Paxcount
|
||||
val paxMetrics = state.paxMetrics.mapNotNull { log ->
|
||||
val pax = decodePaxFromLog(log)
|
||||
if (pax != null) {
|
||||
Pair(log, pax)
|
||||
} else {
|
||||
null
|
||||
val paxMetrics =
|
||||
state.paxMetrics.mapNotNull { log ->
|
||||
val pax = decodePaxFromLog(log)
|
||||
if (pax != null) {
|
||||
Pair(log, pax)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prepare data for graph
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
val graphData = paxMetrics.filter { it.first.received_date / 1000 >= oldestTime }
|
||||
.map {
|
||||
val t = (it.first.received_date / 1000).toInt()
|
||||
Triple(t, it.second.ble, it.second.wifi)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
val graphData =
|
||||
paxMetrics
|
||||
.filter { it.first.received_date / 1000 >= oldestTime }
|
||||
.map {
|
||||
val t = (it.first.received_date / 1000).toInt()
|
||||
Triple(t, it.second.ble, it.second.wifi)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
val totalSeries = graphData.map { it.first to (it.second + it.third) }
|
||||
val bleSeries = graphData.map { it.first to it.second }
|
||||
val wifiSeries = graphData.map { it.first to it.third }
|
||||
val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f)
|
||||
val minValue = 0f
|
||||
val legendData = listOf(
|
||||
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
|
||||
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
|
||||
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
|
||||
)
|
||||
val legendData =
|
||||
listOf(
|
||||
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
|
||||
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
|
||||
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Time frame selector
|
||||
SlidingSelector(
|
||||
options = TimeFrame.entries.toList(),
|
||||
selectedOption = timeFrame,
|
||||
onOptionSelected = { timeFrame = it }
|
||||
onOptionSelected = { timeFrame = it },
|
||||
) { tf: TimeFrame ->
|
||||
OptionLabel(stringResource(tf.strRes))
|
||||
}
|
||||
|
|
@ -232,7 +208,7 @@ fun PaxMetricsScreen(
|
|||
wifiSeries = wifiSeries,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
timeFrame = timeFrame
|
||||
timeFrame = timeFrame,
|
||||
)
|
||||
}
|
||||
// List
|
||||
|
|
@ -240,16 +216,11 @@ fun PaxMetricsScreen(
|
|||
Text(
|
||||
text = stringResource(R.string.no_pax_metrics_logs),
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(paxMetrics) { (log, pax) ->
|
||||
PaxMetricsItem(log, pax, dateFormat)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -261,8 +232,7 @@ fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? {
|
|||
// First, try to parse from the binary fromRadio field (robust, like telemetry)
|
||||
try {
|
||||
val packet = log.fromRadio.packet
|
||||
if (packet != null && packet.hasDecoded() &&
|
||||
packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) {
|
||||
if (packet != null && packet.hasDecoded() && packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) {
|
||||
val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
|
||||
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax
|
||||
}
|
||||
|
|
@ -313,33 +283,26 @@ fun unescapeProtoString(escaped: String): ByteArray {
|
|||
|
||||
@Composable
|
||||
fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = dateFormat.format(Date(log.received_date)),
|
||||
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val total = pax.ble + pax.wifi
|
||||
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f, fill = true)
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.uptime) + ": " + formatUptime(pax.uptime),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.alignByBaseline()
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ import com.geeksville.mesh.util.GraphUtil.plotPoint
|
|||
@Suppress("MagicNumber")
|
||||
private enum class Metric(val color: Color, val min: Float, val max: Float) {
|
||||
SNR(Color.Green, -20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */
|
||||
RSSI(Color.Blue, -140f, -20f);
|
||||
/**
|
||||
* Difference between the metrics `max` and `min` values.
|
||||
*/
|
||||
RSSI(Color.Blue, -140f, -20f),
|
||||
;
|
||||
|
||||
/** Difference between the metrics `max` and `min` values. */
|
||||
fun difference() = max - min
|
||||
}
|
||||
|
||||
|
|
@ -82,54 +82,44 @@ private const val CHART_WEIGHT = 1f
|
|||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private val LEGEND_DATA = listOf(
|
||||
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
|
||||
LegendData(nameRes = R.string.snr, color = Metric.SNR.color)
|
||||
)
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
|
||||
LegendData(nameRes = R.string.snr, color = Metric.SNR.color),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SignalMetricsScreen(
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.signalMetricsFiltered(selectedTimeFrame)
|
||||
|
||||
Column {
|
||||
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes = listOf(
|
||||
Pair(R.string.snr, R.string.snr_definition),
|
||||
Pair(R.string.rssi, R.string.rssi_definition)
|
||||
),
|
||||
onDismiss = { displayInfoDialog = false }
|
||||
pairedRes =
|
||||
listOf(Pair(R.string.snr, R.string.snr_definition), Pair(R.string.rssi, R.string.rssi_definition)),
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
SignalMetricsChart(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.33f),
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
meshPackets = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true }
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) }
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { meshPacket -> SignalMetricsCard(meshPacket) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,26 +129,23 @@ private fun SignalMetricsChart(
|
|||
modifier: Modifier = Modifier,
|
||||
meshPackets: List<MeshPacket>,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = meshPackets.size)
|
||||
if (meshPackets.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) = remember(key1 = meshPackets) {
|
||||
Pair(
|
||||
meshPackets.minBy { it.rxTime },
|
||||
meshPackets.maxBy { it.rxTime }
|
||||
)
|
||||
}
|
||||
val (oldest, newest) =
|
||||
remember(key1 = meshPackets) { Pair(meshPackets.minBy { it.rxTime }, meshPackets.maxBy { it.rxTime }) }
|
||||
val timeDiff = newest.rxTime - oldest.rxTime
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
|
||||
}
|
||||
val dp by
|
||||
remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
|
||||
}
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
|
|
@ -174,10 +161,7 @@ private fun SignalMetricsChart(
|
|||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(
|
||||
oldest = visibleTimeRange.first,
|
||||
newest = visibleTimeRange.second
|
||||
)
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -194,20 +178,15 @@ private fun SignalMetricsChart(
|
|||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(state = scrollState, reverseScrolling = true)
|
||||
.weight(1f)
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = List(size = 5) { graphColor },
|
||||
)
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier.width(dp),
|
||||
oldest = oldest.rxTime,
|
||||
newest = newest.rxTime,
|
||||
selectedTime.lineInterval()
|
||||
selectedTime.lineInterval(),
|
||||
)
|
||||
|
||||
/* Plot SNR and RSSI */
|
||||
|
|
@ -215,7 +194,6 @@ private fun SignalMetricsChart(
|
|||
val width = size.width
|
||||
/* Plot */
|
||||
for (packet in meshPackets) {
|
||||
|
||||
val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
|
||||
val x = xRatio * width
|
||||
|
||||
|
|
@ -225,7 +203,7 @@ private fun SignalMetricsChart(
|
|||
color = Metric.SNR.color,
|
||||
x = x,
|
||||
value = packet.rxSnr - Metric.SNR.min,
|
||||
divisor = snrDiff
|
||||
divisor = snrDiff,
|
||||
)
|
||||
|
||||
/* RSSI */
|
||||
|
|
@ -234,7 +212,7 @@ private fun SignalMetricsChart(
|
|||
color = Metric.RSSI.color,
|
||||
x = x,
|
||||
value = packet.rxRssi - Metric.RSSI.min,
|
||||
divisor = rssiDiff
|
||||
divisor = rssiDiff,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -257,35 +235,19 @@ private fun SignalMetricsChart(
|
|||
@Composable
|
||||
private fun SignalMetricsCard(meshPacket: MeshPacket) {
|
||||
val time = meshPacket.rxTime * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/* Data */
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(weight = 5f)
|
||||
.height(IntrinsicSize.Min)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
/* Time */
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -297,11 +259,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket) {
|
|||
}
|
||||
|
||||
/* Signal Indicator */
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(weight = 3f)
|
||||
.height(IntrinsicSize.Max)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
|
||||
LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue