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 insrc/crypto,/interface/np2/*lives insrc/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:
| Key | Field | Notes |
|---|---|---|
0 | path | required |
1 | args | optional map of scalar values |
Response envelope keys:
| Key | Field | Notes |
|---|---|---|
0 | status | 0=ok, 1=error |
1 | kind | 0=schema_catalog, 1=schema_target, 2=data, 3=list |
2 | path | |
3 | error_code | required for error responses |
4 | error_detail | optional string detail |
Payload keys vary by kind:
- schema catalog:
10=descriptors, optional11=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:
- transport hands the payload to
ExchangeService, - optional COSE unwrap and signature verify,
decode_request_payload(...)parses{0:path, 1:args?},- the dispatcher selects one
CommandDescriptor, - the handler writes a response through
ResponseWriter, - 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:
- request arg parsing —
src/control/request_,args.hpp - config section encoding/apply —
src/control/config_,helpers.hpp - schema builders —
src/.control/ schema.hpp
Error handling
Use ErrorCode enum values directly (errors.hpp):
InvalidRequestfor malformed requests or contract mismatch,Unauthorizedfor auth policy mismatch,NotFoundfor an unknown endpoint or target,Busyfor exchange guard contention,ServiceUnavailablefor 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 (
/schemaand 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:
- update
src/control/protocol/command_first,protocol.cddl - update the corresponding module documentation and examples,
- 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.