CAN Service Layer Design
| Field | Value |
|---|---|
| Title | CAN Service Layer Design |
| Type | design |
| Status | draft |
| Version | 0.1.0 |
| Component | can-service |
| Date | 2026-08-21 |
IMPORTANT — Implementation-blind document: This document describes behavior, structure, and responsibilities WITHOUT referencing code. No code blocks using programming languages (C++, C, Python, CMake, shell, etc.) are allowed. Use Mermaid diagrams to express behavior instead. Prose descriptions of algorithms are encouraged; source-level details are not.
Diagrams: All visuals must be either a Mermaid fenced code block or ASCII art inline in the document. External image references (
) are not allowed.
Responsibilities
Is responsible for: - Defining the FOC motor CAN
category (ID 0x02) message format — command IDs, response
IDs, scale factors, and error codes — in one canonical location. -
Implementing the server category: decoding incoming CAN frames into
typed observer callbacks and encoding typed results into response
frames. - Implementing the client category: encoding typed setpoints
into CAN frames and decoding response frames into typed callbacks. -
Bridging the server category to ControlModeStateMachine,
translating motor control commands into state machine transitions. -
Providing a user-facing client API that composes the protocol client,
transport, and category client into a single object for use by host-side
tools and SIL tests. - Applying tracing decorators at every layer
boundary so all CAN frames and protocol lifecycle events are observable.
- Delegating identification commands to
ElectricalParametersIdentification and
MechanicalParametersIdentification services and
broadcasting results via response frames. - Persisting encoder
resolution and telemetry rate changes to NonVolatileMemory.
- Broadcasting on-demand telemetry status frames in response to
RequestTelemetry.
Is NOT responsible for: - The CAN protocol framing,
sequence validation, or ACK dispatch — those belong to
can-lite core. - The FOC control algorithms, state machine
transitions, or calibration — those belong to core/foc/ and
core/state_machine/. - Motor physics or hardware-specific
logic.
Component Details
Part A — FOC Motor Message Catalogue
Message type IDs, the category ID, the enums and the scale factors
live in FocMotorMessages.hpp; the field layout of every
message — order, width, signedness, scale and unit — lives beside them
in FocMotorWireContract.hpp as a constexpr
descriptor table. Together these are the authoritative contract for
category 0x02, and no other component may restate any part
of them. The tables below are checked against that table by
TestWireContractDocumentation, so a change to one that is
not made to the other fails the build.
The category occupies slot 0x02 (the first
application-reserved category ID). Commands occupy message types
0x00–0x7F; responses occupy 0x80–0xFF. The
category error frame uses the can-lite reserved type
0xFE.
Every command carries the can-lite sequence byte ahead
of its fields; responses and telemetry do not. A frame whose length is
not exactly the length its descriptor states is rejected by its handler
and never partially decoded; the can-lite protocol server
answers a rejected command with invalidPayload and does not
advance the sequence or client-liveness bookkeeping for it — a payload
of another length is a foreign layout, not a tolerable variant of this
one. The category-error frame on the can-lite reserved type
0xFE carries a descriptor of its own and is held to the
same exactness, so no decode path in this category escapes it.
The descriptors are the specification and the length authority, not a codec: encoders and decoders are written against them by hand. A descriptor edit therefore changes the accepted length and fails the byte-level vectors, which is what surfaces the encode or decode change that has to accompany it.
Contract version.
focContractVersionMajor.focContractVersionMinor identifies
the contract, and QueryContractVersion (0x12)
answers it in ContractVersionResponse (0x92).
A client whose major differs from the server’s shall report the mismatch
and send no further category-0x02 commands; equal majors
are compatible. Adding a message is a minor bump; changing any field
layout, or the meaning of any enumerator, is a major bump.
Physical-to-wire conversions use fixed-point scale factors.
int16 fields round to nearest and saturate
(CanFrameCodec::FloatToFixed16). Friction and inertia of
the reference motors are around 1e-5 and 1e-6 in SI units, which any
useful int16 scale rounds to zero, so they travel as
unsigned 32-bit nano-unit counts:
| Quantity | Wire type | Scale factor | Example |
|---|---|---|---|
| Current | int16 | 10 | 1.5 A → 15 wire |
| Speed | int16 | 1 | 300 rad/s → 300 wire |
| Position | int16 | 100 | 3.14 rad → 314 wire |
| Voltage | int16 | 10 | 24.0 V → 240 wire |
| Bandwidth | int16 | 1 | closed-loop bandwidth in rad/s |
| Resistance | int16 | 1000 | 0.36 Ω (Teknic M-2310P-LN-04K) → 360 wire |
| Inductance | int16 | 1000 | 0.20 mH (Teknic M-2310P-LN-04K) → 200 wire |
| Friction | uint32 | 1000000000 | 1.5e-5 N·m·s/rad (Teknic M-2310P-LN-04K) → 15000 |
| Inertia | uint32 | 1000000000 | 7.06e-6 kg·m² (Teknic M-2310P-LN-04K) → 7060 |
The uint32 fields round to nearest, saturate at
UINT32_MAX and encode a negative or NaN value as
0: friction and inertia are never negative in a physical
plant. The reference-motor values come from
documentation/theory/foc-plant-models.md “Reference
motors”.
Deviations from the can-lite reference example
infra/can-lite (pinned at ea80b3e) ships
examples/foc_motor, which also claims category
0x02 and disagrees with this contract. e-foc’s definitions
are authoritative for this product; the example is documentation.
Because every decode is length-exact, a frame built from the example is
rejected with invalidPayload rather than misread — visibly,
not silently.
| Message / item | e-foc | can-lite example | Why |
|---|---|---|---|
0x03/0x04/0x05 |
one int16 closed-loop bandwidth |
three int16 kp/ki/kd |
documentation/requirements/foc/*-controller.yaml forbid
externally supplied per-axis gains |
0x0A |
Align |
reserved, unused | alignment is a first-class command here |
FocMotorState |
adds partialCalibration = 4 |
stops at calibrating |
speed and position modes can hold a partial record |
0x12 / 0x92 |
contract version query and response | absent | the example has no versioning |
Part B — FocMotorCategoryServer
Inherits CanCategoryServer from can-lite.
Registers one CanMessageHandler per command type; each
handler decodes the payload using CanPayloadReader, skips
the sequence byte (required for server categories), converts wire values
to physical units, and calls the corresponding observer method.
The observer interface provides callbacks for: -
OnStart, OnStop, OnClearFault,
OnEmergencyStop — lifecycle commands with a
CanAckStatus result callback. -
OnSelectControlMode — takes a FocMotorMode and
a result callback. - OnSetTorqueSetpoint,
OnSetSpeedSetpoint, OnSetPositionSetpoint —
take a typed unit quantity and a completion callback. -
OnSetPidCurrent, OnSetPidSpeed,
OnSetPidPosition — receive a bandwidth parameter parsed
from the CAN frame and forward to the corresponding
TrySet*Bandwidth on ControlModeStateMachine. -
OnIdentifyElectrical — calls
CmdReserveExternalCalibration() before starting estimation;
invalidState if rejected; on estimation success calls
CmdCompleteExternalCalibration() and broadcasts
focElectricalParamsResponseId. -
OnIdentifyMechanical — calls
ActiveCalibrationData() to obtain pole pairs and guard the
state in one step; invalidState if not in
Ready; on success broadcasts
focMechanicalParamsResponseId. -
OnRequestTelemetry — broadcasts current state, fault code,
measured speed and measured position via
focTelemetryStatusResponseId. Position is read from the
encoder; speed is the active mode’s
ObserveMotion().measuredSpeed — the outer loop’s window
speed in speed and position mode, the interrupt’s filtered speed in
torque mode (REQ-INT-014). - OnSetEncoderResolution,
OnConfigureTelemetryRate — validate payload, update and
persist ConfigData via NonVolatileMemory.
Part C — FocMotorCategoryClient
Inherits CanCategoryClient from can-lite.
Provides typed Send* methods that encode physical units
into wire format via CanPayloadWriter and call
SendCommand(nodeId, messageType, payload).
Registers response handlers for the server-to-client response message types. Response handlers decode the payload and invoke the corresponding observer callbacks.
Part D — FocMotorCanBridge
Implements the FocMotorCategoryServerObserver interface.
Holds references to FocMotorCategoryServer and
ControlModeStateMachine. Translates each observer callback
into the appropriate state machine call:
OnStart→CmdEnable(); success calls the ack callback, rejection sendsinvalidState.OnStop→CmdDisable().OnClearFault→CmdClearFault().OnEmergencyStop→CmdEmergencyStop().OnSelectControlMode→Select(mode, onDone); the result callback sends the mode response or a category error.OnSetTorqueSetpoint,OnSetSpeedSetpoint,OnSetPositionSetpoint→ validate mode and range, then delegate toTrySet*on the state machine.- PID bandwidth commands →
TrySet*Bandwidth(bandwidth)on the state machine. The state machine applies the samefoc::CommandLimitsranges and the same enabled-state policy the CLI gets: a value outside the range for that loop, or one that does not apply to the active mode, is acknowledgedinvalidPayload; a redesign refused because the drive is running is acknowledgedinvalidState. OnIdentifyElectrical→CmdReserveExternalCalibration()(state guard +Calibratingentry); run estimation;CmdCompleteExternalCalibration(data, cb)on success, which aligns the rotor and persists;invalidState/calibrationFailed/busyon failure. The command covers the electrical steps only — it never drives the mechanical estimator — so speed and position end inIdlereportingpartialCalibration, while torque reachesReady.OnIdentifyMechanical→ActiveCalibrationData()(state guard + pole-pairs extraction in one call); run estimation; broadcast on success;invalidState/calibrationFailed/busyon failure.OnRequestTelemetry→ broadcast current state, fault code, measured speed and measured position viafocTelemetryStatusResponseId; always succeeds.ToCanMotorStatemapsIdletoFocMotorState::partialCalibrationinstead ofidlewhenHasPartialCalibration()reports a non-empty but incomplete NVM record (old-schema or interrupted external calibration).OnSetEncoderResolution/OnConfigureTelemetryRate→ persist toNonVolatileMemory;persistenceFailedon write error;busyif a prior NVM save is in flight.
The bridge validates that the correct control mode is active before
accepting a setpoint command. An out-of-range setpoint results in
invalidPayload. A mode mismatch results in
categoryError/modeMismatch.
Part E — FocMotorCanClient
A self-contained client object for use in host tools and SIL tests.
Internally composes CanFrameTransport,
CanProtocolClient, and FocMotorCategoryClient
as value members (no heap). Constructed with a
hal::Can& reference and a target node ID.
Provides the same Send* API as
FocMotorCategoryClient but hides the transport and protocol
client, so callers only need a CAN bus reference and a node ID.
Part F —
CanLivenessWatchdog
CanProtocolServer already tracks client liveness: it
restarts a timer on every frame from the client and calls
Offline() on its observers when clientTimeout
elapses with no traffic. Nothing acted on that notification, so
unplugging the bus mid-spin left the drive holding its last setpoint
indefinitely.
CanLivenessWatchdog is a
CanProtocolServerObserver that closes the loop. On
Offline() it checks whether the active state machine is in
Enabled; if so it traces and issues
CmdEmergencyStop, which stops the inverter. When the drive
is not enabled the notification is ignored — a bus that goes quiet while
the motor is stopped is not a hazard, and faulting there would obstruct
commissioning.
It is composed in the instantiation layer alongside the CAN bridge, so the bridge and category server stay free of lifecycle policy.
Part G — Fault Broadcast Priority
BroadcastFaultStatus sends its frame through
SendEmergency, at CanPriority::emergency (0),
rather than through SendTelemetry, which uses
CanPriority::telemetry (12). CAN arbitration is by
identifier, and the priority field is the high bits of the identifier,
so a telemetry-priority fault notification loses arbitration to routine
telemetry on a busy bus — exactly when the bus is busiest and the
notification matters most. The payload is unchanged.
Arbitration only helps a frame that reaches the controller. The
can-lite send queue therefore orders queued frames by
priority, keeps two of its slots for emergency frames only, and lets an
emergency frame displace the newest lowest-priority frame when it is
full, so a fault notification is not refused behind queued
telemetry.
Part H — Tracing Decorators
All tracing is applied via can-lite’s decorator
classes:
| Decorator | Wraps | Traces |
|---|---|---|
TracingCan |
hal::Can |
Every raw CAN frame sent and received |
TracingCanProtocolServerObserver |
CanProtocolServer |
Server online/offline events |
TracingCanProtocolClientObserver |
CanProtocolClient |
Server online/offline, ACK timeouts per node |
These are composed in the instantiation layer
(Logic.hpp) so the production code paths gain full
observability without any tracing logic in the category or bridge
classes themselves.
Interfaces
Provided
| Interface | Purpose | Contract |
|---|---|---|
| FocMotorCategoryServerObserver | Observer interface for the server category | Callbacks must not block or allocate; called from the event dispatcher context |
| FocMotorCategoryClientObserver | Observer interface for the client category | Callbacks must not block or allocate |
| FocMotorCanClient public API | User-facing send methods (Start, Stop,
SetTorque, SetSpeed,
SetPosition) |
Returns bool indicating whether the frame was queued;
ACK is delivered async |
Required
| Interface | Purpose | Contract |
|---|---|---|
CanFrameTransport |
Sends and receives raw CAN frames | Provided by can-lite; must be constructed before the
category objects |
ControlModeStateMachine |
Accepts motor control commands | All methods are synchronous or deliver results via
infra::Function |
hal::Can |
Raw CAN bus access | Required by CanProtocolServer and
CanProtocolClient |
services::Tracer |
Diagnostic output | Must outlive the tracing decorator objects |
Data Model
| Entity | Field | Type / Unit | Range | Notes |
|---|---|---|---|---|
| FocMotorMode | mode | enum uint8 | torque=0, speed=1, position=2 | Transmitted as single byte |
| Torque setpoint | iq | int16 wire | INT16_MIN – INT16_MAX | Physical = wire / 10 [A] |
| Speed setpoint | speed | int16 wire | INT16_MIN – INT16_MAX | Physical = wire / 1 [rad/s] |
| Position setpoint | position | int16 wire | INT16_MIN – INT16_MAX | Physical = wire / 100 [rad] |
| FocMotorCategoryError | code | enum uint8 | 0–5 | See FocMotorMessages.hpp |
Sequence Diagrams
Start Command (server path)
SelectControlMode (server path)
Bandwidth Rejection Path
outOfRange and wrongMode map to
invalidPayload; notWhileEnabled maps to
invalidState, so a client can tell a badly formed request
from one that was well formed but arrived while the drive was
running.
Block Diagram
Constraints & Limitations
| Constraint | Value / Description |
|---|---|
| No heap | All objects are value members or statically allocated;
infra::Function for callbacks |
| One observer per server | CanCategoryServer uses
infra::Subject<Observer> — only one bridge may attach
at a time |
| Setpoint range | Torque: bounded by inverter MaxCurrentSupported; speed:
1000 rad/s; position: ±2π rad |
| Sequence byte | Server handlers always skip the first byte (sequence number) before reading payload fields |
| Ident re-entrancy | A second identification command while one is in-flight returns
busy via SendCategoryError |
| NVM re-entrancy | A second config-persist command while one is in-flight returns
busy via SendCategoryError |
| Mechanical ident | mechIdent is optional (nullable pointer); if absent,
OnIdentifyMechanical returns
notImplemented |
| Telemetry speed/pos | Speed is the active mode’s
ObserveMotion().measuredSpeed, zero while disabled;
position is the encoder |
Open Questions
| # | Question | Resolution | Status |
|---|---|---|---|
| 1 | Implement telemetry push (BroadcastFaultStatus, periodic status) | On-demand via OnRequestTelemetry; periodic timer
deferred |
resolved |
| 2 | PID bandwidth commands | Accepted: single bandwidth value per command decoded by server,
forwarded to TrySet*Bandwidth on
ControlModeStateMachine |
resolved |
| 3 | Mechanical identification via CAN | Delegated to injected
MechanicalParametersIdentification*; nullable for targets
lacking it |
resolved |