UNIMOC (UNIversal MOtor Control) is a platform-independent
C++ library and Zephyr RTOS firmware for
Field-Oriented Control (FOC) of multi-phase electric motors. It targets
embedded micro-controllers (STM32 series, first the STM32G473 battery case
controller). The algorithm code is portable and tested with Zephyr's ztest
and twister on native_sim.
Zephyr is the primary OS: logging, Cyphal/CAN FD, and settings run in
Zephyr threads. Only the FOC hot path runs in zero-latency interrupts. The
same firmware runs on Linux through Zephyr's native_sim with a motor
simulation.
- Features
- Supported Motor Types
- Library Structure
- Cyphal (UAVCAN v1) Interface
- NVM-Backed Settings
- Hardware Bring-Up Guide
- Getting Started
- Contributing
- License
| Category | Capability |
|---|---|
| Motor types | PMSM, ASM (induction), EESM (wound-rotor synchronous) |
| Observers | Back-EMF / PLL (PMSM), HFI sensorless (IPMSM), ASM rotor-flux Luenberger, EESM excitation LPF |
| Control | MTPA, field-weakening, ASM flux PI, EESM excitation PI (current or flux mode) |
| Position | Absolute multi-turn tracker (±4096+ rev), cascaded P+PI position controller, homing state machine |
| Modulation | Space Vector PWM (SVPWM), dead-time compensation |
| Network | Cyphal (UAVCAN v1) over CAN FD — every setting a register, arming, setpoints, telemetry, traces (cymon) and the log on the bus, also for the simulation (see Cyphal) |
| Persistence | Live RAM settings for tuning; one flash entry per changed register (Zephyr settings on ZMS); saving only while the power stage is disabled |
| Platform | HAL-free algorithms; tested on Linux x86-64 with GTest; Zephyr firmware for the BCC (STM32G473) and native_sim |
| Runtime | Zephyr threads for logging, Cyphal, and settings; zero-latency IRQs only for the FOC hot path |
Surface-mount and interior-PM motors. Control path: MechanicalObserver (back-EMF PLL) → MTPA → FieldWeakening → SVM. Low-speed sensorless: HFI observer (requires inductance saliency, IPMSM only).
Squirrel-cage induction motors. Control path: AsmFluxObserver (Luenberger) → AsmFluxController (PI) → MechanicalObserver (shared PLL) → SVM.
Wound-rotor synchronous motors with external excitation (slip-rings or brushless exciter). Stator control path identical to PMSM. Additionally:
- ExcitationController — PI loop on rotor current I_f (CurrentMode) or flux ψ_f = L_m · I_f (FluxMode).
- ExcitationObserver — first-order low-pass filter on measured I_f → adaptive ψ_f estimate fed into MTPA.
- Excitation setpoint and mode received via Cyphal subject.
lib/
├── motor_control/
│ ├── src/
│ │ ├── asm_flux_controller.hpp # ASM rotor-flux PI controller
│ │ ├── dead_time_compensation.hpp # Inverter dead-time compensation
│ │ ├── excitation_controller.hpp # EESM rotor excitation PI (current / flux mode)
│ │ ├── field_weakening.hpp # Voltage-headroom field-weakening integrator
│ │ ├── mtpa.hpp # Maximum-Torque-Per-Ampere (IPMSM)
│ │ ├── position_controller.hpp # Cascaded P+PI position/speed controller + homing FSM
│ │ ├── svm.hpp # Space Vector PWM modulator
│ │ ├── asm_flux_observer.hpp # ASM full-order Luenberger rotor-flux observer
│ │ ├── excitation_observer.hpp # EESM excitation current LPF -> flux estimate
│ │ ├── hfi.hpp # 4-step High-Frequency Injection (IPMSM standstill)
│ │ ├── mechanical_observer.hpp # Back-EMF observer + PLL (PMSM / shared with ASM)
│ │ ├── position_tracker.hpp # Absolute multi-turn position + homing
│ │ └── *_system.hpp # Rotor, stator, and three-phase coordinate systems
├── cyphal/
│ ├── can_media.hpp # CAN interface boundary (frames in and out)
│ ├── canard_transport.hpp # Cyphal/CAN transport on libcanard + O1Heap
│ ├── control_mode.hpp # Cyphal setpoint-to-control-mode mapping
│ ├── cyphal_interface.hpp # Register names + subject port IDs + command codes
│ ├── node.hpp # Node services: heartbeat, GetInfo, registers, commands, PnP, log
│ ├── node_identity.hpp # Node name + hw/sw version
│ ├── register_description.hpp # Unit, limits, effect and help of a register (registers.json)
│ ├── register_value.hpp # uavcan.register.Value helpers, register provider boundary
│ └── transport.hpp # Transfer-level transport contract (CAN, later UDP, tests)
├── settings/
│ ├── nvm_settings.hpp # Typed aggregate of NVM-backed parameters
│ ├── settings_registers.hpp # Register table with descriptions + byte encoding of one value
│ ├── settings_profile.hpp # Immutable target capabilities and defaults
│ ├── settings_storage.hpp # Platform key/value storage contract
│ ├── settings_store.hpp # Live settings, snapshot, save gate, authorized mutation API
│ └── unit_symbol.hpp # SI symbol of a unit type (registers.json)
├── system/
│ ├── rotor_angle.hpp # Q1.31 electrical angle + revolution counter, sin/cos LUT
│ ├── rotor_system.hpp # d/q rotating reference frame
│ ├── stator_system.hpp # α/β stationary reference frame
│ └── three_phase_system.hpp # Three-phase (a/b/c) vector
└── units/
└── units.hpp # SI units with dimension exponents
The firmware is one Zephyr application (app/). At 32 kHz PWM the execution
contexts are (details in docs/hotpath_plan.md):
| Context | Runs | Latency |
|---|---|---|
| Sample ISR (ADC end of injected sequence, every half PWM period, 64 kHz) | Raw current copy, overcurrent check on raw counts | Zero-latency IRQ |
| Control cycle (every fourth sample, 16 kHz) | FOC hot path: mean current, Park, PI once, four duty entries (inverse Park, HFI step, SVM), entry 0 into the CCR preload, entries 1 to 3 by DMA | Zero-latency IRQ |
| Observer (after each control cycle, 16 kHz) | Flux observer, Kalman, HFI, angles of the next cycle (SlowUpdate) |
Software-pended normal-priority IRQ |
| Zephyr threads | Speed and position control (about 2 kHz, planned), logging (RTT), the Cyphal node, settings, startup sequencing | Normal scheduling |
Zero-latency handlers may not call kernel APIs, log, or lock. The
hardware-specific parts are selected by the board and Kconfig, not by
#ifdef. The integration status and the open items are in
docs/zephyr_port_plan.md and
docs/zephyr_workflow.md.
UNIMOC is configured, commanded and traced over
Cyphal v1.0 on CAN FD. The same node code runs on
the battery case controller (FDCAN3) and on native_sim; only the CAN
interfaces and the simulation registers are platform specific.
Status: integrated and tested on
native_sim(ztest scenariosunimoc.lib.cyphalandunimoc.sim.cyphal_drive, and by hand with yakut). The BCC builds with the node on FDCAN3 but has not run on the board; its SN65HVD230 is a classic CAN transceiver, so CAN FD frames run at 1 Mbit/s without bit rate switch (boards/unimoc/bcc/bcc.dts). Not implemented yet: firmware update over Cyphal. Speed, position and UDRAL servo setpoints run the outer loop in the control thread; the startup FSM runs over theunimoc.startup.*registers. The cymon traces run on both platforms; their cost in the control cycle is not yet measured on the board.
| Layer | Where | Content |
|---|---|---|
| DSDL | dsdl/unimoc/, lib/cymon/dsdl/cymon/ + OpenCyphal public types |
Vendor types unimoc.drive.CurrentSetpoint.0.1, unimoc.drive.Telemetry.0.1, unimoc.sim.Plant.0.1, the cart types unimoc.cart.*.0.1, the trace types cymon.*.0.1; Nunavut generates the C serialization at configure time |
| Transport | lib/cyphal/canard_transport.* |
libcanard 4.0 with an O1Heap arena, one TX queue per CAN interface (redundant interfaces, mixed MTU) |
| Node | lib/cyphal/node.* |
Heartbeat, uavcan.node.port.List, GetInfo, uavcan.register.List/Access, ExecuteCommand, plug-and-play (uavcan.pnp.NodeIDAllocationData.1.0), uavcan.diagnostic.Record, application services on register-assigned IDs (AddServer()) |
| Drive application | src/drive/ |
Settings registers, arming, setpoints, telemetry, commands, the cart wheel drive (cart_control.*) |
| Traces | src/trace/, lib/cymon/ (cymon-lib 0.2) |
cymon services, float mirror of the control state sampled in the control cycle |
| Zephyr glue | hardware/zephyr_common/ |
Cyphal thread, CAN interface on the chosen zephyr,canbus, log backend to uavcan.diagnostic.Record |
| Platform | hardware/zephyr_bcc/, hardware/zephyr_sim/ |
BCC: transceiver pin. Sim: slcan pseudo terminals, plant registers and plant trace, the register description file (register_file.cpp) |
libcanard, O1Heap and the public regulated data types are pinned in
west.yml; Nunavut is a Python requirement
(pip install -r unimoc/scripts/requirements-cyphal.txt).
uavcan.node.id is 0 on a fresh BCC: the node then requests an ID with
uavcan.pnp.NodeIDAllocationData.1.0 (an allocator such as
yakut monitor --plug-and-play <db> answers) and stores the allocated ID.
The simulation starts with node-ID 10 (--node-id=<n> overrides it per
process, --cart-wheel=<n> the cart wheel). GetInfo reports the name from uavcan.node.description, the
hardware version of the platform, the software version from app/VERSION, the
git hash as software_vcs_revision_id and the hwinfo device ID as unique-ID
(also the register uavcan.node.unique_id). A changed node-ID or port-ID
takes effect after a restart.
Every persistent setting (ForEachRegister() in
lib/settings/settings_registers.hpp, 130 registers) is a Cyphal register
under the same name; yakut rl <node> lists them. Units and floats are
real32[1], counts and enums natural8[1], uavcan.node.id and the port-IDs
natural16[1], the name a string; writes accept any numeric type. A write is
validated against the hardware capabilities and changes the RAM settings
only; a rejected write answers with the unchanged value. The drive applies
new settings to the controller while it is disengaged (at the latest when it
engages). While engaged, the gains of the current, speed and position loops
(unimoc.control.current.*, unimoc.control.speed.*, the position gains,
limits and tolerances), the telemetry rate and the command timeout take
effect at once without a restart (live tuning); any other change waits for
the disengage and also holds back live tuning until then. Such a write is
reported with a uavcan.diagnostic.Record warning ("settings wait for the
disengage: ", then "waiting settings applied"), and the read-only
unimoc.settings.pending (bit) reads 1 while changes wait. ExecuteCommand STORE_PERSISTENT_STATES (65530) saves them to
flash, refused (BAD_STATE) while the power stage is enabled.
Every register is described next to its definition: the settings in
ForEachRegister(), the other registers in their provider
(RegisterProvider::Describe()). The description holds the unit (taken from
the unit type of a setting where it has one), the limits of the validation
that hold for the register alone, when a write takes effect (immediate,
also while engaged; disengaged; restart) and one line of help. The
simulation writes all of it as JSON and exits:
unimoc/app/build/sim/zephyr/zephyr.exe -flash=/tmp/fresh.bin --describe-registers=registers.jsonOne entry per register in the order of uavcan.register.List: name, value
type and size, mutable, persistent, the value of a fresh simulated node as
default, unit, min, max, applies and help; the schema is in
docs/settings_plan.md. The firmware CI and every release attach it as
unimoc-registers-<version>.json. Tools such as
cypilot pick it by the software
version and VCS revision of GetInfo. The limits are a hint: the node's
validation decides, also on limits between registers or of the hardware
(named in the help).
| Register | Default | Type | Function |
|---|---|---|---|
uavcan.sub.readiness.id |
65534: 1024 + node-ID | reg.udral.service.common.Readiness.0.1 |
ENGAGED (3) releases the power stage, SLEEP/STANDBY disable it; one subject per drive (below) |
uavcan.sub.current_setpoint.id |
107 | unimoc.drive.CurrentSetpoint.0.1 |
d/q current setpoint [A] |
uavcan.sub.torque_setpoint.id |
100 | uavcan.si.unit.torque.Scalar.1.0 |
Torque [N m], converted to i_q with 3/2 p ψ_PM |
uavcan.sub.speed_setpoint.id |
101 | uavcan.si.unit.angular_velocity.Scalar.1.0 |
Mechanical speed [rad/s], speed loop of the control thread, limited to the motor speed range |
uavcan.sub.position_setpoint.id |
102 | uavcan.si.unit.angle.Scalar.1.0 |
Mechanical position [rad] from home (the estimate at start) |
uavcan.sub.servo_setpoint.id |
108 | reg.udral.physics.dynamics.rotation.Planar.0.1 |
UDRAL servo: the commanded (non-NaN) field selects position, speed or torque |
uavcan.pub.telemetry.id |
212 | unimoc.drive.Telemetry.0.1 |
Current controller trace at unimoc.telemetry.rate (20 Hz) |
uavcan.sub.wheel_command.id |
110 | unimoc.cart.WheelCommand.0.1 |
Cart: torque per wheel from the VDC; the drive takes unimoc.cart.wheel |
uavcan.sub.pedal_state.id |
111 | unimoc.cart.PedalState.0.1 |
Cart: pedals for limp-home mode |
uavcan.sub.battery_status.id |
112 | unimoc.cart.BatteryStatus.0.1 |
Cart: current limits of the BMS, replace unimoc.battery.limits.* while fresh |
uavcan.pub.drive_status.id |
214 | unimoc.cart.DriveStatus.0.1 |
Cart: wheel speed, torque, drive and brake torque limits, DC link, flags at unimoc.cart.status_rate (50 Hz) |
uavcan.pub.sim_plant.id (sim only) |
213 | unimoc.sim.Plant.0.1 |
Plant truth: phase currents, shaft angle and speed, torque, Hall code |
uavcan.srv.cymon_variables.id |
120 | cymon.GetVariableList.0.1 |
Trace variables: index -> name and unit |
uavcan.srv.cymon_buffer_info.id |
121 | cymon.GetBufferInfo.0.1 |
Capture limits and the tick period (62.5 us) |
uavcan.srv.cymon_setup.id |
122 | cymon.SetupCapture.0.1 |
Channels, sample period, samples, pre-trigger depth |
uavcan.srv.cymon_arm.id |
123 | cymon.ArmTrigger.0.1 |
Arm (rising, falling, hysteresis, immediate) or disarm |
uavcan.srv.cymon_read.id |
124 | cymon.ReadSamples.0.1 |
Capture state; once complete, the frames page by page |
uavcan.pub.cymon_trigger.id |
65535 | cymon.TriggerEvent.0.1 |
Published when a capture triggers (disabled by default) |
uavcan.sub.cymon_trigger.id |
65535 | cymon.TriggerEvent.0.1 |
Triggers the armed capture on another node's event (disabled by default) |
Each port has a read-only .type register. Two enabled ports may not share
a subject-ID (or two services a service-ID): such a write is rejected, so to
swap two IDs, disable one port (65535) first. The cymon trigger is the
exception: publishing and subscribing one shared trigger subject lets every
node of a bus trigger the others. Readiness and setpoints must be
refreshed within unimoc.control.command_timeout (0.5 s): a missing
readiness disengages the drive, a missing setpoint falls back to zero
current. The current vector is limited to unimoc.motor.limits.i_max.
The battery current is limited as well, in every mode: the drive estimates
it from the mechanical power of the q current (k_t i_q ω / V_dc, losses
ignored) and scales i_q to unimoc.battery.limits.drive_current when
motoring and to unimoc.battery.limits.charge_current when braking. The
charge limit falls linearly from unimoc.battery.limits.regen_derate_voltage
(55 V) to zero at unimoc.battery.limits.trip_voltage (58 V, at most the
hardware's 60 V), where the control cycle trips the power stage. The trip
switches all transistors off: above the line-to-line back-EMF peak the
motor still feeds the DC link through the body diodes, so the trip level
must stay above the battery voltage and a brake chopper has to hold the
DC link (section 7.1 of docs/system_plan.md). The BCC has none (an
accepted risk); the new 4850 hardware gets a fourth half bridge that
drives a brake resistor with a PMSM or the excitation winding of an EESM
(section 5.2.1). The BCC voltages
are accepted values, to be checked against its schematic during bring-up.
The cart messages (dsdl/unimoc/cart/, section 6.2 of
docs/system_plan.md) make the drive one wheel of the cart:
- Wheel command. The first wheel command after the engage makes the
drive a cart drive until it disengages: the torque of its wheel
(
unimoc.cart.wheel, 0 front left to 3 rear right;unimoc.cart.reversedfor a mirrored wheel) replaces the other setpoints and applies on reception, limited like a torque setpoint. Arming stays with the readiness. - Limp-home. Without a wheel command for
unimoc.cart.timeout(0.1 s, at most the command timeout) the torque comes from the pedal state: brake pedal first, against the motion and fading out belowunimoc.cart.standstill_speed(1 rad/s), drive pedal forward only, both up tounimoc.cart.limp_home_torque(5 N m) and ramped atunimoc.cart.torque_ramp(50 N m/s). The readiness timeout waits during limp-home. Without the pedals too the torque ramps to zero and then the readiness timeout disengages. A fresh wheel command takes over again only at standstill. - Battery limits. A fresh battery status replaces
unimoc.battery.limits.drive_currentand.charge_currentby the BMS limits timesunimoc.cart.battery_share(the drives of one battery share them); with the contactor open or precharging the drive takes no battery current. Without the BMS the registers apply again. - Drive status. Wheel speed and torque estimate in the cart frame, the torque the drive can apply now with and against the motion (motor current limit and battery power window), DC-link voltage and estimated battery current, temperatures in K (NaN when not measured), the power stage, engaged, limp-home, regen derated and trip flags.
- Bus-off. The CAN controller leaves bus-off on its own; the drive
drops every cart input at once (the torque ramps to zero), counts it in
unimoc.can.bus_off_countand logs the return to the bus.
An overcurrent or overvoltage trip or a power stage fault while engaged disengages the drive;
it engages again only after a readiness other than ENGAGED (re-arm).
STANDBY while disengaged acknowledges a latched trip or fault without
releasing the outputs; the settings can only be stored without one.
With CONFIG_UNIMOC_EBIKE=y (off by default) the drive becomes the motor of a
pedal-assist e-bike (docs/ebike_plan.md). The crank torque and cadence sensor (E-Rider
T9N) and the display (KT-LCD8, Kunteng protocol) feed the assist law (lib/ebike/, src/ebike/):
- Low speed: the motor torque follows the pedal torque (
unimoc.ebike.torque_ratio); above the crossover speed the motor power follows the pedal power. The assist ratio is the base ratio of the display level (unimoc.ebike.gain_1togain_5) timesexp(pedal power / unimoc.ebike.power_scale), limited tounimoc.ebike.max_ratio. It fades out below the speed limit of the display, stays within the torque limit of the drive and ramps (rise_time,fall_time). - The e-bike control engages the drive on demand: while the crank turns at a display level above 0, and for
unimoc.ebike.engage_holdafterwards. The readinessENGAGEDof the bus no longer engages it;SLEEPandSTANDBYrelease it and keep it released until the pedals stood still forunimoc.ebike.rearm_time, as a trip or a refused engage do. Without a display request forunimoc.ebike.display_timeoutthe level counts as 0. - Registers (all persistent):
unimoc.ebike.pulses_per_rev,wheel_circumference[m],gear_ratio,battery_cells,nominal_voltage,gain_1togain_5,power_scale,max_ratio,torque_ratio,torque_deadband,min_cadence,speed_taper,rise_time,fall_time,engage_hold,rearm_time,display_timeout; the direction isunimoc.cart.reversed. A change while engaged waits for the disengage. The defaults are untuned starting values: try them on the bench first. - BCC wiring (from the schematic): the display transmit line is PB10 (USART3_TX); the display receive line is on PB15, which has no UART receiver on the STM32G473, so TIM15 channel 2 captures its edges and a software receiver decodes the bytes; the cadence pulse is PA5; the crank torque is a channel of the injected ADC sequence behind a 2/3 divider.
native_sim: a simulated rider and display (hardware/zephyr_sim/sim_ebike.hpp) drive the control in the tests (sim_cyphal_driveattaches the control itself); the simulation app keeps the bus readiness for the pytest integration tests, soCONFIG_UNIMOC_EBIKEis off there too.
The factory value of uavcan.sub.readiness.id is 65534, "follows the
node-ID": the drive subscribes the readiness on subject 1024 + node-ID (1034
for node 10), so each drive of a bus is armed on its own subject, and a tool
or the VDC arms several drives by publishing on each of their subjects.
Writing another subject-ID fixes it (65535 disables it), writing 65534
returns to the node-ID; the register keeps the written value. A drive
without a node-ID (plug-and-play) subscribes once the ID is allocated. Two
ports on one subject are rejected, including the readiness subject of the
node-ID. Settings saved before this scheme keep their readiness subject
until a factory reset.
The vendor-specific status code of the heartbeat gives the drive state
without any other subscription (cyphal::vendor_status,
docs/gui_plan.md, section 3):
| Bits | Meaning |
|---|---|
| 0 to 2 | Power stage: 0 disabled, 1 enabled, 2 fault, 3 blocked (external enable missing) |
| 3 | Engaged |
| 4 | Trip latched (overcurrent or overvoltage); STANDBY acknowledges it |
| 5 | Emergency stop latched until a restart (health CAUTION as well) |
| 6 | Settings wait for the disengage (unimoc.settings.pending) |
| 7 | Local control: the e-bike control engages the drive, the bus readiness does not |
| Command | Effect |
|---|---|
| 65535 RESTART | Disengages and restarts (the simulation re-executes itself) |
| 65532 FACTORY_RESET | Factory settings in RAM and flash; refused while engaged |
| 65531 EMERGENCY_STOP | Disengages; engaging is refused until a restart |
| 65530 STORE_PERSISTENT_STATES | Saves the RAM settings to flash; refused while engaged |
The drive records up to 8 signals of the control cycle into a RAM ring
buffer (8 KB, CONFIG_UNIMOC_TRACE_*) and a tool reads the capture back over
the bus: the cymon protocol of cymon-lib (lib/cymon, 0.2),
driven by the Scope page of the host tool
cymon. The control cycle copies its
state into a float mirror (src/trace/trace_signals.hpp) and samples it with
cymon::Device::Tick() at 16 kHz (zero latency: no lock, no kernel call, no
function pointer); the services run in the Cyphal thread
(src/trace/trace_node.*).
| IDs | Variables |
|---|---|
| 0..11 | i_d, i_q, i_d_ref, i_q_ref [A], u_d, u_q [V], electrical_angle [rad, 0 to 2 pi], electrical_speed [rad/s] (observer, one cycle late), dc_link_voltage [V], i_a, i_b, i_c [A] (newest sample) |
| 12.. (sim only) | Plant truth: sim.i_a..sim.i_c, sim.i_d, sim.i_q, sim.electrical_angle, sim.electrical_speed, sim.mechanical_speed, sim.torque |
The sample period is a whole multiple of the control cycle; a capture of 8 channels by 256 frames takes about 0.4 s of bus time at 1 Mbit/s.
With CONFIG_UNIMOC_HOT_PATH_TIMING=y the platform measures the sample
interrupt of each control boundary (including the control cycle), the control
cycle alone and the observer in cycles of the DWT counter (170 MHz on the
BCC; hardware/hot_path_timing.hpp). Read-only registers:
| Register | Type | Content |
|---|---|---|
unimoc.timing.sample_isr, .control_cycle, .observer |
natural32[4] | min, mean, max [cycles], number of records |
unimoc.timing.overruns |
natural32[2] | duty plans applied late, control cycles that started while the observer still ran |
unimoc.timing.clock_hz |
natural32[1] | the timestamp clock |
unimoc.timing.reset |
natural8[1] | write 1: new statistics |
west build -b bcc unimoc/app -d unimoc/app/build/bcc -- -DCONFIG_UNIMOC_HOT_PATH_TIMING=y
yakut call 10 uavcan.register.access '{name: {name: unimoc.timing.control_cycle}}'The times start at the first instruction of the handler (the 12 cycles of
the exception entry are not included). On native_sim the counts are real,
the times are 0: simulated time stands still inside a cycle.
cymon serve -i socketcan:vcan0 --dsdl unimoc/dsdl/unimoc --open # simulation
cymon serve -i canable:/dev/ttyACM0 --dsdl unimoc/dsdl/unimoc --open # BCC, CANable 2.0The bench log of the BCC goes to SEGGER RTT; USB and the shell are not used.
Every Zephyr log line up to CONFIG_UNIMOC_CYPHAL_LOG_LEVEL (info) and the
drive events (engaged, disengaged and why) are also published as
uavcan.diagnostic.Record.
The simulation (native_sim) runs in real time with the PMSM plant and has
two kinds of CAN interfaces:
- SocketCAN
vcan0(CAN FD), the same Zephyr CAN driver path as the board. Needs thevcankernel module;--can-if=<name>selects another interface. - slcan pseudo terminals (classic CAN), for hosts without
vcan(WSL2 kernels without CAN):uart_1touart_4, one tool process each; the terminals form one bus.
pip install -r unimoc/scripts/requirements-cyphal-tools.txt # yakut, python-can
west build -b native_sim/native/64 unimoc/app -d unimoc/app/build/sim
# with vcan (once, as root): modprobe vcan; ip link add vcan0 type vcan;
# ip link set vcan0 mtu 72 up
unimoc/app/build/sim/zephyr/zephyr.exe # prints "uart_1 connected to pseudotty: /dev/pts/N"
. unimoc/scripts/sim_yakut_env.sh # vcan0, or: . unimoc/scripts/sim_yakut_env.sh /dev/pts/N
yakut call 10 uavcan.node.getinfo
yakut rl 10 # all registers
yakut reg 10 unimoc.sim.shaft_mode 1 # imposed speed (0 rad/s: locked rotor)
yakut pub --period 0.05 1034:reg.udral.service.common.Readiness '{value: 3}' \
107:unimoc.drive.CurrentSetpoint '{i_d: 0, i_q: 5}' # second terminal
yakut sub 212:unimoc.drive.Telemetry 213:unimoc.sim.Plant # third terminal
yakut sub uavcan.diagnostic.Record
yakut cmd 10 65530 # store the settings (flash.bin in the working directory)Several drives on one bus: unimoc/scripts/sim_cluster.sh starts N drives
(default 4) on vcan0, drive k with node-ID 10 + k, its own flash file and
unique-ID, and cart wheel k mod 4 (--cart-wheel=<n>, like --node-id, sets
unimoc.cart.wheel of one process); --setup-vcan creates the interface
with sudo, --help lists the options. Ctrl+C stops all of them.
With slcan, each yakut process needs its own terminal (/dev/pts/N,
N+1, ...); the terminal numbers can change when the simulation restarts.
Simulation registers (volatile): unimoc.sim.shaft_mode (0 free, 1 imposed
speed), unimoc.sim.imposed_speed [rad/s], unimoc.sim.load_torque [N m],
unimoc.sim.dc_link_voltage [V], unimoc.sim.external_enable, and
unimoc.sim.plant_follows_settings (the plant takes the motor parameters of
the settings when they are applied).
Deterministic runs: unimoc.sim.paused (bit) stops the cycle timer, also
across a settings restart of the controller; then a write of
unimoc.sim.advance (natural32, at most 16000 = 1 s) runs that many control
cycles before the response. unimoc.sim.cycle_count and
unimoc.sim.cycle_period_ns (62500 at 32 kHz PWM) give the virtual time; the
count restarts with the controller. A write of 1 to unimoc.sim.reset
(refused while the power stage is enabled) puts the plant back to
standstill, the virtual time to 0 and restarts the controller with the
applied settings; register inputs and a pause stay. The drive's readiness and setpoint
timeouts still run in real time, so keep publishing the readiness while
paused.
The rotor angle of the controller comes from its observer only (the Hall
fusion is milestone M5/M6 of docs/hotpath_plan.md); at standstill the
estimate drifts, so the current loop is meaningful in the controller frame
(telemetry), not yet as a torque on the simulated shaft.
All settings are aggregated in unimoc::settings::NvmSettings
(lib/settings/nvm_settings.hpp). Runtime ownership is provided by
unimoc::settings::SettingsStore. The design is:
- Validated by target capability and cross-field checks, before a change becomes live and when the stored registers are loaded. Unusable stored registers are replaced by the target factory profile.
- Complete — every tunable parameter from every controller and observer lives in a single flat struct.
- Live and persistent — operations change the RAM copy at once (live
tuning, no flash access).
SettingsOperations::SaveToFlash()writes only the registers that changed since the last load or save, one flash entry per register under its Cyphal register name; a register that is back at its factory value is erased. It is refused (kPersistBlocked) unless the power stage is disabled, because a flash write can stall the zero-latency control cycle. A register that is not stored has its factory value, so new firmware can add registers without invalidating stored settings. - Factory motor — the motor defaults describe the cart wheel: a 6.5"
36 V hoverboard hub motor with a 3.50-6 pneumatic tyre (15 pole pairs,
0.15 Ω, 0.35 mH, ψ 23 mWb, k_t 0.52 N m/A, 15 A, current PI at about
320 Hz). These are typical values, not a measurement of the actual motor;
the platform profiles leave them as they are and only set the power stage
limits. The simulation runs this motor too; the sim tests that were written
for a smaller motor load it from
tests/common/sim_test_motor.hpp. - Platform-agnostic — the hardware layer supplies an immutable profile and
a key/value storage adapter. The Zephyr platforms use the settings
subsystem on ZMS in the
storage_partition(hardware/zephyr_common/ zephyr_settings_storage.cpp); onnative_simthat is the flash simulator. - Read-only to application code — control code receives a
SettingsSnapshot; only an explicitly passedSettingsOperationscapability can commit changes. - Explicitly serialized — unit wrappers are encoded through their numeric
values as little-endian floats, one value per register
(
lib/settings/settings_registers.hpp). The C++ object layout is not persisted.
#include "hardware_interface.hpp"
#include "settings_store.hpp"
unimoc::settings::SettingsStore settings_store{
unimoc::hardware::kSettings.GetSettingsProfile(),
unimoc::hardware::kSettings.GetSettingsStorage()};
const auto load_status = settings_store.Load();
const auto settings = settings_store.GetSnapshot();
// Application consumers receive only a const settings record.
current_control.Init(settings.Get(), timer_clock_hz);
// Operation endpoints such as Cyphal receive the mutation capability.
const auto operations = settings_store.GetOperations();
operations.SetMotorCurrentLimit(unimoc::unit::Current{20.0F});
if (load_status == unimoc::system::SettingsStatus::kStorageError) {
// Handle a storage failure before enabling the drive.
}The target profile is compiled into the hardware adapter as a const object,
so stock parameters and immutable safety capabilities are included in the
firmware image. The writable settings are separate and are never allowed
to raise those capabilities.
Status: runs on
native_sim(ztestunimoc.sim.cyphal_driveand the pycyphal integration test); not yet run on the board. The FSM is driven by theunimoc.startup.*registers and stepped in the Cyphal thread. Log lines go to the Zephyr log on SEGGER RTT and asuavcan.diagnostic.Recordon the bus.
HwStartup is a step-by-step bring-up wizard for commissioning a new UNIMOC
board. It verifies the ADC signal chain, gate drivers, and DC-link voltage
measurement before any motor is connected, then performs current-sense and
ADC-trigger calibration with the motor attached. All calibration results are
committed to NVM automatically when the sequence completes.
The FSM never advances on its own. Each step completes its measurement,
logs the result through the Zephyr log, then pauses and waits for the user to write 1 to
unimoc.startup.step before moving on. This gives you time to review scope
traces and enter external meter readings.
| Item | Used in step(s) |
|---|---|
| Cyphal/UAVCAN v1 bus + tool (e.g. Yakut, Cymon) | All steps |
| Oscilloscope (optional but recommended) | PWM_DISABLE, DUTY_FORCE_* |
| Digital multimeter — DC voltage | DC_LINK_VOLTAGE_CHECK |
| AC/DC clamp-meter (or shunt + oscilloscope) | CURRENT_SENSE_CALIBRATION |
| Motor with UVW + PE leads, motor shaft free to rotate | Phase 2 steps |
Caution: Do not connect a motor until the
CONNECT_MOTORstep instructs you to do so.
Power on the drive without a motor connected. Using your Cyphal tool, write:
unimoc.startup.run = 1
The firmware logs:
[STARTUP] Hardware bring-up sequence started.
[STARTUP] Phase 1 — NO MOTOR CONNECTED.
[STARTUP] Advance each step by writing 1 to
[STARTUP] unimoc.startup.step
The FSM immediately enters PWM_DISABLE.
All gate-driver outputs are held at 50 % neutral duty. The log confirms the duty is applied and invites you to verify on a scope.
Action: Confirm PWM signals are toggling correctly on the scope, then advance:
unimoc.startup.step = 1
The firmware samples 1024 ADC readings on both current-sense channels (phases A and B) with zero current flowing and computes the mean offset.
The log reports:
[STARTUP] ADC_OFFSET_CAL: offset_a=<value> A, offset_b=<value> A
A warning is printed if either offset exceeds offset_threshold_A (default
0.5 A). The results are stored in StartupResults::adc_offset_a/b and
written to NvmSettings::adc_offset_a/b at the end of the sequence.
Action: Review the offsets. Values > ±0.5 A suggest a hardware problem. Advance:
unimoc.startup.step = 1
1024 samples are collected again. The standard deviation (RMS noise) is
computed for each channel and compared against noise_threshold_A (default
0.1 A).
The log reports:
[STARTUP] ADC_NOISE_FLOOR: rms_a=<value> A, rms_b=<value> A
A warning is printed if noise exceeds the threshold.
Action: Review the noise figures. High noise may indicate a power-supply or layout problem. Advance:
unimoc.startup.step = 1
Three forced-duty steps (5 %, 50 %, 95 %) check that the gate driver and bootstrap circuit produce valid PWM across the full duty range. Each step holds the duty for 1000 samples, then waits.
The log reports the applied duty:
[STARTUP] DUTY_FORCE_LOW: duty=0.05 — verify current/scope. Press NEXT to continue.
[STARTUP] DUTY_FORCE_MID: duty=0.50 ...
[STARTUP] DUTY_FORCE_HIGH: duty=0.95 ...
Action: Observe gate-drive waveforms or check that the DC-link current response looks correct for each duty level. Advance after each step:
unimoc.startup.step = 1
The firmware reads the DC-link voltage ADC channel while the PWM is running. Before advancing you must enter a reference measurement from a voltmeter.
Action:
- Measure the DC-link voltage with a calibrated multimeter.
- Write the reading:
unimoc.startup.ext_vdc_V = <measured voltage, e.g. 48.2> - Advance:
unimoc.startup.step = 1
The firmware computes gain_vdc = ext_vdc_V / adc_vdc and reports it. A
warning is printed if the error exceeds vdc_gain_tolerance (default 2 %).
The correction factor is stored in NvmSettings::adc_gain_vdc at DONE.
The firmware briefly disables then re-enables the gate-driver enable signal while measuring the ADC response on the current-sense inputs. This confirms that the enable pin is wired correctly and that the gate driver is live.
The log reports:
[STARTUP] GATE_DRIVER_ENABLE_CHECK: delta_ia=<value> A — gate driver responding.
A warning is printed if the response is smaller than twice the measured noise floor (gate driver may not be wired or may be faulty).
Action: Advance:
unimoc.startup.step = 1
⚠️ Before advancing past this step:
- Power off the drive.
- Connect the motor (UVW + protective earth).
- Power on again.
- Ensure the motor shaft is free to rotate.
The log prints a prominent warning:
[STARTUP] !! PHASE 1 COMPLETE — PREPARE FOR PHASE 2 !!
[STARTUP] !! ACTION REQUIRED:
[STARTUP] !! 1. POWER OFF the drive now.
[STARTUP] !! 2. CONNECT the motor (UVW + PE).
[STARTUP] !! 3. POWER ON again.
[STARTUP] !! 4. Write 1 to unimoc.startup.step
After reconnecting and powering on, write:
unimoc.startup.step = 1
⚠️ Caution: Phase 2 injects live voltages into the motor windings. An over-current (OC) trip limit equal tooc_fraction × max_phase_current_A(default 10 %) is active throughout. The FSM transitions immediately to FAULT if this limit is exceeded. Independently, the fast ISR trips the power stage at the hard limitmax_phase_current(60 A on the BCC) and latches a fault until it is cleared.
The firmware sweeps the ADC trigger offset by ±5 µs in 0.5 µs steps (21 positions) while injecting a small voltage, measuring the current-sense noise at each position. The position with the lowest noise is selected as the optimal ADC sampling instant.
The log reports:
[STARTUP] PHASE_ADC_ALIGNMENT: best_pos=<n>, optimal_ticks=<value>
[STARTUP] Suggest writing adc_trigger_offset=<value> to hardware HAL.
The optimal offset is stored in StartupResults::adc_trigger_offset_optimal
for the application to commit to the hardware timer configuration.
Action: Note the suggested adc_trigger_offset value. Advance:
unimoc.startup.step = 1
A small voltage (fraction v_cal_fraction of V_dc, default 0.2 %) is forced
on the α-axis to produce a small but measurable current. The firmware reads
the phase-A ADC while a reference clamp-meter provides the true current.
Action:
- Attach a clamp-meter (or calibrated shunt + scope) to phase A.
- Write the clamp-meter reading:
unimoc.startup.ext_current_A = <measured current, e.g. 0.85> - The firmware computes
gain_a = ext_current_A / adc_mean_iaand reports it. A warning is printed if the error exceedsgain_tolerance(default 5 %). - Advance:
unimoc.startup.step = 1
The gain correction factors are stored in NvmSettings::adc_gain_a and
adc_gain_b at DONE.
Note: If
ext_current_Ahas not been written before advancing, the firmware logs a warning and marks the step as a non-fatal skip (no gain correction applied).
When the sequence reaches DONE the firmware automatically commits the
calibration through its authorized SettingsOperations capability:
-
Builds one candidate settings record containing:
NvmSettings field Source adc_offset_aMean phase-A ADC reading at zero current adc_offset_bMean phase-B ADC reading at zero current adc_gain_vdc1 / gain_vdccomputed in DC_LINK_VOLTAGE_CHECKadc_gain_a1 / gain_acomputed in CURRENT_SENSE_CALIBRATIONadc_gain_b1 / gain_bcomputed in CURRENT_SENSE_CALIBRATION -
Validates the candidate and applies it to the live settings as one transaction. A rejected candidate leaves the active settings unchanged and puts the startup FSM into
FAULT. The calibration is not written to flash: save it withSettingsOperations::SaveToFlash()once the power stage is disabled. -
Prints a full summary to the Zephyr log:
[STARTUP] HARDWARE STARTUP AID — SUMMARY [STARTUP] PWM_DISABLE: PASS [STARTUP] ADC_OFFSET_CAL: PASS ... [STARTUP] adc_offset_a = 0.012 A [STARTUP] adc_offset_b = -0.007 A [STARTUP] gain_vdc = 1.003 [STARTUP] gain_a = 0.997 [STARTUP] adc_trig_opt = 176 ticks [STARTUP] NVM calibration fields updated.
Individual step results can also be read back at any time via:
unimoc.startup.results # uint8 array; 1=pass, 0=fail/pending per step index
unimoc.startup.gain_a
unimoc.startup.gain_b
unimoc.startup.gain_vdc
unimoc.startup.adc_offset_a
unimoc.startup.adc_offset_b
To abort the sequence at any time, write:
unimoc.startup.run = 0
The FSM enters FAULT, releases all forced-duty overrides, and applies safe 50 % neutral duty.
To restart from scratch after a fault or abort, write:
unimoc.startup.run = 1
This resets all accumulators and results, then re-enters PWM_DISABLE from the beginning.
Requirements: Zephyr 4.4.0 with the Zephyr SDK 1.0.1 (arm-zephyr-eabi),
CMake ≥ 3.20 (Zephyr's minimum), Ninja, Python 3 with west. On Windows, run the build in WSL2.
The setup steps are in docs/zephyr_workflow.md.
Run these from the west workspace root, which contains unimoc/ and zephyr/:
# Linux host simulation (native_sim)
west build -b native_sim/native/64 unimoc/app -d unimoc/app/build/sim
# Battery Case Controller (STM32G473)
west build -b bcc unimoc/app -d unimoc/app/build/bcc
The BCC has not run on hardware yet.
All tests run under twister on native_sim; there is no hosted test build.
tests/lib/<library>/ (ztest) covers the libraries, tests/sim/ (ztest) runs
the application core against the simulated drive, and
tests/integration/cyphal_sim/ (pytest with pycyphal) drives the running
simulation over Cyphal. From the west workspace root:
# All scenarios on native_sim and bcc, with clang-tidy as static analysis (as in CI)
unimoc/scripts/twister_sca.sh
# clang-format check of all C/C++ sources (--fix to apply)
unimoc/scripts/check_format.sh
# pycyphal integration tests of the simulation (needs scripts/requirements-cyphal-tools.txt)
west twister -T unimoc/app -p native_sim/native/64 --scenario unimoc.app.cyphal_integration
# One suite without static analysis, while iterating
west twister -T unimoc/tests/lib/motor_control -p native_sim/native/64Prebuilt firmware: every CI run uploads the BCC image (unimoc-bcc) and the
native_sim executable (unimoc-native_sim) as artifacts of the
Zephyr workflow.
Releases (below) attach the same files with checksums, the twister report and
the API documentation.
A release is made by merging a pull request from develop into main (merge
commit). Nothing is tagged by hand (docs/release_plan.md):
-
The version is in the Zephyr version file
app/VERSION. While thedeveloptomainpull request is open, the Release prepare workflow commits the next version andCHANGELOG.mdtodevelopand comments the release on the pull request. The labelrelease-as:X.Y.Zforces a version. -
Versions follow semantic versioning and come from the conventional commits of the squash merges into
develop(the pull request title is checked):Title Version (below 1.0) fix: ...,perf: ...,refactor: ...patch feat: ...,feat!: ...minor docs:,test:,ci:,build:,chore:none A change of the DSDL types or of register and subject IDs is always
feat!orfix!, because it changes the bus protocol. -
The merge runs the Zephyr workflow on
main. After the tests and all firmware builds passed, itsreleasejob creates the tagvX.Y.Zand the GitHub release with the change log and every build (BCC,native_sim, later ones),SHA256SUMS, the twister report and the Doxygen HTML. The release is a step of that CI run, so the badge ofmainshows it.
twister_sca.sh needs clang-tidy (LLVM 18) and CodeChecker
(pip install codechecker) in addition to the Zephyr setup;
check_format.sh needs exactly clang-format 22.1.1
(pip install clang-format==22.1.1).
Requirements: Doxygen and Graphviz (dot).
mkdir -p build && doxygen docs/DoxyfileThe generated HTML documentation is placed in build/doxygen/html/ and
contains only UNIMOC-owned C++ code.
Contributions are welcome! Please:
- Fork the repository and create a feature branch.
- Follow the existing code style (
.clang-formatis provided). - Add ztest coverage for new algorithm headers in
tests/lib/. - Run
scripts/check_format.shandscripts/twister_sca.shbefore opening a pull request — CI runs the same two steps. - Open a pull request against
main.
UNIMOC is free software released under the
GNU General Public License v3.0. See the LICENSE file for
the full text.