Module Documentation Index » Control Module » Control Developer Guide

Control Developer Guide

This guide is for firmware developers adding or changing PacketRF control endpoints. It complements the higher-level overview in Control Module and the normative wire schema in Control Wire Protocol CDDL.

If your goal is "I need to add an endpoint that returns some runtime data" or "I need to add a config write that does some operation", this is the right page.

Design rules

Control code is built around a short set of rules that have all been paid for in actual debug sessions, so the rationale below is not theoretical.

  • One transport-independent inner protocol. CoAP and the local serial framing both carry the same inner exchange. A handler does not know which transport it was reached over.
  • Integer-keyed envelope on the wire, textual domain keys inside values/items. Wire keys stay compact and cheap to decode; semantic keys stay legible and grep-able.
  • Strict fail-closed behavior on malformed input or budget overflow. Partial valid-looking output is much harder to debug than an honest error.
  • Module-owned command families live in their owning modules. /crypto/* lives in src/crypto, /interface/np2/* lives in src/npr, and so on. Generic control core stays generic.
  • No legacy compatibility layers in handlers. The control surface is allowed to break cleanly when it changes; we have a versioned schema for that.

Wire model

Request envelope keys:

KeyFieldNotes
0pathrequired
1argsoptional map of scalar values

Response envelope keys:

KeyFieldNotes
0status0=ok, 1=error
1kind0=schema_catalog, 1=schema_target, 2=data, 3=list
2path
3error_coderequired for error responses
4error_detailoptional string detail

Payload keys vary by kind:

  • schema catalog: 10=descriptors, optional 11=groups,
  • schema target: optional 20=targets, 21=fields,
  • data: 30=values,
  • list: 40=items.

Hard limits

The control layer enforces these:

  • max inner payload: 1400 B,
  • max request args: 16,
  • max request path: 96 B,
  • max domain key: 64 B,
  • hard count cap for schema/list arrays: 64.

Do not bypass these in endpoint code. All writer and decoder paths are expected to return an error rather than produce a partial or malformed payload when they hit a budget.

The request pipeline

For each request the runtime walks:

  1. transport hands the payload to ExchangeService,
  2. optional COSE unwrap and signature verify,
  3. decode_request_payload(...) parses {0:path, 1:args?},
  4. the dispatcher selects one CommandDescriptor,
  5. the handler writes a response through ResponseWriter,
  6. the exchange layer maps errors and optionally signs the response.

The relevant files:

Adding an endpoint

Decide ownership first:

  • generic, scenario-independent endpoint → src/control,
  • feature-specific endpoint → owning module (e.g. src/net/*, src/npr/*, src/crypto/*).

Register a descriptor

A CommandDescriptor ties a path, a match mode, an authorization policy, a callback, and a user context together:

CommandDescriptor{
    "/my/feature/status",
    MatchMode::Exact,
    AuthPolicy::PublicRead,
    &MyFeatureControl::handle_status,
    this,
}

Validate the request contract early

Reject unexpected args or auth state before doing real work:

if (!request.args.empty()) {
    out_response->set_error(ErrorCode::InvalidRequest);
    return;
}
if (!request.signer_is_admin) {
    out_response->set_error(ErrorCode::Unauthorized);
    return;
}

Emit the response envelope explicitly

For a single object payload (kind=data):

if (!out_response->wire_begin_ok_response(protocol::wire::Kind::Data,
                                          request.path, 1u) ||
    !out_response->wire_add_key_u64(
        static_cast<uint64_t>(protocol::wire::ResponseKey::Values)) ||
    !out_response->wire_begin_map(2u) ||
    !out_response->wire_add_key_text("module") ||
    !out_response->wire_add_text("my_feature") ||
    !out_response->wire_add_key_text("active") ||
    !out_response->wire_add_bool(true)) {
    out_response->set_error(ErrorCode::ServiceUnavailable);
}

For a list payload (kind=list), use ResponseKey::Items and wire_begin_array. For schema endpoints, the helpers in src/control/schema.* do most of the work for you.

Use shared helpers where they fit

Before reinventing something, check:

Error handling

Use ErrorCode enum values directly (errors.hpp):

  • InvalidRequest for malformed requests or contract mismatch,
  • Unauthorized for auth policy mismatch,
  • NotFound for an unknown endpoint or target,
  • Busy for exchange guard contention,
  • ServiceUnavailable for internal/runtime dependency failures.

Do not parse string error labels in firmware logic. The numeric codes are the contract.

Write endpoints

The exchange/auth layer enforces:

  • a valid signature is required,
  • a nonce is required and must match the current device nonce,
  • an admin key is required for admin-only operations.

Endpoint handlers are still responsible for validating their business invariants. Returning InvalidRequest for a bad combination of arguments is your job, not the auth layer's.

Test checklist for a new endpoint

For every new endpoint, add tests for:

  • happy-path output shape and key names,
  • the unauthorized access path,
  • invalid args, missing args, duplicate args,
  • overflow / fail-closed behavior on tight buffer budgets,
  • schema endpoint consistency (/schema and the target schema for this endpoint).

Prefer contract-style tests that decode the response envelope and assert wire keys. They catch drift without being fragile.

CDDL and documentation workflow

When you change the wire contract:

  1. update src/control/protocol/command_protocol.cddl first,
  2. update the corresponding module documentation and examples,
  3. update the firmware implementation and tests in the same change set.

The CDDL file in src/control/protocol is the source-of-truth location for firmware developers; it is also what host tools read to understand the wire format.