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:

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.