diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 11398d0b..5dff423b 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -89,6 +89,12 @@ android:parentActivityName=".PrefsAct" android:launchMode="singleTop" /> + + 460800 921600 + + Off Duty + En Route + In Service + Returning + Committed + Special + Priority + EMERGENCY! + diff --git a/res/values/strings.xml b/res/values/strings.xml index 4e97d0dd..89989c81 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -249,6 +249,19 @@ Manual, Periodic or SmartBeaconing™ Location Settings +Normal Compressed Beacons +Send Compressed Beacons +Send Uncompressed Beacons + +Mic-E Compressed Beacons +Send Mic-E Beacons +Send Uncompressed Beacons + +Compressed Beacon Settings +Normal & Mic-E Compression + +Mic-E Status + SmartBeaconing™ SmartBeaconing™ help Fast Speed [km/h] diff --git a/res/xml/compressed.xml b/res/xml/compressed.xml new file mode 100644 index 00000000..76e63861 --- /dev/null +++ b/res/xml/compressed.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml index 2f2fff0f..dbc1cda0 100644 --- a/res/xml/preferences.xml +++ b/res/xml/preferences.xml @@ -52,6 +52,15 @@ + + + + (1, 1, 1) + case "En Route" => (1, 1, 0) + case "In Service" => (1, 0, 1) + case "Returning" => (1, 0, 0) + case "Committed" => (0, 1, 1) + case "Special" => (0, 1, 0) + case "Priority" => (0, 0, 1) + case "EMERGENCY!" => (0, 0, 0) + case _ => (1, 1, 1) // Default if status is not found + } + + def degreesToDDM(dd: Double): (Int, Double) = { + val degrees = Math.floor(dd).toInt + val minutes = (dd - degrees) * 60 + (degrees, minutes) + } + + def miceLong(dd: Double): (Int, Int, Int) = { + val (degrees, minutes) = degreesToDDM(Math.abs(dd)) + val minutesInt = Math.floor(minutes).toInt + val minutesHundreths = Math.floor(100 * (minutes - minutesInt)).toInt + (degrees, minutesInt, minutesHundreths) + } + + + def encodeDest(dd: Double, longOffset: Int, west: Int, messageA: Int, messageB: Int, messageC: Int, ambiguity: Int): String = { + val north = if (dd < 0) 0 else 1 + + val (degrees, minutes, minutesHundreths) = miceLong(dd) + + val degrees10 = Math.floor(degrees / 10.0).toInt + val degrees1 = degrees - (degrees10 * 10) + + val minutes10 = Math.floor(minutes / 10.0).toInt + val minutes1 = minutes - (minutes10 * 10) + + val minutesHundreths10 = Math.floor(minutesHundreths / 10.0).toInt + val minutesHundreths1 = minutesHundreths - (minutesHundreths10 * 10) + + val sb = new StringBuilder + + if (messageA == 1) sb.append(characters(degrees10 + 22)) else sb.append(characters(degrees10)) + if (messageB == 1) sb.append(characters(degrees1 + 22)) else sb.append(characters(degrees1)) + if (messageC == 1) sb.append(characters(minutes10 + 22)) else sb.append(characters(minutes10)) + + if (north == 1) sb.append(characters(minutes1 + 22)) else sb.append(characters(minutes1)) + if (longOffset == 1) sb.append(characters(minutesHundreths10 + 22)) else sb.append(characters(minutesHundreths10)) + if (west == 1) sb.append(characters(minutesHundreths1 + 22)) else sb.append(characters(minutesHundreths1)) + + val encoded = sb.toString() + + // Replace indices 4 and 5 with 'Z' or 'L', depending on 'west' + val validAmbiguity = ambiguity.max(0).min(4) + val encodedArray = encoded.toCharArray // Convert the encoded string to a char array + + // A map that specifies the modification rules for each index based on ambiguity + val modifyRules = Map( + 2 -> (messageC, 'Z', 'L'), + 3 -> (north, 'Z', 'L'), + 4 -> (longOffset, 'Z', 'L'), + 5 -> (west, 'Z', 'L') + ) + + // Loop over the indices based on validAmbiguity + for (i <- (6 - validAmbiguity) until 6) { + modifyRules.get(i) match { + case Some((condition, trueChar, falseChar)) => + val charToUse = if (condition == 1) trueChar else falseChar + encodedArray(i) = charToUse + case None => // No modification if the index is not in modifyRules + } + } + + // Return the modified string + val finalEncoded = new String(encodedArray) + + finalEncoded + } + + def encodeInfo(dd: Double, speed: Double, heading: Double, symbol: String): (String, Int, Int) = { + + val (degrees, minutes, minutesHundreths) = miceLong(dd) + + val west = if (dd < 0) 1 else 0 + + val sb = new StringBuilder + sb.append("`") + + val speedHT = Math.floor(speed / 10.0).toInt + val speedUnits = speed - (speedHT * 10) + + val headingHundreds = Math.floor(heading / 100.0).toInt + val headingTensUnits = heading - (headingHundreds * 100) + + var longOffset = 0 + + if (degrees <= 9) { + sb.append((degrees + 118).toChar) + longOffset = 1 + } else if (degrees >= 10 && degrees <= 99) { + sb.append((degrees + 28).toChar) + longOffset = 0 + } else if (degrees >= 100 && degrees <= 109) { + sb.append((degrees + 8).toChar) + longOffset = 1 + } else if (degrees >= 110) { + sb.append((degrees - 72).toChar) + longOffset = 1 + } + + if (minutes <= 9) sb.append((minutes + 88).toChar) else sb.append((minutes + 28).toChar) + sb.append((minutesHundreths + 28).toChar) + + if (speed <= 199) sb.append((speedHT + 108).toChar) else sb.append((speedHT + 28).toChar) + sb.append((Math.floor(speedUnits * 10).toInt + headingHundreds + 32).toChar) + sb.append((headingTensUnits + 28).toChar) + + sb.append(symbol(1)) + sb.append(symbol(0)) + sb.append("`") + + (sb.toString(), west, longOffset) + } + + def altitude(alt: Double): String = { + val altM = Math.round(alt * 0.3048).toInt + val relAlt = altM + 10000 + + val val1 = Math.floor(relAlt / 8281.0).toInt + val rem = relAlt % 8281 + val val2 = Math.floor(rem / 91.0).toInt + val val3 = rem % 91 + + // Ensure that the characters are treated as strings and concatenate properly + charFromInt(val1).toString + charFromInt(val2).toString + charFromInt(val3).toString + "}" + } + + private def charFromInt(value: Int): Char = (value + 33).toChar + + def formatCourseSpeedMice(location: Location): (Int, Int) = { + // Default values + val status_spd = if (location.hasSpeed && location.getSpeed > 2) { + // Convert speed from m/s to knots, and return as an integer + mps2kt(location.getSpeed).toInt + } else { + 0 // If no valid speed or below threshold, set speed to 0 + } + + val course = if (location.hasBearing) { + // Get bearing as an integer (course) + location.getBearing.asInstanceOf[Int] + } else { + 0 // If no bearing, set course to 0 + } + + (status_spd, course) + } + + def formatAltitudeMice(location: Location): Option[Int] = { + if (location.hasAltitude) { + // Convert altitude to feet, round to nearest integer, and wrap in Some + Some(math.round(m2ft(location.getAltitude)).toInt) + } else { + None // If no altitude, return None + } + } def passcode(callssid : String) : Int = { // remove ssid, uppercase, add \0 for odd-length calls @@ -44,6 +217,20 @@ object AprsPacket { else "" } + + def formatAltitudeCompressed(location : Location) : String = { + if (location.hasAltitude) { + var altitude = m2ft(location.getAltitude) + var compressedAltitude = ((math.log(altitude) / math.log(1.002)) + 0.5).asInstanceOf[Int] + var c = (compressedAltitude / 91).asInstanceOf[Byte] + 33 + var s = (compressedAltitude % 91).asInstanceOf[Byte] + 33 + // Negative altitudes cannot be expressed in base-91 and results in corrupt packets + if(c < 33) c = 33 + if(s < 33) s = 33 + "%c%c".format(c.asInstanceOf[Char], s.asInstanceOf[Char]) + } else + "" + } def formatCourseSpeed(location : Location) : String = { // only report speeds above 2m/s (7.2km/h) @@ -55,12 +242,35 @@ object AprsPacket { "" } + def formatCourseSpeedCompressed(location : Location) : String = { + // only report speeds above 2m/s (7.2km/h) + if (location.hasSpeed && location.hasBearing) { + // && location.getSpeed > 2) + var compressedBearing = (location.getBearing.asInstanceOf[Int] / 4).asInstanceOf[Int] + var compressedSpeed = ((math.log(mps2kt(location.getSpeed)) / math.log(1.08)) - 1).asInstanceOf[Int] + var c = compressedBearing.asInstanceOf[Byte] + 33; + var s = compressedSpeed.asInstanceOf[Byte] + 33; + // Negative speeds a courses cannot be expressed in base-91 and results in corrupt packets + if(c < 33) c = 33 + if(s < 33) s = 33 + "%c%c".format(c.asInstanceOf[Char], s.asInstanceOf[Char]) + } else { + "" + } + } + def formatFreq(csespd : String, freq : Float) : String = { if (freq == 0) "" else { val prefix = if (csespd.length() > 0) "/" else "" prefix + "%07.3fMHz".formatLocal(null, freq) } } + + def formatFreqMice(freq : Float) : String = { + if (freq == 0) "" else { + "%07.3fMHz".formatLocal(null, freq) + } + } def formatLogin(callsign : String, ssid : String, passcode : String, version : String) : String = { "user %s pass %s vers %s".format(formatCallSsid(callsign, ssid), passcode, version) diff --git a/src/AprsService.scala b/src/AprsService.scala index 1fb87427..03e31b47 100644 --- a/src/AprsService.scala +++ b/src/AprsService.scala @@ -10,6 +10,7 @@ import _root_.android.widget.Toast import _root_.net.ab0oo.aprs.parser._ + object AprsService { val PACKAGE = "org.aprsdroid.app" // action intents @@ -230,18 +231,89 @@ class AprsService extends Service { Digipeater.parseList(digipath, true), payload) } + def newPacketMice(payload: String, destString: String) = { + val digipath = prefs.getString("digi_path", "WIDE1-1") + val parsedDigipath = Digipeater.parseList(digipath, true) + val callsign_ssid = prefs.getCallSsid() + Log.d("newPacketMice", s"digipath retrieved: $digipath") + + // Construct the micePacket string + val micePacketString = s"$callsign_ssid>$destString${if (digipath.nonEmpty) s",$digipath" else ""}:$payload" + + // Log or return the constructed packet + Log.d("newPacketMice", s"Constructed MICE Packet: $micePacketString") + + val micePacketParsed = Parser.parse(micePacketString) + + micePacketParsed + } def formatLoc(symbol : String, status : String, location : Location) = { val pos = new Position(location.getLatitude, location.getLongitude, 0, symbol(0), symbol(1)) pos.setPositionAmbiguity(prefs.getStringInt("priv_ambiguity", 0)) - val status_spd = if (prefs.getBoolean("priv_spdbear", true)) - AprsPacket.formatCourseSpeed(location) else "" + + // Calculate status_spd + val status_spd = if (prefs.getBoolean("priv_spdbear", true)) { + if(prefs.getBoolean("compressed_location", false)) { + // Compressed format + AprsPacket.formatCourseSpeedCompressed(location) + } else { + AprsPacket.formatCourseSpeed(location) + } + } else "" + // Log status_spd + Log.d("formatLoc", s"Status Speed: $status_spd") + + // Calculate status_freq val status_freq = AprsPacket.formatFreq(status_spd, prefs.getStringFloat("frequency", 0.0f)) - val status_alt = if (prefs.getBoolean("priv_altitude", true)) - AprsPacket.formatAltitude(location) else "" - val comment = status_spd + status_freq + status_alt + " " + status; + + // Calculate status_alt + val status_alt = if (prefs.getBoolean("priv_altitude", true)) { + // if speed is empty then use compressed altitude, otherwise use full length altitude + if(prefs.getBoolean("compressed_location", false) && status_spd == "") { + // Compressed format + AprsPacket.formatAltitudeCompressed(location) + } else { + AprsPacket.formatAltitude(location) + } + } else "" + // Log status_alt + Log.d("formatLoc", s"Status Altitude: $status_alt") + + if(prefs.getBoolean("compressed_location", false)) { + if(status_spd == "") { + // Speed is empty, so we can use a compressed altitude + if(status_alt == "") { + // Altitude is empty, so don't send any altitude data + pos.setCsTField(" sT") + } else { + // 3 signifies current GPS fix, GGA altitude, software compressed. + pos.setCsTField(status_alt + "3") + } + val packet = new PositionPacket( + pos, status_freq + " " + status, /* messaging = */ true) + packet.setCompressedFormat(true) + newPacket(packet) + } else { + // Speed is present, so we need to append the altitude to the end of the packet using the + // uncompressed method + // Apply the csT field with speed and course + // [ signifies current GPS fix, RMC speed, software compressed. + pos.setCsTField(status_spd + "[") + val packet = new PositionPacket( + pos, status_freq + status_alt + " " + status, /* messaging = */ true) + packet.setCompressedFormat(true) + newPacket(packet) + } + } else { + val packet = new PositionPacket( + pos, status_spd + status_freq + status_alt + " " + status, /* messaging = */ true) + newPacket(packet) + } + + //val comment = status_spd + status_freq + status_alt + " " + status; // TODO: slice after 43 bytes, not after 43 UTF-8 codepoints - newPacket(new PositionPacket(pos, comment.slice(0, 43), /* messaging = */ true)) + //newPacket(new PositionPacket(pos, comment.slice(0, 43), /* messaging = */ true)) } def sendPacket(packet : APRSPacket, status_postfix : String) { @@ -263,13 +335,56 @@ class AprsService extends Service { } def sendPacket(packet : APRSPacket) { sendPacket(packet, "") } + def formatLocMice(symbol : String, status : String, location : Location) = { + val privambiguity = 5 - prefs.getStringInt("priv_ambiguity", 0) + val ambiguity = if (privambiguity == 5) 0 else privambiguity + + Log.d("MICE", s"Set Ambiguity $ambiguity") + + val miceStatus = prefs.getString("p__location_mice_status", "Off Duty") + val (a, b, c) = AprsPacket.statusToBits(miceStatus) + + val status_freq = AprsPacket.formatFreqMice(prefs.getStringFloat("frequency", 0.0f)) + val (status_spd, course) = AprsPacket.formatCourseSpeedMice(location) + + // Encoding process + val (infoString, west, longOffset) = AprsPacket.encodeInfo(location.getLongitude, status_spd, course, symbol) + val destString = AprsPacket.encodeDest(location.getLatitude, longOffset, west, a, b, c, ambiguity) + + val altitudeValue = if (prefs.getBoolean("priv_altitude", true)) { + AprsPacket.formatAltitudeMice(location) + } else { + None + } + val altString = altitudeValue.map(alt => AprsPacket.altitude(alt.toInt)).getOrElse("") + + val formatPayload = infoString + + (if (altString.isEmpty) "" else altString) + + (if (status.isEmpty) "" else status) + + (if (status.nonEmpty && status_freq.nonEmpty) " " else "") + + (if (status_freq.isEmpty) "" else status_freq) + "[1" + + Log.d("formatLoc", s"MICE: $infoString $destString $altString") + + val packet = newPacketMice(formatPayload, destString) + + packet + + } + def postLocation(location : Location) { var symbol = prefs.getString("symbol", "") if (symbol.length != 2) symbol = getString(R.string.default_symbol) val status = prefs.getString("status", getString(R.string.default_status)) - val packet = formatLoc(symbol, status, location) - + + // Use inline prefs.getBoolean to decide the packet format + val packet = if (prefs.getBoolean("compressed_mice", false)) { + formatLocMice(symbol, status, location) + } else { + formatLoc(symbol, status, location) + } + Log.d(TAG, "packet: " + packet) sendPacket(packet, " (±%dm)".format(location.getAccuracy.asInstanceOf[Int])) } diff --git a/src/CompressedPrefs.scala b/src/CompressedPrefs.scala new file mode 100644 index 00000000..38d87db8 --- /dev/null +++ b/src/CompressedPrefs.scala @@ -0,0 +1,77 @@ +package org.aprsdroid.app + +import _root_.android.content.SharedPreferences +import _root_.android.os.Bundle +import _root_.android.preference.{PreferenceActivity, PreferenceManager, CheckBoxPreference, ListPreference} +import _root_.android.util.Log + +class CompressedPrefs extends PreferenceActivity with SharedPreferences.OnSharedPreferenceChangeListener { + + lazy val prefs = new PrefsWrapper(this) + + def loadXml() { + // Load compressed.xml preferences + addPreferencesFromResource(R.xml.compressed) + } + + override def onCreate(savedInstanceState: Bundle) { + super.onCreate(savedInstanceState) + loadXml() + getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener(this) + + // Update preferences state on activity creation + updateCheckBoxState() + } + + override def onDestroy() { + super.onDestroy() + getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this) + } + + override def onSharedPreferenceChanged(sp: SharedPreferences, key: String) { + // Handle preference changes for specific keys + key match { + case "compressed_location" | "compressed_mice" => + // Update checkbox states when either preference changes + updateCheckBoxState() + case "p__location_mice_status" => + // Handle changes for the p__location_mice_status preference (if needed) + updateStatus() + case _ => // No action for other preferences + } + } + + // This method will enable/disable the checkboxes based on their current state + private def updateCheckBoxState(): Unit = { + val compressedLocationPref = findPreference("compressed_location").asInstanceOf[CheckBoxPreference] + val compressedMicePref = findPreference("compressed_mice").asInstanceOf[CheckBoxPreference] + val locationMiceStatusPref = findPreference("p__location_mice_status").asInstanceOf[ListPreference] + + // If "compressed_location" is checked, disable "p__location_mice_status" + if (compressedLocationPref.isChecked) { + locationMiceStatusPref.setEnabled(false) + compressedMicePref.setEnabled(false) // Also disable "compressed_mice" when "compressed_location" is checked + } else { + locationMiceStatusPref.setEnabled(true) // Enable "p__location_mice_status" when "compressed_location" is not checked + compressedMicePref.setEnabled(true) // Re-enable "compressed_mice" if "compressed_location" is unchecked + } + + // If "compressed_mice" is checked, disable "compressed_location" + if (compressedMicePref.isChecked) { + compressedLocationPref.setEnabled(false) + } else { + compressedLocationPref.setEnabled(true) + } + } + + + // Method to handle updates related to p__location_mice_status + private def updateStatus(): Unit = { + val statusPref = findPreference("p__location_mice_status").asInstanceOf[ListPreference] + val statusValue = statusPref.getValue + + // Here, you can handle actions based on the selected status. + // For example, logging the selected status: + Log.d("CompressedPrefs", s"Selected Location Mice Status: $statusValue") + } +}