Platform
The HMI Car Platform
evolved from a simple Kotlin-CLI app to an elaborate “Service Framework”
where pieces that interact with the car can be started and stopped at run-time, through a GUI and through configuration files.
I figured this was necessary because each car that e39-rpi is installed into might have different desired features:
- Some cars might already have a Bluetooth dongle attached to the tuner and wouldn’t want to use e39-rpi to stream music
- I originally designed the integration to also work by sending screen drawing commands via Ibus, just like how Bluebus does it. The idea that the Raspberry Pi would draw directly to the screen came later in the project.
- I wanted to interop with cars that have the factory telephone, or
Bluebus
without breaking those features.
Platform Origins
When I started the project in late 2020, I started with an empty main.kt
file
and needed to separate out the parts of the project:
- Reading raw IBUS data from the serial port, packetizing it, and forwarding it to other parts
- Other pieces can listen to the data and react to it
- Optionally, pieces can write IBUS packets to the serial port.
I chose Dagger2 as the Dependency Injection framework because I’m familiar with it from Android, and like it’s compile-time dependency checking.
I started with a minimal ApplicationScope
, and defined some Service
classes. I then
started them with a Platform
class.
Services
I liked the concept of an Android service and wanted to replicate it here.
interface Service {
fun onCreate()
fun onShutdown()
}
The above methods are called whenever the Platform
starts or stops the service.
Long-running Services
I created a JoinableService
so that I could make a LongRunningService
. Long Running Services
provide a suspend fun doWork()
to their subclasses so that (by being a suspend fun) they can subscribe to Channels, Flows, or kick-off long running tasks.
interface JoinableService : Service {
var jobToJoin : Job?
}
The jobToJoin
is cancelled when the JoinableService is shutdown.
abstract class LongRunningService constructor(
private val coroutineScope: CoroutineScope,
private val parsingDispatcher: CoroutineDispatcher
) : JoinableService {
override var jobToJoin : Job? = null
override fun onCreate() {
jobToJoin = coroutineScope.launch(parsingDispatcher) {
doWork()
}
}
override fun onShutdown() {
jobToJoin?.cancel(cause = ForegroundPlatform.PlatformShutdownCancellationException())
}
abstract suspend fun doWork()
}
PlatformServiceRunner
Below is the bare-bones PlatformServiceRunner
that the main.kt
called early on in the project:
class PlatformServiceRunner @Inject constructor(
private val coroutineScope: CoroutineScope,
iBusInputMessageParser: IBusInputMessageParser,
coolingFanController: CoolingFanController,
serialPublisherService: SerialPublisherService,
serialListenerService: SerialListenerService,
bluetoothService: BluetoothService,
telephoneButtonVideoSwitcherService: TelephoneButtonVideoSwitcherService
) : Service {
private val services = listOf<Service>(
coolingFanController,
iBusInputMessageParser,
serialPublisherService,
serialListenerService,
bluetoothService,
telephoneButtonVideoSwitcherService
)
override fun onCreate() {
runBlocking(coroutineScope.coroutineContext) {
val jobsToJoin = mutableListOf<Job>()
services.forEach {
it.onCreate()
if (it is JoinableService) {
it.jobToJoin?.let { job -> jobsToJoin.add(job) }
}
}
jobsToJoin.joinAll()
}
}
override fun onShutdown() {
services.forEach {
if (it is JoinableService) {
it.jobToJoin?.cancel(ForegroundPlatform.PlatformShutdownCancellationException())
}
it.onShutdown()
}
}
}
Hooking up Platform parts
Each Service
in the Platform is a class that is created by Dagger. The platform provides
some singleton variables so that the Services can communicate with the outside world.
/// Incoming IBus packets
@Named(ApplicationModule.IBUS_MESSAGE_INGRESS) val incomingMessages : MutableSharedFlow<IBusMessage>,
/// IBus Packets that should be written out to the car
@Named(ApplicationModule.IBUS_MESSAGE_OUTPUT_CHANNEL) private val messagesOut : Channel<IBusMessage>,
/// A channel that allows decorated messages to appear in the PicoCommsDebugWindow
@Named(ApplicationModule.IBUS_COMMS_DEBUG_CHANNEL) private val commsDebugChannel : MutableSharedFlow<IbusCommsDebugMessage>,
This is documented more in-depth in Platform Service Channels
Dagger Scopes
The ApplicationScope
is the root scope of the Dagger dependency tree. All of the “Platform-related” pieces
belong to the ConfiguredCarScope
. The ConfiguredCarScope
is a subcomponent that, when constructed, requires a CarPlatformConfiguration.
Constructing the ConfiguredCarScope
requires discussion of the ConfigurablePlatform
.
Configurable Platform
The ConfigurablePlatform
is the class responsible for:
- Reading the Configuration to see which services (and
ServiceGroups
) should be running when the ConfigurablePlatform is started - Creating the
ConfiguredCarScope
by passing in the configuration read from theConfigurationStorage
(which is backed by a HOCON file) - Exposing a
StateFlow<List<ConfigurablePlatformServiceRunStatusViewer.RunStatusRecordGroup>>
which allows other objects in theApplicationScope
to view which services are running, and start and stop them at run-time.
Services and ServiceGroups
The ConfigurablePlatform
introduces the notion of ServiceGroups
.
When a ServiceGroup
is started and stopped, all of the PlatformService
s belonging to it are started and stopped. A PlatformService
is a decorated Service
.
The configuration only stores a list of which ServiceGroups will run on startup. I figured that if the configuration is meant to enable/disable features for services, it should do so at a less granular level than just specifying which services should be started and stopped.
PlatformService
A PlatformService
wraps a Service
and provides a StateFlow<RunStatus>
which allows the ConfigurablePlatform to report whether the service is running, stopped, or zombie.
enum class RunStatus {
STOPPED,
RUNNING,
ZOMBIE //Hasn't watch-dogged in a while.
}
Graphic Display of Service Status
For some visual interest in this dry wiki page about a platform with no users, here’s a picture of what we’re working towards:
Manual construction of PlatformServices and ServiceGroups
Initially, construction was done by writing out which services belonged to which PlatformServiceGroup:
PlatformServiceGroup(
name = "CliPrinters",
description = "Services that print debug info to stdout.",
children = listOf(
PlatformService(
name = "PlatformMetronomeLogger",
description = "Prints ticks to stdout at an interval to show the Car Platform is running.",
baseService = platformMetronomeLogger,
logger = logger
),
PlatformService(
name = "IncomingIbusMessageCliPrinter",
description = "Prints incoming IBusMessages to stdout",
baseService = incomingIbusMessageCliPrinter,
logger = logger
),
PlatformService(
name = "IbusInputEventCliPrinter",
description = "Prints IBusInputEvents to stdout. " +
"This is an abstraction over messages to indicate actions.",
baseService = ibusInputEventCliPrinter,
logger = logger
)
)
),
This necessated a class, ConfigurablePlatformServiceList
, whose responsibility was to take in every Service as an injected constructor parameter, and provide an accessor for List<PlatformServiceGroup>
Service Discovery
The downside of the ConfigurablePlatformServiceList
approach is that it requires placing the Name
, Description
, and group membership information far away from the service.
Wouldn’t it be nice to use an annotation to put that information next to the service class definition?
I made this work with kapt
generating Kotlin code with KotlinPoet
. The following is an example of what Services look like:
@PlatformServiceInfo(
name = "PlatformMetronomeLogger",
description = "Prints ticks to stdout at an interval to show the Car Platform is running."
)
@CliPrinterServiceGroup
class PlatformMetronomeLogger @Inject constructor(
private val logger: Logger,
coroutineScope: CoroutineScope,
parsingDispatcher: CoroutineDispatcher
) : LongRunningLoopingService(coroutineScope, parsingDispatcher) {
var prevTime : Long = 0L
override suspend fun doWork() {
val currentTime = Date().toInstant().epochSecond
val delta = currentTime - prevTime
prevTime = currentTime
logger.v("Metronome", "Tick. $delta")
delay(10 * 1000)
}
}
Annotation Definitions
The PlatformServiceInfo
annotation provides the name
and description
needed
for the PlatformService
class discussed above.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class PlatformServiceInfo(
val name : String,
val description : String
)
Similarly, the PlatformServiceGroup
annotation provides the name
and description
for the PlatformServiceGroup
as discussed above:
@Target(AnnotationTarget.ANNOTATION_CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class PlatformServiceGroup(
val name : String,
val description : String
)
But hang on… Our example with the PlatformMetronomeLogger
had a @CliPrinterServiceGroup
annotation? What’s that about? Seeing the definition of the @CliPrinterServiceGroup
will guide us towards the algorithm and data structures used by the annotation processor (and the code that uses its output).
@PlatformServiceGroup(
name = "CliPrinters",
description = "Services that print debug info to stdout."
)
annotation class CliPrinterServiceGroup
ServiceAnnotationProcessor
For those following along at home, we’re looking through the code in the ServiceAnnotationProcessor
I wrote this with Kapt because KSP wasn’t ready at the time (Februrary 2022). Kapt has the disadvantage vs KSP that it only allows a single pass of annotation processing: You can generate source files, but you can’t generate source files used by other annotation processors (such as Dagger!!). We’ll run into this awkward limitation as we find the ServiceAccessors
later on.
Generated Code
The ServiceAnnotationProcessor
generates a class called DiscoveredServiceGroups
. It’s not checked into Git, but let’s take a look at what output it produces so we can understand how it’s made.
public class DiscoveredServiceGroups {
public fun getAllGroups(): Set<DiscoveredPlatformServiceGroup> = setOf(
DiscoveredPlatformServiceGroup(name= "RadioListenerServiceGroup", description=
"Services that listen to radio updates"),
DiscoveredPlatformServiceGroup(name= "DBusTrackInfoNowPlayingServiceGroup", description=
"A service group for services that listen to the DBus track info and print it out to NowPlaying"),
...
}
public fun getAllServiceInfos(): Set<DiscoveredServiceInfo> {
// Returns all services annotated with PlatformServiceInfo
return setOf(
DiscoveredServiceInfo(name= "RadioTextFieldReaderService", description=
"Emits all the incoming radio text messages to a flow", implementingClass =
RadioTextFieldReaderService::class, accessor = {
discoveredServiceRadioTextFieldReaderService() } ),
...
}
public fun getServiceGroupsForService():
Map<DiscoveredServiceInfo, List<DiscoveredPlatformServiceGroup>> {
// Returns all discovered groups for a service
return mapOf(
DiscoveredServiceInfo(name= "RadioTextFieldReaderService", description=
"Emits all the incoming radio text messages to a flow", implementingClass =
RadioTextFieldReaderService::class, accessor = {
discoveredServiceRadioTextFieldReaderService() } ) to
listOf(DiscoveredPlatformServiceGroup(name= "RadioListenerServiceGroup", description=
"Services that listen to radio updates"),
), ...)
}
}
The above methods lend themselves to the following data structures made by the AnnotationProcessor:
data class DiscoveredPlatformServiceGroup(
val name : String,
val description : String
)
data class DiscoveredServiceInfo(
val name : String,
val description : String,
val implementingClass : TypeMirror
)
Think of TypeMirror
as all the type information that the JVM had at run-time, but only in the context of writing code generators, and not really usefull for doing reflective operations to instantiate the object dynamically.
DiscoveredServiceInfo (PlatformService)
The first step is to traverse all the Elements env.getElementsAnnotatedWith(PlatformServiceInfo::class.java)
, take the Element
and convert it into a DiscoveredServiceInfo
:
private fun Element.toDiscoveredServiceInfo() : DiscoveredServiceInfo {
return DiscoveredServiceInfo(
name = this.getAnnotation(PlatformServiceInfo::class.java).name,
description = this.getAnnotation(PlatformServiceInfo::class.java).description,
implementingClass = this.asType()
)
}
Next, it’s important to take the data, and make the code generation one function so that it can be re-used:
private fun DiscoveredServiceInfo.toCode() : CodeBlock.Builder.() -> Unit {
val discoveredService = this
return {
val accessorLambda = CodeBlock.builder().add(" { discoveredService${discoveredService.name}() } ").build()
add("DiscoveredServiceInfo(name= %S, description= %S, implementingClass = %T::class, accessor = %L), \n",
discoveredService.name,
discoveredService.description,
discoveredService.implementingClass.asTypeName(),
accessorLambda
)
}
}
with a typical usage looking like
//This returns all discovered services
FunSpec.builder("getAllServiceInfos")
.addComment("Returns all services annotated with PlatformServiceInfo")
.returns(
SET.parameterizedBy(ClassName(
"ca.stefanm.ibus.car.platform",
"DiscoveredServiceInfo"
))
)
.apply {
addCode(CodeBlock.builder()
.add("return setOf(\n")
.withIndent {
services.map { service ->
service.toDiscoveredServiceInfo()
}.map { discoveredService ->
discoveredService.toCode()(this)
}
}.add(")").build()
)
}.build()
which makes the code we saw at the start of the section.
So far, so easy: Find every class annotated with PlatformServiceInfo
, something that kapt is really good at doing, and for each of those generate some book-keeping code.
Munching the ServiceGroups together
The code to make getServiceInfosByGroup()
is pretty gross. It pretty much starts with every service annotated by PlatformServiceInfo
, then collects a list of the PlatformServiceGroup
annotations on it. It then makes a map, inverts it (swaps the value to keys), then munches duplicates together so the value is a list type.
I have to admit I wrote it in a pretty intense debugging session and was channeling vibes reminiscent of my university LISP course.
val groupsByServices = services.map { service ->
service to service
.annotationMirrors
.map { it.annotationType }
.map { it.asElement() }
.filter { it.getAnnotation(PlatformServiceGroup::class.java) != null }
.map {
DiscoveredPlatformServiceGroup(
name = it.getAnnotation(PlatformServiceGroup::class.java).name,
description = it.getAnnotation(PlatformServiceGroup::class.java).description
)
}
}.toMap()
.mapKeys { it.key.toDiscoveredServiceInfo() }
.mapValues { tuple -> tuple.value.map { it to tuple.key } }
.entries
.map { it.value }
.flatten()
.groupBy { it.first }
.mapValues { it.value.map { it.second } }
Accessors, and Dagger interop
This is where I wish I could’ve done two-passes of annotation processing.
Recall that a PlatformService
needs an instance of the baseService
:
PlatformService(
name = "PlatformMetronomeLogger",
description = "Prints ticks to stdout at an interval to show the Car Platform is running.",
baseService = platformMetronomeLogger,
logger = logger
),
But, to provide that instance of baseService
in a class that takes in the list would require knowing all the discovered services at the same time Dagger is processing the @Inject
annotation and generating factories, which is in the same annotation pass as the service discovery annotation processor. It’s a catch-22!
So, we need a way to get whatever “thing” that baseService
needs. We can’t inject it as a class variable, but maybe we can call a method on our ApplicationScope
to get that thing.
Confusingly, there are DiscoveredPlatformService
and DiscoveredServiceInfo
in the application code as well. These are unrelated to the similarly named classes within the annotation processor. The annotation Processor outputs these, while using the other ones as internal book-keeping.
data class DiscoveredPlatformServiceGroup(
val name: String,
val description: String
)
data class DiscoveredServiceInfo(
val name: String,
val description: String,
val implementingClass: KClass<*>,
val accessor: ConfiguredCarComponent.() -> Service
)
The accessor
is a lambda that returns a service usable as the baseService
. The gnerated code for a particular service looks like this:
DiscoveredServiceInfo(name= "SerialListenerService", description=
"Listens for serial mesages from the IBus dongle.", implementingClass =
SerialListenerService::class, accessor = { discoveredServiceSerialListenerService() } )
Because the accessor lambda is an extension on ConfiguredCarComponent
, it necessitates putting the corresponding method manually in that file:
fun discoveredServiceSerialListenerService() : SerialListenerService
This is a manual step unfortunately. However, when adding a new service, it’s pretty easy to figure out you missed it: Dagger complains at compile time that the method is missing from the component, and you can just copy-paste the error message into the component and be off to the races.
If I were to rewrite this with KSP, I would like to avoid this manual step.
Bringing it all together
All of this generated code, which exists to replace the old PlatformServiceList
class (located in the ConfigurablePlatformServiceList.kt
file), uses all this generated code somewhat anti-climactically:
@ConfiguredCarScope
class PlatformServiceList @Inject constructor(
logger: Logger,
private val configurablePlatform: ConfigurablePlatform
) {
val list : List<PlatformServiceGroup> =
DiscoveredServiceGroups().getServiceInfosByGroup()
.map {
PlatformServiceGroup(
name = it.key.name,
description = it.key.description,
children = it.value.map { discoveredService ->
PlatformService(
name = discoveredService.name,
description = discoveredService.description,
logger = logger,
baseService = discoveredService.accessor.invoke(configurablePlatform.configuredCarComponent!!)
)
}
)
}
}
Dagger SubComponents
Using Dagger nested subcomponents provides encapsulation that has pros and cons.
Having all the @ConfiguredCarComponent
classes take in a configuration is great. However, let’s say that we have some piece of UI that wants to stop a service. How can it do that without having a reference to the service?
There is a RunStatusViewer
class in the ConfigurablePlatform.kt
file that can should be refactored for simplicity.
Platform Configuration
Debug Window
This window (Platform -> Configure Platform in menu) shows the options for enabling platform service groups at startup.
HMI Settings
The platform configuration is also available in the HMI Car Settings
Return to Top