mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: add MeshServiceExample project to repo (#2038)
Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com>
This commit is contained in:
parent
833e6f04dd
commit
c757224269
39 changed files with 1874 additions and 2 deletions
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
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? {
|
||||
return 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?,
|
||||
val dataType: Int, // A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions)
|
||||
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
|
||||
) : 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) : this(
|
||||
to = to,
|
||||
bytes = text.encodeToByteArray(),
|
||||
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
|
||||
channel = channel
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 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
|
||||
}
|
||||
|
||||
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)
|
||||
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
|
||||
|
||||
override fun createFromParcel(parcel: Parcel): DataPacket {
|
||||
return DataPacket(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<DataPacket?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh
|
||||
|
||||
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,241 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Parcelable
|
||||
import com.geeksville.mesh.util.GPSFormat
|
||||
import com.geeksville.mesh.util.bearing
|
||||
import com.geeksville.mesh.util.latLongToMeter
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
//
|
||||
// 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 {
|
||||
return "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 {
|
||||
|
||||
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
|
||||
fun isValid(): Boolean {
|
||||
return 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)
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.DMS(this)
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.UTM(this)
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.MGRS(this)
|
||||
else -> GPSFormat.DEC(this)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "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 {
|
||||
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 barometricPressure: Float,
|
||||
val gasResistance: Float,
|
||||
val voltage: Float,
|
||||
val current: Float,
|
||||
val iaq: Int,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
|
||||
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
|
||||
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() {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val timeout = 15 * 60
|
||||
return (now - lastHeard <= timeout)
|
||||
}
|
||||
|
||||
/// 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
|
||||
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,75 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.util
|
||||
|
||||
import android.widget.EditText
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
|
||||
/**
|
||||
* 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', ' ')
|
||||
}
|
||||
|
||||
// Return a one line string version of an object (but if a release build, just say 'might be PII)
|
||||
fun Any.toPIIString() = this.toOneLineString()
|
||||
|
||||
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
|
||||
|
||||
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 -> "?"
|
||||
}
|
||||
}
|
||||
|
||||
// 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,327 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.util
|
||||
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.Position
|
||||
import mil.nga.grid.features.Point
|
||||
import mil.nga.mgrs.MGRS
|
||||
import mil.nga.mgrs.utm.UTM
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.PI
|
||||
|
||||
/*******************************************************************************
|
||||
* Revive some of my old Gaggle source code...
|
||||
*
|
||||
* GNU Public License, version 2
|
||||
* All other distribution of Gaggle must conform to the terms of the GNU Public License, version 2. The full
|
||||
* text of this license is included in the Gaggle source, see assets/manual/gpl-2.0.txt.
|
||||
******************************************************************************/
|
||||
|
||||
object GPSFormat {
|
||||
fun DEC(p: Position): String {
|
||||
return String.format("%.5f %.5f", p.latitude, p.longitude).replace(",", ".")
|
||||
}
|
||||
|
||||
fun DMS(p: Position): String {
|
||||
val lat = degreesToDMS(p.latitude, true)
|
||||
val lon = degreesToDMS(p.longitude, false)
|
||||
fun string(a: Array<String>) = String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3])
|
||||
return string(lat) + " " + string(lon)
|
||||
}
|
||||
|
||||
fun UTM(p: Position): String {
|
||||
val UTM = UTM.from(Point.point(p.longitude, p.latitude))
|
||||
return String.format(
|
||||
"%s%s %.6s %.7s",
|
||||
UTM.zone,
|
||||
UTM.toMGRS().band,
|
||||
UTM.easting,
|
||||
UTM.northing
|
||||
)
|
||||
}
|
||||
|
||||
fun MGRS(p: Position): String {
|
||||
val MGRS = MGRS.from(Point.point(p.longitude, p.latitude))
|
||||
return String.format(
|
||||
"%s%s %s%s %05d %05d",
|
||||
MGRS.zone,
|
||||
MGRS.band,
|
||||
MGRS.column,
|
||||
MGRS.row,
|
||||
MGRS.easting,
|
||||
MGRS.northing
|
||||
)
|
||||
}
|
||||
|
||||
fun toDEC(latitude: Double, longitude: Double): String {
|
||||
return "%.5f %.5f".format(latitude, longitude).replace(",", ".")
|
||||
}
|
||||
|
||||
fun toDMS(latitude: Double, longitude: Double): String {
|
||||
val lat = degreesToDMS(latitude, true)
|
||||
val lon = degreesToDMS(longitude, false)
|
||||
fun string(a: Array<String>) = "%s°%s'%.5s\"%s".format(a[0], a[1], a[2], a[3])
|
||||
return string(lat) + " " + string(lon)
|
||||
}
|
||||
|
||||
fun toUTM(latitude: Double, longitude: Double): String {
|
||||
val UTM = UTM.from(Point.point(longitude, latitude))
|
||||
return "%s%s %.6s %.7s".format(UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing)
|
||||
}
|
||||
|
||||
fun toMGRS(latitude: Double, longitude: Double): String {
|
||||
val MGRS = MGRS.from(Point.point(longitude, latitude))
|
||||
return "%s%s %s%s %05d %05d".format(
|
||||
MGRS.zone,
|
||||
MGRS.band,
|
||||
MGRS.column,
|
||||
MGRS.row,
|
||||
MGRS.easting,
|
||||
MGRS.northing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format as degrees, minutes, secs
|
||||
*
|
||||
* @param degIn
|
||||
* @param isLatitude
|
||||
* @return a string like 120deg
|
||||
*/
|
||||
fun degreesToDMS(
|
||||
_degIn: Double,
|
||||
isLatitude: Boolean
|
||||
): Array<String> {
|
||||
var degIn = _degIn
|
||||
val isPos = degIn >= 0
|
||||
val dirLetter =
|
||||
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
|
||||
degIn = abs(degIn)
|
||||
val degOut = degIn.toInt()
|
||||
val minutes = 60 * (degIn - degOut)
|
||||
val minwhole = minutes.toInt()
|
||||
val seconds = (minutes - minwhole) * 60
|
||||
return arrayOf(
|
||||
degOut.toString(), minwhole.toString(),
|
||||
seconds.toString(),
|
||||
dirLetter.toString()
|
||||
)
|
||||
}
|
||||
|
||||
fun degreesToDM(_degIn: Double, isLatitude: Boolean): Array<String> {
|
||||
var degIn = _degIn
|
||||
val isPos = degIn >= 0
|
||||
val dirLetter =
|
||||
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
|
||||
degIn = abs(degIn)
|
||||
val degOut = degIn.toInt()
|
||||
val minutes = 60 * (degIn - degOut)
|
||||
val seconds = 0
|
||||
return arrayOf(
|
||||
degOut.toString(), minutes.toString(),
|
||||
seconds.toString(),
|
||||
dirLetter.toString()
|
||||
)
|
||||
}
|
||||
|
||||
fun degreesToD(_degIn: Double, isLatitude: Boolean): Array<String> {
|
||||
var degIn = _degIn
|
||||
val isPos = degIn >= 0
|
||||
val dirLetter =
|
||||
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
|
||||
degIn = abs(degIn)
|
||||
val degOut = degIn
|
||||
val minutes = 0
|
||||
val seconds = 0
|
||||
return arrayOf(
|
||||
degOut.toString(), minutes.toString(),
|
||||
seconds.toString(),
|
||||
dirLetter.toString()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A not super efficent mapping from a starting lat/long + a distance at a
|
||||
* certain direction
|
||||
*
|
||||
* @param lat
|
||||
* @param longitude
|
||||
* @param distMeters
|
||||
* @param theta
|
||||
* in radians, 0 == north
|
||||
* @return an array with lat and long
|
||||
*/
|
||||
fun addDistance(
|
||||
lat: Double,
|
||||
longitude: Double,
|
||||
distMeters: Double,
|
||||
theta: Double
|
||||
): DoubleArray {
|
||||
val dx = distMeters * sin(theta) // theta measured clockwise
|
||||
// from due north
|
||||
val dy = distMeters * cos(theta) // dx, dy same units as R
|
||||
val dLong = dx / (111320 * cos(lat)) // dx, dy in meters
|
||||
val dLat = dy / 110540 // result in degrees long/lat
|
||||
return doubleArrayOf(lat + dLat, longitude + dLong)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return distance in meters along the surface of the earth (ish)
|
||||
*/
|
||||
fun latLongToMeter(
|
||||
lat_a: Double,
|
||||
lng_a: Double,
|
||||
lat_b: Double,
|
||||
lng_b: Double
|
||||
): Double {
|
||||
val pk = (180 / PI)
|
||||
val a1 = lat_a / pk
|
||||
val a2 = lng_a / pk
|
||||
val b1 = lat_b / pk
|
||||
val b2 = lng_b / pk
|
||||
val t1 = cos(a1) * cos(a2) * cos(b1) * cos(b2)
|
||||
val t2 = cos(a1) * sin(a2) * cos(b1) * sin(b2)
|
||||
val t3 = sin(a1) * sin(b1)
|
||||
var tt = acos(t1 + t2 + t3)
|
||||
if (java.lang.Double.isNaN(tt)) tt = 0.0 // Must have been the same point?
|
||||
return 6366000 * tt
|
||||
}
|
||||
|
||||
// Same as above, but takes Mesh Position proto.
|
||||
fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double {
|
||||
return latLongToMeter(
|
||||
a.latitudeI * 1e-7,
|
||||
a.longitudeI * 1e-7,
|
||||
b.latitudeI * 1e-7,
|
||||
b.longitudeI * 1e-7
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees/mins/secs to a single double
|
||||
*
|
||||
* @param degrees
|
||||
* @param minutes
|
||||
* @param seconds
|
||||
* @param isPostive
|
||||
* @return
|
||||
*/
|
||||
fun DMSToDegrees(
|
||||
degrees: Int,
|
||||
minutes: Int,
|
||||
seconds: Float,
|
||||
isPostive: Boolean
|
||||
): Double {
|
||||
return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0)
|
||||
}
|
||||
|
||||
fun DMSToDegrees(
|
||||
degrees: Double,
|
||||
minutes: Double,
|
||||
seconds: Double,
|
||||
isPostive: Boolean
|
||||
): Double {
|
||||
return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun bearing(
|
||||
lat1: Double,
|
||||
lon1: Double,
|
||||
lat2: Double,
|
||||
lon2: Double
|
||||
): Double {
|
||||
val lat1Rad = Math.toRadians(lat1)
|
||||
val lat2Rad = Math.toRadians(lat2)
|
||||
val deltaLonRad = Math.toRadians(lon2 - lon1)
|
||||
val y = sin(deltaLonRad) * cos(lat2Rad)
|
||||
val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) * cos(deltaLonRad))
|
||||
return radToBearing(atan2(y, x))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an angle in radians to degrees
|
||||
*/
|
||||
fun radToBearing(rad: Double): Double {
|
||||
return (Math.toDegrees(rad) + 360) % 360
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the zoom level required to fit the entire [BoundingBox] inside the map view.
|
||||
* @return The zoom level as a Double value.
|
||||
*/
|
||||
fun BoundingBox.requiredZoomLevel(): Double {
|
||||
val topLeft = GeoPoint(this.latNorth, this.lonWest)
|
||||
val bottomRight = GeoPoint(this.latSouth, this.lonEast)
|
||||
val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude))
|
||||
val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude))
|
||||
val requiredLatZoom = log2(360.0 / (latLonHeight / 111320))
|
||||
val requiredLonZoom = log2(360.0 / (latLonWidth / 111320))
|
||||
return maxOf(requiredLatZoom, requiredLonZoom) * 0.8
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor].
|
||||
* @return A new [BoundingBox] with added [zoomFactor]. Example:
|
||||
* ```
|
||||
* // Setting the zoom level directly using setZoom()
|
||||
* map.setZoom(14.0)
|
||||
* val boundingBoxZoom14 = map.boundingBox
|
||||
*
|
||||
* // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0)
|
||||
* val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0)
|
||||
* ```
|
||||
*/
|
||||
fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox {
|
||||
val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2)
|
||||
val latDiff = latNorth - latSouth
|
||||
val lonDiff = lonEast - lonWest
|
||||
|
||||
val newLatDiff = latDiff / (2.0.pow(zoomFactor))
|
||||
val newLonDiff = lonDiff / (2.0.pow(zoomFactor))
|
||||
|
||||
return BoundingBox(
|
||||
center.latitude + newLatDiff / 2,
|
||||
center.longitude + newLonDiff / 2,
|
||||
center.latitude - newLatDiff / 2,
|
||||
center.longitude - newLonDiff / 2
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package com.meshtastic.android.meshserviceexample;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.geeksville.mesh.IMeshService;
|
||||
import com.geeksville.mesh.MessageStatus;
|
||||
import com.geeksville.mesh.NodeInfo;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "MeshServiceExample";
|
||||
private IMeshService meshService;
|
||||
private ServiceConnection serviceConnection;
|
||||
private BroadcastReceiver meshtasticReceiver;
|
||||
private boolean isMeshServiceBound = false;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
EdgeToEdge.enable(this);
|
||||
setContentView(R.layout.activity_main);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
|
||||
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
||||
return insets;
|
||||
});
|
||||
|
||||
TextView mainTextView = findViewById(R.id.mainTextView);
|
||||
ImageView statusImageView = findViewById(R.id.statusImageView);
|
||||
|
||||
// Now you can call methods on meshService
|
||||
serviceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
meshService = IMeshService.Stub.asInterface(service);
|
||||
Log.i(TAG, "Connected to MeshService");
|
||||
isMeshServiceBound = true;
|
||||
statusImageView.setImageResource(android.R.color.holo_green_light);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
meshService = null;
|
||||
isMeshServiceBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
meshtasticReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null || intent.getAction() == null) {
|
||||
Log.w(TAG, "Received null intent or action");
|
||||
return;
|
||||
}
|
||||
// Handle the received broadcast
|
||||
String action = intent.getAction();
|
||||
Log.d(TAG, "Received broadcast: " + action);
|
||||
|
||||
switch (Objects.requireNonNull(action)) {
|
||||
case "com.geeksville.mesh.NODE_CHANGE":
|
||||
// handle node changed
|
||||
try {
|
||||
NodeInfo ni = intent.getParcelableExtra("com.geeksville.mesh.NodeInfo");
|
||||
Log.d(TAG, "NodeInfo: " + ni);
|
||||
mainTextView.setText("NodeInfo: " + ni);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case "com.geeksville.mesh.MESSAGE_STATUS":
|
||||
int id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0);
|
||||
MessageStatus status = intent.getParcelableExtra("com.geeksville.mesh.Status");
|
||||
Log.d(TAG, "Message Status ID: " + id + " Status: " + status);
|
||||
break;
|
||||
case "com.geeksville.mesh.MESH_CONNECTED": {
|
||||
String extraConnected = intent.getStringExtra("com.geeksville.mesh.Connected");
|
||||
boolean connected = extraConnected.equals("CONNECTED");
|
||||
Log.d(TAG, "Received ACTION_MESH_CONNECTED: " + extraConnected);
|
||||
if (connected) {
|
||||
statusImageView.setImageResource(android.R.color.holo_green_light);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "com.geeksville.mesh.MESH_DISCONNECTED": {
|
||||
String extraConnected = intent.getStringExtra("com.geeksville.mesh.Disconnected");
|
||||
boolean disconnected = extraConnected.equals("DISCONNECTED");
|
||||
Log.d(TAG, "Received ACTION_MESH_DISTCONNECTED: " + extraConnected);
|
||||
if (disconnected) {
|
||||
statusImageView.setImageResource(android.R.color.holo_red_light);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "com.geeksville.mesh.RECEIVED.POSITION_APP": {
|
||||
// handle position app data
|
||||
try {
|
||||
NodeInfo ni = intent.getParcelableExtra("com.geeksville.mesh.NodeInfo");
|
||||
Log.d(TAG, "Position App NodeInfo: " + ni);
|
||||
mainTextView.setText("Position App NodeInfo: " + ni);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
Log.w(TAG, "Unknown action: " + action);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction("com.geeksville.mesh.NODE_CHANGE");
|
||||
filter.addAction("com.geeksville.mesh.RECEIVED.NODEINFO_APP");
|
||||
filter.addAction("com.geeksville.mesh.RECEIVED.POSITION_APP");
|
||||
filter.addAction("com.geeksville.mesh.MESH_CONNECTED");
|
||||
filter.addAction("com.geeksville.mesh.MESH_DISCONNECTED");
|
||||
registerReceiver(meshtasticReceiver, filter, Context.RECEIVER_EXPORTED);
|
||||
Log.d(TAG, "Registered meshtasticPacketReceiver");
|
||||
|
||||
while (!bindMeshService()) {
|
||||
try {
|
||||
// Wait for the service to bind
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "Binding interrupted", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
unbindMeshService();
|
||||
}
|
||||
|
||||
private boolean bindMeshService() {
|
||||
try {
|
||||
Log.i(TAG, "Attempting to bind to Mesh Service...");
|
||||
Intent intent = new Intent("com.geeksville.mesh.Service");
|
||||
intent.setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService");
|
||||
return bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to bind", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void unbindMeshService() {
|
||||
if (isMeshServiceBound) {
|
||||
try {
|
||||
unbindService(serviceConnection);
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.w(TAG, "MeshService not registered or already unbound: " + e.getMessage());
|
||||
}
|
||||
isMeshServiceBound = false;
|
||||
meshService = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue