diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index dca21f0..989ec83 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarData.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarData.kt new file mode 100644 index 0000000..7727cc7 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarData.kt @@ -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 = 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 = 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() + // 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 +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index a956659..24d0637 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -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) } /** diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarHardwareFeed.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarHardwareFeed.kt new file mode 100644 index 0000000..7650a55 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarHardwareFeed.kt @@ -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>() + 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 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", + ) + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDataTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDataTest.kt new file mode 100644 index 0000000..f16ddf4 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDataTest.kt @@ -0,0 +1,63 @@ +package app.seb3thehacker.gearslip.car + +import app.seb3thehacker.gearslip.Protobuf +import org.junit.After +import org.junit.Assert.* +import org.junit.Test + +class CarDataTest { + @After fun reset() = CarData.clear() + + private fun batch(field: Int, body: ByteArray) = Protobuf.bytesField(field, body) + + @Test fun `speed odometer and fuel come out in real units`() { + CarData.onEvent( + batch(CarData.SPEED, Protobuf.varintField(1, 12_500)) + + batch(CarData.ODOMETER, Protobuf.varintField(1, 123_456)) + + batch(CarData.FUEL, Protobuf.varintField(1, 42) + Protobuf.varintField(2, 380) + Protobuf.varintField(3, 0)), + ) + val s = CarData.state.value + assertEquals(12.5f, s.speedMps!!, 0.001f) + assertEquals(12_345.6f, s.odometerKm!!, 0.01f) + assertEquals(42, s.fuelPercent) + assertEquals(380, s.rangeKm) + assertEquals(false, s.fuelLow) + assertTrue(s.updatedAt.keys.containsAll(listOf(CarData.SPEED, CarData.ODOMETER, CarData.FUEL))) + } + + @Test fun `a location west and south of zero keeps its sign`() { + CarData.onEvent( + batch( + CarData.LOCATION, + Protobuf.varintField(2, -337_000_000) + Protobuf.varintField(3, -706_000_000) + + Protobuf.varintField(7, 90_000_000), + ), + ) + val l = CarData.state.value.location!! + assertEquals(-33.7, l.latitude, 1e-6) + assertEquals(-70.6, l.longitude, 1e-6) + assertEquals(90f, l.bearingDegrees!!, 0.001f) + assertNull(l.accuracyMeters) + } + + @Test fun `the newest reading in a batch wins, and fuel keeps what a later batch leaves out`() { + CarData.onEvent(batch(CarData.FUEL, Protobuf.varintField(1, 50) + Protobuf.varintField(2, 400))) + CarData.onEvent( + batch(CarData.COMPASS, Protobuf.varintField(1, 10_000_000)) + + batch(CarData.COMPASS, Protobuf.varintField(1, 20_000_000)) + + batch(CarData.FUEL, Protobuf.varintField(1, 49)), + ) + val s = CarData.state.value + assertEquals(20f, s.compassDegrees!!, 0.001f) + assertEquals(49, s.fuelPercent) + assertEquals(400, s.rangeKm) + } + + @Test fun `a car that names no model or year leaves them unknown`() { + CarData.onModel("", "0") + assertNull(CarData.model) + CarData.onModel("Clio", "0") + assertEquals("Clio", CarData.model?.name) + assertNull(CarData.model?.year) + } +}