Return to Home Page

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:

  1. Some cars might already have a Bluetooth dongle attached to the tuner and wouldn’t want to use e39-rpi to stream music
  2. 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.
  3. 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:

  1. Reading raw IBUS data from the serial port, packetizing it, and forwarding it to other parts
  2. Other pieces can listen to the data and react to it
  3. 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:

  1. Reading the Configuration to see which services (and ServiceGroups) should be running when the ConfigurablePlatform is started
  2. Creating the ConfiguredCarScope by passing in the configuration read from the ConfigurationStorage (which is backed by a HOCON file)
  3. Exposing a StateFlow<List<ConfigurablePlatformServiceRunStatusViewer.RunStatusRecordGroup>> which allows other objects in the ApplicationScope 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 PlatformServices 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:

Service Status Viewer showing some of the services

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.

Platform Configuration Window

HMI Settings

The platform configuration is also available in the HMI Car Settings

Car Service List

Service Group Info

Service Info

Return to Top