Updating to the MPSL Timeslot interface

The "Concurrent Multiprotocol Timeslot API" was introduced with v7.0 of the S110 SoftDevice in 2014. The original purpose was to allow customers to build their own proprietary networks using the nRF51's "Bluetooth low energy (BLE), ANT, Enhanced ShockBurst, and other 2.4 GHz protocol implementations." In addition to this primary use case timeslots have always provided another important feature: allowing applications on single-core processors to schedule around disruptions such as radio interrupts and non-volatile memory writes/erases.

Although most of the details involved in using the timeslot interface remain unchanged, the move to the nRF Connect SDK (NCS) requires learning a few new tricks. The goal of this post is to recommend a straightforward template for working with timeslots and use it to point out a few important concepts.

Prereqs

This Enhanced ShockBurst (ESB) Devzone post is a good introduction to the radio's proprietary mode. The SDK parts are a little outdated but this Devozone post is an excellent introduction to the most important timeslot interface concepts.

About the code

The example code was built from the v1.6.0 tag of NCS and integrates the Enhanced ShockBurst (ESB) Transmitter (PTX) sample into the Bluetooth Peripheral UART sample so the two protocols can run concurrently. The full project is available here.

The code consists of three pieces:

  • timeslot.h provides a simple interface by encapsulating all interaction with the Multiprotocol Service Layer
  • proprietary_rf.h uses signals from the timeslot to coordinate the ESB activity
  • main.c sets up the BLE connection, starts and stops the timeslots, and connects the proprietary_rf module's signals

The application advertises the Nordic UART Service and waits for a central to connect. After a connection is established it requests a Connection Interval of 35ms before starting the timeslots:

#define DESIRED_CONN_INTERVAL 28
...
static void conn_param_updated(struct bt_conn *conn, uint16_t interval,
                 uint16_t latency, uint16_t timeout)
{
    /* NOTE: This may be called multiple times at the beginning of the connection. */
    LOG_INF("Connection params updated: (interval=%d, SL=%d, timeout=%d)",
                interval, latency, timeout);
    int err;

    if (DESIRED_CONN_INTERVAL != interval) {
        LOG_INF("Requesting new Connection Interval");
        struct bt_le_conn_param param = {
            .interval_min = DESIRED_CONN_INTERVAL,
            .interval_max = DESIRED_CONN_INTERVAL,
            .latency = latency,
            .timeout = timeout,
        };

        err = bt_conn_le_param_update(conn, &param);
        if (err == -EALREADY) {
            /* Connection parameters are already set. */
            err = 0;
        }
        if (err) {
            LOG_ERR("bt_conn_le_param_update failed (err=%d)", err);
        }
    } else {
        if (!timeslot_running) {
            err = timeslot_start(TS_LEN_US);
            if (err) {
                LOG_ERR("timeslot_start failed (err=%d)", err);
            } else {
                timeslot_running = true;
            }
        }        
    }
}

When a connection is established it looks something like this:

BLE activity logic analyzer screenshot

Radio notifications

Each of those Connection Events can be up to CONFIG_SDC_MAX_CONN_EVENT_LEN_DEFAULT microseconds in length. However, if the BLE connection has little or no data to send then they will close early to make space available on the MPSL's schedule. With the default peripheral_uart configuration the actual length of the Connection Events varies by around 2ms.

Note that each Connection Event in the animation is preceded by an "active" radio notification. Radio notifications can be used to notifiy the application that a scheduled activity (e.g. a Connection Event) is about to occur and/or that an activity has just finished. They aren't enabled by default because they aren't free; an interrupt vector must be specified so the MPSL can signal the application in a safe and predictable manner.

The timeslot module configures the MPSL to send radio notifications 800us before scheduled events and uses the QDEC_IRQn as the interrupt vector by default (because it only gets used in very specific applications). A different interrupt vector can be selected in timeslot.h if necessary.

err = mpsl_radio_notification_cfg_set(MPSL_RADIO_NOTIFICATION_TYPE_INT_ON_ACTIVE,
                                        MPSL_RADIO_NOTIFICATION_DISTANCE_800US,
                                        TIMESLOT_IRQN);
if (err) {
    p_timeslot_callbacks->error(err);
}

This is one of the steps performed by timeslot_open, which needs to be called early in main.c, before Bluetooth is enabled. Note that the timeslot module calls all MPSL functions (which are notably not reentrant) from a cooperative thread to prevent them from being preempted by other threads that might also be using the MPSL. But why are radio notifications needed in the first place?

Requests

When operating as a BLE peripheral it is expected that the scheduling of Connection Events can move around a little bit over time as the stack adjusts its timing to match the central device's clock (which may drift). One possible solution for making sure that the timeslots stay out of the way of the BLE connection is to use radio notifications to determine when to make requests.

All timeslot sessions must start with a request of type "earliest". A key observation is that the result of earliest requests is dependent on when the request is made -- the resulting timeslot might not start immediately if the MPSL's schedule is not empty. Furthermore, the start of a timeslot that closely follows a Connection Event can shift around, depending on whether or not the Connection Event closes early.

In this project the goal is to have timeslots of fixed length that are scheduled as consistently as possible. This is accomplished by introducing a delay between the radio notification and the request to ensure that the timeslot starts CONFIG_SDC_MAX_CONN_EVENT_LEN_DEFAULT microseconds after the Connection Event.

case SIGNAL_CODE_RNH_ACTIVE:
    if (timeslot_requested) {
        break;
    }
    k_sleep(K_USEC(CONFIG_SDC_MAX_CONN_EVENT_LEN_DEFAULT -
                       TS_REQUEST_DELAY_US + TS_RNH_DISTANCE_US));
    timeslot_requested = true;
    err = mpsl_timeslot_request(mpsl_session_id, &request_earliest);
    if (err) {
        p_timeslot_callbacks->error(err);
    }
    break;

The result looks like this:

If an alternative approach is required then the timeslot module can be modified to suit the application's needs. For example, removing the delay before the timeslot request and using timeslot extensions would be useful in scenarios like mesh networks where maximizing radio availibility is important.

When power consumption is criticial the opposite is true; timing should be as precise as possible so all of the radios in the network can wake up for transactions and spend the rest of the time disabled. If the k_sleep delay is not accurate enough then it can be replaced with an RTC or Timer.

As a final thought, if frequent collisions are anticipated between BLE activity and timeslots then both timeslot priority and BLE slave latency can be used to provide extra timeslot scheduling time.

MPSL callbacks

The biggest update to the timeslot interface is the introduction of the Zero Latency IRQ (ZLI). Servicing priority zero interrupts has always required a bit of care but with ZLIs it's also necessary to avoid using any kernel functionality. The timeslot module accomplishes this by reusing the interrupt vector that was assigned to the radio notifications. This introduces a small amount of latency but allows the priority of the MPSL callback signals to be lowered enough to make kernel functionality available again. It is a reasonable approach because the radio notification signals do not occur within timeslots and the 800us radio notification distance provides separation.

static mpsl_timeslot_signal_return_param_t*
mpsl_cb(mpsl_timeslot_session_id_t session_id, uint32_t signal)
{
    switch (signal) {
    case MPSL_TIMESLOT_SIGNAL_START:
    		...
        if (timeslot_stopping) {
        		...
            return &action_end;
        }
        ...
        mpsl_callback_signal = MPSL_TIMESLOT_SIGNAL_START;
        NVIC_SetPendingIRQ(TIMESLOT_IRQN);
        break;
    ...
}
...
static void radio_notify_cb(const void *context)
{
    if (!timeslot_started)
    {
        /* Ignore RNH events until the timeslot is started. */
        return;
    }

    if (INVALID_MPSL_SIGNAL != mpsl_callback_signal) {
        /* This is an MPSL callback. */
        switch (mpsl_callback_signal) {
        case MPSL_TIMESLOT_SIGNAL_START:
            k_poll_signal_raise(&timeslot_sig, SIGNAL_CODE_START);
            break;
        case MPSL_TIMESLOT_SIGNAL_RADIO:
            k_poll_signal_raise(&timeslot_sig, SIGNAL_CODE_RADIO);
            break;
        case MPSL_TIMESLOT_SIGNAL_TIMER0:
            k_poll_signal_raise(&timeslot_sig, SIGNAL_CODE_TIMER0);
            break;
        default:
            k_poll_signal_raise(&timeslot_sig, SIGNAL_CODE_UNEXPECTED);
            break;
        };
        mpsl_callback_signal = INVALID_MPSL_SIGNAL;
    } else {
        /* This is a radio notification. */
        ...
        k_poll_signal_raise(&timeslot_sig, SIGNAL_CODE_RNH_ACTIVE);
    }
}

Note that from the application's standpoint ZLIs can effectively poke a hole in the kernel's irq_lock.

ESB functionality

The contents of the ESB PTX sample were copy-and-pasted into the proprietary_rf module. The ESB library is initialized at the start of every timeslot and disabled at the end. The only mandatory change to the ESB library itself is to skip the initialization of the RADIO_IRQHandler (because this is done by the SoftDevice Controller). The esb_get_pid and esb_set_pid functions are optional; they are particularly useful when packet payloads don't necessarily change for every transmission.

If ESB isn't used then the TIMESLOT_CALLS_RADIO_IRQHANDLER symbol can be set to zero so the timeslot module emits a radio_irq callback instead of calling RADIO_IRQHandler.

A second development kit running the ESB Receiver (PRX) sample can be used to observe ESB activity.