Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

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
languagepy
# 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 thisGet 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
# (coming soon)
spi_exchange(tx_bytes, rx_len, reg, spi_port=1, spi_mode=0, baudrate=6000000, cs=None) -> rx: list

# Send a raw CAN message
# (coming soon)
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
# (coming soon)
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.

Communication between the two is all encoded as CAN messages.

...

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
# (coming soon)
can_send(msg_name: str, channel=None, **data)

# Register a GPIO interrupt; callback defaults to printing the port/pin
# (coming soon)
register_gpio_interrupt(port, pin, edge=RISING, callback=None)

# Unregister a GPIO interrupt previously registered
# (coming soon)
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 CAN codegen-tooling library’s nice packing/unpacking features, but it is what it is.

General format of the babydriver CAN message:

Code Block
uint8 id
7 * uint8: message-defined data

That is, the CAN message consists of 8 uint8s, the first of which is a babydriver-specific ID. The other 7 uint8s are generic data fields, the meaning of which is determined by the ID. Babydriver message IDs will be defined in an enum in C and a series of constants in Pythonoh 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
languagec
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) should 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.

We’ll have a generic status message sent by the firmware project at the end of any operation, allocating the uint8s as follows:

Code Block
uint8 id = 0
uint8 status
6 * uint8 unused 

The status will be one of the status codes from status.h. The firmware project will send this message to the Python project at the end of every operation to signify that the operation is done and give the status.

More babydriver messages will be specified in tickets and/or on Confluence when they come up. Here are a couple examples.

Implementing gpio_set

The flow for gpio_set will look like this.

...

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 = X1  // some constant defined in C/Python
GPIO_SET
uint8 port    // port of the gpioGPIO pin to set: 0 for A, 1 for B, etc
uint8 pin     // pin number of the gpioGPIO pin to set: 0-15
uint8 state   // 1 orstate 0 to set to: high0 or low respectively

The Python project then blocks until it sees a status babydriver CAN message or it times out.

The firmware project receives the message, initializes gpio on the pin, sets the appropriate gpio pin, and responds back with a status message indicating success or failure.

Upon receiving the status message, the Python project returns from gpio_set(), raising an exception if the status code was non-zero.

Implementing SPI functions

SPI, I2C, etc are more special because they require transferring more data than can be fit in the 7 data uint8s we’re given. Thus, we need multiple messages.

To start a SPI exchange, the Python project will send these two messages:

Code Block
Metadata message 1:
uint8 id = X   // some constant
uint8 spi_port // 0 or 1 for SPI_PORT_1 or SPI_PORT_2
uint8 tx_len_low  // low byte of tx_len (a uint16)
uint8 tx_len_high // high byte of tx_len
uint8 rx_len_low  // low byte of rx_len (another uint16)
uint8 rx_len_high // high byte of rx_len
uint8 cs_port // port of chip select gpio pin
uint8 cs_pin  // pin of chip select gpio pin
Code Block
Metadata message 2:
uint8 id = X  // some constant
4 * uint8 baudrate // a uint32

Note that since we’re representing everything as uint8s, we have to break up multiple-byte fields into their individual bytes and reconstruct them. This sucks, but it is what it is. Again, we use little-endian order to match the native STM32F0xx byte order.

The Python project will then send enough generic “babydriver data” messages (another type of message with just 7 uint8s) to cover tx_len. The firmware project will send back enough generic data messages to cover the rx_len bytes received, then the status message.

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 script

  • setup 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

...

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.