host: car apps get the car's speed, odometer, fuel and range, compass and GPS

This commit is contained in:
Seb3thehacker committed 2026-10-26 16:37:04 -07:00
1 parent f4b8391fa3
commit b168eb7c04
5 files changed
+361 -26

No files matched your search

@@ -185,6 +185,7 @@ class GearslipRunner(
guidanceLink?.close()
guidanceLink = null
CarSensors.clear()
app.seb3thehacker.gearslip.car.CarData.clear()
CarMic.current = null
CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected()
@@ -467,6 +468,7 @@ class GearslipRunner(
log.i("head unit: $info")
SessionReport.headUnit(info.toString())
SessionReport.headUnitInfo = info
app.seb3thehacker.gearslip.car.CarData.onModel(info.carModel, info.carYear)
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
SessionReport.profile(profile?.name)
@@ -785,6 +787,7 @@ class GearslipRunner(
MSG_SENSOR_EVENT_INDICATION -> {
CarSensors.onEvent(body)
app.seb3thehacker.gearslip.car.CarData.onEvent(body)
logSensorEvent(body)
onNightSensor(body)
}
@@ -0,0 +1,110 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The car's sensors, read into real values for the car apps that ask (a map's speed, an EV app's
* range). [CarSensors] keeps the raw readings for the Vehicle data screen; this keeps the few with
* a known meaning, decoded the way Android Auto's SensorBatch scales them:
*
* gps_location = 1 { latitude_e7 = 2; longitude_e7 = 3; accuracy_e3 = 4; altitude_e2 = 5;
* speed_e3 = 6; bearing_e6 = 7 }
* compass = 2 { bearing_e6 = 1 } speed = 3 { speed_e3 = 1 } (metres per second)
* odometer = 5 { kms_e1 = 1 } fuel = 6 { level = 1 (percent); range = 2 (km); low = 3 }
*
* Each value is null until the car first sends it, and the whole lot clears when it disconnects.
*/
object CarData {
class Location(
val latitude: Double,
val longitude: Double,
val accuracyMeters: Float?,
val altitudeMeters: Double?,
val speedMps: Float?,
val bearingDegrees: Float?,
)
data class Snapshot(
val speedMps: Float? = null,
val odometerKm: Float? = null,
val fuelPercent: Int? = null,
val rangeKm: Int? = null,
val fuelLow: Boolean? = null,
val compassDegrees: Float? = null,
val location: Location? = null,
/** When each kind last changed, by its SensorBatch field number. */
val updatedAt: Map<Int, Long> = emptyMap(),
)
/** What the car says it is, from its service discovery: model and year. It names no maker. */
class Model(val name: String, val year: Int?)
private val flow = MutableStateFlow(Snapshot())
val state: StateFlow<Snapshot> = flow
@Volatile var model: Model? = null
private set
fun onModel(name: String, year: String) {
model = name.takeIf { it.isNotBlank() }?.let { Model(it, year.toIntOrNull()?.takeIf { y -> y > 0 }) }
}
fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis()
val batch = Wire.fields(body)
var s = flow.value
val touched = HashSet<Int>()
// Each kind may come several times in one batch: the last is the newest.
Wire.allBytes(batch, LOCATION).lastOrNull()?.let { bytes ->
val f = Wire.fields(bytes)
val lat = Wire.varint(f, 2)?.toInt()
val lon = Wire.varint(f, 3)?.toInt()
if (lat != null && lon != null) {
s = s.copy(
location = Location(
lat / 1e7, lon / 1e7,
Wire.varint(f, 4)?.let { it / 1e3f },
Wire.varint(f, 5)?.let { it.toInt() / 1e2 },
Wire.varint(f, 6)?.let { it.toInt() / 1e3f },
Wire.varint(f, 7)?.let { it.toInt() / 1e6f },
),
)
touched += LOCATION
}
}
Wire.allBytes(batch, COMPASS).lastOrNull()?.let { bytes ->
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(compassDegrees = it.toInt() / 1e6f); touched += COMPASS }
}
Wire.allBytes(batch, SPEED).lastOrNull()?.let { bytes ->
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(speedMps = it.toInt() / 1e3f); touched += SPEED }
}
Wire.allBytes(batch, ODOMETER).lastOrNull()?.let { bytes ->
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(odometerKm = it.toInt() / 10f); touched += ODOMETER }
}
Wire.allBytes(batch, FUEL).lastOrNull()?.let { bytes ->
val f = Wire.fields(bytes)
s = s.copy(
fuelPercent = Wire.varint(f, 1)?.toInt() ?: s.fuelPercent,
rangeKm = Wire.varint(f, 2)?.toInt() ?: s.rangeKm,
fuelLow = Wire.varint(f, 3)?.let { it != 0L } ?: s.fuelLow,
)
touched += FUEL
}
if (touched.isEmpty()) return
flow.value = s.copy(updatedAt = s.updatedAt + touched.associateWith { now })
}
fun clear() {
flow.value = Snapshot()
model = null
}
const val LOCATION = 1
const val COMPASS = 2
const val SPEED = 3
const val ODOMETER = 5
const val FUEL = 6
}
@@ -227,6 +227,7 @@ class CarAppConnection(private val context: Context) {
private fun disconnect(keepLocation: Boolean) {
main.removeCallbacks(handshakeTimeout)
hardware.stop()
clearToasts()
lent?.let { sendSurfaceDestroyed(it) }
lent = null
@@ -684,38 +685,25 @@ class CarAppConnection(private val context: Context) {
}
/**
* Answers car-hardware questions with "this car doesn't have that".
* Answers car-hardware questions from what the car itself sends: see [CarHardwareFeed].
*
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but
* it has to be an actual answer. Returning no binder for this service at all is what made
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the
* service, and the library's own "unsupported" path only runs once a reply arrives.
*
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
* Every question has to get an actual answer, even "unsupported". Returning no binder for
* this service at all is what made Organic Maps crash in onAppStart: its CarSensorsManager
* assumes a host that offers the service, and the library's own "unsupported" path only runs
* once a reply arrives. The reply's IBinder must be a real IOnDoneCallback: the app
* dispatches the reply through it.
*/
private val hardware = CarHardwareFeed { name -> noop(name).asBinder() }
private val hardwareHost = object : ICarHardwareHost.Stub() {
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
override fun getCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun getCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
hardware.get(type, result)
override fun subscribeCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun subscribeCarHardwareResult(type: Int, params: Bundleable?, result: ICarHardwareResult?) =
hardware.subscribe(type, result)
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit
private fun unsupported(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
runCatching {
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = hardware.unsubscribe(type)
}
/**
@@ -0,0 +1,171 @@
package app.seb3thehacker.gearslip.host
import android.os.IBinder
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Mileage
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.car.CarData
import app.seb3thehacker.gearslip.car.CarSettings
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* What a car app gets when it asks the car hardware API (CarInfo, CarSensors) about the car:
* the model, speed, odometer, fuel and range, compass and the car's own GPS, read from [CarData].
* Anything else, or anything the car never sends, is answered "this car doesn't have that".
*
* A one-off question ([get]) is answered at once. A subscription gets the current value, if
* there is one, and then each new one as the car sends it, until the app unsubscribes or the
* connection ends ([stop]).
*/
internal class CarHardwareFeed(private val callback: (name: String) -> IBinder) {
private val subscribers = ConcurrentHashMap<Int, CopyOnWriteArrayList<ICarHardwareResult>>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Volatile private var watching: Job? = null
fun get(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
send(target, type, valueOf(type, CarData.state.value) ?: return unsupported(target, type))
}
fun subscribe(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
if (type !in SENSOR_OF) return unsupported(target, type)
subscribers.getOrPut(type) { CopyOnWriteArrayList() }.add(target)
GearslipLog.i("host: an app subscribed to car hardware ${NAMES[type] ?: type}")
valueOf(type, CarData.state.value)?.let { send(target, type, it) }
watch()
}
fun unsubscribe(type: Int) {
subscribers.remove(type)
}
fun stop() {
subscribers.clear()
watching?.cancel()
watching = null
}
fun close() {
stop()
scope.cancel()
}
/** Sends each subscribed kind again when the car's reading of it changes. */
private fun watch() {
if (watching?.isActive == true) return
watching = scope.launch {
var seen = CarData.state.value.updatedAt
CarData.state.collect { s ->
for ((type, targets) in subscribers) {
val sensor = SENSOR_OF[type] ?: continue
if (s.updatedAt[sensor] == seen[sensor]) continue
val value = valueOf(type, s) ?: continue
targets.forEach { send(it, type, value) }
}
seen = s.updatedAt
}
}
}
private fun valueOf(type: Int, s: CarData.Snapshot): Any? {
val imperial = CarSettings.imperial()
val distanceUnit = known(if (imperial) CarUnit.MILE else CarUnit.KILOMETER)
val at = s.updatedAt[SENSOR_OF[type]] ?: System.currentTimeMillis()
fun <T : Any> v(value: T) = CarValue(value, at, CarValue.STATUS_SUCCESS)
return when (type) {
TYPE_MODEL -> CarData.model?.let { m ->
Model.Builder().setName(v(m.name)).apply { m.year?.let { setYear(v(it)) } }.build()
}
TYPE_SPEED -> s.speedMps?.let {
Speed.Builder()
.setRawSpeedMetersPerSecond(v(it))
.setDisplaySpeedMetersPerSecond(v(it))
.setSpeedDisplayUnit(known(if (imperial) CarUnit.MILES_PER_HOUR else CarUnit.KILOMETERS_PER_HOUR))
.build()
}
TYPE_MILEAGE -> s.odometerKm?.let {
Mileage.Builder().setOdometerMeters(v(it * 1000f)).setDistanceDisplayUnit(distanceUnit).build()
}
TYPE_ENERGY_LEVEL -> if (s.fuelPercent == null && s.rangeKm == null && s.fuelLow == null) null else {
EnergyLevel.Builder().apply {
s.fuelPercent?.let { setFuelPercent(v(it.toFloat())) }
s.rangeKm?.let { setRangeRemainingMeters(v(it * 1000f)) }
s.fuelLow?.let { setEnergyIsLow(v(it)) }
setDistanceDisplayUnit(distanceUnit)
}.build()
}
TYPE_COMPASS -> s.compassDegrees?.let { Compass(v(listOf(it))) }
TYPE_CAR_LOCATION -> s.location?.let { l ->
val location = android.location.Location("car").apply {
latitude = l.latitude
longitude = l.longitude
l.accuracyMeters?.let { accuracy = it }
l.altitudeMeters?.let { altitude = it }
l.speedMps?.let { speed = it }
l.bearingDegrees?.let { bearing = it }
time = at
}
CarHardwareLocation(v(location))
}
else -> null
}
}
private fun known(value: Int) = CarValue(value, System.currentTimeMillis(), CarValue.STATUS_SUCCESS)
private fun send(target: ICarHardwareResult, type: Int, value: Any) {
runCatching {
target.onCarHardwareResult(type, true, Bundleable.create(value), callback("carHardwareResult"))
}.onFailure {
// The app went away: stop telling it.
subscribers.values.forEach { list -> list.remove(target) }
GearslipLog.w("host: could not send car hardware ${NAMES[type] ?: type}: ${it.javaClass.simpleName}")
}
}
private fun unsupported(target: ICarHardwareResult, type: Int) {
runCatching { target.onCarHardwareResult(type, false, null, callback("carHardwareResult")) }
.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
companion object {
// ICarHardwareResultTypes in the car library.
private const val TYPE_MODEL = 1
private const val TYPE_ENERGY_LEVEL = 4
private const val TYPE_SPEED = 5
private const val TYPE_MILEAGE = 6
private const val TYPE_COMPASS = 21
private const val TYPE_CAR_LOCATION = 23
/** Which of the car's sensors each kind comes from, for knowing when it changed. */
private val SENSOR_OF = mapOf(
TYPE_ENERGY_LEVEL to CarData.FUEL,
TYPE_SPEED to CarData.SPEED,
TYPE_MILEAGE to CarData.ODOMETER,
TYPE_COMPASS to CarData.COMPASS,
TYPE_CAR_LOCATION to CarData.LOCATION,
)
private val NAMES = mapOf(
TYPE_MODEL to "model", TYPE_ENERGY_LEVEL to "energy level", TYPE_SPEED to "speed",
TYPE_MILEAGE to "mileage", TYPE_COMPASS to "compass", TYPE_CAR_LOCATION to "car location",
)
}
}