feat: add MeshServiceExample project to repo (#2038)

Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com>
This commit is contained in:
James Rich 2025-06-06 20:43:32 +00:00 committed by GitHub
parent 833e6f04dd
commit c757224269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1874 additions and 2 deletions

View file

@ -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)
}
}
}

View file

@ -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"
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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;
}
}
}