Return to Home Page

Inter-device Logging

What’s neat is that the system allows long-term storage of Pico log messages through IBUS on the PI. The system does this using the PicoToPiAndPiToPicoMessaging system.

Logging on the Pico

The Pico implements an Android-style logger interface that looks like this:

namespace pico::logger {

    class BaseLogger {

    public:
        virtual void d(std::string tag, std::string message);
        virtual void i(std::string tag, std::string message);
        virtual void w(std::string tag, std::string message);
        virtual void e(std::string tag, std::string message);
        virtual void wtf(std::string tag, std::string message);

        enum class Level { DEBUG, INFO, WARN, ERROR, WTF};

        virtual void print(Level level, std::string tag, std::string message) = 0;
    };

} // logger

which allows instances of BaseLogger to be injected into any class for debug logs.

Initialization

Initialization of the logger is done in the LoggerFactory, which depends on the Bus Topology

The LoggerFactory exposes a method buildAndGetLogger() which reads the bus topology, and calls the factory methods logsToStdio() or logsToUart()

Stdio logs

This logger just does a Pico SDK printf of every log message that comes in.

    void LoggerFactory::logsToStdio() {
        //We want to print out all the logs to stdout, because stdout is setup to go over UART1,
        //which goes to the picoprobe, which can be read from the laptop.
        this->returnedBaseLogger = std::make_shared<logger::StdioPrintFLogger>();
    }

It was pretty handy early in the project when the IBUS logic wasn’t finished yet, and I needed a way to test the pico while it was being developed.

void StdioPrintFLogger::print(
                pico::logger::BaseLogger::Level level,
                std::string tag,
                std::string message
        ) {

        std::string levelString;
        switch (level) {
            case Level::DEBUG : { levelString = "DEBUG" ; break; }
            case Level::INFO : { levelString = "INFO" ; break; }
            case Level::WARN : { levelString = "WARN" ; break; }
            case Level::ERROR : { levelString = "ERROR" ; break; }
            case Level::WTF : { levelString = "WTF" ; break; }
            default: { levelString = "" ; break; }
        }
        printf("[%s]    [%s]:[%s] \n", levelString.c_str(), tag.c_str(), message.c_str());
    }

This can be seen in action here: UART StdLogger

UART Logs

The logsToUart() method in the LoggerFactory makes an IbusOutLogger, which uses an IbusLoggerOutput writer.

namespace pico::logger::ibus {
    void IBusOutLogger::print(Level level, std::string tag, std::string message) {
        std::string levelString;
        switch (level) {
            case Level::DEBUG : { levelString = "D" ; break; }
            case Level::INFO : { levelString = "I" ; break; }
            case Level::WARN : { levelString = "W" ; break; }
            case Level::ERROR : { levelString = "E" ; break; }
            case Level::WTF : { levelString = "?" ; break; }
            default: { levelString = "" ; break; }
        }
        loggerOutput->print(fmt::format("{} [{}] {}", levelString, tag, message));
    }

    IBusOutLogger::IBusOutLogger(std::shared_ptr<pico::ibus::output::writer::IbusLoggerOutput> loggerOutput) {
        this->loggerOutput = loggerOutput;
    }
} // ibus

The IbusOutLogger implements BaseLogger, mildly compresses the levelString for brevity, and sends the message to the loggerOutput writer.

namespace pico::ibus::output::writer {
    IbusLoggerOutput::IbusLoggerOutput(
            std::function<std::shared_ptr<dma::IDmaManager>()> dmaManagerAccessor
    ) {
        this->dmaManagerAccessor = dmaManagerAccessor;
    }

    std::shared_ptr<dma::IDmaManager> IbusLoggerOutput::getDmaManager() {
        return dmaManagerAccessor();
    }

    void IbusLoggerOutput::print(std::string message) {

        if (dmaManagerAccessor() == nullptr) {
            //We cannot log anything here until the dma manager has been set up.
            return;
        }

        if (splitLogMessagesAtTruncationPoint) {
            //TODO Split into multiple messages and pump them all out.
            //TODO
        } else {
            //Just take the first part of the message and print one out.
            std::string shortened = message.substr(0, std::min((int)message.size(), truncateLogMessagesToCharacters));

            schedulePicoToPiMessageForWrite(messages::PicoToPiMessage {
                .messageType = messages::PicoToPiMessage::MessageType::LogStatement,
                .loggerMessage = shortened
            });
        }
    }
} // writer

The use of the dmaManagerAccessor() is to prevent a dependency cycle. When the device is configured to use a UART logger, it will have log statements that happen before and during the DmaManager setup. As well, almost every dependency in the graph needs a logger, which needs a DmaManager, which needs a logger…. So, the pattern chosen is like the Dagger2 Provider<DmaManager> technique, allowing a late initialization on first use.

namespace pico::di {
    void NoVideoFactory::initializeAllSmartPointers() {
        //We have a knot. The logger factory needs dma manager to build a dependency,
        //but dma manager needs a logger.

        this->busTopologyManager = std::make_shared<pico::ibus::topology::BusTopologyManager>();

        // When this is called, it'll return null, because the class variable isn't initialized yet. 
        std::function<std::shared_ptr<pico::ibus::dma::IDmaManager>()> dmaManagerAccessor = [this](){
            return this->dmaManager;
        };

        this->loggerFactory = std::make_shared<logger::factory::LoggerFactory>(dmaManagerAccessor, busTopologyManager);
        this->logger = loggerFactory->buildAndGetLogger();

        this->observerRegistry = std::make_shared<ibus::observerRegistry::ObserverRegistry>(this->logger);
        
        // Set the class variable for the dmaManager. Every invocation of the accessor can not get the DmaManager.
        this->dmaManager = std::make_shared<ibus::dma::SingleCoreDmaManager>(logger, observerRegistry, busTopologyManager);


        this->watchdogManager = std::make_shared<watchdog::WatchdogManager>(logger);
        this->powerSwitchManager = std::make_shared<hardware::pi4powerswitch::GpioPi4PowerSwitchManager>(this->logger);
        // ... more initializations....

Pico Log Viewer

I built a screen in the e39-rpi HMI to view all the incoming IBUS messages, as well as the parsed PicoToPi (and outgoing PiToPico) messages.

Pico Log Message

Pi Logging Infrastructure

The e39-rpi HMI has a very similar logger class to the one on the Pico.

LogMessageParser

A LogMessageParser on the Pi listens for incoming Pico log messages, and forwards them to the pi logger, as well as the commsDebugChannel, which is a Channel that populates the PicoCommsDebugWindow.

@PlatformServiceInfo(
    name = "LogMessageParser",
    description = "Forwards Pico log messages to the Pi logger"
)
@PicoToPiParserGroup
class LogMessageParser @Inject constructor(
    @Named(ApplicationModule.IBUS_MESSAGE_INGRESS) val incomingMessages: MutableSharedFlow<IBusMessage>,
    @Named(ApplicationModule.IBUS_COMMS_DEBUG_CHANNEL) private val commsDebugChannel: MutableSharedFlow<IbusCommsDebugMessage>,
    private val logger: Logger,
    @Named(ConfiguredCarModule.SERVICE_COROUTINE_SCOPE) private val coroutineScope: CoroutineScope,
    @Named(ConfiguredCarModule.SERVICE_COROUTINE_DISPATCHER) parsingDispatcher: CoroutineDispatcher
) : LongRunningService(coroutineScope, parsingDispatcher) {

    override suspend fun doWork() {
        incomingMessages
            .filter { it.sourceDevice == IBusDevice.PICO && it.destinationDevice == IBusDevice.PI }
            .collect { raw ->

                val message = try {
                    PicoToPi.parser().parseFrom(raw.data.toByteArray())
                } catch (e: Throwable) {
                    logger.e("LogMessageParser", "Could not parse protobuf", e)
                    return@collect
                }

                if (message.messageType == PicoToPi.MessageType.LogStatement) {
                    onLogMessage(raw, message)
                }
            }
    }

    suspend fun onLogMessage(raw: IBusMessage, parsed: PicoToPi) {
        commsDebugChannel.emit(
            IbusCommsDebugMessage.IncomingMessage.PicoToPiMessage(
                Instant.now(),
                raw,
                parsed
            )
        )
        logger.i("PICO", parsed.loggerStatement)
    }
}

Pi Logging Framework

The Pi has a very simple home-brew logging interface that is injected everywhere

interface Logger {
    fun v(tag : String, msg : String)
    fun d(tag : String, msg : String)
    fun i(tag : String, msg : String)
    fun w(tag : String, msg : String)
    fun e(tag : String, msg : String)
    fun e(tag : String, msg : String, e : Throwable)
}

@ApplicationScope
class StdOutLogger @Inject constructor() : Logger {
    override fun v(tag: String, msg: String) {
        println("VERBOSE : $tag / $msg")
    }

    override fun d(tag: String, msg: String) {
        println("DEBUG : $tag / $msg")
    }

    override fun i(tag: String, msg: String) {
        println("INFO : $tag / $msg")
    }

    override fun w(tag: String, msg: String) {
        println("WARN : $tag / $msg")
    }

    override fun e(tag: String, msg: String) {
        println("ERROR : $tag / $msg")
    }

    override fun e(tag: String, msg: String, e: Throwable) {
        e(tag, "$msg exception: {${e.toString()}} ${e.message} ${e.printStackTrace()}")
    }
}

When the Pi is run on the device, it’s run by a startup script that pipes the output of stdout to a file. This allows reading the logs later by ssh-ing into the device. Some day I can setup forwarding from this interface to JournalD, Log4j, or whatever.

Return to Top