Goal
Facilitate hardware triggering firmware actions.
E.g.
Read ADC
Interact with GPIO
SPI
I2C
UART
CAN messages
PWM - later
Requirements
MVP
Python library to be accessed via python shell
Call functions like
...
What is Babydriver?
When debugging a board, performing frequently-needed simple actions like flipping a GPIO pin or sending an I2C command takes a lot of work. You need to write several lines of C and reflash the whole board for every action - and this process is totally inaccessible to hardware members.
Babydriver was designed to make this easier. When the Babydriver project is flashed onto a board, the user is dropped into a Python REPL where they can call Python functions interactively to perform these actions. The Python Babydriver project communicates with the firmware on the controller board through CAN messages to carry them out.
How do I use it?
To install any necessary dependencies, type python3 -m pip install -r projects/baby_driver/scripts/requirements.txt
from the firmware_xiv repository root.
Typing make babydriver
from the firmware_xiv repository root inside the box will do two things:
flash the Babydriver firmware project onto the attached controller board
open the Babydriver Python REPL
The following functions are available:
Code Block | ||
---|---|---|
| ||
# Set a GPIO pin's state gpio_set(port, pin, state: bool) # Get a GPIO pin's state as a bool gpio_get(port, pin) -> bool # re-init pin every time this Get an ADC reading, in mV if raw is calledFalse adc_read_raw/converted(gpio_address(port, pin, raw=False) -> int spi_exchange(spi # Write tx_bytes over I2C; perform a register write if reg is not None i2c_write(i2c_port, modeaddress, tx_bytes: list[int], reg=None) # Read rx_len, baudrate=None, cs=None) -> rx: list # re-init spi for given port every call i2c_write bytes over I2C; perform a register read if reg is not None # (coming soon) i2c_read(i2c_port, addraddress, tx: listrx_len, reg=None) -> None i2c_read(i2c_port, addr list[int] # Perform a SPI exchange, first sending tx_bytes, then reading rx_len bytes # cs is an optional (port, pin) specifying the chip select GPIO pin to use spi_exchange(tx_bytes, rx_len, spi_port=1, spi_mode=0, baudrate=6000000, regcs=None) -> rx: list # Send a raw CAN message can_send_raw(msg_id, data: list[int], device_id=BABYDRIVER_DEVICE_ID, channel=None) -> None # Load a DBC database specifying the format of our CAN messages load_dbc(path) -> None # could be auto-loaded in 'make babydriver' can_send_msg(msg_name, *data) -> None # auto checks based on dbc if valid |
Details
Two components: python library, and firmware project
Generic status message:
timeout
success
add as we need
Flow for gpio:
register rx handler for
gpio_set
messagehandler inits gpio port with output and given state
Example: gpio_set
CAN message could look like:
Code Block |
---|
id: gpio_set
data:
uint8 port,
uint8 pin,
bool state |
Flow for spi:
Code Block |
---|
metadata msg 1:
uint16 port, mode, tx_len, rx_len
metadata msg 2:
uint32 baudrate
uint8 cs_port, cs_pin
expect enough messages to cover tx_len bytes (8 * uint8)
then, return messages:
listen for enough messages to cover rx_len (8 * uint8)
so, messages required:
- spi_metadata_1
- spi_metadata_2
- babydriver_data_tx
- babydriver_data_rx |
Task list
...
setup project structure
make new C project
init CAN, write main() method, other setup (not registering specific rx handlers)
includes a
scripts
folder with python scriptsetup makefile to allow
make babydriver
program babydriver
puts you in a python shell with baby driver library loaded
...
write simple CAN abstraction layer
sending message, waiting for message, initializing CAN
have simple representation of can message
the
pack()
function takes in a list of tuples(len_in_bytes, val)
...
setup gpio functions in python
refer to this doc for details on flow
...
setup gpio functions in C and create CAN message in codegen-tooling
refer to this doc for details
...
dbc_filename)
# Send a CAN message with human-readable names from the loaded DBC database
# Fields can be specified in keyword arguments, e.g. status=1, voltage=3500
can_send(msg_name: str, channel=None, **data)
# Register a GPIO interrupt; callback defaults to printing the port/pin
register_gpio_interrupt(port, pin, edge=RISING, callback=None)
# Unregister a GPIO interrupt previously registered
unregister_gpio_interrupt(port, pin) |
GPIO ports can be specified as strings ('a'
, 'B'
) or numbers. I2C ports are 1 or 2, SPI ports are 1 through 4.
How does it work?
There are two sides to Babydriver: a firmware project in C, which runs on the microcontroller, and a Python project, which runs on your laptop. The project lives in the projects/baby_driver
directory: the Python project is in scripts
and the firmware project is in the rest.
The two sides communicate through CAN messages. Since we only have 64 CAN message IDs available, we cram all babydriver-related CAN messages into one message ID: SYSTEM_CAN_MESSAGE_BABYDRIVER = 63
. This sucks, because we can’t use our codegen-tooling library’s nice packing/unpacking features, but oh well.
Our general Babydriver CAN message has 64 bits of data, which is the maximum allowed in a CAN message. We reserve the first byte for an ID of what type of Babydriver message it is (the “Babydriver CAN message ID”), which leaves 7 bytes of data. Babydriver message IDs are allocated in babydriver_msg_defs.h
on the C side (and documented there too), and in message_defs.py
on the Python side.
One Babydriver CAN message ID is reserved for use by the system: BABYDRIVER_MESSAGE_STATUS = 0
is the ID of the status message, which is sent at the end of every transaction to report success or failure. It looks like this:
Code Block | ||
---|---|---|
| ||
uint8 id = 0
uint8 status // the status code for the situation from status.h
6 * uint8 unused |
That is, the first byte is the ID (0), the second byte is the status code, and the remaining six bytes are unused (by convention zero).
Info |
---|
Any multi-byte fields in a Babydriver CAN message (like using two uint8s to represent a uint16) must be in little-endian order to match the native STM32F0xx byte order. That is, the least significant byte should be specified first and the most significant byte last. |
Some details: Firmware side
Each major function / function grouping’s implementation is stored in a separate header/C file pair. Each of them are initialized in main.c along with all libraries used.
The main infrastructure on the firmware side is the dispatcher
module, which is used to register callbacks to be run whenever a specific Babydriver CAN message ID is received. See dispatcher.h for more information.
Some details: Python side
Each major function / function grouping is stored in a separate Python file. repl_setup.py
is run just before the Python REPL opens in order to import all the functions that are visible to the user.
can_util.py
is the main CAN infrastructure used on the Python side. It’s a wrapper over python-can used by each function to interact with the CAN bus. Functions use send_message
to send CAN messages and next_message
to wait for CAN messages. See the docstrings in can_util.py for more information.
Example: gpio_set
When the gpio_set
function is called in Python, the Python side (gpio_set.py) sends a babydriver CAN message that looks like this:
Code Block |
---|
uint8 id = 1 // GPIO_SET
uint8 port // port of the GPIO pin to set: 0 for A, 1 for B, etc
uint8 pin // pin number of the GPIO pin to set: 0-15
uint8 state // state to set to: 0 for low, 1 for high
4 * uint8 unused |
Upon receipt of this babydriver CAN message, the firmware side (gpio_set.c) initializes the pin and sets it to the appropriate state, then uses dispatcher’s features to send back a status message with the status of the operation. If the status code was nonzero (non-OK), the Python side throws an exception to alert the user.