Cocotte
Cocotte is a ready-to-use framework to build applications running on microcontrollers or small computers. It removes the non-existential questions of an embedded software project. Letting the user concentrates on what matters for their product.
Users model their application within the Cocotte boundaries and get many common features for free. Developers can jump from one project to another with ease as they recognize the structure of the application.
It compares to the experience of transitioning from a custom-made PHP code to structured framework à la Ruby on Rails/Django/Laravel for a web application.
Cocotte leverages the Rust type system to model the application. This allows to catch bug early in the development process, avoid runtime errors and make it impossible to represent an illegal state.
Application Development
Business logic
Cocotte provides a way to model the behavior of the application in a predictable way. This isolated behavior can be tested in a controlled environment running on a development machine without the embedded hardware.
Once the business logic reaches a certain threshold, it can be hard to test if the code is not well isolated and if hardware interfaces are not abstracted.
Communication to the outside world
Embedded systems often need to communicate with the outside world, be it Bluetooth, an industrial bus protocol or the cloud it does not matter. The developer does not need to rewrite the code from scratch for every new transport they need to support.
Tooling
As Cocotte knows how the state and configuration of the application is structured, it can provide tooling around them.
Cocotte also provides utilities that helps the developer to understand what went through a system. For example, Cocotte can keep a history of all the configurations changes that happened. To accomodate as much hardware as possible, those tools are optional can be activated when needed.
Cloud
Cocotte provides an optional cloud integration out of the box.
Hardware Support
Cocotte runs on top of Embassy which provides the necessary abstractions. If your microcontroller is supported by Embassy, it is supported by Cocotte. Many STM, Nordic, ESP, Risc-V are supported.
Principles & Concepts
Cocotte starts with two main concepts: Configuration and State. Once
defined, those entities can be stored to disk, updated and exposed to
the outside world (USB, BLE, BACnet, Modbus, MQTT) through Renderers.
Application
The central point of interaction for the system. The application holds the
configuration and the state. It reacts to Event and send Action when it
needs to interact with hardware. It also controls what is exposed through the
communication protocols.
All those elements are explained in the following sections.
Configuration
The configuration is the set of parameters that configures the application. In most situation, a configuration does not change frequently (if ever). The user might set it up at one point in time and never touch it again.
Ideally, any exposed medium can change the configuration (e.g, USB and BLE). Cocotte ensures that the configuration is updated in an atomic manner and write access is protected.
TODO: Compose configuration with subconfiguration TODO: AuthN/AuthZ on configuration access and changes (session-based system?)
State
The state is the current known state of the application or the hardware below it. The state can changes (very) frequently. It can be read by the user and updated by the application from within.
The state is read-only for the user. Only the application itself can update it. Cocotte provides ways to listen for state updates and act on them.
TODO: Define lazy state: state that we don't care of until we do (eg., a sensor value) TODO: Compose state with substate TODO: Define alarms on state
Controllers
Controllers can perform actions on something external. That something can be a board, a simulator or anything that the application will interact with.
The application applies Action(s) on the controller went it needs to interact
with it.
System
As system can handle events coming from the outside world. In most cases, an application is a system.
Renderer
A renderer is a way to expose the configuration or state to the outside world. Cocotte provides many renderers out of the box: USB, BLE, MQTT, BACnet, Modbus.
Renderers have sane defaults and can be customized elements by element.
Architecture
The main idea of Cocotte is to separate the business logic of the application and underlying platform. One of the goal of this design to allow to test the logic outside the embedded system.
When the embedded drivers are stable there is no need to use them to test the application code. Of course developing those drivers necessitates the hardware platform.
Cocotte also helps the user to expose the state and configuration of the application in a unified way. Be it by USB, Bluetooth, MQTT or BACnet, modbus, KNX, Cocotte provides the way to expose the data in a way that fits the transport. It does not try to shoehorn all those transport in a clunky serial port-like protocol.
Modeling configuration and state
As a general principle, Cocotte wants to avoid to be able to represent an illegal state. It leverages the Rust type system to enforce this. This might feel a bit restrictive at first but we believe it saves a lot of debugging session in the long run.
Exposing configuration and state
Updating configuration
Cocotte aims to provide an atomic way to update a configuration. Transactions are important when changing multiple values of the state at the same time.
Bluetooth Low Engergy (BLE)
USB HID
MQTT
Coding guidelines & conventions
We want two things:
- An easy to read framework codebase.
- An easy and predictable application codebase.
Here are some principles that Cocotte developers should follow to reach those goals.
Macros should be avoided as much as possible : Macros require a brain switch to operate at another level.
Builders first, macros second : Macros are a great way to provide a nice API to the user but we want them to be built upon a manageable API. Always explore builders first.
Rust code first : Avoid definitions in an external format (e.g., YAML, TOML) to express something that will be used in code. Rust should be expressive enough for a vast majority of situations.
Sane defaults but customizable : Provide a good out-of-the-box experience in 80% of the cases.
No unsafe
: It's sometimes unavoidable but our dependencies (-hal crates, Embassy) should
already have abstracted the lower level pieces.
No unwraps : Unwraps are only allowed in tests and in examples.
Update mondays : We want to keep our dependencies up to date. If possible the dependencies should be updated every monday.
Blinker
This example demonstrates how to build a simple application that controls the blinking of 4 LEDs and 4 buttons. The user can change the pattern and speed of the blinking.
- B1: changes the pattern,
- B2: changes the speed,
- B3: lights up all the leds while pressed/ regardless of the current state,
- B4: pauses the execution.
Application modeling
Basics
#![allow(unused)] fn main() { pub struct Config { pub pattern: BlinkingPattern, pub speed: BlinkingSpeed, } pub struct State { ticks: usize, /// Tells if we are currently in override where all the leds are lit up all_led_override: bool, is_paused: bool, } pub enum BlinkingPattern { Off, /// Led 1-2-3-4 LeftToRight, /// Led 1-2-4-3 Round, /// 1/3 - 2/4 Column, Cross, } pub enum BlinkingSpeed { Slow, Standard, Fast, } pub struct App { pub config: Config, pub state: State, } }
What is noticeable in this example:
- We use plain Rust struct to define our configuration and state,
- We don't define the actual blinking speed of the leds, only high-level values.
- There are no references to any specific implementation of the hardware.
- An application is the composition of the configuration and the state
Handling Events
There are only two kind of events in this example: button presses and clock ticks. We treat clock ticks as events so we can manipulate time explicitly. That might not suit all designs but in this case it fits perfectly.
Button presses are forwarded to the application through events. Again, we use plain rust facilities to define them and we don't tie them to actual buttons yet.
As described in the principles and concepts
section, this direction is handled by the System trait.
pub enum Event {
ClockTick,
CyclePatternRequest,
CycleSpeedRequest,
/// Lights up all the leds, regardless of the current state and config
OverrideChange(bool),
TogglePause,
}
impl System for App {
type Event = Event;
async fn process_event(&mut self, event: Event) {
match event {
Event::ClockTick => {
if !self.state.is_paused {
self.state.ticks += 1;
}
// perform what is needed at each tick
}
Event::CyclePatternRequest => {
// take the next pattern in the list
}
Event::CycleSpeedRequest => {
// ...
}
Event::OverrideAllLeds(active) => {
// ...
}
Event::TogglePause => {
// ...
}
}
}
}
The actual implementation of each event handling will be described later.
Acting on the hardware
The Controller trait is the inverse of the System trait: it lets the
application acts on the outside world through Action.
A controller is not actually tied to the hardware, it is an entity that can receive orders from the application. This is ideal to write a simulator or an actual hardware implementation.
The blinker app can only do one action: light up the leds in a given pattern.
pub enum BlinkerAction {
LitUpLED(LitPattern),
}
/// Implementation for a hardware board running on a nRF52.
impl<'a> Controller for NRFController<'a> {
type Action = BlinkerAction;
async fn perform(&self, action: Self::Action) {
match action {
core::Action::LitUpLED(pattern) => self.led_control.send(pattern).await,
}
}
}