Building a Quadruped - Part 1: From a Motor to a Leg

Bench notes: one brushless motor, two controllers on a CAN bus, and a five-bar leg

Table of Contents

This is the first part of a build log about creating a quadruped from scratch. This part covers the first steps, ending with driving a planar five-bar linkage with two motors via CAN bus. The problems along the way are covered as well, e.g. encoder cabling, a second motor axis on the controller that exists in software but not on the board, and figuring out the correct settings for getting the motor spinning under all conditions.

About this series

This is a build log and NOT a tutorial. Everything written here is for one specific setup, and the numbers are derived specifically for it.

The code is available on GitHub: github.com/micha-net/tamnan-robotics

Planned parts:

  1. From a motor to a moving leg (this part) - PoC for all parts, first single motor actuation, then two motors via CAN and a leg via a five-bar linkage (without abduction)
  2. Building the full body, by adding abduction to have a full hip, constructing the rest of the body, incl. a new gearbox
  3. Wiring everything up and building the code for the first movement
  4. Getting the robot to walk freely, by adding a battery, lidar and a compute unit, then training an AI model, …

3 steps

The goal for this first part gets broken down into 3 steps:

  1. Single motor actuation. Controlled with a magnetic encoder and a motor control unit, connected via USB.
  2. Two motors via CAN bus. To work towards the final configuration with 12 joints, a proof of concept with 2 motors controlled via CAN bus was created. The bus setup allows for minimal cabling.
  3. Two motors driving one leg. The calculation of the kinematics and link lengths, then building the parts via CAD and printing them.

The setup / test bench

As getting parts to my current location took quite a long time due to shipping and customs, I moved for a month to Shenzhen, to source, test and buy all needed parts in bulk. For assembly and testing I booked a desk at Troublemaker Shenzhen, where all needed tools were available.

A full bill of materials is available at the end. Here is a short overview of the most important parts.

PartDetail
MotorEaglepower LA8308 KV90
ControllerODESC V4.2 - an ODrive v3.6 clone, running ODrive firmware
EncoderAS5047P, 14-bit absolute over SPI (16384 counts/turn)
GearboxOpenQDD V1 for PoC
Power Supply40–1,000 VA bench supply
HostLinux, Python 3.9, odrive package 0.6.11, python-can for the bus

The LA8308 is a 36N40P outrunner with 40 rotor poles and therefore 20 pole pairs. As the ODrive documentation was written for 7-pole-pair motors, a bunch of values had to be re-derived and recalibrated.

The motor is rated for 900 W continuous. For now the motor was run at 24 V, to keep the risk of damage and personal harm low.

The scripts

To get everything running a handful of Python scripts were created along the way, available via the tamnan-robotics repository. The scripts split mainly by transport (USB vs. CAN) for now, rather than by task. The odrive package uses USB only and cannot see the CAN bus at all, while python-can uses the bus and knows nothing about ODrive configuration.

ScriptPurpose
odrive_motor.pyShared core to connect, configure, calibrate, arm, sample
odrive_run.pyRun one motor over USB
odrive_web.pyWeb dashboard to execute and read telemetry
odrive_can_setup_via_usb.pyEnable CAN on one board and give it a node ID
odrive_can_check.pyListen for heartbeats and report what the bus is doing
odrive_can_run.pySpin several motors at once via the bus
leg_kinematics.pyFive-bar geometry, IK/FK, gaits and the cycle check
leg_run.pyFive-bar leg simulation and execution over CAN
leg_web.pyWeb dashboard to simulate the linkage and get back telemetry
bench/Single-purpose diagnostic tools

Everything below runs from the repository root:

git clone https://github.com/micha-net/tamnan-robotics.git
cd tamnan-robotics
uv sync          # Python 3.9.6, the odrive package 0.6.11, python-can

Building the one motor setup

The encoder cabling

As a first test, the motor was run via the controller but without encoder feedback. Afterwards the first task was to get the magnetic encoder connected to the setup.

The AS5047P magnetic encoder breakout goes on the ODESC’s SPI header. Both are 6-pin 1.25 mm connectors. Unfortunately the pins did not align.

Encoder (P3):   VCC   GND   CS    SCLK  MISO  MOSI
ODESC (SPI):    GND   MOSI  MISO  SCK   CS    3.3V

A straight cable would put 3.3 V into MOSI and tie VCC to GND. Therefore one end was re-pinned via a breadboard.

The check after recabling is passive. bench/odrive_encoder_raw.py samples the encoder while the shaft is turned by hand.

./bench/odrive_encoder_raw.py --still 5 --duration 20

It reads shadow_count rather than pos_estimate. pos_estimate is the output of the encoder PLL, whose corner is encoder.config.bandwidth, so judging noise from it measures the filter as much as the sensor. Furthermore, a low bandwidth would hide a possible problem.

Controller firmware

The ODESC V4.2 is a v3.6 clone running ODrive firmware and it reports:

Firmware: 0.0.0    Hardware: 3.6.56

ODrive 0.5.0 firmware changed positions from counts to turns, and velocities from counts/s to turns/s. So the task was to find out which config is running. Furthermore, the board mixes features from both generations:

PresentAbsent
input_vel, SPI_ABS_AMS, turns conventiongpio*_mode, enable_brake_resistor
motor.config.directionencoder.config.direction

pos_estimate and shadow_count track the same shaft, and shadow_count is in counts on every firmware, so their ratio over one hand-turned rotation reveals the convention:

d(pos_estimate) / d(shadow_count) ≈ 1        → counts
d(pos_estimate) / d(shadow_count) ≈ 1 / cpr  → turns

This now runs as part of the hand-rotation check that happens before anything arms, where the operator is turning the shaft to prove SPI works at all. bench/odrive_units_check.py is the same measurement on its own, and takes no arguments:

./bench/odrive_units_check.py

Calibration

First, the encoder offset calibration failed with CPR_POLEPAIRS_MISMATCH, which means the measured counts missed calib_scan_distance × cpr / (2π × pole_pairs) by more than calib_range. bench/odrive_encoder_diagnose.py was created to diagnose the issue:

./bench/odrive_encoder_diagnose.py

The cause was calib_scan_distance. Its default is 16π ≈ 50 electrical radians, which is 1.14 mechanical turns on a 7-pole-pair motor - and only 0.40 turns at 20 pole pairs. Magnet eccentricity produces an angle error that cycles once per mechanical revolution, so it cancels only over whole turns. A partial sweep leaves what remains as a systematic count error of a few percent, which is enough to trip the 2% consistency check with nothing broken at all. The correct value was defined as turns × 2π × pole_pairs and set as calibration timeout. odrive_run.py derives the timeout from calib_scan_distance:

./odrive_run.py \
	--mode velocity \
	--target 1 \
	--duration 10 \
	--recalibrate \
	--encoder-calib-turns 4 \
	--encoder-calib-omega 6 \
	--encoder-bandwidth 400 \
	--current-control-bandwidth 2500 \
	--vel-gain 0.3 \
	--vel-integrator-gain 1.5

The motor is calibrated on the first run via this script, by spinning the motor on its own for twenty seconds. The calibration flag is then set. Recalibration can be triggered via --recalibrate.

Three more significant errors showed up during development:

  • ENCODER_ERROR_NO_RESPONSE (0x4) means the rotor did not move, not that the encoder did not send signals. The firmware raises it when shadow_count changes by ≤ 8 counts across the scan - under 0.2°. So the fault was the magnet mount, not the SPI cable.
  • is_calibrated outlives reboots. An axis will show it is calibrated on a board where the phases came off / were switched since.
  • Calibration torque comes from motor.config.calibration_current, not from current_lim, and not from calibration_lockin.current. Both calibration states apply an open-loop voltage vector, so current_lim never clamps them. Turning the wrong one of those three has no effect whatsoever, which is a fine way to spend an hour.

Noise - OVERSPEED without the shaft spinning

With the motor idle and unpowered, vel_estimate read ±2.5 turns/s of pure noise. 4.92 turns/s was peak to peak, which exceeds the 4.8 turns/s overspeed trip that a vel_limit of 4.0 implies. That reading comes from the run script in the mode that connects and streams telemetry without configuring or arming anything:

./odrive_run.py --monitor-only

The cause was encoder.config.bandwidth, which defaults to 1000 rad/s. Setting it to 100 rad/s fixed it. The bandwidth is now written on every run rather than inherited from whatever the last script left in flash, and --encoder-bandwidth overrides it:

./odrive_run.py --encoder-bandwidth 100 --duration 10

Lowering the bandwidth has a floor, because the commutation angle comes from the same PLL.

BandwidthPosition errorElectrical error at 20 pole pairs
50 rad/s0.0080 turns58°
100 rad/s0.0020 turns14°
200 rad/s0.0005 turns

Velocity gain and integrator gain

A velocity command with gains too weak to break stiction does not move the shaft. The guard that catches it works from total phase current rather than Iq, and it reports numbers rather than asserting a cause:

CurrentMeaningResponse
Under 15% of the limitGains too low to break stictionWarn once, keep running
15–80%Ambiguous - something may be holding itWarn once, keep running
Over 80%The loop has nothing left and the shaft still will not turnStop - that current goes into one place with no airflow

A low d-axis share is not proof that commutation is right. Id and Iq are measured in the frame the controller believes it is in, and the current loop drives Id to zero in that frame whether or not it matches the rotor. A constant offset error therefore reads as 0% while producing only Iq × cos(error) of real torque (nothing at all at 90°). The d-axis share catches an angle turning wrongly but is blind to one that is turning late.

The guard prints the numbers behind every verdict, and while the setpoint is still ramping it judges only saturation. An integrator winding up toward the torque that breaks stiction looks exactly like a stalled shaft, right up until the instant it succeeds.

The integrator is what starts this motor. Proportional gain alone tops out at vel_gain × error, and at 20 pole pairs of cogging torque that is not enough to break free. Both gains are flags, so the difference is one run apart. The following values make the motor turn, but in visible steps. The loop breaks stiction, overshoots, sticks again:

./odrive_run.py \
	--mode velocity \
	--target 1 \
	--duration 10 \
	--encoder-bandwidth 400 \
	--current-control-bandwidth 2500 \
	--vel-gain 0.1 \
	--vel-integrator-gain 0.5

Tripling both, keeping the integrator at roughly 5× the proportional gain, made it smooth:

./odrive_run.py \
	--mode velocity \
	--target 1 \
	--duration 10 \
	--encoder-bandwidth 400 \
	--current-control-bandwidth 2500 \
	--vel-gain 0.3 \
	--vel-integrator-gain 1.5
Bandwidth

--current-control-bandwidth is in there for a non-obvious reason. On a 20-pole-pair motor it is a speed limit rather than a response-time setting. Commutation frequency is `pole_pairs × turns/s`, so the firmware's 1000 rad/s default is reached at 8 turns/s, where the current vector lags the rotor by 45° and roughly a third of the torque is gone. Raising it to 2500 buys headroom; the ceiling is about 5027 rad/s, a tenth of the 8 kHz current loop rate, past which the loop amplifies current-sense noise instead of tracking it.

Currently stopping coasts rather than brakes. Commanding zero velocity on a spinning rotor pushes energy back into the bus, and a supply with no brake resistor answers with DC_BUS_OVER_VOLTAGE. Every stop here is a request to IDLE.

One motor, turning

A motor, encoder and controller were connected to a printed stand, to fix it to the desk.

The printed motor test stand

Below are some default values for turns/s for velocity, Nm for torque and turns for position, to get the motor running:

./odrive_run.py                                      # 1 turn/s for 10 s on the encoder
./odrive_run.py --target 5 --duration 30 --csv run.csv
./odrive_run.py --mode torque --target 0.15
./odrive_run.py --web                                # adds http://127.0.0.1:8420

The position mode did need one more gain. ODrive’s position loop is vel_setpoint = pos_gain × pos_error, so at a gain of 20 anything past 0.3 turns of error already saturates a 6 turns/s velocity clamp. The move then travels at the clamp rather than at the speed the gain implies:

./odrive_run.py \
	--mode position \
	--target 0 \
	--duration 30 \
	--encoder-bandwidth 400 \
	--current-control-bandwidth 2500 \
	--vel-gain 0.3 \
	--vel-integrator-gain 0.5 \
	--pos-gain 20

A target of 0 with a duration is a hold rather than a move. The axis arms in position mode, primed with its own measured position, and stays there for thirty seconds. That is useful to feel how stiff a given pos_gain actually is, by pushing the shaft by hand.

--web is a simple dashboard, showing stats and charts for angle, speed, current, power and temperature. In this mode it is read-only; executing odrive_web.py adds a control panel, so the mode, target and the run itself all come from the browser:

./odrive_web.py                            # control panel on 127.0.0.1:8420
./odrive_web.py --monitor-only             # same page, controls disabled

The odrive_web dashboard

The final result was a running and controllable motor:

Wiring up multiple motors

Each motor control unit provides a USB-C port, to connect to a host. For the 12 joints, 12 MCUs are needed. Individual cabling may work via e.g. a USB hub, but would be messy. Therefore a connection via CAN bus was established.

As the controller configuration cannot be set over the CAN bus, each controller had to be configured via USB first.

Three different scripts have been designed: one script that configures CAN over USB, one that listens to the bus and transmits nothing, and one that commands motion from it.

ScriptDescription
odrive_can_setup_via_usb.pyEnables CAN on one board, sets its node ID, saves and reboots it
odrive_can_check.pyListens for heartbeats, reports the bus state
odrive_can_run.pyArms several nodes and commands a setpoint

CAN bus on a breadboard

As a PoC, two boards and a USB–CAN adapter were set up on a breadboard. CAN needs a 120 Ω resistor on both ends. The USB–CAN adapter used has a resistor on board, so only one resistor had to be connected on the breadboard.

CANHCANL120 Ωinside theadapter120 ΩresistorUSB–CANnode 1node 2Ω60 Ωpower off
Two terminators, three nodes, one pair. Terminators define the two physical ends of a transmission line. The USB–CAN adapter already contains one on a switch. Every node is connected in parallel and adds nothing to the DC resistance. The setup can be tested via an ohmmeter.

Power connections

The power was drawn from a DC bench power supply and distributed to the boards via Y-splitters for + and -.

+ 24 V − DC / common the part that needs a decision
BENCH SUPPLY24 V · 5 A+PRECHARGEresistor + bypass switch,or a soft-start moduleI₁ + I₂trunk carries the sum+DISTRIBUTION POINTTwo Y-splitters, a busbar, or atwo-pole terminal block. Same thing.MOUNT IT NEAR THE BOARDSnot at the supply's own terminalsODESC V4.2 - node 1own brake resistor on AUX+DC−DCI₁A C B → motor 12 ΩODESC V4.2 - node 2own brake resistor on AUX+DC−DCI₂A C B → motor 22 Ωthe − side is also theshared reference that lets the2-pin CAN connector carry no groundkeep both drops short androughly equal - and run each+/− pair together, not apart
One trunk, one split, two short drops. Every board hangs off the same two nodes, so no board's current flows through another board's copper. The split belongs close to the boards, where the fast current changes are - long trunk, short drops, not the reverse.
Board startup

Each board has five 100 µF capacitors. Two boards on one splitter is ten capacitors charging through a single wire. A current-limited supply will trip the OCP before either board starts up. For two motors with a supply of 6 A and 24 V it was not a problem. Let's see what happens with more controllers on the wire.

ODESC and the second axis

This one cost an afternoon of debugging, and it presented as four unrelated faults.

The ODESC V4.2 has one motor connector, the firmware has two. ODrive’s image for the v3.6 is a dual-axis design, which instantiates axis0 and axis1 by default.

So axis1 is a complete software object on a board that cannot possibly drive it. It has a configuration, a state machine, its own can_node_id, and its own heartbeat. It announces itself on the bus and accepts state requests. It fails only at the point where it needs something physical.

Both axes default to node 0, so setting only axis0 leaves them sharing the same address. Giving the imaginary axis a distinct node ID fixed it:

./odrive_can_setup_via_usb.py --serial <SN> --axis1-node-id 11 --axis1-heartbeat-ms 0

--show reports both axes’ node IDs, heartbeats, states and errors, and warns when the two collide:

./odrive_can_setup_via_usb.py --show          # read the configuration, write nothing

The tell was in the first successful bus scan, as one board heartbeated three times as often as the other, with identical configuration. At first this was dismissed as possibly a slightly different firmware version.

Rates, IDs, and a reboot that is not optional

Every node on the bus shares one bit rate. This works, as the nodes have different IDs, but a node clocked at the wrong rate does not just fail to understand the traffic but destroys it. So a single misconfigured board takes the bus down for all of them.

Therefore the following three settings need to be set precisely for each board:

SettingIf it is missing
can.config.baud_rateThe peripheral has no rate to clock at
axis node idMust be set and unique - 0 is the unconfigured default
axis heartbeat rate ms0 disables heartbeats entirely. The board joins the bus, answers a probe, and announces nothing

After setting the values, each board was rebooted and the values read again, to make sure they were saved. So the setup script saves, reboots, reconnects by serial number rather than by find_any(), reads the values off the rebooted board, and exits non-zero if they did not persist.

Every board ships as node 0, so before they are told apart there is no way to aim a write at one of them: find_any() returns whichever answered first. The setup script therefore refuses to run with more than one board on USB unless --serial names the target. Connect them one at a time:

./odrive_can_setup_via_usb.py --node-id 1     # then the next board as --node-id 2
./odrive_can_check.py --expect 1 2            # both nodes heartbeating?

--expect names the nodes that are expected and exits non-zero for a missing one.

Furthermore, a bus with one live node cannot work even in principle. CAN requires a second node to acknowledge, so a lone board retransmits, fails, and slides toward error-passive rather than heartbeating cleanly. Two powered boards are the minimum setup.

Two Linux details

Two udev rules are needed in a Linux environment to make the board connect. Both live in udev/ and are installed once per machine:

sudo cp udev/91-odrive.rules udev/92-can-up.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger

The first is about USB. Without it the ODrive device node comes up root:root 0660, libusb cannot open it, and every connection fails with [UsbInterface] Failed to open USB device: -3 (LIBUSB_ERROR_ACCESS).

The second is about the CAN link, and is not a permissions problem at all. socketcan can be reached over an AF_CAN socket rather than libusb, so no USB rule can affect it. What it fixes is that unplugging a USB–CAN adapter destroys the network interface, and replugging creates a new one: observed as can0 at ifindex 5 coming back as ifindex 6. The up state and the bitrate are gone on every plug. By hand, that is:

sudo ip link set can0 up type can bitrate 250000

The socketcan bitrate is set by the kernel, not by the Python library. The bitrate argument in the library is accepted but ignored.

Both udev rules are part of the Git repository.

Two motors, one bus

odrive_can_run.py arms all nodes in one pass, as a board that arms e.g. two seconds before the other ones is two seconds of one motor pushing a potentially coupled load alone.

./odrive_can_run.py --nodes 1 2 --dry-run     # preflight only, arms nothing
./odrive_can_run.py --nodes 1 2               # 1 turn/s for 5 s, then coast
./odrive_can_run.py --nodes 1 2 --target 2 --duration 10

--dry-run is the preflight part, both nodes set to IDLE with a heartbeat, but no arming. This does not calibrate but will not work without calibration. Calibration needs to be done via USB mode.

A heartbeat carries axis_error and a state. So no current, no temperature, no gains. Therefore the stall guard that worked from phase current over USB has to work from encoder estimates instead, and it can no longer distinguish weak gains from a held shaft or a frozen encoder.

The leg

The first PoC for a leg was two motors, each with a 1:9 gearbox, driving one planar five-bar linkage.

The five-bar leg design

Three scripts have been designed to run the leg: leg_kinematics.py is the geometry with no hardware and no I/O, leg_web.py draws the linkage, and leg_run.py is the one to run.

./leg_run.py                                     # simulate, dashboard on :8421
./leg_run.py --gait trot --stride 120 --check    # cycle check only, no browser
./leg_run.py --live --nodes 1 2 --home-foot 0 -250 --duration 15

Simulation is the default. Nothing is sent to the bus or armed unless --live is given.

The leg_web simulation dashboard

Five-bar linkage

Both motors are responsible for a single step of a single leg and sit at the hip. No motor travels with the limb, to keep the leg’s inertia low. This gives the leg the ability to move fast enough to catch e.g. a stumble.

The geometry is two crank pivots hip_spacing apart on a horizontal base, a crank on each, and two lower links meeting at the foot. Currently there is no abduction, so the leg cannot move sideways out of its plane. That would be the third degree of freedom, and will be done in part 2 of this series.

ComponentMeasurement
Between the hips215 mm
Cranks150/150 mm
Lower links183 and 169 mm

That asymmetry of the lower links was a quick fix and will get resolved in further designs. So Geometry defines four lengths and every one of them is a flag, read from a .env file:

./leg_run.py \
	--hip-spacing 215 \
	--upper 150 \
	--lower-left 183 \
	--lower-right 169 \
	--gear-ratio 9 \
	--crank-min -170 --crank-max -10 \
	--min-foot-angle 70

--upper sets both cranks and --upper-left / --upper-right override one side. The crank stops are important to set, as without them a crank can sweep into the chassis. On the test stand nothing physically stops a crank, but just adding -170/-10 prevents a crank from leaving the lower half-plane.

Four ways to close

A five-bar linkage can close four ways for the same pair of crank angles, while a physical mechanism only ever sits in one of them. Forward and inverse kinematics have to agree about which one, or the drawing on screen and the setpoints going to the motors describe different machines.

So the code commits to one assembly mode: knees out and the foot on the clockwise side of the knee-to-knee vector. Then the code checks it on every single pose, by running each IK result back through FK and reporting the distance between them. It reads 1e-13 mm when the conventions agree and tens of millimeters when they do not. A pose that has drifted onto another branch gets reported rather than drawn.

That round trip is also what leg_kinematics.py runs as its own test, over the whole workspace, with no hardware and no bus involved:

./leg_kinematics.py --self-test

Checks

Before a live run arms, the whole gait cycle (361 points) is walked. --check runs only that:

./leg_run.py --gait trot --stride 120 --clearance 40 --period 1.2 --check
FindingWhy it is a refusal
A point outside the linkageWith the distance it is short by, at the phase it happens
The lower links within 5.7° of straightThe Jacobian is singular there: the foot cannot be driven along the line through the knees at any torque
A chain past 99.5% of full extensionThe same singularity, seen from one side
Knees crossingWhatever the maths says, the links occupy the same space
The lower links closing inside the minimum foot angleThe same collision at the other end of the linkage
A crank past its configured stopThe stop is where the chassis begins; the simulator has no other way of knowing that
IK and FK disagreeingThe path leaves the assembly mode, and a real linkage cannot change branch without passing through a singularity
Peak motor speed past 60% of no-load36 turns/s no-load at 24 V and KV 90; past that, back-EMF leaves too little voltage for current and the loop stops tracking

The straightness metric catches the links going straight, resulting in a singularity. The foot-angle floor catches them shutting on each other like scissors, which the straightness metric reads as its safest possible value right up to the collision. Both default to unconfigured and say so in the report, rather than implying the leg is guarded when it is not.

Currently there are no dynamics calculated in the simulation, so no mass, friction, backlash, gearbox loss or current loop. Again, this will be added in a later part.

Starting point and execution

An ODrive position estimate counts turns from wherever the encoder happened to be zeroed. That has no relation to where a crank is actually pointing. Currently there is no homing, so during execution the human confirms that the foot is at this coordinate right now - that is what --home-foot X Y is:

./leg_run.py \
	--live \
	--nodes 1 2 \
	--home-foot 0 -250 \
	--gait trot --stride 80 --period 1.2 \
	--duration 15 \
	--pos-gain 20 \
	--current-limit 5

The run then proves the declaration rather than trusting it:

  1. The cycle check runs. Any error and nothing is armed.
  2. Bus preflight: both nodes IDLE, clean, heartbeating.
  3. The declared pose becomes a motor-turns offset per axis.
  4. Both axes arm in position mode, each primed with its own measured position first. A position loop closed around the 0.0 left in the setpoint field is a dash to encoder zero at the velocity limit.
  5. The leg holds still, and a browser page draws the pose this run believes it is in. Does the drawing match the real leg? If not, the mapping is wrong and nothing has moved.
  6. A 20 mm nudge of the foot in +x, drawn before it is commanded. Did the foot go the same way? If not, a motor direction is inverted relative to the model.
  7. Only then a slow move to the start of the path, and only then the gait.

Steps 5 and 6 are two questions no sensor on this bench can answer, and they are the entire reason a visual dashboard exists rather than a log file. A failed step 6 is a motor direction inverted relative to the model, and the fix is to stop and re-run with --invert-left or --invert-right. --skip-visual-check turns both off; it is for a mapping already proven this session, not for a first run.

--nodes takes the two node IDs in the order LEFT RIGHT, and gets them wrong silently if they are swapped - which is another thing step 6 catches.

Both axes are also written in one pass with nothing between them. A five-bar with one crank a frame ahead of the other is a linkage being asked for a pose neither motor was told about.

Conclusion for part 1

Part one was acquiring the right parts and showing a PoC of all parts assembled. To add abduction and the rest of the body should be “just” a scaling problem now.

Part 2 will show the construction of the full body, including abduction as the third degree of freedom.

APPENDIX - Bill of materials

The list below contains all parts needed for the full four-legged robot, not just for this part of the series - e.g. twelve motors and twelve controllers, three actuators per leg. The third one per leg is the abduction axis that Part 2 is about.

Some minor additional parts were sourced, to make sure everything is available in case of deviation from the plan.

ItemQty¥ RMB€ EUR
Eaglepower 8308 KV90 brushless motor124,500.00572.13
ODESC V4.2 brushless servo controller (ODrive 3.6 compatible)123,156.00401.25
Magnetic encoders + magnets (AS5047P)-433.2055.08
Thin-section deep groove ball bearings, 65 mm bore12205.2026.09
6001RS deep groove ball bearings, 12 × 28 × 8 mm10163.9920.85
Knurled brass thread inserts, 370 pcs1154.0619.59
Brass heat-set inserts, M2–M6, knurled3090.8111.55
PLA+ filament, 1.75 mm, 1 kg270.208.93
USB–CAN module265.968.39
JST-SYP/PH 2.0 mm connector kit, 2–5 pin149.006.23
USB–CAN debugger / “PCAN analyzer”, 1 Mbit/s245.505.79
608ZZ deep groove ball bearings, 8 × 22 × 7 mm1040.005.09
GH1.25 mm crimped cable + DuPont shell assortment139.505.02
Screws and nuts, mixed-39.505.02
Breadboard, 830 tie points139.365.00
Push-in wire connector terminals, 1-in multi-out1036.884.69
Brass heat-set inserts, M1–M8, knurled5032.504.13
Push-in wire connector terminals, 1-in multi-out1029.473.75
Metal film resistor kit, 600 pcs, 1/4 W127.583.51
M3 hex brass standoffs, single-head8010.731.36
Screw, nut and washer set, M3–M619.091.16
Mini self-adhesive breadboard54.550.58
XH2.54 2-pin wire-to-board connector, 24 AWG10.120.02
Total9,243.201,175.21

Quantities are as recorded on the sheet (a few of the kit lines are counted in packs rather than in pieces). The live version, with the original Taobao marketplace listings, is available here: Bill of materials (Google Sheets).

Motors and controllers are 83% of the entire robot - €973 of €1,175.