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 (![alt](/home/runner/work/e-foc/e-foc/documentation/design/path)) 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:

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)

Sequence diagram

SelectControlMode (server path)

Sequence diagram

Bandwidth Rejection Path

Sequence diagram

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

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