mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Validate input and correct navigation and sensor services
This commit is contained in:
1 parent
c04c35b5cd
commit
6efdad5e53
11 files changed
+355
-138
No files matched your search
@@ -18,6 +18,7 @@ import app.seb3thehacker.gearslip.car.CarSensors
|
||||
import app.seb3thehacker.gearslip.car.CarServices
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import java.io.IOException
|
||||
import app.seb3thehacker.gearslip.protocol.InputProtocol
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.Executors
|
||||
@@ -660,12 +661,15 @@ class GearslipRunner(
|
||||
|
||||
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
|
||||
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||
val navigation = ServiceDiscovery.findNavStatusService(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no navigation status channel - the cluster won't show turns")
|
||||
return
|
||||
}
|
||||
val channel = navigation.serviceId
|
||||
log.i("navigation status channel: $channel")
|
||||
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
|
||||
clusterNav = ClusterNav(channel, navigation.minimumIntervalMs.toLong()) { id, body ->
|
||||
send(id, body, encrypted = true, channel = channel)
|
||||
}
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
@@ -721,7 +725,7 @@ class GearslipRunner(
|
||||
*/
|
||||
private fun onNightSensor(body: ByteArray) {
|
||||
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
|
||||
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
|
||||
val isNight = (Wire.varint(Wire.fields(night), 1) ?: 0L) != 0L
|
||||
if (isNight != lastSensorNight) {
|
||||
lastSensorNight = isNight
|
||||
log.i("car says it's ${if (isNight) "night" else "day"}")
|
||||
@@ -804,69 +808,60 @@ class GearslipRunner(
|
||||
MSG_KEY_BINDING_RESPONSE ->
|
||||
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
|
||||
|
||||
MSG_INPUT_REPORT -> {
|
||||
val report = Wire.fields(body)
|
||||
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
|
||||
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||
}
|
||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||
if (touch == null) {
|
||||
return
|
||||
}
|
||||
val touchFields = Wire.fields(touch)
|
||||
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
|
||||
val pointers = Wire.allBytes(touchFields, 1)
|
||||
if (pointers.isEmpty()) return
|
||||
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
|
||||
val size = config?.pixelSize()
|
||||
// The car crops its margin off the frame and measures taps from the corner of what
|
||||
// it shows, so they come in short by half the margin: the 2024 VW (margin 240) sent
|
||||
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a
|
||||
// 480-high frame with a 130 margin. Without this every tap landed 120 px too high
|
||||
// and the VW couldn't press past its first screen.
|
||||
val shift = config?.let(::marginsFor) ?: Insets.NONE
|
||||
// A stretched frame: the car's tap is on the frame, the UI is drawn smaller.
|
||||
val drawn = drawSize
|
||||
val points = pointers.mapIndexedNotNull { index, pointer ->
|
||||
val fields = Wire.fields(pointer)
|
||||
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val id = Wire.varint(fields, 3)?.toInt() ?: index
|
||||
if (drawn != null && size != null) {
|
||||
TouchPoint(
|
||||
id,
|
||||
clamp(rawX * drawn.first / size.first, drawn.first),
|
||||
clamp(rawY * drawn.second / size.second, drawn.second),
|
||||
)
|
||||
} else {
|
||||
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
|
||||
}
|
||||
}
|
||||
if (points.isEmpty()) return
|
||||
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
|
||||
if (points.size > 1 && touchesSeen < 3) {
|
||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||
}
|
||||
|
||||
touchesSeen++
|
||||
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
MSG_INPUT_REPORT -> onInputReport(body)
|
||||
|
||||
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInputReport(body: ByteArray) {
|
||||
val report = Wire.fields(body)
|
||||
if (Wire.varint(report, 1) == null) return
|
||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||
// Touchpads (field 7) need a separate gesture pipeline; their coordinates are not
|
||||
// positions on the projected screen.
|
||||
val touch = Wire.bytes(report, 3)
|
||||
if (touch == null) {
|
||||
return
|
||||
}
|
||||
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||
if (videoStarted && !videoProjected) {
|
||||
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||
}
|
||||
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
|
||||
// The car crops its margin off the frame and measures taps from the corner of what it
|
||||
// shows, so they come in short by half the margin: the 2024 VW (margin 240) sent y=6,
|
||||
// inside the cropped band, and the Uconnect's never passed 351 on a 480-high frame with a
|
||||
// 130 margin. Without this every tap landed 120 px too high.
|
||||
val shift = config?.let(::marginsFor) ?: Insets.NONE
|
||||
val size = config?.pixelSize()
|
||||
// A stretched frame (see [screenShape]): the car's tap is on the frame, the UI is drawn smaller.
|
||||
val drawn = drawSize
|
||||
val stretched = drawn != null && size != null
|
||||
val (action, actionIndex, raw) =
|
||||
(if (stretched) InputProtocol.touch(touch, size) else InputProtocol.touch(touch, size, shift.left, shift.top)) ?: return
|
||||
val points = if (drawn != null && size != null) {
|
||||
raw.map { TouchPoint(it.id, it.x * drawn.first / size.first, it.y * drawn.second / size.second) }
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
if (points.size > 1 && touchesSeen < 3) {
|
||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||
}
|
||||
|
||||
touchesSeen++
|
||||
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the head unit to forward its buttons. Many units send nothing from the steering wheel
|
||||
* until the phone names the keys it wants. What the unit offered is asked for; a unit that
|
||||
@@ -893,8 +888,9 @@ class GearslipRunner(
|
||||
for (key in Wire.allBytes(Wire.fields(keyEvent), 1)) {
|
||||
val fields = Wire.fields(key)
|
||||
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
|
||||
val down = Wire.varint(fields, 2) == 1L
|
||||
val longPress = Wire.varint(fields, 4) == 1L
|
||||
val down = (Wire.varint(fields, 2) ?: continue) != 0L
|
||||
if (Wire.varint(fields, 3) == null) continue
|
||||
val longPress = (Wire.varint(fields, 4) ?: 0L) != 0L
|
||||
log.i("<- key event: keycode=$keycode ${if (down) "down" else "up"}${if (longPress) " long" else ""}")
|
||||
if (!CarKeys.onKey(keycode, down, longPress) && down) log.i("unhandled keycode=$keycode")
|
||||
}
|
||||
@@ -910,6 +906,7 @@ class GearslipRunner(
|
||||
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
|
||||
// int32, so a turn the other way arrives as a 64-bit two's complement varint.
|
||||
val delta = Wire.varint(fields, 2)?.toInt() ?: continue
|
||||
if (keycode != 65536 || delta == 0) continue
|
||||
log.i("<- relative event: keycode=$keycode delta=$delta")
|
||||
CarKeys.onRotate(delta)
|
||||
}
|
||||
@@ -1260,13 +1257,6 @@ class GearslipRunner(
|
||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
value < 0 -> 0f
|
||||
value >= limit -> (limit - 1).toFloat()
|
||||
else -> value.toFloat()
|
||||
}
|
||||
|
||||
private fun onPingRequest(body: ByteArray) {
|
||||
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
||||
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||
|
||||
@@ -144,6 +144,26 @@ object Wire {
|
||||
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||
|
||||
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
|
||||
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
|
||||
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
|
||||
|
||||
/** Null means a packed field contained an incomplete or overflowing varint. */
|
||||
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
|
||||
for (field in fields.filter { it.number == number }) {
|
||||
if (field.wireType == 0) add(field.varint)
|
||||
else if (field.wireType == 2) {
|
||||
val bytes = field.bytes ?: continue
|
||||
var position = 0
|
||||
while (position < bytes.size) {
|
||||
val (value, next) = readVarint(bytes, position) ?: return null
|
||||
add(value)
|
||||
position = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
|
||||
@@ -76,7 +76,7 @@ object ServiceDiscovery {
|
||||
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sink = Wire.bytes(service, 3) ?: continue
|
||||
val sinkFields = Wire.fields(sink)
|
||||
val audioConfigs = Wire.allBytes(sinkFields, 3)
|
||||
@@ -116,13 +116,26 @@ object ServiceDiscovery {
|
||||
*/
|
||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
||||
|
||||
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */
|
||||
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
|
||||
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||
|
||||
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||
fun findNavStatusService(response: ByteArray): NavStatusService? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
|
||||
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
|
||||
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
|
||||
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
|
||||
NavStatusService(id, interval.toInt())
|
||||
}
|
||||
|
||||
private fun serviceId(fields: List<Wire.Field>): Int? =
|
||||
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
|
||||
|
||||
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null
|
||||
if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +147,7 @@ object ServiceDiscovery {
|
||||
fun findMicService(response: ByteArray): AudioService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
|
||||
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
|
||||
return AudioService(
|
||||
@@ -179,9 +192,7 @@ object ServiceDiscovery {
|
||||
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
|
||||
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
|
||||
// Packed as declared, though a car may send each method as its own varint.
|
||||
val methods = fields.filter { it.number == 2 }.flatMap { f ->
|
||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||
}
|
||||
val methods = Wire.repeatedInts(fields, 2)
|
||||
return BluetoothService(id, address, methods)
|
||||
}
|
||||
return null
|
||||
@@ -215,8 +226,8 @@ object ServiceDiscovery {
|
||||
* repeated TouchScreen touchscreen = 2; ... }
|
||||
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
|
||||
*
|
||||
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
|
||||
* scaled to the projected video size before injection. [keycodesSupported] is what the
|
||||
* Touch reports use projected video coordinates; these dimensions describe the input
|
||||
* hardware and help select a video configuration. [keycodesSupported] is what the
|
||||
* head unit says it will actually send over the Input channel as key events (steering-wheel
|
||||
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
|
||||
* know ahead of time whether a button like voice/PTT is even wired up on this hardware.
|
||||
@@ -245,38 +256,18 @@ object ServiceDiscovery {
|
||||
private fun inputServices(response: ByteArray): List<InputService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val input = Wire.bytes(service, 4) ?: continue
|
||||
val inputFields = Wire.fields(input)
|
||||
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
|
||||
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
|
||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
|
||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
|
||||
// Declared packed, but some units send each keycode as its own field.
|
||||
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||
}
|
||||
val keycodes = Wire.repeatedInts(inputFields, 1)
|
||||
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
||||
private fun packedVarints(data: ByteArray): List<Int> = buildList {
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (pos < data.size) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
add(result.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main screen's video sink. A head unit with a second screen, such as LIVI with a
|
||||
* cluster display, offers a sink for each; the one without a display id (0) is the main one.
|
||||
@@ -289,7 +280,7 @@ object ServiceDiscovery {
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sink = Wire.bytes(service, 3) ?: continue
|
||||
|
||||
val sinkFields = Wire.fields(sink)
|
||||
@@ -316,16 +307,15 @@ object ServiceDiscovery {
|
||||
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||
* Sensor { required SensorType sensor_type = 1; }
|
||||
*
|
||||
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
||||
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
||||
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
|
||||
* SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
|
||||
* The vehicle data screen keeps their raw contents for diagnostics.
|
||||
*/
|
||||
class SensorService(val serviceId: Int, val types: List<Int>)
|
||||
|
||||
fun findSensorService(response: ByteArray): SensorService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sensorSource = Wire.bytes(service, 2) ?: continue
|
||||
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
|
||||
Wire.varint(Wire.fields(it), 1)?.toInt()
|
||||
|
||||
@@ -5,16 +5,7 @@ import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
|
||||
*
|
||||
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
|
||||
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
|
||||
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
|
||||
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
|
||||
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
|
||||
* any type that turns out to be mislabeled.
|
||||
*/
|
||||
/** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
|
||||
object CarSensors {
|
||||
|
||||
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
|
||||
@@ -30,16 +21,10 @@ object CarSensors {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
|
||||
* every sensor type into one shared set of message-level field numbers (field N = "the data
|
||||
* for sensor type N", roughly) rather than tagging events with the type id from
|
||||
* SensorType.type - so field number is read here as a best-effort type id, same as the
|
||||
* other services in this file.
|
||||
*/
|
||||
/** Each SensorBatch field contains repeated readings of one sensor type. */
|
||||
fun onEvent(body: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
val fields = Wire.fields(body)
|
||||
val fields = Wire.fields(body).filter { it.wireType == 2 }
|
||||
val byType = fields.groupBy { it.number }
|
||||
val updated = byType.mapValues { (number, group) ->
|
||||
Reading(
|
||||
@@ -61,10 +46,7 @@ object CarSensors {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
|
||||
* a label, not a fact - see [CarSensors] doc.
|
||||
*/
|
||||
/** Sensor type values shared by the discovery service and SensorBatch. */
|
||||
object SensorType {
|
||||
private val NAMES = mapOf(
|
||||
1 to "Location (GPS)",
|
||||
@@ -89,6 +71,10 @@ object SensorType {
|
||||
20 to "Gyroscope",
|
||||
21 to "GPS satellites",
|
||||
22 to "Toll card",
|
||||
23 to "Vehicle energy model",
|
||||
24 to "Trailer",
|
||||
25 to "Raw vehicle energy model",
|
||||
26 to "Raw EV trip settings",
|
||||
)
|
||||
|
||||
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
||||
|
||||
@@ -24,23 +24,36 @@ import app.seb3thehacker.gearslip.Protobuf
|
||||
*/
|
||||
class ClusterNav(
|
||||
val channelId: Int,
|
||||
minimumIntervalMs: Long = 0,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
|
||||
@Volatile private var running = false
|
||||
private var worker: Thread? = null
|
||||
private var updatesRequested = true
|
||||
private var sentStatus = -1
|
||||
private var sentTurn: String? = null
|
||||
private var sentDistance: String? = null
|
||||
|
||||
@Synchronized
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
// The car saying when it wants turns. Sending all along is harmless, so these are only logged.
|
||||
MSG_START -> GearslipLog.i("cluster: <- start")
|
||||
MSG_STOP -> GearslipLog.i("cluster: <- stop")
|
||||
MSG_START -> {
|
||||
updatesRequested = true
|
||||
sentStatus = -1
|
||||
sentTurn = null
|
||||
sentDistance = null
|
||||
GearslipLog.i("cluster: <- start")
|
||||
}
|
||||
MSG_STOP -> {
|
||||
updatesRequested = false
|
||||
GearslipLog.i("cluster: <- stop")
|
||||
}
|
||||
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
@@ -48,19 +61,28 @@ class ClusterNav(
|
||||
private fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
Thread({
|
||||
worker = Thread({
|
||||
while (running) {
|
||||
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
|
||||
Thread.sleep(TICK_MS)
|
||||
try {
|
||||
Thread.sleep(updateIntervalMs)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}, "gearslip-cluster").start()
|
||||
}, "gearslip-cluster").also { it.start() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
running = false
|
||||
worker?.interrupt()
|
||||
worker = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun tick() {
|
||||
if (!running || !updatesRequested) return
|
||||
val turn = NavTurn.current()
|
||||
val status = when {
|
||||
turn == null -> STATUS_INACTIVE
|
||||
@@ -82,7 +104,7 @@ class ClusterNav(
|
||||
val maneuver = step.maneuver
|
||||
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
|
||||
val road = step.road.text().ifBlank { step.cue.text() }
|
||||
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}"
|
||||
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
|
||||
if (key == sentTurn) return
|
||||
sentTurn = key
|
||||
var body = Protobuf.stringField(1, road) +
|
||||
|
||||
@@ -31,7 +31,7 @@ class CarMediaStatus(
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.seb3thehacker.gearslip.protocol
|
||||
|
||||
import app.seb3thehacker.gearslip.TouchPoint
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
|
||||
/** Decodes the touch coordinates and pointer index carried by an InputReport. */
|
||||
internal object InputProtocol {
|
||||
data class Touch(val action: Int, val actionIndex: Int, val points: List<TouchPoint>)
|
||||
|
||||
/** [shiftX] and [shiftY] move each point by the margin the car crops off before measuring. */
|
||||
fun touch(body: ByteArray, size: Pair<Int, Int>?, shiftX: Int = 0, shiftY: Int = 0): Touch? {
|
||||
val fields = Wire.fields(body)
|
||||
val action = Wire.varint(fields, 3)?.toInt() ?: 0
|
||||
if (action !in setOf(0, 1, 2, 5, 6)) return null
|
||||
val pointers = Wire.allBytes(fields, 1)
|
||||
if (pointers.isEmpty() || pointers.size > 32) return null
|
||||
val actionIndex = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||
if (actionIndex !in pointers.indices) return null
|
||||
val points = pointers.map { pointer ->
|
||||
val values = Wire.fields(pointer)
|
||||
val x = Wire.varint(values, 1)?.toInt() ?: return null
|
||||
val y = Wire.varint(values, 2)?.toInt() ?: return null
|
||||
val id = Wire.varint(values, 3) ?: return null
|
||||
if (id !in 0L..31L) return null
|
||||
TouchPoint(id.toInt(), clamp(x + shiftX, size?.first), clamp(y + shiftY, size?.second))
|
||||
}
|
||||
if (points.map { it.id }.toSet().size != points.size) return null
|
||||
return Touch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
limit <= 0 -> 0f
|
||||
else -> value.coerceIn(0, limit - 1).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.view.Surface
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
@@ -229,4 +230,27 @@ class ConnectionLifecycleTest {
|
||||
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.set(runner, value)
|
||||
}
|
||||
|
||||
|
||||
@Test fun `touchpad reports are not injected as touchscreen taps`() {
|
||||
var touches = 0
|
||||
val projection = object : Projection {
|
||||
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) = Unit
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) { touches++ }
|
||||
override fun onProjectionStopped() = Unit
|
||||
}
|
||||
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream(), ::identity, projection)
|
||||
val input = GearslipRunner::class.java.getDeclaredMethod("onInputReport", ByteArray::class.java)
|
||||
.apply { isAccessible = true }
|
||||
val touch = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
|
||||
Protobuf.varintField(2, 200) + Protobuf.varintField(3, 0))
|
||||
try {
|
||||
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(7, touch))
|
||||
assertEquals(0, touches)
|
||||
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(3, touch))
|
||||
assertEquals(1, touches)
|
||||
} finally {
|
||||
runner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ServiceDiscoveryChannelTest {
|
||||
private fun service(id: Long, field: Int, payload: ByteArray = byteArrayOf()) =
|
||||
Protobuf.bytesField(1, Protobuf.varintField(1, id) + Protobuf.bytesField(field, payload))
|
||||
|
||||
@Test fun `invalid channel IDs cannot alias the control or another service channel`() {
|
||||
val config = Protobuf.varintField(1, 16000) + Protobuf.varintField(2, 16) + Protobuf.varintField(3, 1)
|
||||
val audio = Protobuf.bytesField(3, config)
|
||||
val microphone = Protobuf.bytesField(2, config)
|
||||
val sensor = Protobuf.bytesField(1, Protobuf.varintField(1, 1))
|
||||
for (id in listOf(-1L, 0L, 256L, 0x1_0000_0001L)) {
|
||||
assertNull(ServiceDiscovery.findMediaStatusChannel(service(id, 9)))
|
||||
assertNull(ServiceDiscovery.findNavStatusService(service(id, 8, Protobuf.varintField(1, 1000))))
|
||||
assertNull(ServiceDiscovery.findVideoService(service(id, 3, Protobuf.bytesField(4, byteArrayOf()))))
|
||||
assertNull(ServiceDiscovery.findInputService(service(id, 4)))
|
||||
assertNull(ServiceDiscovery.findMicService(service(id, 5, microphone)))
|
||||
assertNull(ServiceDiscovery.findSensorService(service(id, 2, sensor)))
|
||||
assertTrue(ServiceDiscovery.findAudioServices(service(id, 3, audio)).isEmpty())
|
||||
}
|
||||
assertEquals(255, ServiceDiscovery.findMediaStatusChannel(service(255, 9)))
|
||||
}
|
||||
|
||||
@Test fun `navigation discovery preserves the requested update interval`() {
|
||||
val navigation = requireNotNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, 1500))))
|
||||
assertEquals(6, navigation.serviceId)
|
||||
assertEquals(1500, navigation.minimumIntervalMs)
|
||||
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8)))
|
||||
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, -1))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ClusterNavTest {
|
||||
@Test fun `head unit minimum interval limits repeated navigation updates`() {
|
||||
val first = CountDownLatch(1)
|
||||
val next = CountDownLatch(1)
|
||||
val count = AtomicInteger()
|
||||
val nav = ClusterNav(6, 1000) { _, _ ->
|
||||
if (count.incrementAndGet() == 1) first.countDown() else next.countDown()
|
||||
}
|
||||
try {
|
||||
nav.onMessage(8, Protobuf.varintField(1, 0))
|
||||
assertTrue(first.await(2, TimeUnit.SECONDS))
|
||||
nav.onMessage(32769, byteArrayOf())
|
||||
assertFalse("the fixed 500ms timer must not override the head unit", next.await(700, TimeUnit.MILLISECONDS))
|
||||
assertTrue(next.await(2, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
nav.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `head unit stop pauses updates and start resends current status`() {
|
||||
val messages = mutableListOf<Int>()
|
||||
val nav = ClusterNav(6) { id, _ -> messages += id }
|
||||
// Drive polling synchronously so the test does not depend on a background timer.
|
||||
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
|
||||
val tick = ClusterNav::class.java.getDeclaredMethod("tick").apply { isAccessible = true }
|
||||
running.set(nav, true)
|
||||
try {
|
||||
tick.invoke(nav)
|
||||
assertEquals(listOf(32771), messages)
|
||||
nav.onMessage(32770, byteArrayOf())
|
||||
tick.invoke(nav)
|
||||
assertEquals(1, messages.size)
|
||||
nav.onMessage(32769, byteArrayOf())
|
||||
tick.invoke(nav)
|
||||
assertEquals(listOf(32771, 32771), messages)
|
||||
nav.stop()
|
||||
tick.invoke(nav)
|
||||
assertEquals(2, messages.size)
|
||||
} finally {
|
||||
nav.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `missing channel open status does not start publishing`() {
|
||||
val nav = ClusterNav(6) { _, _ -> error("channel was not accepted") }
|
||||
nav.onMessage(8, byteArrayOf())
|
||||
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
|
||||
assertFalse(running.getBoolean(nav))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.seb3thehacker.gearslip.protocol
|
||||
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class InputProtocolTest {
|
||||
private fun pointer(id: Int) = Protobuf.bytesField(1,
|
||||
Protobuf.varintField(1, 100) + Protobuf.varintField(2, 200) + Protobuf.varintField(3, id.toLong()))
|
||||
|
||||
@Test fun `multitouch preserves pointer IDs and action index`() {
|
||||
val body = pointer(7) + pointer(3) + Protobuf.varintField(2, 1) + Protobuf.varintField(3, 5)
|
||||
val touch = requireNotNull(InputProtocol.touch(body, 800 to 480))
|
||||
assertEquals(5, touch.action)
|
||||
assertEquals(1, touch.actionIndex)
|
||||
assertEquals(listOf(7, 3), touch.points.map { it.id })
|
||||
assertEquals(100f, touch.points[1].x)
|
||||
}
|
||||
|
||||
@Test fun `invalid pointer index is rejected instead of targeting a different finger`() {
|
||||
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, 1), null))
|
||||
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, -1), null))
|
||||
}
|
||||
|
||||
@Test fun `missing required pointer fields and duplicate IDs are rejected`() {
|
||||
assertNull(InputProtocol.touch(Protobuf.bytesField(1, Protobuf.varintField(1, 20)), null))
|
||||
assertNull(InputProtocol.touch(pointer(1) + pointer(1), null))
|
||||
}
|
||||
|
||||
@Test fun `pointer IDs must fit the Android motion event range`() {
|
||||
for (id in listOf(-1L, 32L, 0x1_0000_0000L)) {
|
||||
val body = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
|
||||
Protobuf.varintField(2, 200) + Protobuf.varintField(3, id))
|
||||
assertNull(InputProtocol.touch(body, null))
|
||||
}
|
||||
assertEquals(31, requireNotNull(InputProtocol.touch(pointer(31), null)).points.single().id)
|
||||
}
|
||||
|
||||
@Test fun `oversized pointer sets are rejected before constructing motion events`() {
|
||||
val body = (0..32).fold(byteArrayOf()) { bytes, id -> bytes + pointer(id) }
|
||||
assertNull(InputProtocol.touch(body, null))
|
||||
}
|
||||
|
||||
@Test fun `optional touch fields use down and first pointer defaults`() {
|
||||
val touch = requireNotNull(InputProtocol.touch(pointer(0), 50 to 60))
|
||||
assertEquals(0, touch.action)
|
||||
assertEquals(0, touch.actionIndex)
|
||||
assertEquals(49f, touch.points.single().x)
|
||||
assertEquals(59f, touch.points.single().y)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user