Rudder controller
The rudder controller (project name staartstuk controller) is the CAN-bus node that operates everything inside the new main propulsion unit described on the Rudder page. It replaces the off-the-shelf prototyping platform listed in the requirements table there with a purpose-built 4-layer board.
One board handles four jobs: it positions the back hydrofoil actuator, drives and supervises the cooling-water pump, measures the water cooling circuit, and reports the steering angle. It has no local user interface — every command arrives over the CAN-bus and every measurement leaves over it.
At a glance
| Property | Value |
|---|---|
| Microcontroller | STM32L471RGT6 (Cortex-M4F, LQFP64, 1 MB flash, 96 KB SRAM) |
| System clock | 80 MHz, from a 16 MHz crystal through the PLL |
| Supply | 24 V from the CAN-bus cable; on-board 3.3 V step-down module |
| Bus | CAN 2.0A, 1 Mbit/s, 11-bit standard identifiers |
| PCB | 4 copper layers, roughly 88 × 45 mm |
| Application type | 0x01 — its address in the bootloader protocol
|
| Firmware | Rust, embassy async runtime, binary rudder-controller
|
| Firmware source | firmware/ in the eoi-can monorepo
|
| Hardware source | Altium project AKD-Rudder_Controller (Altium 365)
|
| Bus-wide message list | CAN_MESSAGES.md |
| Field update | Over CAN, no debug probe needed — see #Firmware update over CAN |
The same firmware repository also builds two other boards, the height-sensor controller and the e-paper dashboard. Those are different PCBs; they share the microcontroller, the clock setup, the CAN driver and the bootloader, but nothing else. This page covers the rudder controller only.
What the board controls
Hydrofoil actuator
A geared NEMA 11 stepper drives the hydrofoil trim pushrod through a TMC2209 driver. Position is open-loop step counting: there is no encoder, and the only absolute reference is a mechanical end stop found by StallGuard during homing. The commanded position is a dimensionless 1000–2000 setpoint (servo convention) spanning the full mechanical travel, which is roughly 12 degrees of foil movement.
- Motor: StepperOnline 11HS12-0674D-PG14 — 0.67 A per phase, 200 full steps per revolution, 13.73:1 planetary gearbox
- Driver: TMC2209 at 8 microsteps, configured over a single-wire UART at 115200 baud
- Run current about 0.47 A rms, reduced to about 0.31 A rms while homing into the stop
- Current sensing through 100 mΩ 1 W resistors, giving roughly 1.06 A rms full scale
- Step rate ramps from 400 Hz to a 2 kHz cruise, about 33 degrees per second at the foil shaft
- Full travel is 20000 microsteps
Behavior is covered under #Back-foil servo behavior.
Cooling-water pump
The board switches and current-limits the 24 V circulation pump (ZC-A210) through a TI DRV8256E H-bridge, and reports the driver's fault line.
- The current limit is set by the microcontroller's DAC feeding the driver's VREF input, which is why it can be changed in firmware rather than by swapping resistors. The driver's relation is I_max = VREF / 0.66 V per amp; the firmware programs 1.0 A currently.
- The driver's SLEEP pin is held awake and PHASE (direction) is held forward as static configuration. Only ENABLE is switched.
- The pump only runs while the battery is actually discharging. The board watches BMS message
0x107and enables the pump only while its discharge state reads On. If no BMS frame arrives for 2 seconds the pump is disabled. This is deliberate: there is no point circulating water when the drive is not drawing power, and a silent bus must not leave the pump running unattended. - The FAULT output is open-drain and active-low, pulled up inside the microcontroller. Its raw level is broadcast once per second on
0x212.
Cooling-circuit and motor instrumentation
Two DWS-MH-02 flowmeters were designed in, one on the cooling inlet and one on the outlet. Each provides a pulse output and a built-in NTC. The pulse output is counted in hardware by a timer in external-clock mode, so no pulse is lost to interrupt latency, and the count is converted to mL/min once per second.
Only the inlet flowmeter is read by the current firmware. The outlet flowmeter's NTC input shares a microcontroller pin with the motor NTC, and the motor temperature won that pin, so 0x216 (FlowSensorOut) is not transmitted. The code for it still exists and can be re-enabled if the pin conflict is resolved in a later board revision.
- Flow scaling: 22.9 Hz corresponds to 1880 mL/min (datasheet)
- Both NTC inputs sit in a divider with 47 kΩ to 3.3 V, a 1 kΩ series resistor and a 100 nF filter capacitor, sized for the flowmeter's 50 kΩ (25 °C, B = 3950 K) thermistor
- An NTC reading at either rail (open or shorted) reports the sentinel value −32768 rather than an absurd temperature
Note: see #Known gaps the motor temperature on address 0x217 is deprecated. Currently on the boat, the motor temperature shown on the display comes from the separate motor-NTC node on 0x219 instead.
Steering angle
A potentiometer on the steering column is read by ADC1 and reported ten times per second as a normalized position: −1000 fully left, 0 centred, +1000 fully right. The potentiometer has no fixed raw-to-angle relation, so the three reference positions are calibrated on the vehicle and stored in the board's emulated EEPROM. See #Steering angle calibration.
The sensor connector's third pin is a presence detect: the sensor pulls it to ground, so with the internal pull-up a high reading means nothing is plugged in.
Both the 3.3 V feed and the ground return of this connector go through 47 Ω series resistors, so a shorted sensor cable cannot pull the board's rails down.
Board temperature
A Würth WSEN-TIDS sensor on I²C2 (address 0x3F, its address pin tied low) reports the PCB temperature once per second on 0x211 in hundredths of a degree. This is a board health measurement, not a process measurement — it is how you tell whether the sealed compartment is cooking.
Hardware
Power
24 V arrives on the CAN-bus connector and splits three ways:
- Logic — through a resettable PTC fuse (30 V, 100 mA hold) into a Würth 173950336 fixed step-down module that produces the 3.3 V rail.
- Pump — straight to the DRV8256E, with a 150 µF aluminium-polymer bulk capacitor local to the driver.
- Stepper — straight to the TMC2209, with its own 150 µF bulk capacitor.
The two motor rails are unfused on the board, so the inrush requirement from the Rudder requirements table is met by the bulk capacitors rather than by current limiting. The USB port is separately powered from its own VBUS through a second PTC fuse and a 3.3 V LDO, so plugging in USB does not back-feed the 24 V side.
Key components
| Designator | Part | Function |
|---|---|---|
| U1 | STM32L471RGT6TR | Microcontroller |
| U2 | DRV8256ERGER | H-bridge for the cooling pump |
| U3 | TCAN3403DRBRQ1 | 3.3 V CAN FD transceiver with standby control |
| U4 | FT234XD-R | USB-to-UART bridge on the USB-C port |
| U5 | Würth 173950336 | 24 V to 3.3 V step-down module |
| U6 | TMC2209-LA-T | Stepper driver for the trim actuator |
| U7 | AP2138N-3.3 | 3.3 V LDO for the USB side only |
| U8 | Würth 2521020222501 | WSEN-TIDS I²C board temperature sensor |
| X1 | Würth 830069392 | 16 MHz crystal — system clock |
| X2 | Würth 830062558 | 32.768 kHz crystal — not used by the firmware |
| SW1 | Würth 428542320816 | WS-ROSV sealed IP67 rotary switch, 4-bit — not read by the firmware |
| D2 | Würth 156120M173000 | RGB status LED |
| F1, F2 | 1210L010WR, 0ZCK0010FF2G | Resettable PTC fuses — logic rail and USB VBUS |
Connectors
All field connectors are Molex Micro-Lock Plus, 1.25 mm pitch, top entry SMD.
| Designator | Part | Positions | Purpose |
|---|---|---|---|
| J3, J4 | 505568-0581 | 5 | CAN-bus in and out — daisy chain |
| J6 | 505568-1071 | 10 | To the Rudder. Contains Stepper, pump and inlet flowmeter |
| J2 | 505568-0581 | 5 | Outlet flowmeter |
| J5 | 505568-0471 | 4 | Steering angle sensor |
| J1 | Würth 490107670812 | 8 | Debug — SKEDD solderless connector |
| USB1 | USB4145-03-0070-C | USB-C | Serial console over USB |
J3 and J4 — CAN-bus (5-pin)
The two connectors are wired in parallel so the board sits in the middle of a daisy chain. All five signals, including the safety line, pass straight through.
| Pin | Signal |
|---|---|
| 1 | Safety |
| 2 | 24 V |
| 3 | GND |
| 4 | CAN L |
| 5 | CAN H |
Note: this is the board's own pin order. It is not the same order as the 5-pin Binder connector on the CAN-bus cable — check the harness, do not assume the numbering carries across.
The safety line is a pass-through between J3 and J4 only. It is not connected to the microcontroller, so this board neither reads nor breaks the safety circuit.
J6 — stepper, pump and inlet flowmeter (10-pin)
| Pin | Signal | Notes |
|---|---|---|
| 1 | 3.3 V | Flowmeter supply, through a 47 Ω series resistor |
| 2 | Inlet flowmeter NTC | 47 kΩ divider to 3.3 V |
| 3 | Inlet flowmeter pulse | Hardware pulse counter |
| 4 | GND | |
| 5 | Stepper A1 | Coil A |
| 6 | Stepper A2 | Coil A |
| 7 | Stepper B1 | Coil B |
| 8 | Stepper B2 | Coil B |
| 9 | Pump + | |
| 10 | Pump − |
The motor's wire colours are listed in the connection table on the Rudder page (red B+, black A+, blue B−, green A−). Which end of each coil goes to A1 versus A2 is not fixed by the schematic — check it against the motor datasheet before first power-up, because reversing a coil reverses the direction of travel and the homing direction is fixed in firmware.
Both motor loads share this one connector, so check its per-contact current rating against the 1.0 A pump limit plus the stepper phase current before running both at full load.
J2 — outlet flowmeter (5-pin)
| Pin | Signal |
|---|---|
| 1 | 3.3 V, through a 47 Ω series resistor |
| 2 | Outlet flowmeter pulse |
| 3 | Outlet flowmeter NTC |
| 4 | Not connected |
| 5 | GND |
Not read by the current firmware.
J5 — steering angle sensor (4-pin)
| Pin | Signal |
|---|---|
| 1 | 3.3 V, through a 47 Ω series resistor |
| 2 | Potentiometer wiper — ADC input |
| 3 | Presence detect — pull to ground to signal "sensor connected" |
| 4 | GND, through a 47 Ω series resistor |
J1 — debug
Standard SKEDD SWD programming connector: the programmer's cable presses directly into the board, so no header has to be fitted or removed.
| Pin | Signal | Notes |
|---|---|---|
| 1 | VTREF (3.3 V) | |
| 2 | SWDIO | |
| 3 | SWCLK | |
| 4 | SWO | |
| 5 | J-Link RX | Footprint only, not connected |
| 6 | J-Link TX | Footprint only, not connected |
| 7 | RESET | |
| 8 | GND |
Microcontroller pin map
Every assignment below was cross-checked against both the schematic and the firmware source.
| Pin | Peripheral | Net | Function |
|---|---|---|---|
| PA0 | TIM2_CH1 | COUNT_FLOW_IN | Inlet flowmeter pulse input, hardware counter on rising edges |
| PA1 | ADC2 | NTC_FLOW_IN | Inlet flowmeter NTC |
| PA2 | TIM15_CH1 | COUNT_FLOW_OUT | Outlet flowmeter pulse input (unused by firmware) |
| PA3 | ADC2 | NTC_FLOW_OUT | Used by firmware as the motor NTC (see #Known gaps) |
| PA4 | DAC1_OUT1 | SET_CURR | Pump driver VREF — sets the current limit |
| PA5 | GPIO out | SLEEP | Pump driver awake, held high |
| PA6 | GPIO out | ENABLE | Pump on/off, gated by the BMS discharge state |
| PA7 | GPIO out | DIR | Pump direction, held low (forward) |
| PA9 | USART1_TX | TX_UART1 | USB serial console (unused by firmware) |
| PA10 | USART1_RX | RX_UART1 | USB serial console (unused by firmware) |
| PA13 | SWDIO | SWDIO | Debug |
| PA14 | SWCLK | SWCLK | Debug |
| PB1 | ADC1 | POT_ANALOG_IN | Steering angle potentiometer |
| PB2 | GPIO in, pull-up | POT_FEEDBACK | Steering sensor presence detect — low means connected |
| PB3 | — | STEP_INDEX | TMC2209 INDEX (unused) |
| PB4 | GPIO in | STEP_DIAG | TMC2209 DIAG — high on a StallGuard stall |
| PB5 | GPIO out | STEP_EN | TMC2209 ENABLE, active low |
| PB7 | GPIO out | CAN1_STBY | Transceiver standby, held low so the transceiver is active |
| PB8 | CAN1_RX | CAN1_RX | CAN-bus |
| PB9 | CAN1_TX | CAN1_TX | CAN-bus |
| PB10 | I2C2_SCL | I2C2_SCL | Board temperature sensor, 10 kΩ pull-up |
| PB11 | I2C2_SDA | I2C2_SDA | Board temperature sensor, 10 kΩ pull-up |
| PB12 | GPIO in | CAN_SETTING1 | Rotary switch, weight 1 (unused by firmware) |
| PB13 | GPIO in | CAN_SETTING4 | Rotary switch, weight 8 (unused by firmware) |
| PB14 | GPIO in | CAN_SETTING2 | Rotary switch, weight 2 (unused by firmware) |
| PB15 | GPIO in | CAN_SETTING3 | Rotary switch, weight 4 (unused by firmware) |
| PC1 | GPIO out | GREENn | Green LED — heartbeat, toggles once per second |
| PC2 | GPIO out | REDn | Red LED — the bootloader double-flashes it |
| PC3 | GPIO out | BLUEn | Blue LED |
| PC5 | GPIO in, pull-up | nFAULT | Pump driver fault, open drain, active low |
| PC10 | GPIO out | STEP_DIR | TMC2209 DIR |
| PC11 | GPIO out | STEP | TMC2209 STEP |
| PC12 | UART5_TX | TX_UART5 | TMC2209 single-wire UART, through 1 kΩ |
| PD2 | UART5_RX | RX_UART5 | TMC2209 single-wire UART |
Watch the rotary switch bit order. The weights are not in pin order: PB12 is 1, PB14 is 2, PB15 is 4 and PB13 is 8. There are no external pull-ups on these lines — the schematic expects the microcontroller's internal pull-ups to be enabled, and the switch pulls each bit to ground.
Clocking
The board runs at 80 MHz derived from the 16 MHz crystal (×10 PLL, ÷2). The firmware starts the crystal itself with a bounded wait rather than letting the runtime spin on it forever, so a dead crystal produces a diagnosable degraded boot instead of a silent hang. If the crystal does not start, the firmware falls back to the internal 16 MHz oscillator, logs an error and keeps running — but CAN is unreliable in that state, because the internal oscillator's ±1 % accuracy is not good enough for 1 Mbit/s. A board that intermittently drops off the bus is worth checking here first.
The 32.768 kHz crystal X2 is in the design, but the firmware keeps the low-speed oscillator switched off: boards were found to hang waiting for it to start, and nothing in the firmware uses the RTC. The embassy 32.768 kHz time base is unrelated and is derived from a general-purpose timer.
The ADCs are clocked from the system clock.
CAN messages
The board joins the bus at 1 Mbit/s using 11-bit standard identifiers and an accept-all hardware filter, so it sees every frame on the bus and ignores what does not concern it. All multi-byte fields are little-endian.
Transmitted by the rudder controller
| ID | Message | DLC | Rate |
|---|---|---|---|
0x020 |
ServoRudderStatus | 6 | every 100 ms |
0x032 |
Bootloader response | 2–8 | on request only |
0x211 |
TemperatureRudderController | 2 | every 1 s |
0x212 |
CoolingPumpStatus | 1 | every 1 s |
0x213 |
SteeringAngle | 5 | every 100 ms |
0x215 |
FlowSensorIn | 8 | every 1 s |
0x217 |
MotorTemperature | 4 | every 1 s |
0x218 |
SteeringAngleCalibrationAck | 6 | once per accepted calibration command |
0x216 (FlowSensorOut) is allocated to this board but not transmitted by the current firmware.
Received by the rudder controller
| ID | Message | DLC | Effect |
|---|---|---|---|
0x010 |
ServoRudderSetpoint | 2 | Commands the back-foil position and feeds the 2 s watchdog |
0x021 |
ServoRudderCommand | 1 | 0x00 = Initialize, starts homing
|
0x030 |
Bootloader discovery | 1 | Answers with state and version on 0x032
|
0x031 |
Bootloader command | 1 | Addressed to this board only |
0x033 |
Bootloader write data | 8 | Firmware payload during an update |
0x107 |
BMS TemperaturesAndStates | 8 | Byte 7, the discharge state, gates the cooling pump |
0x214 |
SteeringAngleCalibration | 1 | Captures a calibration reference point |
Every other frame on the bus is ignored.
Message layouts
0x010 — ServoRudderSetpoint (received)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0–1 | Setpoint | u16 LE | 1000–2000. Values outside this range are rejected, and a rejected setpoint does not feed the communication watchdog. |
0x020 — ServoRudderStatus (sent, 10 Hz)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0 | State | u8 enum | 0 = Uninitialized, 1 = Operational, 2 = Homing, 3 = FailSafe, 4 = Fault |
| 1–2 | Current setpoint | u16 LE | 1000–2000 |
| 3–4 | Actual position | u16 LE | 1000–2000, in setpoint units |
| 5 | Fault cause | u8 enum | 0 = None, 1 = StallDuringMove, 2 = HomingTimeout, 3 = DriverNoUartResponse, 4 = DriverError |
0x021 — ServoRudderCommand (received)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0 | Command | u8 enum | 0 = Initialize. Starts homing from any state, and is the only way out of FailSafe or Fault. |
0x211 — TemperatureRudderController (sent, 1 Hz)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0–1 | Board temperature | i16 LE | Hundredths of a degree Celsius. 2500 = 25.00 °C. |
0x212 — CoolingPumpStatus (sent, 1 Hz)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0 | Driver fault input level | u8 | Raw pin level: 0 = fault asserted, 1 = healthy |
This reports the driver's fault line only. It does not say whether the pump is currently enabled — that follows from the BMS discharge state on 0x107.
0x213 — SteeringAngle (sent, 10 Hz)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0–1 | Position | i16 LE | −1000 full left to +1000 full right, 0 = centre. Held at 0 whenever the calibration is not valid. |
| 2–3 | Averaged raw ADC code | u16 LE | 0–4095, 12-bit |
| 4 | Status bits | u8 | See below |
| Bit | Name | Meaning |
|---|---|---|
| 0 | CalValid | Calibration present and plausible; the reported position is meaningful |
| 1 | CalMissing | Nothing stored yet, or every stored record is corrupt |
| 2 | CalInvalid | Stored but incomplete or implausible |
| 3 | OutOfRange | Raw reading is outside the calibrated travel; position is clamped |
| 4 | StorageError | The last write to persistent storage failed; sticky until one succeeds |
| 5 | NotConnected | Presence pin reads high, so no sensor is plugged in |
Bits 0, 1 and 2 are mutually exclusive. Bit 5 is independent of the others: a stored calibration stays valid while the sensor is unplugged.
0x214 — SteeringAngleCalibration (received)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0 | Command | u8 enum | 1 = CaptureLeft, 2 = CaptureCenter, 3 = CaptureRight, 4 = Clear |
Remaining bytes are ignored. No calibration values travel over the bus — the board captures whatever the sensor reads at the moment the command arrives.
0x215 — FlowSensorIn (sent, 1 Hz)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0–1 | Flow rate | u16 LE | mL/min |
| 2–3 | Water temperature | i16 LE | Hundredths of a degree Celsius. −32768 means the NTC reads open or shorted. |
| 4–5 | Raw pulses | u16 LE | Pulses counted in the last one-second window |
| 6–7 | Raw ADC code | u16 LE | 0–4095, 12-bit NTC reading |
0x217 — MotorTemperature (sent, 1 Hz)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0–1 | Motor temperature | i16 LE | Hundredths of a degree Celsius. −32768 means the NTC reads open or shorted. See #Known gaps. |
| 2–3 | Raw ADC code | u16 LE | 0–4095, 12-bit |
0x218 — SteeringAngleCalibrationAck (sent)
| Byte | Field | Type | Values |
|---|---|---|---|
| 0 | Echoed command | u8 | As received on 0x214
|
| 1 | Result | u8 enum | 0 = ok, 1 = storage error, 2 = stored but the set is not yet usable |
| 2–3 | Captured raw ADC code | u16 LE | 0 for Clear |
| 4 | Captured endpoints | u8 bits | 0x01 = left, 0x02 = centre, 0x04 = right |
| 5 | Status bits | u8 | Same encoding as byte 4 of 0x213
|
0x107 — BMS TemperaturesAndStates (received)
Only byte 7 is used by this board.
| Byte | Field | Type | Values |
|---|---|---|---|
| 7 | Discharge state | u8 enum | 0 = Init, 1 = Idle, 2 = PreChargeOn, 3 = On, 4 = PreChargeTimeout, 5 = Error |
The cooling pump is enabled only while this byte reads 3.
Back-foil servo behaviour
State machine
| State | Meaning | Setpoints | Motor |
|---|---|---|---|
| 0 Uninitialized | Boot state. No absolute position known. | Ignored | Driver disabled, shaft free |
| 1 Operational | Homed and following setpoints. | Followed | Energized, hold current at standstill |
| 2 Homing | Running the homing sequence. | Ignored; the latest is picked up on entering Operational | Energized, reduced current |
| 3 FailSafe | Setpoint watchdog expired; parked at setpoint 1000. | Ignored, latched | Energized, holding |
| 4 Fault | See the fault causes below. | Ignored, latched | Holding if the recovery re-home succeeded, disabled otherwise |
Transitions:
- Initialize on
0x021from any state moves to Homing. This is the only way out of FailSafe and Fault — recovery is a deliberate operator action, never automatic. - Homing success moves to Operational and the watchdog starts immediately. If no setpoint arrives within 2 seconds the servo parks in FailSafe.
- Homing failure moves to Fault with the driver disabled.
- Watchdog: in Operational, every valid setpoint re-arms a 2-second timer. On expiry the servo moves to setpoint 1000 and latches FailSafe.
- Stall while moving: if DIAG trips during a move the step count can no longer be trusted, so the servo latches Fault (StallDuringMove) and immediately re-runs homing to park, holding, at the failsafe position. It stays in Fault until Initialize.
Homing sequence
- Read the driver's
IFCNTregister over UART, three attempts. No response gives Fault (DriverNoUartResponse) with the driver left disabled. - Write the five configuration registers, then read
IFCNTagain and require it to have advanced by exactly 5. A mismatch gives Fault (DriverError) — this catches a driver that answers but is not actually accepting writes. - Enable the driver and step at constant speed toward the home stop until DIAG trips. The StallGuard load value is logged about every 100 ms for threshold tuning.
- If 1.2× the full travel is stepped without a stall, give up with Fault (HomingTimeout).
- Back off 200 microsteps from the stop. That position is defined as position 0, which is setpoint 1000. Switch to the normal run current and go Operational.
Fault causes
| Value | Cause | What to check |
|---|---|---|
| 0 | None | — |
| 1 | StallDuringMove | Mechanism jammed, or the StallGuard threshold is too sensitive. The servo re-homed itself and holds at 1000. Clear the jam, then send Initialize. |
| 2 | HomingTimeout | No stall found: motor unplugged, driver unpowered, threshold too insensitive, or the homing direction inverted. |
| 3 | DriverNoUartResponse | TMC2209 not answering: no driver supply, UART wiring, or wrong slave address. |
| 4 | DriverError | UART works but register writes did not stick. |
Tuning
The tuning constants sit in one block at the top of firmware/app/src/servo_rudder.rs, with a bring-up checklist in firmware/docs/rudder-servo.md. The values that must be calibrated against the real mechanics before the servo is trusted are the full travel in microsteps, the StallGuard threshold, and the direction level that moves toward the home stop.
A quick bench sweep:
cansend can0 021#00 # Initialize (home)
cansend can0 010#E803 # setpoint 1000
cansend can0 010#D007 # setpoint 2000Steering angle calibration
Procedure
Calibration is captured live — no values are sent over the bus. Move the steering to a reference position, then send the matching command on 0x214. The board averages readings over 200 ms and stores the result immediately, so a session can be interrupted and resumed.
- Steer fully left, send
0x01 - Steer to centre, send
0x02 - Steer fully right, send
0x03
Order does not matter. Hold the steering still for the 200 ms capture. The calibration becomes active on the next sample once all three points are captured and plausible. 0x04 clears it and returns the board to the uncalibrated safe state.
cansend can0 214#01 # capture full left
cansend can0 214#02 # capture centre
cansend can0 214#03 # capture full right
cansend can0 214#04 # clearA calibration is rejected, and the reported position forced to 0, when any of the three points is missing, when a raw code exceeds 4095, when the center does not lie strictly between the two endpoints, or when either half of the travel is narrower than 100 ADC codes. Both wiring polarities are accepted — full left may read either above or below full right.
Where it is stored
The STM32L471 has no data EEPROM, so a dedicated 4 KB block at the very end of flash emulates one. It sits outside the application partition, so a firmware update over CAN leaves the calibration intact, and it is in the other flash bank from the executing code, so writing it never stalls the processor. The block is an append-only log of 256 records, each with a magic number, version and CRC. The newest record that validates wins on load; when the log fills, the block is erased and writing restarts.
Noise rejection
The potentiometer signal is electrically noisy — the stepper driver and the pump are both close by. Two layers of averaging handle it, because either alone leaves a gap:
- The ADC averages 16 conversions per read in hardware. That suppresses white noise by 4×, but the burst spans only about 52 µs, so it does nothing for interference slower than roughly 20 kHz.
- Twenty reads are spread evenly across each 100 ms reporting window and box-averaged. Averaging over exactly one window puts nulls at 10 Hz and every harmonic, which is what rejects the periodic interference from the stepper and pump.
Together that is 320 conversions per report for about 1 % ADC duty cycle and roughly 18× white-noise rejection, at the cost of 50 ms of group delay. Calibration captures average 40 reads over 200 ms, so an endpoint is never set by a single noisy moment.
Firmware update over CAN
The first 80 KB of flash holds a CAN bootloader, so the board can be updated in place without opening the compartment or attaching a debug probe. The bootloader validates the application on boot (magic number plus CRC32) and auto-boots it two seconds after the last command addressed to this board.
Flash layout
Region Address Range Size Description
-------- -------------------------- ----- --------------------------
BOOT 0x08000000 - 0x08013FFF 80K Bootloader
HEADER 0x08014000 - 0x080147FF 2K Application metadata header
APP 0x08014800 - 0x080FEFFF 938K Application firmware
CONFIG 0x080FF000 - 0x080FFFFF 4K Emulated EEPROM (steering calibration)Addressing
Because several boards share the bus, each board type owns its own block of three identifiers derived from its application type, and the bootloader's hardware filter rejects the other blocks outright. This is what stops an erase command aimed at one board from wiping the others.
| Board | App type | Command | Response | Write data |
|---|---|---|---|---|
| Rudder controller | 0x01 | 0x031 |
0x032 |
0x033
|
| Height-sensor controller | 0x02 | 0x034 |
0x035 |
0x036
|
| Dashboard | 0x03 | 0x037 |
0x038 |
0x039
|
0x030 is a discovery broadcast. The host sends a state query on it and every board answers on its own response identifier, which is how the bus is enumerated. Answering discovery does not extend a board's auto-boot window, so repeated scanning cannot pin boards in the bootloader.
A running application answers state and version queries too, and rejects everything destructive. That is how the host tool tells a bootloader from a running application on one identifier, and why updating a board reboots it first rather than erasing under a live application.
Using the flash tool
Set up the interface on the host once:
sudo ip link set can0 type can bitrate 1000000
sudo ip link set can0 upThen:
eoi-flash-tool scan # what is on the bus?
eoi-flash-tool flash rudder-controller # target comes from the ELF
eoi-flash-tool --board rudder-controller version # which build is on it?
eoi-flash-tool --board rudder-controller reboot # reset just this boardflash needs no board argument: the target is read from the firmware image itself, so pointing the tool at a file can only ever address the board that file was built for. The other boards keep running untouched.
The bootloader itself can only be replaced over SWD, so it routinely sits several commits behind the application it boots. The version command reports which of the two answered.
Diagnostics
| Symptom | Meaning |
|---|---|
| Green LED toggling once per second | Application running normally |
| Red LED double-flashing | Sitting in the bootloader, waiting or flashing |
| No LED activity | No power, or a boot hang |
| On the bus but dropping frames | Suspect the 16 MHz crystal; the firmware falls back to the internal oscillator, which is not accurate enough for 1 Mbit/s |
0x020 state stuck at 0 |
The servo has never been homed since power-up. Send Initialize. |
0x020 state 3 (FailSafe) |
No setpoint arrived for 2 seconds. Whatever commands the foil has stopped talking. |
0x213 position stuck at 0 |
Steering calibration missing or invalid; read the status byte |
0x213 status bit 5 set |
Steering sensor not plugged in |
0x212 byte 0 reads 0 |
Pump driver is reporting a fault |
A 4-second independent watchdog runs in hardware and is petted once per second by the heartbeat task, so wedged firmware resets itself rather than sitting silent on the bus. Detailed logging is available over SWD through defmt.
Known gaps
- Outlet flowmeter is not read.
0x216is allocated and J2 is fitted, but its NTC line shares a pin with the motor NTC. Resolving this needs a board change, not a firmware change. - Servo tuning is not finished. The full travel in microsteps, the StallGuard threshold and the homing direction are all still marked for calibration against the real mechanics in the firmware. Until they are measured, homing behaviour on the real mechanism is not guaranteed.
- The rotary switch is not read. A sealed 4-bit hex switch is fitted and wired to PB12–PB15, but no firmware uses it. It was presumably intended for a node address or a variant selection.
- The USB console is not used. The USB-C port and its FT234XD bridge are wired to USART1 (PA9/PA10), but the firmware logs over SWD instead. The port currently only supplies its own 3.3 V rail.
- The bus-wide reference is out of date for this board. CAN_MESSAGES.md still describes
0x213as an angle in degrees over 4 bytes and0x214as reserved. The firmware sends a normalized ±1000 position over 5 bytes with a status byte, and the calibration protocol is implemented. This page follows the firmware. - Motor specification mismatch. The Rudder page lists the trim actuator as an 11HS12-0674S-PG5 (5:1); the firmware is tuned for an 11HS12-0674D-PG14 (13.73:1). One of the two is wrong, and it changes the travel and speed figures.
See also
- Rudder — the propulsion unit this board lives in
- CAN-bus — bus wiring, cable pinout and the bus-wide identifier map
- Battery — source of the discharge state that gates the cooling pump
- Foils — what the back-foil trim actuator is for
- Instrumentation Panel — where these measurements are displayed
- Datalogger — where they are recorded
- Altium PCB — house PCB design conventions