Extending can-lite with Categories
Status: Living document
can-lite ships two categories — System (0x0) and
Firmware Upgrade (0x1). Everything domain-specific belongs
in the consuming project as an application category
using identifiers 0x2–0xF. This document is
the reference for writing one.
A complete worked example lives in examples/setpoint/; a
minimal one used by the integration tests lives in integration_tests/support/TestCategories.hpp.
1. What a category is
A category is a server/client pair that shares one 4-bit category ID:
| Side | Base class | Owns | Message type range |
|---|---|---|---|
| Server | CanCategoryServer |
Command handlers, Send*Response() methods |
0x00–0x7F |
| Client | CanCategoryClient |
Response handlers, Send*Command() methods |
0x80–0xFF |
The two base classes carry distinct infra::IntrusiveList
node types, so the compiler prevents registering a client category on a
server and vice versa.
Only can_lite.core is needed to write a category — do
not link can_lite.server or
can_lite.client.
2. Define the identifiers
Put every wire constant in one *Definitions.hpp so the
wire format is reviewable in a single place: the category ID, the
command message type IDs (0x00–0x7F), the
response message type IDs (0x80–0xFF), and the
category’s own error codes as a uint8_t scoped enumeration.
SetpointDefinitions.hpp in the worked example shows the
layout.
IsApplicationCategoryId(),
IsCommandMessageType() and
IsResponseMessageType() from
CanProtocolDefinitions.hpp are constexpr, so
the definitions header can static_assert that its category
ID lies in the application range and that every command and response ID
lies in its own.
3. Bind handlers with
CanMessageHandler
CanMessageHandler<Owner> binds a message type ID
to a member function. It stores an ID, a reference and a member pointer
— no allocation, no vtable per message, and no nested class per message
type.
Declare one handler per message type as a member of the category,
initialised with the message type ID, the category itself and the member
function that handles it. That member function takes the frame’s
const hal::Can::Message& and returns bool.
Register every handler once, in the category’s constructor, with
AddMessageTypes(...). Each message type ID may be
registered once per category; adding a second handler for an ID already
registered asserts, rather than leaving one handler silently shadowing
the other.
A handler returns true when it accepted the payload and
false when it rejected it. On false the server
answers invalidPayload for you, so do not also acknowledge
it. A command rejected this way does not advance the sequence counter.
Return true whenever the payload was valid, even if the
command then fails for another reason and you report that with a
different status.
For a message that also arrives as a reassembled ISO-TP PDU, pass a
second member function:
CanMessageHandler<Owner>{ id, *this, &Owner::HandleFrame, &Owner::HandlePdu }.
The PDU handler follows the same true/false
contract.
4. Read and write
payloads with CanPayload
CanPayloadReader and CanPayloadWriter are
cursor-based and big-endian, so no byte offsets are computed by hand.
Both track validity as a sticky flag: one check covers
the whole payload. The reader’s Available() and the
writer’s Remaining() report the bytes left in the frame,
for a payload whose tail varies in length.
A command handler in a category that validates sequence numbers first
skips the sequence byte, then reads its fields in wire order, then
returns false if Valid() no longer holds —
that one check covers every read before it.
Reads past the end return zero and clear Valid(); writes
past 8 bytes are dropped and clear Valid().
SendResponse() and SendCommand() refuse to
send an invalid payload, so overflow can never reach the bus.
Available: ReadUInt8, ReadInt16,
ReadUInt16, ReadInt32,
ReadUInt32, ReadFloat32,
ReadFixed16(scale), Skip,
ReadRemaining, Available — and the matching
Write* methods plus WriteBytes.
5. Send frames
The base classes own the transport and fill in the category ID and priority.
Server:
| Method | Priority | Message type |
|---|---|---|
SendResponse(messageType, payload) |
response |
as given |
SendTelemetry(messageType, payload) |
telemetry |
as given |
SendEmergency(messageType, payload) |
emergency |
as given |
SendCategoryError(originatingCommandId, code) |
response |
0xFE |
SendCommandAck(messageType, status) |
response |
system ACK |
Report faults with SendEmergency(). Emergency frames
leave the node’s send queue ahead of all other traffic and have reserved
queue capacity (REQ-CAN-039, REQ-CAN-040).
Client:
| Method | Sequence byte |
|---|---|
SendCommand(nodeId, messageType[, payload][, priority]) |
prepended automatically |
SendCommandWithoutSequence(nodeId, messageType[, payload][, priority]) |
none |
A client send method builds its payload with a
CanPayloadWriter and hands it, with the target node and the
message type, to SendCommand().
Do not write the sequence byte yourself —
SendCommand() reserves the next number, prepends it, and
gives the number back if the send queue refuses the frame or the frame
never leaves the node.
Use SendCommandWithoutSequence() only when the matching
server category overrides RequiresSequenceValidation() to
return false (as Firmware Upgrade does, because block
indices already order the transfer). priority defaults to
CanPriority::command; pass
CanPriority::emergency for safety-critical commands.
6. Report errors
Two mechanisms, used together:
CanAckStatus— the universal outcome every command reports (success,invalidPayload,invalidState,rateLimited, …).- Category error response (
0xFE) — a category-defined error code when the universal status is not specific enough. Payload is{ originatingCommandId, categoryErrorCode }.
A handler that fails for a category-specific reason sends
SendCategoryError(originatingCommandId, errorCode) and then
acknowledges the command with
CanAckStatus::categoryError.
Client categories observe it by binding a handler to
canCategoryErrorResponseMessageTypeId.
7. Expose events through an observer
Every category notifies its consumer through
infra::Subject / infra::SingleObserver.
Asynchronous work is handed back through an infra::Function
completion callback so handlers never block.
The observer interface derives from
infra::SingleObserver<MyCategoryServerObserver, MyCategoryServer>,
inherits its constructor with a using declaration, and
declares one pure virtual method per event. An event the application
must answer takes an infra::Function completion as its last
parameter, which the application calls when it is done — immediately or
later.
Observer callbacks must not allocate and must not block.
8. Register
Construct a server category over server.Transport() and
register it with server.RegisterCategory(). Construct its
client counterpart over client.Transport(), passing the
client itself as the CanSequenceSource, and register it
with client.RegisterCategory().
CanProtocolClient implements
CanSequenceSource, which is all a client category needs —
categories never depend on CanProtocolClient itself.
Always use client.Transport() for a category’s
CanFrameTransport&, rather than constructing a second,
independent CanFrameTransport over the same
hal::Can. Each CanFrameTransport owns its own
in-flight frame and send queue; two independent transports wrapping the
same hal::Can would each believe they alone own the
outstanding transmission, and concurrent sends from both could overlap
on the HAL.
RegisterCategory() returns false, without
registering, if the category’s ID is already registered or if 8
categories (canMaxRegisteredCategories) are already
registered on that server or client — check the return value if
registration can plausibly fail in your composition. Registered category
IDs are reported automatically by category discovery.
UnregisterCategory() returns false,
changing nothing, if the category is not registered on that server or if
it is the server’s built-in system category. On success it clears the
acknowledger the server handed the category, so a later acknowledgement
— from an asynchronous completion that outlived the registration, say —
is dropped rather than sent through a server that no longer owns it.
A registered category is a node in the server’s or client’s intrusive list and does not unlink itself. Unregister a category before destroying it: otherwise the list holds a dangling entry, which the next frame for that category — and the protocol object’s own destruction — will follow.
9. Test
Unit tests use GoogleTest with
testing::StrictMock<> only.
can_lite.test_util provides hal::CanMock, plus
CanCategoryServerStub / CanCategoryClientStub,
which own a CAN mock, a CanFrameTransport and a
CanSequenceSource so a test category stays
default-constructible.
Cover at minimum, per message type: the happy path, a short payload, and — for server categories — an out-of-sequence command.