Skip to content

Overhauled CoAP Stack and API #20792

@carl-tud

Description

@carl-tud

Hello RIOT Community,

RIOT features multiple CoAP libraries: gcoap, nanocoap_sock, and the nanocoap parser. In the future, it’d be great to have a single, unified, and modular library, facilitating CoAP over various protocols such as UDP, DTLS, and potentially TCP, TLS.

It’s time for something new 🥳!

Below I will give a brief overview before I outline the proposed design of a new client and server API, the improved options API, and the modular transport driver design.

Overview of the New CoAP Library for RIOT

The new CoAP library should provide a unified, versatile CoAP API for RIOT that is both easily extensible and beginner-friendly. Until a final, better name is found, I’m calling it the unified CoAP API, or unicoap for short.

The new CoAP stack will be based upon gcoap. The goal is to extend gcoap to provide synchronous and asynchronous APIs, and support message deduplication optionally.

The new API aims to reduce the need for in-depth protocol knowledge and implementation details while offering convenience APIs for commonly used features such as block-wise transfer and OSCORE. It minimizes boilerplate code.

For example, this is what a simple blocking GET request would look like with the new client API (error handling omitted):

unicoap_response_t response;
uint8_t buffer[CONFIG_UNICOAP_DEFAULT_PDU_BUFER_SIZE];
unicoap_send_request(unicoap_request_empty(UNICOAP_METHOD_GET), 
    unicoap_uri("coaps://iot.example.com/foo/bar?a=b"), &response, buffer, sizeof(buffer), 0);

printf("status code: %u", unicoap_response_get_status(&response));
my_dump_buffer(response.payload, response.payload_size);

Design

The new CoAP implementation comprises four main parts: a new API, the library messaging internals, and a new, modular parser and transport design. The latter two components are extensible enough to support CoAP over TCP or TLS.

unicoap-stack2

Client API

Today, users have to choose between nanocoap and gcoap for sending requests. nanocoap provides a synchronous interface for client requests, and gcoap on the other hand offers async callback-based functionality. Generally, the gcoap async interface is more versatile, yet sometimes, your application can’t perform any useful work until the request comeback has come in. Plus, nesting your application logic in response handlers, async or sync, becomes quickly messy. Hence, the new API will define both synchronous and async APIs.

The following synchronous request function blocks until a response is received (or the corresponding timeout is exceeded), and copies relevant response data into the supplied buffer.

int unicoap_send_request_aux(
    unicoap_request_t request,
    unicoap_resource_identifier_t resource_identifier,
    unicoap_response_t* response,
    uint8_t* buffer, size_t buffer_capacity, uint16_t flags,
    unicoap_profile_t* profile, unicoap_aux_t* aux);

Request data (method, payload (+ size), and optionally any options) must be passed through the request parameter. To avoid unnecessary boilerplate, unicoap supports defining requests/responses via convenience functions, including, but not limited to:

static inline unicoap_request_t unicoap_request_empty(unicoap_method_t method) { ... }
static inline unicoap_request_t unicoap_request_string(unicoap_method_t m, char* payload)`) { ... }
static inline unicoap_request_t unicoap_request(unicoap_method_t method, uint8_t* payload, size_t payload_size) { ... }
static inline unicoap_request_t unicoap_request_options(unicoap_method_t m, uint8_t* payload, size_t size, unicoap_options_t* options) { ... }

The unicoap_resource_identifier_t consists of an identifier type and value. This API allows for different representations of resource identifiers: The URI passed to unicoap_uri(...) specifies the transport type via the URI’s scheme, the resource’s address, the Uri-Path and Uri-Queries. unicoap_endpoint_udp(...) and unicoap_endpoint_dtls(...) specify the endpoint, i.e. for UDP/DTLS the address and port. This design prevents constructing URIs for requests where the raw sock_udp_ep_t already exists in the application and avoids having too many client APIs, especially with convenience APIs for block-wise and potential future identifiers like CRIs.

The flags parameter serves for, e.g., signaling to the CoAP stack that the request should be sent as a confirmable message.

Details about the response (such as status code, the payload (+ size), and options) can be obtained from the response out parameter.

For callback-based response handling, unicoap defines an async and a synchronous variant.

int unicoap_send_request_sync_callback(
    unicoap_request_t request,
    unicoap_resource_identifier_t resource_identifier,
    unicoap_response_callback callback, void* callback_args,
    uint16_t flags, unicoap_profile_t* profile);

int unicoap_send_request_async(
    unicoap_request_t request,
    unicoap_resource_identifier_t resource_identifier,
    unicoap_response_callback callback, void* callback_args,
    uint16_t flags, unicoap_profile_t* profile);

Note

Block-Wise Transfer
Sending: unicoap defines an auto-slice flag that can be passed alongside a client request or server response to instruct the stack that the request should be sliced and transmitted block-wise. In addition to that, you can use a slicer to send blocks manually.
Receiving: unicoap is also going to provide callback-based client request functions similar to the ones above that support automatic Block2 block-wise transfers. With these APIs, the callback is called per response block arriving at the client. The client and server API will also optionally (resource-intensive for async scenarios) support block-wise reassembly of requests/responses via a flag present in the resource definition or the requests flags parameter.

Note

On OSCORE and Profiles
unicoap is going to enable support for OSCORE. In order to leave room for future CoAP extensions akin to OSCORE, I’d like to introduce profiles⏤common characteristics for requests and responses that dictate special treatment is to be applied to a given message. E.g., an OSCORE security context stores characteristics relevant for encrypting/decrypting CoAP requests and responses.


Server API

unicoap will leverage existing resource definition APIs (nanocoap XFA and gcoap listeners), with slight modifications concerning flags (for things like restricting a resource from being accessed over unsecured transports). In contrast to existing APIs however, unicoap will allow sending responses after the request handler returns (needed for proxy operation).


A Note About Options

Let’s look at that unicoap_options_t* options field from before. unicoap_options_t provides a view on CoAP options in a message buffer. The new API implements several APIs for option manipulation, with special focus on how repeatable and non-repeatable ("single-instance") options are handled.
Non-repeatable options like Accept:

int unicoap_options_get_accept(unicoap_options_t* options, uint16_t* format);
int unicoap_options_set_accept(unicoap_options_t* options, uint16_t format);
int unicoap_options_remove_accept(unicoap_options_t* options);

Repeatable options like Etag and Uri-Query.:

int unicoap_options_get_next_etag(unicoap_option_iterator* iterator, uint8_t** value);
int unicoap_options_add_etag(unicoap_options_t* options, uint8_t* value, size_t value_size);
int unicoap_options_remove_etags(unicoap_options_t* options);

For options like Uri-Path whose combined values form another, aggregated value (here, the URI path is formed by concatenating all URI path components), you can voluntarily use convenience APIs like:

int unicoap_options_get_uri_path(unicoap_options_t* options, char* path, size_t capacity);

Note

APIs for getting, setting, adding, and removing options with arbitrary option numbers still exist:
unicoap_options_get(...), unicoap_options_copy_value(...), unicoap_options_set(...), unicoap_options_remove (non-repeatable);
unicoap_options_add(...), unicoap_options_copy_values, unicoap_options_remove_all (repeatable).

Tip

nanocoap also enforced a requirement where you would need to insert options in the order dictated by their corresponding option number. In unicoap this requirement is no longer present, yet inserting options in the order they’ll occur in the final packet will still deliver the best performance.


Exchangeable Transport Protocols and Message Formats

Expand for details With unicoap, it’s easy to implement CoAP over, e.g., TCP. unicoap strives for driver equality, i.e., the built-in UDP and DTLS drivers won’t receive special treatment. Driver functionality is conditionally compiled in (e.g., IS_USED(MODULE_UNICOAP_TRANSPORT_CARRIER_PIGEON)).

In unicoap’s transport layer, each ’driver’ must support two basic functions and a parser. As an example, let’s pick the UDP driver which provides these functions:

int _udp_init(event_queue_t* queue);
int _udp_send_message(unicoap_message_t* message, unicoap_properties_t* properties, sock_udp_ep_t* endpoint);
int unicoap_parse_pdu_udp_dtls(const uint8_t* pdu, size_t size, unicoap_message_t* message, unicoap_properties_t* properties)

When you call unicoap_init() in your application, unicoap will spin up a thread (again, like gcoap does), create an event queue and ask your driver to perform any setup work. _udp_init opens a GNRC socket and registers a callback for incoming datagrams on the queue via sock_udp_event_init. In the UDP case, the driver reads any available data from the socket and ultimately calls the following function (ignoring error handling).

void _process_pdu(
    uint8_t *pdu, size_t size,
    unicoap_parser_t parser,
    unicoap_endpoint_t remote,
    unicoap_endpoint_t local
)

Because the CoAP header format varies from transport to transport, you will need to pass in a parser. These parsers aren’t entirely transport-specific, i.e., a certain parser (that is, a header format) is shared between multiple transports. The option format and payload (i.e., payload marker + payload that follows) remain the same across all CoAP message formats, however. In the UDP case, the appropriate parser is passed. Ultimately, after parsing the header, the parser calls _parse_options_and_payload which is shared functionality among all parsers.

Sending works similarly, as each message is effectively passed down to the proper _my_transport_send_message(...) function which, in turn, also uses the corresponding header encoder.

static int _send_message(unicoap_message_t* message, unicoap_propertied_t* properties, unicoap_endpoint_t remote) {
    switch (remote.proto) {
#if IS_USED(MODULE_UNICOAP_UDP)
        case UNICOAP_PROTO_UDP:
            return _udp_send_message(message, properties, remote.transport_endpoint.udp);
#endif
#if IS_USED(MODULE_UNICOAP_DTLS)
       case UNICOAP_PROTO_DTLS:
/* ... */
/* ... */
/* ... */
#endif
        /* MARK: unicoap_transport_extension_point */
    }
}

(where unicoap_properties_t holds the message ID, token, type)


Implementation Status

Expand for details

At the moment the following components are implemented:

  • API
    • client
    • server
    • block-wise helpers
    • Observe
  • Parser
    • UDP/DTLS header parser
    • options parser
    • options API
    • options convenience APIs
  • Messaging
    • event loop
    • Block-wise reassembly and fragmentation
    • deduplication
    • Observe
  • Transport
    • vector send + recv interface
    • UDP driver implementation
    • DTLS driver implementation
    • parser-transport interface
  • gcoap and nanocoap modules (need adjustments)
    • gcoap DNS
    • nanocoap/gcoap cache
    • gcoap forward proxy
    • gcoap fileserver (VFS)

Thanks for reading! I’d love to hear your thoughts on the new API.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions