Talking to Hardware

Programming for Robotics

Course 1 · Chapter 2 · Talking to Hardware

Programming for Robotics 1 section 7 drew a seam: the robot logic calls three methods (range_m, ticks, set_duty) and anything that provides them can be plugged in. It tested everything against fakes and left the real class as a short sketch. This chapter fills the seam in. It covers the six ways a processor talks to the world (digital pins, PWM, the ADC, I2C, SPI and UART), what each is for, how fast it is, how it is wired, and how to write the code on both sides of the wire: a driver that turns bytes into physical units, and a fake bus that lets the driver be tested on an ordinary PC before any wire is connected. It ends with the real PicoHardware for the build robot of Robotics Fundamentals 10 section 9, assembled from pieces that were tested.

What was run, and what was not
Every Python block in this chapter that has an output below it was run with CPython 3 on a PC, against fake pins, buses and timers, and the output shown is what it printed. Nothing in this chapter was run on a Raspberry Pi Pico or a Raspberry Pi. The blocks marked “MicroPython” or “gpiozero” (the pin examples, the SPI and UART snippets, PicoHardware) were only syntax-checked, and every library name, method name and return value in them is written from memory of the MicroPython, gpiozero and smbus2 documentation: check each against the documentation for your firmware version and library version. The register map of the IMU in section 8 is invented (modelled on common IMU chips) so that the example is complete and testable; a real chip has its own map in its own datasheet.

As in Programming for Robotics 1, the blocks build on each other like one growing file: later blocks use names that earlier blocks define.

1. Six ways to talk to the world

Basic Electronics 10 section 1 listed what a board offers: pins that can be a digital input, a digital output, an analogue input, a PWM output, or a special function (serial, I2C, SPI). The program sees each as a different kind of object, with a different cost in wires, time and code:

InterfaceWiresTypical speedWhat it carriesTypical robot use
Digital input / output1 per signala pin can toggle in microseconds; a Python loop reads it in tens of microsecondsone bit, on or offline sensors, bump switches, motor direction, the ultrasonic trigger, an enable pin
PWM output1 per channelcarrier 50 Hz (servos) to 20 kHz (silent motors)a duty cycle: an average voltage (Basic Electronics 10 section 5)motor speed, servo angle, LED brightness
ADC input1 per channela reading takes microseconds; Python limits it to thousands per seconda number from the voltage (Basic Electronics 10 section 4)battery voltage, analogue range sensors, potentiometers, current sensors
I2C2 (SDA, SCL), shared by many devices100 kHz, 400 kHz, 1 MHzbytes to and from a device chosen by its addressIMUs, time-of-flight sensors, small displays, ADC and servo-driver chips
SPI3 shared (SCK, MOSI, MISO) plus 1 chip select per device1 MHz to tens of MHzbytes, both directions at once, device chosen by its chip-select wirefast ADCs, SD cards, displays, some IMUs
UART (serial)2 (TX, RX) per device, crossed over9600 to 115 200 baud is commona stream of bytes, no clock wire, no addressesGPS modules, lidar, Bluetooth modules, the debug console, another microcontroller

The choice is usually made for you by the part: the chip comes with an I2C or SPI or serial interface and you use what it has. What you control is the speed, the wiring and the code. Before looking at each, here is the time it takes to move 12 bytes (an IMU's six 16-bit values) over each. The formulas are explained in sections 4 and 5; the functions are used again in section 12.

def i2c_bits(n_read, n_reg_bytes=1): """Bit times for START, address+W, register byte(s), repeated START, address+R, n_read bytes, STOP. Every byte takes 9 bit times (8 data bits and the ACK). START and STOP (about one bit time each) are ignored.""" return 9 * (2 + n_reg_bytes + n_read) def i2c_time_s(n_read, f_hz, n_reg_bytes=1): return i2c_bits(n_read, n_reg_bytes) / f_hz def spi_time_s(n_bytes, f_hz): return 8 * n_bytes / f_hz def uart_time_s(n_bytes, baud): return 10 * n_bytes / baud # 8N1: start bit + 8 data bits + stop bit = 10 bits per byte rows = [ ("I2C 100 kHz", i2c_time_s(12, 100000)), ("I2C 400 kHz", i2c_time_s(12, 400000)), ("SPI 1 MHz", spi_time_s(1 + 12, 1000000)), # one command byte, then 12 data bytes ("SPI 10 MHz", spi_time_s(1 + 12, 10000000)), ("UART 9600 baud", uart_time_s(12, 9600)), ("UART 115200 baud", uart_time_s(12, 115200)), ] for name, seconds in rows: print("%-17s %10.1f us" % (name, seconds * 1e6))
I2C 100 kHz 1350.0 us I2C 400 kHz 337.5 us SPI 1 MHz 104.0 us SPI 10 MHz 10.4 us UART 9600 baud 12500.0 us UART 115200 baud 1041.7 us

The same 12 bytes take 10 microseconds or 12.5 milliseconds depending on the bus. Against a 20 ms loop, the slow serial link alone uses 62.5 % of the period (12.5 / 20), and a blocking read of it would stall the loop for that long (Programming for Robotics 1 section 5). The speed of a bus is a budget item, exactly like the stages of Programming for Robotics 1 section 12.

2. Pins: digital I/O, the ADC and PWM

On a Raspberry Pi Pico running MicroPython, a pin is an object from the machine module. The block below shows the four things you do with pins all the time. It is written from memory of the MicroPython documentation and has not been run on a board; the pin numbers (GP numbers, not physical pin numbers) are those of the build robot, which section 10 lists in full.

# MicroPython on a Raspberry Pi Pico: from memory, NOT run on hardware, NOT run here (syntax-checked only) from machine import Pin, PWM, ADC led = Pin(25, Pin.OUT) # a digital output led.value(1) # high: the LED lights led.value(0) button = Pin(21, Pin.IN, Pin.PULL_UP) # a digital input with the internal pull-up (Basic Electronics 8 section 5) pressed = button.value() == 0 # the button closes to ground, so 0 means pressed motor_pwm = PWM(Pin(12)) # a PWM output motor_pwm.freq(20000) # 20 kHz carrier, above hearing motor_pwm.duty_u16(32768) # duty as a 16-bit number: 32768 of 65535 is about 50 % battery = ADC(Pin(26)) # an analogue input (GP26 is ADC0) raw = battery.read_u16() # always a 16-bit number, 0 to 65535, whatever the real resolution

The ADC: counts to volts

The Pico's ADC is 12 bits (Basic Electronics 10 section 4), but MicroPython's read_u16() returns a number scaled to 16 bits so that code does not change between boards. The scaling is from memory: the 12-bit code is shifted up by four places (a multiplication by 16), so the lowest four bits carry no information. To convert, divide by 65535 and multiply by the reference voltage, then undo any voltage divider in front of the pin.

ADC counts to volts (N = 12 bits, Vref = 3.3 V): LSB = Vref / 2^N = 3.3 / 4096 = 0.806 mV read_u16() value: u16 = code x 16 volts at the pin V(pin) = u16 / 65535 x Vref Divider in front of the pin: k = R2 / (R1 + R2) battery voltage V(bat) = V(pin) / k one LSB of the battery = LSB / k The build robot's battery divider (Robotics Fundamentals 10 section 9.5): R1 = 56 k (top), R2 = 33 k (bottom): k = 33 / (56 + 33) = 0.3708 8.4 V full 2S pack -> 3.115 V at the pin (under 3.3 V) 7.4 V nominal -> 2.744 V 7.4 V: code = floor(2.7438 / 3.3 x 4096) = 3405 u16 = 3405 x 16 = 54 480 V(pin) = 54 480 / 65535 x 3.3 = 2.7433 V V(bat) = 2.7433 / 0.3708 = 7.399 V one LSB of the battery = 0.806 mV / 0.3708 = 2.17 mV the 6.6 V low-battery cutoff: pin = 2.447 V, code = 3037, u16 = 48 592 Dividing by 65535 rather than 65536 is a convention: the largest 12-bit code, 4095, scaled by 16 is 65 520, so the largest reading gives 3.2992 V, not 3.3 V. The difference is 0.02 %. Averaging n readings of independent noise improves the noise by sqrt(n): 16 readings -> 4 times less noise, at the cost of 16 conversions (a time cost: Programming for Robotics 4, planned and not yet written, filters properly).
VREF_V = 3.3 def u16_to_volts(raw_u16, vref_v=VREF_V): return raw_u16 / 65535 * vref_v def divider_ratio(r_top_ohm, r_bottom_ohm): return r_bottom_ohm / (r_top_ohm + r_bottom_ohm) K_BATTERY = divider_ratio(56000, 33000) # the battery divider of the build robot def battery_v(raw_u16, k=K_BATTERY, vref_v=VREF_V): return u16_to_volts(raw_u16, vref_v) / k code12 = int(7.4 * K_BATTERY / VREF_V * 4096) # what a 12-bit ADC would give for a 7.4 V pack raw_u16 = code12 << 4 # read_u16() scales it up to 16 bits print("divider k = %.4f pin = %.4f V" % (K_BATTERY, 7.4 * K_BATTERY)) print("12-bit code %d read_u16 gives %d volts at the pin %.4f" % (code12, raw_u16, u16_to_volts(raw_u16))) print("battery = %.3f V (true value 7.400 V)" % battery_v(raw_u16)) print("one 12-bit LSB of the battery = %.3f mV" % (1000 * VREF_V / 4096 / K_BATTERY)) cutoff_code = int(6.6 * K_BATTERY / VREF_V * 4096) print("6.6 V cutoff: 12-bit code %d, read_u16 %d" % (cutoff_code, cutoff_code << 4))
divider k = 0.3708 pin = 2.7438 V 12-bit code 3405 read_u16 gives 54480 volts at the pin 2.7433 battery = 7.399 V (true value 7.400 V) one 12-bit LSB of the battery = 2.173 mV 6.6 V cutoff: 12-bit code 3037, read_u16 48592

PWM: duty to a motor command

PWM is set by a frequency and a duty cycle (Basic Electronics 10 section 5). On the Pico, duty_u16 takes the duty as a 16-bit number, whatever the counter really has. Two decisions are the program's: the frequency and the mapping from a command to a duty.

  • Frequency. A 125 MHz clock and a 20 kHz carrier leave 125 000 000 / 20 000 = 6250 steps per period (Basic Electronics 10 section 5), about 12.6 bits: ample for a motor. Below about 15 kHz the motor whines; far above 20 kHz the driver loses energy switching. A Pico has one frequency per PWM slice, and each slice serves two pins (section 10), so both motor pins of the build robot are on one slice and share the 20 kHz.
  • Deadband. A small motor does not turn below about 18 % duty (Basic Electronics 9 section 2), so a command of 5 % would only heat it. The mapping sends the smallest meaningful command to the first duty that moves the wheel, spreads the rest linearly up to 100 %, and sends tiny commands to zero.
Duty step count: steps = f(clk) / f(PWM) 125e6 / 20e3 = 6250 steps bits = floor(log2(6250)) = 12 Command (-1 to +1) to duty, with a deadband d (here 0.18) and a cutoff of 0.02: |cmd| below 0.02: duty = 0 (stop) otherwise: duty = d + (1 - d) x |cmd| direction = sign(cmd) Worked example: cmd = 0.5 -> duty = 0.18 + 0.82 x 0.5 = 0.59 duty_u16 = round(0.59 x 65535) = 38 666 cmd = 1.0 -> duty 1.00 (65 535); cmd = 0.02 -> duty = 0.18 + 0.82 x 0.02 = 0.1964 (the smallest command that moves the motor) The counter holds whole steps: 0.59 x 6250 = 3687.5, so the real duty is a step or half a step away from 0.59 (a 0.008 % difference: no problem). The command-to-duty mapping is a model of the motor: the deadband of YOUR motor is measured, not read from a table (Programming for Robotics 5, planned and not yet written, calibrates it on the real robot). A command that changes sign must not reverse a spinning motor instantly: a ramp (Programming for Robotics 5) limits how fast the duty may change.
DEADBAND = 0.18 # a motor will not turn below about 18 % duty (Basic Electronics 9 section 2) def command_to_duty(cmd, deadband=DEADBAND, cutoff=0.02): """A command from -1 to +1 becomes (direction, duty). Tiny commands mean stop; the rest spread from the deadband to 1.""" cmd = max(-1.0, min(1.0, cmd)) magnitude = abs(cmd) if magnitude < cutoff: return 0, 0.0 return (1 if cmd > 0 else -1), deadband + (1.0 - deadband) * magnitude def duty_to_u16(duty): return int(round(duty * 65535)) def pwm_steps(f_clk_hz, f_pwm_hz): return int(f_clk_hz // f_pwm_hz) # how many counter values fit in one PWM period steps = pwm_steps(125000000, 20000) print("steps at 20 kHz from 125 MHz:", steps) for cmd in (0.0, 0.01, 0.02, 0.1, 0.5, 1.0, -0.5): direction, duty = command_to_duty(cmd) print("cmd %+5.2f -> dir %+d duty %.4f duty_u16 %5d" % (cmd, direction, duty, duty_to_u16(duty))) print("0.59 duty is %.1f steps of %d" % (0.59 * steps, steps))
steps at 20 kHz from 125 MHz: 6250 cmd +0.00 -> dir +0 duty 0.0000 duty_u16 0 cmd +0.01 -> dir +0 duty 0.0000 duty_u16 0 cmd +0.02 -> dir +1 duty 0.1964 duty_u16 12871 cmd +0.10 -> dir +1 duty 0.2620 duty_u16 17170 cmd +0.50 -> dir +1 duty 0.5900 duty_u16 38666 cmd +1.00 -> dir +1 duty 1.0000 duty_u16 65535 cmd -0.50 -> dir -1 duty 0.5900 duty_u16 38666 0.59 duty is 3687.5 steps of 6250

Now the motor as an object. A TB6612-style driver (the example of Robotics Fundamentals 10 section 9.2) takes two direction inputs and one PWM input per motor. MotorChannel does not import machine: it is given its pins, so the same class runs on a Pico or against the fake pins below. (The truth table in the comments, including that both-low coasts and both-high brakes, is from memory of the TB6612FNG datasheet: check the datasheet of your module.)

class FakePin: """Records every value written to it, like a pin you can inspect.""" OUT = 1 IN = 0 def __init__(self, name): self.name = name self._level = 0 self.writes = [] def value(self, level=None): if level is None: return self._level self._level = level self.writes.append(level) class FakePWM: def __init__(self): self.freq_hz = None self.u16 = 0 def freq(self, f_hz): self.freq_hz = f_hz def duty_u16(self, value): self.u16 = value class MotorChannel: """One motor on a TB6612-style driver: two direction pins and one PWM pin. Works with machine.Pin/PWM or the fakes.""" def __init__(self, in1, in2, pwm, freq_hz=20000): self.in1, self.in2, self.pwm = in1, in2, pwm pwm.freq(freq_hz) self.set(0.0) # start stopped def set(self, cmd): direction, duty = command_to_duty(cmd) if direction > 0: self.in1.value(1) # IN1 high, IN2 low: forward self.in2.value(0) elif direction < 0: self.in1.value(0) # IN1 low, IN2 high: reverse self.in2.value(1) else: self.in1.value(0) # both low: coast (both high would brake) self.in2.value(0) self.pwm.duty_u16(duty_to_u16(duty)) in1, in2, pwm = FakePin("AIN1"), FakePin("AIN2"), FakePWM() motor = MotorChannel(in1, in2, pwm) print("after start: IN1=%d IN2=%d duty_u16=%d freq=%d Hz" % (in1.value(), in2.value(), pwm.u16, pwm.freq_hz)) for cmd in (0.5, -0.5, 0.0): motor.set(cmd) print("cmd %+.1f: IN1=%d IN2=%d duty_u16=%d" % (cmd, in1.value(), in2.value(), pwm.u16))
after start: IN1=0 IN2=0 duty_u16=0 freq=20000 Hz cmd +0.5: IN1=1 IN2=0 duty_u16=38666 cmd -0.5: IN1=0 IN2=1 duty_u16=38666 cmd +0.0: IN1=0 IN2=0 duty_u16=0

Note the order inside MotorChannel.__init__: the PWM frequency is set, then the motor is told to stop. A motor driver must never be left with an undefined input, so the safe state is the first thing the constructor establishes (Basic Electronics 10 section 1: pins float at power-on, which is why the build robot's standby pin has a pull-down resistor).

3. The Raspberry Pi is different

A Raspberry Pi (Basic Electronics 10 section 9) is a computer running Linux, not a bare board. Its pins are the same kind of thing (3.3 V logic, not 5 V tolerant: Basic Electronics 10 section 1), but three things change. There is no analogue input: an ADC chip is added over I2C or SPI (Basic Electronics 7). The pin numbers are the Broadcom (BCM) numbers, not the Pico's GP numbers. And the operating system can pause your program for milliseconds at any moment, so timing that a Pico does in hardware (counting encoder edges, measuring a 1 ms echo) is far less reliable (Basic Electronics 10 section 6 advised a Pico or Arduino beside the Pi for fast encoders).

The usual Python library is gpiozero, which gives each device its own class. This sketch is from memory of its documentation, has not been run on a Pi, and uses arbitrary BCM pin numbers, not the build robot's map:

# CPython on a Raspberry Pi with gpiozero: from memory, NOT run on hardware, NOT run here (syntax-checked only) from gpiozero import DistanceSensor, Motor, Button, PWMOutputDevice, DigitalOutputDevice sonar = DistanceSensor(echo=24, trigger=23, max_distance=4.0) # BCM pin numbers: an example, not a wiring plan print(sonar.distance) # metres (a float from 0 to max_distance); the library does the timing drive = Motor(forward=17, backward=18, pwm=True) # for a driver whose TWO inputs both take PWM drive.forward(0.5) # speed 0 to 1 drive.value = -0.5 # or one number from -1 to +1 drive.stop() direction = DigitalOutputDevice(22) # for a driver with ONE direction pin and ONE PWM pin (like our TB6612 wiring) speed = PWMOutputDevice(27, frequency=1000) direction.on() speed.value = 0.59 # duty 0 to 1 start = Button(5, pull_up=True) print(start.is_pressed)

The same two jobs are easier on a Pi in one way and harder in another. Easier: libraries exist for almost everything, and you can plot, log to disk and use a full network. Harder: the DistanceSensor times its echo in software on a non-real-time system, so its readings can jitter by more than a Pico's; PWM is often generated in software (jittery) unless a hardware-timed backend is selected (from memory: gpiozero can use the pigpio library for that). For a robot that must keep a 50 Hz loop, a common design is a Pico or Arduino doing the fast, timing-critical work and a Pi doing the planning (Programming for Robotics 9, planned and not yet written), joined by UART or USB serial (section 5).

4. I2C: two wires, many devices

I2C (“eye-squared-C”) joins a host and several devices on two shared wires: SDA (data) and SCL (clock). The host drives the clock; every transfer starts with the address of the device it wants, and only that device answers. Both wires are open-drain (Basic Electronics 8 section 5): a chip can only pull a wire low, and a pull-up resistor to the supply makes it high again, which is why the bus needs two resistors and why two devices can never fight over it.

3.3 V --+----------------------------------------------+ | | [ Rp ] [ Rp ] ONE pair of pull-ups for the whole bus | | | SCL --+------------+------------+--------+ | | | | | +---+----+ +---+----+ +---+----+ | | Pico | | IMU | | ToF | | | (host) | | 0x68 | | 0x29 | | +---+----+ +---+----+ +---+----+ | | | | +-- SDA ----+------------+------------+----- (continues to every further device) All grounds are joined (not drawn). Every device is chosen by its 7-bit address, set by the chip and sometimes by an address pin.

Speed, address and the cost of a transaction

The standard clock speeds are 100 kHz (standard mode), 400 kHz (fast mode) and 1 MHz (fast-mode plus). Every byte on the wire is 9 bit times: eight data bits plus one acknowledge (ACK) bit, in which the receiver pulls SDA low to say “got it”. To read from a register, the host sends the device address with the write bit, the register number, a repeated START, the address again with the read bit, and then clocks in the data bytes. That is 2 + 1 + n bytes for n data bytes, which is what i2c_bits in section 1 counted. The start of such a transaction looks like this (the first byte of the example below is address 0x68 with the write bit; one character column is a quarter of a bit time; the data wire changes while the clock is low):

SCL ----_--__--__--__--__--__--__--__--__--_ SDA -___--------____----____________________ S A6 A5 A4 A3 A2 A1 A0 W ACK 1 1 0 1 0 0 0 0 0 S = START: SDA falls while SCL is high. A6..A0 = the 7-bit address 1101000 = 0x68, sent most significant bit first. W = the write bit (0). The next bit time is the ACK: the device pulls SDA LOW to answer; SDA stays high if nobody is there (the NACK). The data is read by the receiver while SCL is high. After the last byte the host sends STOP: SDA rises while SCL is high.
Address bytes: the 7-bit address 0x68 goes on the wire as the byte (0x68 x 2) + R/W: write byte 0xD0, read byte 0xD1. Datasheets sometimes print the 8-bit form (0xD0 / 0xD1); drivers and scans use the 7-bit form (0x68). If a scan shows 0x68 and your code uses 0xD0, that is the bug. Usable addresses: 0x08 to 0x77 = 112 (0x00 to 0x07 and 0x78 to 0x7F are reserved). Two identical chips need different addresses: an address pin, or two buses. Transaction time (the formula of section 1): bits = 9 x (2 + register bytes + data bytes) t = bits / f IMU burst, 12 data bytes: bits = 9 x (2 + 1 + 12) = 135 100 kHz: 1.350 ms 400 kHz: 337.5 us 14 data bytes: bits = 9 x 17 = 153 400 kHz: 382.5 us 2 data bytes: bits = 9 x 5 = 45 400 kHz: 112.5 us START and STOP add about two bit times; the host's software adds its own overhead per transaction (it can be larger than the bus time: section 12).

The pull-up resistors

Two limits fix the pull-up value, as in Basic Electronics 8 section 5. Too small, and a chip cannot pull the wire below a valid low (it can sink about 3 mA). Too large, and the wire, which has capacitance, rises too slowly for the clock speed. The I2C specification measures the rise time between 30 % and 70 % of the supply: t(rise) = 0.8473 × Rp × C, and allows 1000 ns in standard mode and 300 ns in fast mode.

Rp(min) = (Vcc - VOL) / IOL Rp(max) = t(rise) / (0.8473 x C) (VOL = 0.4 V, IOL = 3 mA are the usual figures) 3.3 V: Rp(min) = (3.3 - 0.4) / 0.003 = 966.7 ohm C = 100 pF, 400 kHz (300 ns): Rp(max) = 300 ns / (0.8473 x 100 pF) = 3540 ohm C = 200 pF, 400 kHz (300 ns): Rp(max) = 1770 ohm choose 1.5 k: t(rise) = 0.8473 x 1500 x 200 pF = 254 ns current when low = 2.9 V / 1500 = 1.93 mA C = 400 pF, 400 kHz (300 ns): Rp(max) = 885 ohm which is BELOW Rp(min): no resistor works. Shorten the wires, remove devices, or slow the bus: C = 400 pF, 100 kHz (1000 ns): Rp(max) = 2950 ohm choose 2.2 k: t(rise) = 0.8473 x 2200 x 400 pF = 746 ns Breakout boards often carry their own pull-ups (4.7 k is common, from memory). They are in PARALLEL: n boards of 4.7 k make 4.7 k / n: 3 boards -> 1.57 k. So check what is already on the bus before adding resistors, and never rely on the Pico's internal pull-ups (about 50 to 80 k: Basic Electronics 10 section 1) for I2C.

I2C in MicroPython, and the clock-stretching and stuck-bus problems

MicroPython's I2C object has scan(), readfrom_mem(addr, reg, n) and writeto_mem(addr, reg, data), which are exactly the three calls that a register-based driver needs. This block is from memory and was not run on a board:

# MicroPython on a Raspberry Pi Pico: from memory, NOT run on hardware, NOT run here (syntax-checked only) from machine import I2C, Pin i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000) # I2C bus 0 on GP0 (SDA) and GP1 (SCL), 400 kHz print([hex(a) for a in i2c.scan()]) # the 7-bit addresses that answered data = i2c.readfrom_mem(0x68, 0x20, 12) # 12 bytes starting at register 0x20, as one burst i2c.writeto_mem(0x68, 0x11, b"\x01") # write one byte to register 0x11

Two failures are specific to I2C. A device may stretch the clock (hold SCL low until it is ready), which makes a transaction take longer than the formula says. And a device that was reset in the middle of a byte can hold SDA low, which blocks the whole bus until it is released; the usual recovery, from memory, is to pulse SCL up to nine times as an ordinary output until SDA rises, then send a STOP. Both are reasons for the timeouts and retries of section 11.

5. SPI and UART

SPI: a clock, two data wires and a chip select

SPI uses three shared wires and one extra wire per device: SCK (clock from the host), MOSI (host out, device in), MISO (device out, host in) and CS (chip select, low while that device is being addressed; every other device ignores the bus). Data moves both ways at once, one bit per clock pulse, so a transfer of n bytes takes 8n / f seconds, with no address, no ACK and a clock that can be many megahertz.

+--------------+ +--------------+ | Pico (host) | | SPI device | | SCK |------------> | SCK | | MOSI |------------> | MOSI | | MISO | <------------| MISO | | CS |------------> | CS | | GND |--------------| GND | +--------------+ +--------------+ A second device shares SCK, MOSI and MISO and gets its own CS wire from another pin. Mode 0 (clock idles low, data sampled on the rising edge), one byte 0x9A = 10011010 sent most significant bit first: CS ----____________________________________---- SCK _____--__--__--__--__--__--__--__--_________ MOSI ____----________--------____----____________ 1 0 0 1 1 0 1 0 The four modes are the four combinations of CPOL (the idle level of the clock, 0 or 1) and CPHA (which edge samples): mode 0 = (0,0), 1 = (0,1), 2 = (1,0), 3 = (1,1). The device's datasheet names the mode. A wrong mode does not give an error: it gives bytes that are shifted by one bit or inverted. Time: t = 8 x bytes / f one command byte and 2 data bytes at 1 MHz: 24 us the same at 10 MHz: 2.4 us
# MicroPython on a Raspberry Pi Pico: from memory, NOT run on hardware, NOT run here (syntax-checked only) # (GP16 to GP19 are the build robot's line sensors, so an SPI device means moving pins: this is a general example) from machine import SPI, Pin spi = SPI(0, baudrate=1000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(19), miso=Pin(16)) cs = Pin(17, Pin.OUT, value=1) # chip select idles high (device not selected) cs.value(0) # select the device spi.write(bytes([0x80 | 0x0F])) # a command byte: many chips use the top bit to mean "read this register" reply = spi.read(1) # clock one byte out of the device cs.value(1) # release it

UART: bytes with no clock, and the 8N1 frame

A UART sends bytes with no clock wire at all: the two ends agree beforehand on the baud rate (bits per second), and each byte is wrapped in a frame so the receiver can find it. The common setting is 8N1: 8 data bits, no parity, 1 stop bit. The line idles high; a byte begins with a start bit (low), then the data bits least significant bit first, then a stop bit (high). That is 10 bits for every 8 bits of data, so a byte takes 10 / baud seconds and the most the link can carry is baud / 10 bytes per second.

+--------------+ +--------------+ | Pico | | GPS / lidar | | TX |------------> | RX | TX goes to RX, and RX goes to TX: the wires CROSS | RX | <------------| TX | | GND |--------------| GND | a common ground is required, and a common logic level (3.3 V to 3.3 V) +--------------+ +--------------+ The byte 'A' = 0x41 = 01000001 in 8N1 (one character column is a third of a bit time): TX ------___---_______________---___--------- S b0 b1 b2 b3 b4 b5 b6 b7 P 0 1 0 0 0 0 0 1 0 1 idle high, then S (start, low), then b0 first (the LEAST significant bit), b7 last, then P (stop, high), then idle again. Time per byte: t = 10 / baud 9600 baud: 10 / 9600 = 1.042 ms 960 bytes per second at most 115 200 baud: 10 / 115200 = 86.8 us 11 520 bytes per second at most A 9-byte frame at 115 200 baud: 9 x 86.8 = 781 us. An 82-character GPS sentence at 9600 baud: 82 x 1.042 = 85.4 ms, which is over four 20 ms passes of the loop. So a loop must never wait for a whole message: read whatever has ARRIVED each pass (uart.any() tells how many bytes are waiting), keep the partial message, and finish it on the next pass. Baud-rate mismatch: the receiver samples near the middle of each bit, so the last sample (the stop bit) is 9.5 bit times after the start edge. It would be lost if the clocks differed by half a bit over that span: 0.5 / 9.5 = 5.3 % of theoretical tolerance in total. A rule of thumb is to keep the combined error of the two ends to 2 or 3 %.
# MicroPython on a Raspberry Pi Pico: from memory, NOT run on hardware, NOT run here (syntax-checked only) # (GP0 and GP1 are also the I2C pins of this chapter: pick one use of that pair) from machine import UART, Pin uart = UART(0, baudrate=115200, tx=Pin(0), rx=Pin(1)) # 8 data bits, no parity, 1 stop bit are the defaults waiting = uart.any() # how many bytes are in the receive buffer right now: NEVER blocks if waiting: chunk = uart.read(waiting) # exactly what has arrived (or None if nothing) uart.write(b"hello\n")

6. Libraries or your own driver?

A driver is the code that knows one chip: its address, its registers, its units. Most popular chips already have one, written by someone else. The decision is not “never write your own” or “always write your own”; it depends on what you need:

Use an existing library whenWrite your own driver when
The chip is common and the library is maintained and fits your board and languageNo library exists for your chip, language or board (a very common case in MicroPython, where libraries are smaller than on Arduino)
You want to be running today and the speed is not criticalYou need to control the timing: one burst read of all the registers, not one transaction per value
The chip has many features and you use most of themYou use two features of a chip that has two hundred, and the library's size or its blocking calls get in the way
You can read its code and are sure what it does when the bus failsThe library hides errors (returns zero on failure, or blocks forever), which Programming for Robotics 1 section 11 forbids
It lets you inject the bus, so you can still test with a fakeYou want to test without hardware, and the library talks to real pins in its constructor

Writing a driver is smaller than it sounds: it is the datasheet's register map turned into a class of fifty lines. The rules that make it a good driver are the ones the rest of this chapter follows: take the bus as an argument (so a fake can replace it), check the chip's identity at start-up, read related values in one burst so they describe the same instant, convert to SI units inside the driver, raise an OSError when the bus fails (never return a fake zero), and keep the constants from the datasheet next to the code that uses them.

7. Reading a datasheet register map

A register-based chip is a small block of memory that you read and write by address. The datasheet's register map tells you what each address holds. Below is the map of an invented six-axis IMU modelled on common chips (the gyroscope and accelerometer of Robotics Fundamentals 6 section 4), so that the example is complete and can be tested. The things to find in any real datasheet are the same: the device address, an identity register, the configuration register and what its bits mean, how to wake the chip, where the data is, in which byte order, and how many LSB make one physical unit.

RegisterAddressR/WContents
WHO_AM_I0x0FRfixed value 0x6A: proves the right chip answered
CONFIG0x10R/Wbits 3:2 gyro range: 00 = ±250, 01 = ±500, 10 = ±1000, 11 = ±2000 degrees/s; bits 1:0 accelerometer range: 00 = ±2 g, 01 = ±4 g, 10 = ±8 g, 11 = ±16 g
POWER0x11R/Wbit 0: 1 = measuring, 0 = asleep (the chip starts asleep)
GYRO X, Y, Z0x20 to 0x25Rthree signed 16-bit values, big endian (the high byte is at the lower address)
ACCEL X, Y, Z0x26 to 0x2BRthree signed 16-bit values, big endian

The device address is 0x68 (7-bit; 0x69 if the address pin is high). The sensitivity is given in LSB per unit, one value per range setting: gyroscope 131, 65.5, 32.8 and 16.4 LSB per degree/s for the four ranges, accelerometer 16 384, 8192, 4096 and 2048 LSB per g. (Robotics Fundamentals 6 section 4 quotes the first of each: 131 and 16 384.)

From two bytes to a physical value

Step 1, join the bytes: raw = (high x 256) + low BIG endian: the high byte comes first (lower address). LITTLE endian reverses them: raw = (low x 256) + high. Step 2, make it signed: if raw is at least 32768: raw = raw - 65536 (16-bit two's complement: 0x8000 to 0xFFFF are the negative numbers) Step 3, scale to a unit: value = raw / (LSB per unit) Worked example: the bytes 0xFF, 0x38 (big endian): raw = 0xFF38 = 65 336 65 336 is at least 32 768 so raw = 65 336 - 65 536 = -200 -200 / 131 = -1.527 degrees/s read little endian the same bytes would give 0x38FF = 14 591, a plain positive number: the WRONG answer, and nothing warns you. Limits: 0x7FFF = +32 767 (the most positive) 0x8000 = -32 768 (the most negative). Accelerometer: bytes 0x40, 0x00 -> raw = 16 384 -> 16 384 / 16 384 = 1.000 g on the +-2 g range. Resolution: one LSB = 1 / (LSB per unit): gyro +-250 range: 1 / 131 = 0.00763 degrees/s +-2000 range: 1 / 16.4 = 0.0610 degrees/s Range against resolution: the full scale is 32 767 / 131 = 250.1 degrees/s (the datasheet's 131 is a rounding of 32768 / 250 = 131.072: an error of 0.055 %). A bigger range stops a fast spin from saturating (a reading stuck at full scale) but makes every LSB coarser by the same factor: 2000 / 250 = 8 times. Units inside the program are SI (radians per second): 1 degree/s = pi / 180 = 0.017453 rad/s so 10 degrees/s = 0.1745 rad/s.

A fake bus, a fake chip and a driver

The fake bus has the same three methods as MicroPython's I2C. Devices are plain objects with read(reg) and write(reg, value). The fake IMU holds true physical values and works out the register bytes from them, so it checks the driver in the direction that matters: physical value in, bytes on the bus, physical value out. (A fake is only as right as your reading of the datasheet. It tests your code, not your understanding of the chip: the first run on real hardware is still the final test.) The bus can also be told to fail on demand, which no real bus does politely.

class FakeI2C: """Stands in for machine.I2C: scan(), readfrom_mem() and writeto_mem(). Devices are objects with read(reg) and write(reg, value).""" def __init__(self): self.devices = {} self.transactions = 0 self.fail_next = 0 # make this many upcoming transactions fail with a bus error def add(self, addr, device): self.devices[addr] = device def scan(self): return sorted(self.devices) def _open(self, addr): self.transactions += 1 if self.fail_next > 0: self.fail_next -= 1 raise OSError(5) # EIO: a bus error if addr not in self.devices: raise OSError(19) # ENODEV: nobody answered at this address return self.devices[addr] def readfrom_mem(self, addr, reg, n): device = self._open(addr) return bytes(device.read(reg + i) for i in range(n)) def writeto_mem(self, addr, reg, data): device = self._open(addr) for i, value in enumerate(data): device.write(reg + i, value) class FakeImu: """An invented IMU-like device with the register map of the table above. Holds TRUE physical values.""" GYRO_FULL_SCALE_DPS = (250, 500, 1000, 2000) ACCEL_FULL_SCALE_G = (2, 4, 8, 16) def __init__(self): self.regs = {0x0F: 0x6A, 0x10: 0x00, 0x11: 0x00} self.gyro_dps = [0.0, 0.0, 0.0] self.accel_g = [0.0, 0.0, 1.0] def _raw(self, value, full_scale): raw = int(round(value / full_scale * 32768)) return max(-32768, min(32767, raw)) # the device saturates beyond its range def read(self, reg): if 0x20 <= reg <= 0x2B: word_index, byte_index = divmod(reg - 0x20, 2) config = self.regs[0x10] if word_index < 3: full_scale = self.GYRO_FULL_SCALE_DPS[(config >> 2) & 3] raw = self._raw(self.gyro_dps[word_index], full_scale) else: full_scale = self.ACCEL_FULL_SCALE_G[config & 3] raw = self._raw(self.accel_g[word_index - 3], full_scale) word = raw & 0xFFFF return (word >> 8) if byte_index == 0 else (word & 0xFF) # big endian: high byte first return self.regs.get(reg, 0) def write(self, reg, value): self.regs[reg] = value

Now the driver itself. It takes the bus as an argument, checks WHO_AM_I, writes the configuration, wakes the chip, and reads all twelve data bytes in one burst (so the six values describe the same instant, and the transaction costs one address overhead instead of six). The scale tables are the datasheet's, written once at the top.

GYRO_LSB_PER_DPS = (131.0, 65.5, 32.8, 16.4) # from the datasheet: one value per range setting ACCEL_LSB_PER_G = (16384.0, 8192.0, 4096.0, 2048.0) def to_int16(high, low): """Two bytes (big endian) to a signed 16-bit number (two's complement).""" raw = (high << 8) | low if raw & 0x8000: raw -= 0x10000 return raw class Imu: ADDR = 0x68 REG_WHO_AM_I = 0x0F REG_CONFIG = 0x10 REG_POWER = 0x11 REG_DATA = 0x20 WHO_AM_I_VALUE = 0x6A def __init__(self, i2c, addr=ADDR, gyro_range=0, accel_range=0): self.i2c = i2c self.addr = addr self.gyro_range = gyro_range self.accel_range = accel_range def begin(self): who = self.i2c.readfrom_mem(self.addr, self.REG_WHO_AM_I, 1)[0] if who != self.WHO_AM_I_VALUE: raise OSError("not the expected IMU: WHO_AM_I = 0x%02X" % who) self.i2c.writeto_mem(self.addr, self.REG_CONFIG, bytes([(self.gyro_range << 2) | self.accel_range])) self.i2c.writeto_mem(self.addr, self.REG_POWER, b"\x01") # wake up def read(self): """One burst of 12 bytes: all six values are from the same instant. Returns (gyro dps xyz, accel g xyz).""" data = self.i2c.readfrom_mem(self.addr, self.REG_DATA, 12) words = [to_int16(data[2 * i], data[2 * i + 1]) for i in range(6)] gyro = tuple(w / GYRO_LSB_PER_DPS[self.gyro_range] for w in words[:3]) accel = tuple(w / ACCEL_LSB_PER_G[self.accel_range] for w in words[3:]) return gyro, accel bus = FakeI2C() chip = FakeImu() bus.add(0x68, chip) chip.gyro_dps = [10.0, -20.0, 0.5] chip.accel_g = [0.0, 0.0, 1.0] imu = Imu(bus) imu.begin() gyro, accel = imu.read() print("bus transactions: begin 3 + read 1 = %d" % bus.transactions) print("gyro dps: %.3f %.3f %.3f (true 10, -20, 0.5)" % gyro) print("accel g: %.3f %.3f %.3f" % accel) chip.gyro_dps = [300.0, 0.0, 0.0] gyro, _ = imu.read() print("300 dps on the +-250 range reads %.1f dps (saturated)" % gyro[0]) imu_wide = Imu(bus, gyro_range=3) imu_wide.begin() gyro, _ = imu_wide.read() print("300 dps on the +-2000 range reads %.2f dps; one LSB is %.4f dps" % (gyro[0], 1 / GYRO_LSB_PER_DPS[3]))
bus transactions: begin 3 + read 1 = 4 gyro dps: 10.008 -20.008 0.504 (true 10, -20, 0.5) accel g: 0.000 0.000 1.000 300 dps on the +-250 range reads 250.1 dps (saturated) 300 dps on the +-2000 range reads 299.70 dps; one LSB is 0.0610 dps

Read the output as the formulas above predict: 10 dps is 1310.72 counts, which the chip can only store as 1311; 1311 / 131 = 10.008, an error of 0.008 dps, most of it (0.0055) from the datasheet's rounded 131 and the rest (0.002) from rounding to whole counts. The 0.5 dps is a few counts (66 of them), so it is only good to about a percent. A 300 dps spin on the ±250 range saturates and reads about 250, which is why the range must match the robot (a robot that turns at 3 rad/s, 172 dps, fits in ±250 with margin; one spun by hand might not). The wider range reads 299.70 with a coarser step.

The two quick checks on the byte handling, which are the two classic driver bugs (the wrong byte order and the missing sign step), run in a few lines:

raw_bytes = bytes([0xFF, 0x38]) print("big endian: %6d (0xFF38 as an unsigned number is %d)" % (int.from_bytes(raw_bytes, "big", signed=True), 0xFF38)) print("little endian: %6d (the same bytes, the wrong order)" % int.from_bytes(raw_bytes, "little", signed=True)) print("to_int16(0xFF, 0x38) = %d to_int16(0x7F, 0xFF) = %d to_int16(0x80, 0x00) = %d" % (to_int16(0xFF, 0x38), to_int16(0x7F, 0xFF), to_int16(0x80, 0x00))) print("-200 / 131 = %.3f dps 16384 / 16384 = %.1f g 10 dps = %.4f rad/s" % (-200 / 131, 16384 / 16384, 10 * 3.141592653589793 / 180))
big endian: -200 (0xFF38 as an unsigned number is 65336) little endian: 14591 (the same bytes, the wrong order) to_int16(0xFF, 0x38) = -200 to_int16(0x7F, 0xFF) = 32767 to_int16(0x80, 0x00) = -32768 -200 / 131 = -1.527 dps 16384 / 16384 = 1.0 g 10 dps = 0.1745 rad/s

The same driver on a Raspberry Pi

On a Pi the I2C library is usually smbus2, whose method names (from memory) differ from MicroPython's: read_i2c_block_data(addr, reg, n) returns a list, and write_i2c_block_data(addr, reg, list) takes one. A ten-line adapter gives it the MicroPython names, so the Imu class above runs unchanged. The check below uses a fake with smbus2's method names (also from memory) wrapped around the fake bus, so it tests the adapter's list and bytes handling, not the library.

class SmbusAdapter: """Gives an smbus2-style bus the readfrom_mem / writeto_mem names that Imu uses.""" def __init__(self, smbus): self.smbus = smbus def readfrom_mem(self, addr, reg, n): return bytes(self.smbus.read_i2c_block_data(addr, reg, n)) def writeto_mem(self, addr, reg, data): self.smbus.write_i2c_block_data(addr, reg, list(data)) class FakeSmbus: """Method names as in smbus2 (from memory), backed by the FakeI2C above.""" def __init__(self, i2c): self._i2c = i2c def read_i2c_block_data(self, addr, reg, n): return list(self._i2c.readfrom_mem(addr, reg, n)) def write_i2c_block_data(self, addr, reg, data): self._i2c.writeto_mem(addr, reg, bytes(data)) chip.gyro_dps = [0.0, 0.0, 90.0] pi_imu = Imu(SmbusAdapter(FakeSmbus(bus))) pi_imu.begin() gyro, accel = pi_imu.read() print("through the adapter: gyro z %.2f dps, accel z %.3f g" % (gyro[2], accel[2]))
through the adapter: gyro z 90.05 dps, accel z 1.000 g

8. The ultrasonic sensor: pulse width to distance, with a timeout

The HC-SR04 style sensor (Robotics Fundamentals 5 section 3, wired in Robotics Fundamentals 10 section 9.5) is read by timing a pulse: a 10 microsecond high pulse on the trigger pin makes it send a burst of sound, and its echo pin then goes high for as long as the sound took to go out and come back. The pulse width gives the distance, and a missing echo gives a timeout, which must become “no reading” (None), not a wrong number (Programming for Robotics 1 section 11).

d = v x t / 2 v(T) = 331.3 + 0.606 T m/s (T in degrees C) t = the echo pulse width (the factor 2: out and back) Worked example (20 C): v = 343.42 m/s echo width 1458 us: d = 343.42 x 0.001458 / 2 = 0.2504 m echo width 5830 us: d = 1.001 m One microsecond of echo = 343.42 x 1e-6 / 2 = 0.1717 mm of range (the timer is not the limit: the sensor's own accuracy is millimetres or worse). Timeout: the longest useful echo is for the longest range: t(max) = 2 x 4 / 343.42 = 23.3 ms so a timeout of 25 000 us (a little over t(max)) is enough. A timed-out echo, or a range outside 0.02 to 4 m, means NO reading: return None. (The 25 ms is the time the plain blocking read can freeze the loop: Programming for Robotics 1 section 5.) Temperature: if the program assumes 20 C on a 0 C day, the speed is wrong by (343.42 - 331.3) / 331.3 = 3.7 %, and so is every distance: 1.000 m reads as 1.037 m.

The class below takes its trigger pin, echo pin, a function that times a pulse (machine.time_pulse_us on a Pico, a fake in a test) and a microsecond sleep. On MicroPython, time_pulse_us(pin, 1, timeout_us) returns the width in microseconds, or a negative number on a timeout (from memory: −2 if the pulse never started, −1 if it never ended).

def speed_of_sound_mps(temp_c=20.0): return 331.3 + 0.606 * temp_c def echo_to_range_m(echo_us, temp_c=20.0): return echo_us * 1e-6 * speed_of_sound_mps(temp_c) / 2 class Ultrasonic: """An HC-SR04 style sensor. pulse_us(pin, level, timeout_us) times a pulse (machine.time_pulse_us on a Pico, a fake in tests).""" def __init__(self, trig, echo, pulse_us, sleep_us, timeout_us=25000, min_m=0.02, max_m=4.0, temp_c=20.0): self.trig, self.echo = trig, echo self.pulse_us, self.sleep_us = pulse_us, sleep_us self.timeout_us = timeout_us self.min_m, self.max_m, self.temp_c = min_m, max_m, temp_c def read_m(self): self.trig.value(0) self.sleep_us(2) self.trig.value(1) self.sleep_us(10) # the 10 microsecond trigger pulse self.trig.value(0) echo_us = self.pulse_us(self.echo, 1, self.timeout_us) if echo_us < 0: # -2: the echo never started, -1: it never ended (a timeout) return None range_m = echo_to_range_m(echo_us, self.temp_c) if not (self.min_m <= range_m <= self.max_m): return None return range_m class FakeEchoTimer: """Hands out the pulse widths of a script, one per call, like time_pulse_us would measure them.""" def __init__(self, widths_us): self.widths_us = list(widths_us) def __call__(self, pin, level, timeout_us): return self.widths_us.pop(0) timer = FakeEchoTimer([1458, 5830, 22740, 100, -2, -1]) sonar = Ultrasonic(FakePin("TRIG"), FakePin("ECHO"), timer, lambda us: None) for _ in range(6): value = sonar.read_m() print("read_m() ->", "None" if value is None else "%.3f m" % value) print("one microsecond of echo = %.4f mm of range" % (echo_to_range_m(1) * 1000)) true_echo_us = 2 * 1.0 / speed_of_sound_mps(0.0) * 1e6 print("a wall at 1.000 m on a 0 C day echoes after %.0f us; read with 20 C assumed: %.3f m" % (true_echo_us, echo_to_range_m(true_echo_us)))
read_m() -> 0.250 m read_m() -> 1.001 m read_m() -> 3.905 m read_m() -> None read_m() -> None read_m() -> None one microsecond of echo = 0.1717 mm of range a wall at 1.000 m on a 0 C day echoes after 6037 us; read with 20 C assumed: 1.037 m

The 100 microsecond echo is 17 mm, under the 20 mm minimum, so it is rejected; the two negative values are the timeout cases. This version blocks for up to 25 ms inside pulse_us, which is the problem Programming for Robotics 1 section 5 identified (the loop cannot hold 50 Hz). The cure it described is a start-then-collect pattern: trigger now, return at once, and let the echo pin's edge interrupt record the times. The class below is the logic of that, with no hardware in it: the interrupt handler calls on_edge, the main loop calls poll once per pass and gets None until the answer is ready, then the width once, or −1 for a timeout.

class EchoTimer: """Non-blocking echo measurement. An edge interrupt on the echo pin calls on_edge(); the main loop calls poll().""" def __init__(self, timeout_us=25000): self.timeout_us = timeout_us self.started_us = None self.rise_us = None self.width_us = None def start(self, now_us): # called when the trigger pulse is sent self.started_us = now_us self.rise_us = None self.width_us = None def on_edge(self, level, now_us): # the interrupt handler: tiny, no allocation, no printing if level: self.rise_us = now_us elif self.rise_us is not None: self.width_us = now_us - self.rise_us def poll(self, now_us): """None while waiting (or idle); once per ping, the echo width in us, or -1 for a timeout.""" if self.started_us is None: return None if self.width_us is not None: width, self.started_us = self.width_us, None return width if now_us - self.started_us > self.timeout_us: self.started_us = None return -1 return None echo = EchoTimer() echo.start(0) log = [echo.poll(500)] # nothing yet echo.on_edge(1, 620) # the echo pin rises (the interrupt fires) log.append(echo.poll(1500)) # still high: no width yet echo.on_edge(0, 620 + 1458) # and falls 1458 us later log.append(echo.poll(3000)) log.append(echo.poll(3010)) # idle again echo.start(60000) # the next ping: no echo comes log.append(echo.poll(70000)) log.append(echo.poll(90000)) print(log) print("range of the first ping: %.3f m" % echo_to_range_m(log[2]))
[None, None, 1458, None, None, -1] range of the first ping: 0.250 m

On a real board, on_edge is called from a pin interrupt that reads time.ticks_us(), and the subtraction now_us - self.started_us must use ticks_diff (Programming for Robotics 1 section 3: the tick counters wrap). The timing diagram and the MicroPython glue are in PicoHardware (section 10), where the blocking version is used first because it is simplest, and replacing it with this one is a change inside that class only.

9. Wheel encoders with interrupts

An encoder pulse train must not be polled by a 50 Hz loop: at 0.3 m/s the build robot's wheel makes about 881 edges per second, one every 1.1 ms, and a 20 ms pass would see seventeen or eighteen of them at once and know nothing about their order. The edge must be handled when it happens, by an interrupt (Basic Electronics 10 section 6), and the loop reads a counter. The decoding rule is the state table of Robotics Fundamentals 6 section 2: the two channels A and B form a two-bit state that steps through four values in a fixed order, forwards or backwards; each step is one count; a jump of two states means an edge was missed.

state = (A x 2) + B forward: 00 -> 10 -> 11 -> 01 -> 00 reverse: 00 -> 01 -> 11 -> 10 -> 00 Table, index = (previous state x 4) + new state: +1 for a forward step, -1 for a reverse step, 0 for no change, ERR for a jump of two (00 to 11, 01 to 10, and back) new state: 00 01 10 11 previous 00 0 -1 +1 ERR previous 01 +1 0 ERR -1 previous 10 -1 ERR 0 +1 previous 11 ERR +1 -1 0 The build robot (Robotics Fundamentals 10 section 9.3): 600 counts per wheel revolution, 0.340 mm per count. Edge rate: ticks per second = v / 0.00034034 (the unrounded metres per tick) 0.3 m/s -> 881.5 ticks/s per wheel 0.4 m/s -> 1175.3 ticks/s per wheel Per 20 ms pass: 0.3 m/s: 17.6 ticks 0.4 m/s: 23.5 ticks one tick in a pass is 0.340 mm / 0.020 s = 0.0170 m/s (5.7 % of 0.3 m/s); in a 100 ms window 0.0034 m/s Interrupt load: both wheels at 0.4 m/s: 2 x 1175.3 = 2351 interrupts/s. If one handler takes about 30 us (an ASSUMPTION for a MicroPython handler: measure yours), load = 2351 x 30 us = 7.1 %. At 0.3 m/s: 1763 x 30 us = 5.3 %. Handlers must stay short and must not allocate memory or print.
ERR = 2 STEP_TABLE = (0, -1, +1, ERR, # previous state 00 +1, 0, ERR, -1, # previous state 01 -1, ERR, 0, +1, # previous state 10 ERR, +1, -1, 0) # previous state 11 class QuadratureDecoder: """4x decoding: every edge of A or B counts. State = (A << 1) | B. Forward is 00 -> 10 -> 11 -> 01 -> 00.""" def __init__(self, a=0, b=0): self.state = (a << 1) | b self.count = 0 self.errors = 0 # jumps of two states: an edge was missed def step(self, a, b): new = (a << 1) | b delta = STEP_TABLE[(self.state << 2) | new] if delta == ERR: self.errors += 1 # direction unknown: leave the count alone else: self.count += delta self.state = new FORWARD = (0b10, 0b11, 0b01, 0b00) # the states after 00, in order decoder = QuadratureDecoder() for i in range(12): # three full cycles forward state = FORWARD[i % 4] decoder.step(state >> 1, state & 1) print("after 12 forward edges: count %d, errors %d" % (decoder.count, decoder.errors)) for state in (0b01, 0b11, 0b10, 0b00): # four edges backwards (00 -> 01 -> 11 -> 10 -> 00) decoder.step(state >> 1, state & 1) print("after 4 reverse edges: count %d, errors %d" % (decoder.count, decoder.errors)) decoder.step(1, 1) # a missed edge: the state jumps from 00 to 11 print("after a jump 00 -> 11: count %d, errors %d" % (decoder.count, decoder.errors)) decoder.step(0, 1) print("after 11 -> 01 forward: count %d, errors %d" % (decoder.count, decoder.errors))
after 12 forward edges: count 12, errors 0 after 4 reverse edges: count 8, errors 0 after a jump 00 -> 11: count 8, errors 1 after 11 -> 01 forward: count 9, errors 1

The counts follow the table: twelve forward edges make +12, four reverse edges bring it to 8, and the impossible jump is counted as an error instead of guessed. A rising error count on a real robot means the interrupts are being missed (a handler too slow, a noisy wire, a wheel faster than the design), and it belongs in the log (Programming for Robotics 1 section 10).

The decoder is the same code on every board. Only the glue differs: the interrupt must read both pins and call step. In MicroPython that is pin.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=...) (from memory), and the handler is shown inside PicoHardware below. The 50 Hz loop reads the counter; since the interrupt can change it in the middle of a read, the read is done with interrupts briefly disabled (disable_irq and enable_irq in MicroPython, from memory: Basic Electronics 10 section 6 gave the same advice for 8-bit Arduinos).

10. The build robot's pin map and PicoHardware

The pins below are those of the wiring table of Robotics Fundamentals 10 section 9.5 (Pico GP numbers, an example from memory there: check your own pinout), plus one extension: an IMU on I2C bus 0, on GP0 and GP1, which that table leaves free. (GP0 and GP1 are the pins of I2C bus 0 and of UART 0 on the Pico, from memory: you may use the pair for one or the other.) The pin numbers in the sketch of Programming for Robotics 1 section 7 were only an example; this map replaces them.

FunctionPinKindCode (MicroPython, from memory)
Left encoder A, BGP2, GP3digital input, interrupt on both edgesPin(2, Pin.IN, Pin.PULL_UP), .irq(...)
Right encoder A, BGP4, GP5digital input, interrupt on both edgesas above
Ultrasonic trigger, echoGP6, GP7output; input (echo through the 1 k / 2 k divider)Pin(6, Pin.OUT), Pin(7, Pin.IN)
Driver standby (STBY)GP9output, low at reset (10 k pull-down)Pin(9, Pin.OUT, value=0)
Left direction AIN1, AIN2GP10, GP11two outputsPin(10, Pin.OUT)
Left, right speed PWMA, PWMBGP12, GP13PWM, 20 kHz, same slicePWM(Pin(12))
Right direction BIN1, BIN2GP14, GP15two outputsPin(14, Pin.OUT)
Line sensors 1 to 5GP16 to GP20five digital inputsPin(16, Pin.IN)
Start buttonGP21input with pull-upPin(21, Pin.IN, Pin.PULL_UP)
Status LEDGP25output (on-board LED on the plain Pico)Pin(25, Pin.OUT)
Battery monitorGP26 (ADC0)ADC, 56 k / 33 k dividerADC(Pin(26))
IMU (extension)GP0 (SDA), GP1 (SCL)I2C bus 0, 400 kHz, address 0x68I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)

Keeping the map as data in one place, and checking it for mistakes in code, catches the commonest wiring-plan error (one pin given two jobs) before anything is connected. The PWM slice of a Pico pin is (GP / 2) mod 8 and the channel is A for even pins and B for odd ones (from memory of the RP2040 design), so GP12 and GP13 are the two channels of slice 6: one frequency for both, which is why they were chosen together.

PIN_MAP = { "enc_l_a": 2, "enc_l_b": 3, "enc_r_a": 4, "enc_r_b": 5, "trig": 6, "echo": 7, "stby": 9, "l_in1": 10, "l_in2": 11, "l_pwm": 12, "r_pwm": 13, "r_in1": 14, "r_in2": 15, "line1": 16, "line2": 17, "line3": 18, "line4": 19, "line5": 20, "button": 21, "led": 25, "bat_adc": 26, "i2c_sda": 0, "i2c_scl": 1, # the extension: an IMU on I2C bus 0 } used = sorted(PIN_MAP.values()) assert len(used) == len(set(used)), "a pin is used twice" header_pins = list(range(0, 23)) + [26, 27, 28] # GP0 to GP22 and GP26 to GP28 are on the Pico's header print("%d pins in the map; free header pins: %s" % (len(used), [gp for gp in header_pins if gp not in used])) for name in ("l_pwm", "r_pwm"): gp = PIN_MAP[name] print("%s = GP%d: PWM slice %d, channel %s" % (name, gp, (gp // 2) % 8, "AB"[gp % 2]))
23 pins in the map; free header pins: [8, 22, 27, 28] l_pwm = GP12: PWM slice 6, channel A r_pwm = GP13: PWM slice 6, channel B

The free pins are few (GP8, GP22, GP27, GP28), and none of them forms a complete SPI or a second UART pair, so adding an SPI device means moving something (an expander for the line sensors, say). That is the real cost of a bus: pins. Now the class itself, the full PicoHardware for the three methods of Programming for Robotics 1 section 7, plus battery_v, line and start for later chapters. It is assembled from the tested pieces of this chapter (MotorChannel, Ultrasonic, QuadratureDecoder, Imu, check_devices from section 11, battery_v), and only the glue between them and the machine module is new and untested. It has not been run on a board; it is syntax-checked only.

# MicroPython for the build robot on a Raspberry Pi Pico: from memory, NOT run on hardware, NOT run here (syntax-checked only) from machine import Pin, PWM, ADC, I2C, time_pulse_us, disable_irq, enable_irq import time class PicoHardware: """The hardware interface of Programming for Robotics 1 section 7: range_m(), ticks(), set_duty(left, right).""" def __init__(self, cfg=Config, pins=PIN_MAP): self.cfg = cfg p = pins # 1. outputs first, in their safe state: the driver is disabled until the program says otherwise self.stby = Pin(p["stby"], Pin.OUT, value=0) self.led = Pin(p["led"], Pin.OUT, value=0) self.left = MotorChannel(Pin(p["l_in1"], Pin.OUT, value=0), Pin(p["l_in2"], Pin.OUT, value=0), PWM(Pin(p["l_pwm"]))) self.right = MotorChannel(Pin(p["r_in1"], Pin.OUT, value=0), Pin(p["r_in2"], Pin.OUT, value=0), PWM(Pin(p["r_pwm"]))) # 2. the ultrasonic sensor (the blocking version of section 8) self.sonar = Ultrasonic(Pin(p["trig"], Pin.OUT, value=0), Pin(p["echo"], Pin.IN), time_pulse_us, time.sleep_us) # 3. the encoders, counted by interrupts on every edge of both channels self.enc_l = QuadratureDecoder() self.enc_r = QuadratureDecoder() self.l_a = Pin(p["enc_l_a"], Pin.IN, Pin.PULL_UP) self.l_b = Pin(p["enc_l_b"], Pin.IN, Pin.PULL_UP) self.r_a = Pin(p["enc_r_a"], Pin.IN, Pin.PULL_UP) self.r_b = Pin(p["enc_r_b"], Pin.IN, Pin.PULL_UP) self.enc_l.state = (self.l_a.value() << 1) | self.l_b.value() # start from the real wire states self.enc_r.state = (self.r_a.value() << 1) | self.r_b.value() self._isr_left = self._on_left # one stored reference: writing self._on_left in an interrupt would create an object each time (from memory) self._isr_right = self._on_right both_edges = Pin.IRQ_RISING | Pin.IRQ_FALLING for pin in (self.l_a, self.l_b): pin.irq(trigger=both_edges, handler=self._isr_left) for pin in (self.r_a, self.r_b): pin.irq(trigger=both_edges, handler=self._isr_right) # 4. the analogue battery monitor, the line sensors and the IMU on I2C bus 0 self.battery_adc = ADC(Pin(p["bat_adc"])) self.line_pins = [Pin(p["line%d" % i], Pin.IN) for i in range(1, 6)] self.i2c = I2C(0, sda=Pin(p["i2c_sda"]), scl=Pin(p["i2c_scl"]), freq=400000) self.imu = Imu(self.i2c) def _on_left(self, pin): # the interrupt handler: short, no allocation self.enc_l.step(self.l_a.value(), self.l_b.value()) def _on_right(self, pin): self.enc_r.step(self.r_a.value(), self.r_b.value()) def start(self): """Call once before the loop: check that the chips we need answer, wake the IMU.""" missing, _ = check_devices(self.i2c, {Imu.ADDR: "imu"}) if missing: raise OSError("missing I2C device(s): %s" % ["0x%02X" % a for a in missing]) self.imu.begin() def range_m(self): return self.sonar.read_m() # BLOCKS for up to 25 ms: see section 8 and Programming for Robotics 1 section 5 def ticks(self): state = disable_irq() # an interrupt must not change the counts in the middle of the copy left, right = self.enc_l.count, self.enc_r.count enable_irq(state) return left, right def set_duty(self, left, right): self.left.set(left) self.right.set(right) self.stby.value(0 if (left == 0 and right == 0) else 1) # standby (driver off) when both motors are stopped def battery_v(self): return battery_v(self.battery_adc.read_u16()) def line(self): return tuple(pin.value() for pin in self.line_pins)

Three points about it. Order: the outputs are set to their safe states first, before anything can go wrong later in the constructor, and standby (driver off) is the default. The seam held: Robot from Programming for Robotics 1 section 6 calls range_m(), ticks() and set_duty(left, right) and nothing else, so it does not change. What is still open: the blocking range read (replace it with the EchoTimer of section 8, inside this class), the motor ramp and the calibration of the deadband (Programming for Robotics 5), and all the timing behaviour on real silicon, which only a measurement with LoopStats (Programming for Robotics 1 section 2) can tell.

11. Bus errors, checksums, retries and the start-up scan

Real buses fail: a wire is brushed by a motor's noise, a connector works loose, a chip is not powered. The failure shows as an OSError from the library, a wrong byte, or silence. Four defences, each cheap, each testable on the fake bus.

A checksum: CRC-8, with a worked calculation

Where a link can corrupt data (serial links, long wires; many I2C sensors add a check byte), the sender adds a cyclic redundancy check, and the receiver recomputes it. A CRC-8 treats the bytes as one long binary number and divides it by a fixed polynomial, keeping the remainder. The bitwise version is short: put the byte into the running value, then for each of eight steps shift left, and whenever a 1 falls off the top, exclusive-or the polynomial in.

CRC-8 with polynomial 0x07 (x^8 + x^2 + x + 1), start value 0x00. Message: one byte, 0x31 = 00110001. start crc = 0x00 xor 0x31 = 00110001 (0x31) step 1: top bit 0, shift 01100010 (0x62) step 2: top bit 0, shift 11000100 (0xC4) step 3: top bit 1, shift 10001000, xor 0x07 (00000111) = 10001111 (0x8F) step 4: top bit 1, shift 00011110, xor 0x07 = 00011001 (0x19) step 5: top bit 0, shift 00110010 (0x32) step 6: top bit 0, shift 01100100 (0x64) step 7: top bit 0, shift 11001000 (0xC8) step 8: top bit 1, shift 10010000, xor 0x07 = 10010111 (0x97) CRC-8 = 0x97 The sender transmits 0x31, 0x97. The receiver runs the CRC over the message and compares it with the byte received; a difference means the data is damaged. A CRC of n bits detects every burst of errors up to n bits and every single-bit error; a random corruption still passes it once in 2^n (1 in 256 for 8 bits), so a CRC reduces the risk, it does not remove it.
def crc8(data, poly=0x07, init=0x00): """Bitwise CRC-8: xor in a byte, then shift left 8 times, xor-ing the polynomial whenever a 1 falls off the top.""" crc = init for byte in data: crc ^= byte for _ in range(8): if crc & 0x80: crc = ((crc << 1) ^ poly) & 0xFF else: crc = (crc << 1) & 0xFF return crc def crc8_trace(byte, poly=0x07): crc = byte for step in range(1, 9): top = (crc >> 7) & 1 crc = (crc << 1) & 0xFF if top: crc ^= poly print(" step %d: top bit %d -> %s (0x%02X)" % (step, top, format(crc, "08b"), crc)) return crc print("message 0x31, polynomial 0x07, start 0x00:") print(" start: %s (0x31)" % format(0x31, "08b")) result = crc8_trace(0x31) print("CRC = 0x%02X crc8() function: 0x%02X" % (result, crc8(b"\x31"))) print("crc8(b'123456789') = 0x%02X" % crc8(b"123456789")) print("poly 0x31, start 0xFF, bytes BE EF: 0x%02X" % crc8(bytes([0xBE, 0xEF]), 0x31, 0xFF)) def make_frame(payload): return bytes(payload) + bytes([crc8(payload)]) def frame_ok(frame): return len(frame) >= 2 and crc8(frame[:-1]) == frame[-1] frame = make_frame([0x12, 0x34]) detected = 0 for bit in range(8 * len(frame)): # flip each single bit of the frame in turn damaged = bytearray(frame) damaged[bit // 8] ^= 1 << (bit % 8) if not frame_ok(bytes(damaged)): detected += 1 print("frame %s ok: %s" % (frame.hex(), frame_ok(frame))) print("single-bit errors detected: %d of %d" % (detected, 8 * len(frame)))
message 0x31, polynomial 0x07, start 0x00: start: 00110001 (0x31) step 1: top bit 0 -> 01100010 (0x62) step 2: top bit 0 -> 11000100 (0xC4) step 3: top bit 1 -> 10001111 (0x8F) step 4: top bit 1 -> 00011001 (0x19) step 5: top bit 0 -> 00110010 (0x32) step 6: top bit 0 -> 01100100 (0x64) step 7: top bit 0 -> 11001000 (0xC8) step 8: top bit 1 -> 10010111 (0x97) CRC = 0x97 crc8() function: 0x97 crc8(b'123456789') = 0xF4 poly 0x31, start 0xFF, bytes BE EF: 0x92 frame 1234f1 ok: True single-bit errors detected: 24 of 24

The two lines after the trace compare the function with published check values, written here from memory: the CRC-8 (polynomial 0x07) of the ASCII text 123456789 is the standard test value 0xF4, and the Sensirion-style CRC (polynomial 0x31, start 0xFF) of the bytes 0xBE, 0xEF is 0x92 in several Sensirion datasheets. The code agrees with both, which is a good sign for the function, though not a substitute for the datasheet of the chip you actually use. All 24 single-bit errors in the three-byte frame were caught.

Retries with a limit

A transient fault is often gone on the next try, so a read is retried; but a retry costs bus time, and an unlimited retry is a blocking wait in disguise. Give every retry loop a small limit, count the failures, and when the limit is reached report “no reading” (the stale-data rule of Programming for Robotics 1 section 11). The loop does not sleep between tries: it uses the pass structure of Programming for Robotics 1 section 5 if it needs to wait.

def read_with_retry(read, attempts=3): """Call read() until it works; give up after 'attempts' tries. Returns (value, tries used); value None means failure.""" for tries in range(1, attempts + 1): try: return read(), tries except OSError: pass return None, attempts chip.gyro_dps = [0.0, 0.0, 0.5] imu.begin() # put the chip back on the +-250 range bus.fail_next = 2 value, tries = read_with_retry(imu.read) print("two bus errors, then OK: tries used %d, gyro z %.3f dps" % (tries, value[0][2])) bus.fail_next = 5 value, tries = read_with_retry(imu.read) print("five bus errors: value %s after %d tries" % (value, tries)) bus.fail_next = 0 t_one_us = i2c_time_s(12, 400000) * 1e6 print("worst case, 3 tries at 400 kHz: %.1f us of bus time, against a 20000 us period" % (3 * t_one_us))
two bus errors, then OK: tries used 3, gyro z 0.504 dps five bus errors: value None after 3 tries worst case, 3 tries at 400 kHz: 1012.5 us of bus time, against a 20000 us period

A scan at start-up

Before the loop starts, check that every chip the robot needs is there. A missing sensor found at start-up is a clear message; found in the middle of a run it is a stale reading and a stopped robot. The I2C scan() returns the 7-bit addresses that answered. The function below compares them with what the robot expects and also reports devices you did not plan for (a clue to a wiring or address mistake).

def check_devices(bus, expected): """Compare what answers on the bus with what the robot needs. Returns (missing, unexpected).""" found = set(bus.scan()) missing = sorted(addr for addr in expected if addr not in found) unexpected = sorted(found - set(expected)) return missing, unexpected class StubDevice: def read(self, reg): return 0 def write(self, reg, value): pass EXPECTED = {0x68: "imu", 0x3C: "oled display"} bus.add(0x29, StubDevice()) # something we did not plan for missing, unexpected = check_devices(bus, EXPECTED) print("missing:", ["%s at 0x%02X" % (EXPECTED[a], a) for a in missing]) print("unexpected:", ["0x%02X" % a for a in unexpected]) print("0x68 on the wire: write byte 0x%02X, read byte 0x%02X" % (0x68 << 1, (0x68 << 1) | 1)) print("scan range 0x08 to 0x77: %d addresses" % (0x77 - 0x08 + 1))
missing: ['oled display at 0x3C'] unexpected: ['0x29'] 0x68 on the wire: write byte 0xD0, read byte 0xD1 scan range 0x08 to 0x77: 112 addresses

What happens when the check fails is a design decision made in advance: refuse to start and light the LED, or start in a degraded mode without that sensor (Programming for Robotics 7 and Programming for Robotics 12 return to degraded modes; both are planned and not yet written). For a sensor the robot cannot work without, refusing to start is the safe default.

12. Bus speed against the loop budget

Programming for Robotics 1 section 12 measured a pass in stages and kept the utilisation U = C / T under about 50 % on the worst pass. The bus is part of C. With the formula of section 4, t = 9 × (2 + 1 + n) / f, the cost of an added sensor is a calculation, not a guess.

Add to the build robot an IMU (12-byte burst, as in section 7) and a time-of-flight sensor (a 2-byte range read, Robotics Fundamentals 10 section 9.10 suggests one). Bits: IMU: 9 x (2 + 1 + 12) = 135 ToF: 9 x (2 + 1 + 2) = 45 total 180 bits per pass at 100 kHz: 180 / 100 000 = 1.800 ms at 400 kHz: 180 / 400 000 = 0.450 ms The base pass of Programming for Robotics 1 section 12 cost 4.00 ms (U = 20 % of a 20 ms period): 100 kHz: C = 4.00 + 1.80 = 5.80 ms U = 5.80 / 20 = 29 % the fastest loop at U = 50 %: T = C / 0.5 = 11.6 ms = 86.2 Hz 400 kHz: C = 4.00 + 0.45 = 4.45 ms U = 22.25 % T = 4.45 / 0.5 = 8.9 ms = 112.4 Hz The software adds an overhead per transaction (building the call, the library, the garbage collector). If it is 200 us (an ASSUMPTION: measure yours with StageTimer): 400 kHz: bus time = 0.450 + 2 x 0.200 = 0.850 ms C = 4.85 ms U = 24.25 % T = 9.7 ms = 103.1 Hz At 400 kHz the overhead (0.400 ms) is nearly as big as the bus time (0.450 ms): speeding the clock further would not help; fewer transactions would. Read once, in a burst. Fast-plus (1 MHz) only helps if the bus capacitance allows it (section 4) and the chips support it.
def pass_cost_us(reads, f_hz, overhead_us=0.0): """Bus time for a list of register reads (each entry: number of bytes read), plus a software overhead per transaction.""" return sum(i2c_time_s(n, f_hz) * 1e6 + overhead_us for n in reads) READS = [12, 2] # IMU burst, and a 2-byte time-of-flight range BASE_MS = 4.00 # the pass cost of Programming for Robotics 1 section 12 for f_hz, overhead_us in ((100000, 0), (400000, 0), (400000, 200)): bus_ms = pass_cost_us(READS, f_hz, overhead_us) / 1000 cost_ms = BASE_MS + bus_ms print("%6d Hz, overhead %3d us: bus %.3f ms, pass %.3f ms, U = %.1f %%, f(max at 50 %%) = %.1f Hz" % ( f_hz, overhead_us, bus_ms, cost_ms, 100 * cost_ms / 20, 1000 / (cost_ms / 0.5)))
100000 Hz, overhead 0 us: bus 1.800 ms, pass 5.800 ms, U = 29.0 %, f(max at 50 %) = 86.2 Hz 400000 Hz, overhead 0 us: bus 0.450 ms, pass 4.450 ms, U = 22.2 %, f(max at 50 %) = 112.4 Hz 400000 Hz, overhead 200 us: bus 0.850 ms, pass 4.850 ms, U = 24.2 %, f(max at 50 %) = 103.1 Hz

The first rule of bus use follows: few, large transactions. One 12-byte burst costs 135 bits; six separate 2-byte reads would cost 6 × 45 = 270 bits, twice as much, with six chances to fail and six moments that are not the same instant. The second rule: never wait on a bus inside the loop without a limit (a stuck bus, section 4, would freeze the robot), which is why the retries of section 11 have a limit and the driver of section 7 raises an exception.

13. Challenges

Work these with a pencil and with Python. The solutions are text files with the working, and the code in them was run.

Challenge 1: Bus times and pull-up resistors
(a) A magnetometer is read with one register-pointer byte and 6 data bytes. Find the I2C transaction time at 100 kHz and at 400 kHz (9 bit times per byte), and the share of the 10 ms period of a 100 Hz loop that three such sensors use on a 400 kHz bus. (b) Find the time to read the same 6 bytes over SPI at 2 MHz (one command byte first) and over a UART at 57 600 baud (8N1). (c) For a 3.3 V bus with VOL = 0.4 V, IOL = 3 mA and a wire capacitance of 150 pF at 400 kHz (rise time at most 300 ns), find the smallest and largest pull-up resistor, choose an E12 value (1.0, 1.2, 1.5, 1.8, 2.2, 2.7 k ...) with the most margin, and check its rise time and its low-level current. Then show that with 400 pF at 400 kHz no resistor works, and find a value that works at 100 kHz (rise time at most 1000 ns).
View solution
Challenge 2: A temperature sensor driver on a fake bus
An invented temperature sensor at address 0x48 has these registers: 0x00 TEMP_L and 0x01 TEMP_H (a signed 16-bit value, little endian, 256 LSB per degree C), 0x02 STATUS (bit 0 = data ready), 0x03 CONFIG (writing bit 0 = 1 starts one conversion and clears the ready bit until it finishes). Write a TempSensor driver whose read_c(max_polls) starts a conversion, polls STATUS at most max_polls times (no sleeping, no endless loop), reads the two data bytes, converts to degrees C, and raises OSError if the sensor is never ready. Write a fake sensor (and a minimal fake bus) and show it returning 25.0 C from raw 0x1900 and −10.0 C from raw 0xF600, becoming ready after a few polls, and timing out when it never becomes ready.
View solution
Challenge 3: A framed UART sensor with a CRC
A serial sensor sends 4-byte frames: the header 0xAA, a high byte, a low byte, and the CRC-8 (polynomial 0x07, start value 0x00) of the two data bytes. Write a FrameParser with feed(byte) that returns the 16-bit value when a frame with a good CRC completes (and None otherwise), throws away bytes that are not a header, and on a bad CRC drops only the header byte and searches again. Test it on a stream made of the frame for 1000, two noise bytes, the frame for 2000 with one bit flipped, and the frame for 3000, and report the values found, the number of bad CRCs and the number of skipped bytes. Then find the time per frame and the maximum frame rate at 115 200 baud and at 9600 baud, and how many bytes arrive in one 20 ms pass at each.
View solution

Quick reference

  • Choosing: digital pin = 1 bit; PWM = an average voltage out; ADC = a voltage in; I2C = 2 shared wires, addressed devices; SPI = 3 shared wires + 1 chip select per device, fastest; UART = 2 crossed wires, a byte stream. The part decides; you decide speed, wiring and code.
  • Times: I2C bits = 9 × (2 + register bytes + data bytes), t = bits / f; SPI t = 8 × bytes / f; UART (8N1) t = 10 × bytes / baud (115 200 baud: 86.8 µs per byte).
  • I2C wiring: one pair of pull-ups, Rp(min) = (Vcc − 0.4) / 3 mA, Rp(max) = t(rise) / (0.8473 C); 300 ns at 400 kHz, 1000 ns at 100 kHz; parallel boards give R / n. 7-bit address 0x68 is 0xD0 (write) and 0xD1 (read) on the wire. Scan at start-up.
  • SPI: match the mode (CPOL, CPHA) to the datasheet; chip select idles high; a wrong mode gives shifted bytes, not an error. UART: same baud on both ends, TX to RX crossed, common ground; read what has arrived, never wait for a whole message.
  • ADC: read_u16 = 12-bit code × 16 (from memory); V(pin) = u16 / 65535 × Vref; V(bat) = V(pin) / k; one LSB = Vref / 4096 = 0.806 mV.
  • PWM and motors: steps = f(clk) / f(PWM) (6250 at 20 kHz); duty = deadband + (1 − deadband) × |cmd|, tiny commands to 0; set the safe state first.
  • Datasheet to driver: raw = high × 256 + low (big endian); if raw ≥ 32768 subtract 65536 (signed 16-bit); value = raw / (LSB per unit); check WHO_AM_I; read in one burst; raise OSError on failure; SI units inside.
  • Ultrasonic: d = v t / 2, v = 331.3 + 0.606 T; timeout 25 000 µs; a timeout or out-of-range value is None; the blocking read stalls the loop, so start-then-collect with an edge interrupt.
  • Encoders: interrupt on every edge of A and B, a 16-entry state table gives +1, −1 or ERR; ticks per second = v / 0.00034034 (881 at 0.3 m/s); read the counter with interrupts disabled.
  • Faults: CRC-8 (poly 0x07) catches all single-bit errors; retry with a small limit and no sleeping; a retry loop without a limit is a blocking wait. Bus time adds to the pass cost C: few, large transactions.

Next: Programming for Robotics 3, A Simulator to Test In

This chapter made the seam real: a PicoHardware built from tested drivers, a fake bus that let the driver be checked on a PC, and the arithmetic of what each bus costs in time. But the fakes of Programming for Robotics 1 and 2 only fake one wire at a time: a scripted list of ranges, a wall in one dimension. To test a line follower, an obstacle avoider or a planner, the “hardware” has to be a world: a robot that really drives around, whose wheels turn, whose range sensors see walls, and whose encoders and sensors have noise. Programming for Robotics 3 (planned; not yet written) builds that, a small two-dimensional differential-drive simulator in pure Python (using the kinematics of Robotics Fundamentals 4) that provides the same range_m, ticks and set_duty methods, so that every program in the rest of the course can be run, replayed and tested on any PC before it touches a motor.