diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 69b3c6b..316ca55 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -383,7 +383,7 @@ class GearslipRunner( videoChannelId = video.serviceId videoConfigs = video.configs - log.i("video service: channel=${video.serviceId} codec=${video.codecName}") + log.i("video service: channel=${video.serviceId} codec=${video.codecName} display=${video.displayId}") video.configs.forEachIndexed { index, config -> log.i(" config[$index] = $config") } @@ -395,7 +395,7 @@ class GearslipRunner( startAudioChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse) - val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse) + val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) if (input == null) { log.w("no input service advertised - projection will be output-only") } else { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 8c5c0c9..b5fcab2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -51,7 +51,8 @@ object ServiceDiscovery { "$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin" } - class VideoService(val serviceId: Int, val codecType: Int, val configs: List) { + /** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */ + class VideoService(val serviceId: Int, val codecType: Int, val configs: List, val displayId: Int = 0) { val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)" } @@ -131,9 +132,23 @@ object ServiceDiscovery { val touchWidth: Int, val touchHeight: Int, val keycodesSupported: List = emptyList(), + /** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */ + val displayId: Int = 0, ) - fun findInputService(response: ByteArray): InputService? { + /** + * The input for the screen Gearslip projects to ([displayId]). A head unit with two screens + * offers an input service for each: LIVI lists an empty one for its second screen first, + * and taking that one left every touch on the real touchscreen ignored. + */ + fun findInputService(response: ByteArray, displayId: Int = 0): InputService? { + val inputs = inputServices(response) + return inputs.firstOrNull { it.displayId == displayId } + ?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 } + ?: inputs.firstOrNull() + } + + private fun inputServices(response: ByteArray): List = buildList { for (channel in Wire.allBytes(Wire.fields(response), 1)) { val service = Wire.fields(channel) val id = Wire.varint(service, 1)?.toInt() ?: continue @@ -147,9 +162,8 @@ object ServiceDiscovery { val keycodes = inputFields.filter { it.number == 1 }.flatMap { f -> f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt()) } - return InputService(id, width, height, keycodes) + add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0)) } - return null } /** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */ @@ -169,7 +183,16 @@ object ServiceDiscovery { } } + /** + * 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. + */ fun findVideoService(response: ByteArray): VideoService? { + val sinks = videoServices(response) + return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull() + } + + private fun videoServices(response: ByteArray): List = buildList { for (channel in Wire.allBytes(Wire.fields(response), 1)) { val service = Wire.fields(channel) val id = Wire.varint(service, 1)?.toInt() ?: continue @@ -190,9 +213,8 @@ object ServiceDiscovery { heightMargin = Wire.varint(f, 4)?.toInt() ?: 0, ) } - return VideoService(id, codecType, configs) + add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0)) } - return null } /** diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt new file mode 100644 index 0000000..6287e23 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt @@ -0,0 +1,51 @@ +package app.seb3thehacker.gearslip + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * LIVI 2.0.1 offers a second screen alongside the main one, and lists that screen's input first: + * channel 3: video sink, no display id (the main screen) + * channel 19: video sink, display_id = 1, display_type = 1 (a cluster) + * channel 20: input service with only display_id = 1 + * channel 8: input service with a 1280x720 touchscreen and keys (the main screen) + * Taking the first input service left every touch on the main screen ignored. + */ +class ServiceDiscoveryDisplayTest { + + private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value) + + private fun lenField(field: Int, body: ByteArray): ByteArray = + Protobuf.varint(((field shl 3) or 2).toLong()) + Protobuf.varint(body.size.toLong()) + body + + private fun videoSink(id: Int, displayId: Int?): ByteArray { + val config = varintField(1, 2L) + varintField(2, 1L) + varintField(5, 180L) + var sink = varintField(1, 3L) + lenField(4, config) + if (displayId != null) sink += varintField(6, displayId.toLong()) + varintField(7, 1L) + return lenField(1, varintField(1, id.toLong()) + lenField(3, sink)) + } + + private fun input(id: Int, body: ByteArray) = lenField(1, varintField(1, id.toLong()) + lenField(4, body)) + + private fun liviResponse(): ByteArray = + videoSink(3, null) + + videoSink(19, 1) + + input(20, varintField(5, 1L)) + + input(8, lenField(1, Protobuf.varint(3L) + Protobuf.varint(4L)) + lenField(2, varintField(1, 1280L) + varintField(2, 720L))) + + @Test + fun picksTheMainScreensVideo() { + val video = ServiceDiscovery.findVideoService(liviResponse())!! + assertEquals(3, video.serviceId) + assertEquals(0, video.displayId) + } + + @Test + fun picksTheInputForTheProjectedScreen() { + val input = ServiceDiscovery.findInputService(liviResponse(), displayId = 0)!! + assertEquals(8, input.serviceId) + assertEquals(1280, input.touchWidth) + assertEquals(720, input.touchHeight) + assertEquals(listOf(3, 4), input.keycodesSupported) + } +}