Back to the firmware
We can now take a look at the comms-01
project, in the firmware
folder.
We've taken away most of the driver code, and replaced it with the code we need to set up our
RP2040's postcard-rpc
setup.
Setup and Run
In our main
, we've added this code:
#![allow(unused)] fn main() { let driver = usb::Driver::new(p.USB, Irqs); let mut config = example_config(); config.manufacturer = Some("OneVariable"); config.product = Some("ov-twin"); let buffers = ALL_BUFFERS.take(); let (device, ep_in, ep_out) = configure_usb(driver, &mut buffers.usb_device, config); let dispatch = Dispatcher::new(&mut buffers.tx_buf, ep_in, Context {}); spawner.must_spawn(dispatch_task(ep_out, dispatch, &mut buffers.rx_buf)); spawner.must_spawn(usb_task(device)); }
Let's break this down piece by piece:
#![allow(unused)] fn main() { let driver = usb::Driver::new(p.USB, Irqs); }
This line is straight out of embassy-rp
, it just sets up the hardware and interrupt handlers
needed to manage the USB hardware at a low level. You would do this for any embassy-rp
project
using USB.
Next up, we handle some configuration:
#![allow(unused)] fn main() { let mut config = example_config(); config.manufacturer = Some("OneVariable"); config.product = Some("ov-twin"); }
example_config()
is a function from the postcard_rpc::target_server
module. This takes the
configuration structure provided by embassy-usb
, and customizes it in a standard way. This
looks like this:
#![allow(unused)] fn main() { pub fn example_config() -> embassy_usb::Config<'static> { // Create embassy-usb Config let mut config = embassy_usb::Config::new(0x16c0, 0x27DD); config.manufacturer = Some("Embassy"); config.product = Some("USB example"); config.serial_number = Some("12345678"); // Required for windows compatibility. // https://developer.nordicsemi.com/nRF_Connect_SDK/doc/1.9.1/kconfig/CONFIG_CDC_ACM_IAD.html#help config.device_class = 0xEF; config.device_sub_class = 0x02; config.device_protocol = 0x01; config.composite_with_iads = true; config } }
We then overwrite the manufacturer
and product
fields to something specific for our exercise.
Then, we continue configuring the RP2040's USB hardware:
#![allow(unused)] fn main() { let buffers = ALL_BUFFERS.take(); let (device, ep_in, ep_out) = configure_usb(driver, &mut buffers.usb_device, config); let dispatch = Dispatcher::new(&mut buffers.tx_buf, ep_in, Context {}); }
These lines do three things:
- We take some static data buffers that
postcard_rpc
needs for USB communication, as well as for serializing and deserializing messages. configure_usb
, a function frompostcard_rpc::target_server
configures the USB:- It applies the
config
that we just prepared - It configures the low level drivers using the
embassy-usb
interfaces - It gives us back three things:
- the
device
, which is a task that needs to be run to maintain the low level USB driver pieces ep_in
, our USB "Bulk Endpoint", in the In (to the PC) directionep_out
, our USB "Bulk Endpoint", in the Out (to the MCU) direction
- the
- It applies the
- We set up a
Dispatcher
(more on this below), giving it the buffers, theep_in
, and a struct calledContext
Then, we spawn two tasks:
#![allow(unused)] fn main() { spawner.must_spawn(dispatch_task(ep_out, dispatch, &mut buffers.rx_buf)); spawner.must_spawn(usb_task(device)); }
Which look like this, basically "just go run forever":
#![allow(unused)] fn main() { /// This actually runs the dispatcher #[embassy_executor::task] async fn dispatch_task( ep_out: Endpoint<'static, USB, Out>, dispatch: Dispatcher, rx_buf: &'static mut [u8], ) { rpc_dispatch(ep_out, dispatch, rx_buf).await; } /// This handles the low level USB management #[embassy_executor::task] pub async fn usb_task(mut usb: UsbDevice<'static, Driver<'static, USB>>) { usb.run().await; } }
Hopefully, this all makes sense covering the "setup" and "run" parts of getting the postcard-rpc stack going:
- We setup the low level hardware, from the
embassy-rp
drivers - We have a helper function that configures the
embassy-usb
components - We hand those pieces to something from
postcard-rpc
, that uses theembassy-usb
components
Let's scroll back up to the top of the firmware and see what we skipped:
Defining our protocol
At the top of comms-01
, there's some interesting looking code:
#![allow(unused)] fn main() { static ALL_BUFFERS: ConstInitCell<AllBuffers<256, 256, 256>> = ConstInitCell::new(AllBuffers::new()); pub struct Context {} define_dispatch! { dispatcher: Dispatcher< Mutex = ThreadModeRawMutex, Driver = usb::Driver<'static, USB>, Context = Context >; PingEndpoint => blocking ping_handler, } }
The first part with ALL_BUFFERS
we've explained a bit: we're using the ConstInitCell
from
the static_cell
crate to create a "single use" set of buffers that have static lifetime.
The three 256
values are the size in bytes we give for various parts of the USB and postcard-rpc
stack.
We then define a struct called Context
with no fields. We'll look into this more soon!
Finally, we call a slightly weird macro called define_dispatch!
. This comes from the
postcard-rpc
crate, and we'll break that down a bit.
#![allow(unused)] fn main() { dispatcher: Dispatcher< Mutex = ThreadModeRawMutex, Driver = usb::Driver<'static, USB>, Context = Context >; }
First, since postcard-rpc
can work with ANY device that works with embassy-usb
, we need to
define which types we are using, so the macro can create a Dispatcher type for us. The
dispatcher has a couple of responsibilities:
- When we RECEIVE a Request, it figures out what kind of message it is, and passes that message on to a handler, if it knows about that kind of Request.
- If we pass on the message to the handler, we need to deserialize the message, so that the handler doesn't need to manage that
- When that handler completes, it will return a Response. The Dispatcher will then serialize that response, and send it back over USB.
- If an error ever occurs, for example if we ever got a message kind we don't understand, or if deserialization failed due to message corruption, the dispatcher will automatically send back an error response.
NOTE: this macro looks like it's using "associated type" syntax, but it's not really, it's just macro syntax, so don't read too much into it!
How does this Dispatcher
know all the kinds of messages it needs to handle? That's what the next
part is for:
#![allow(unused)] fn main() { PingEndpoint => blocking ping_handler, }
This is saying:
- Whenever we get a message on the
PingEndpoint
- Decode it, and pass it to the
blocking
function calledping_handler
If we look lower in our code, we'll find a function that looks like this:
#![allow(unused)] fn main() { fn ping_handler(_context: &mut Context, header: WireHeader, rqst: u32) -> u32 { info!("ping: seq - {=u32}", header.seq_no); rqst } }
This handler will be called whenever we receive a PingEndpoint
request. All postcard-rpc
take these three arguments:
- A
&mut
reference to theContext
type we defined indefine_dispatch
, you can put anything you like in thisContext
type! - The
header
of the request, this includes the Key and sequence number of the request - The
rqst
, which will be whatever theRequest
type of thisEndpoint
is
This function also returns exactly one thing: whatever the Response
type of this endpoint.
We can see that our ping_handler
will return whatever value it received without modification, and
log the sequence number that we saw.
And that's all that's necessary on the firmware side!