One long evening at the bench. A board that says hello, a light that blinks, a button that is counted, a knob that is read, a display that counts, and at the end a dial whose number follows your hand. Every program from that night, in order, explained from the ground up.
Taught by David Emiru Egwell
By the end you can
1Read a pin as an input and drive a pin as an output, and say why each program works2Decode a rotary knob from two wires using a sixteen-answer table3Join the knob and the display into one working dial, and fix it when it will not flash
The last program of the night, runningclamped 0 to 9999
The four bytes sent to the display
This is a pretend knob, so you can try the finished thing before you learn how it is built. Turn it, and try turning left from zero. Press it, and the number goes to zero. By Chapter 8 you will know the line that does each of those.
Before we begin
How this book teaches
1
You see it work
Each chapter opens with a small working model on the page. You play with it before you read one line of Rust.
2
You get a picture
A shop, a spring door, a tap, a revolving door. If the picture is clear, the code is only the picture written down.
3
You read the code
The full program, exactly as it ran on the board, and then the lines that matter, one idea at a time.
4
You are tested last
The questions are at the very end, after the teaching. A test that comes first only measures what you already knew.
Where this book sits
This is the second book. The first, The Clock That Counts, goes slowly through the display and the clock program, including every mark and symbol in Rust. Here we go wider and faster: from the very first line the board printed, up to the display. Where an idea was already taught in full there, this book tells you which chapter to open.
In use on the night
Version or detail
Board
ESP32-WROOM-32 development boards with micro-USB, on screw-terminal breakouts. Chip revisions v3.0 and v3.1.
esp-hal
1.2.2
esp-println / esp-backtrace
0.18.0 / 0.20.0
esp-bootloader-esp-idf
0.6.0
esp-generate
2.0.0
Computer
Windows, PowerShell. Rust through espup, the esp toolchain.
esp-hal changes its names between versions. If a program does not build on a newer version, this table is the first place to look.
What is tested and what is not
Every program in this book ran on a real board on the night, from hello to the dial. Two pieces were rebuilt afterwards. The hello program in Chapter 2: its text is rebuilt from the generator's own template, because the exact file was not kept. And the use lines of the dial program in Chapter 8 were joined together from the knob and display programs. Use the file your generator makes for hello. Every program also passes a Rust syntax check, but the board is what compiled and ran them. The pictures from everyday life (the watchman, the spring door, the revolving door) are first drafts. Keep them or replace them with your own.
Chapter 1
The workbench and the night
Picture this
A carpenter does not start with the chair. First she sets up the bench, finds the right saw, and makes one clean cut to see that the saw is sharp. Only then does she build.
The first night with a new board is the same. Before anything glows or turns, you set up the bench: the right Rust, the right tools, a cable that carries data, and one tiny program that proves the whole chain from your keyboard to the chip is alive. That tiny program is "hello".
The board is an ESP32 development board, the kind with a micro-USB socket and a chip module marked ESP32-WROOM-32, sitting on a breakout with screw terminals. The terminals are labelled P0, P18, P25 and so on, and each label is a GPIO number. GPIO means general-purpose input/output: a pin that your program can either drive (output) or listen to (input). When this book says "pin 25", it means the terminal marked P25.
Your computer is Windows, using PowerShell. The programs are written in Rust, and they run with no operating system on the chip at all. That is the meaning of the two lines at the top of every program, #![no_std] and #![no_main]. Book 1, Chapters 2 and 3, explain them fully.
Tool
What it does
The picture
espup
Installs the special Rust that knows the Xtensa chip inside the ESP32
The right saw for this wood
. $HOME\export-esp.ps1
Tells this PowerShell window where that Rust lives
Putting the saw on the bench
esp-generate
Makes a new project folder with a working skeleton program
A cut-out template
cargo run --release
Builds the program, sends it to the board, and opens the monitor
Cut, fit, and look at it, in one go
espflash board-info
Asks the board who it is: chip, flash size, address
Knocking on the door
The sequence for a new project is short. Run it once, and every program in this book uses the same folder afterwards.
. $HOME\export-esp.ps1
espflash board-info
esp-generate -o esp32 -o esp32-wroom-32 -o log -o esp-backtrace hello
cd hello
cargo run --release
Note
Why -o esp32?
Older guides say --chip esp32. That switch was removed in esp-generate 2.0.0. If you see "unknown option", this is why. Each -o ticks one option: the chip, the module, the log printing, and the crash reporter.
Note
Which port?
If cargo run asks which serial port, pick the one called CP210x. On the night it was COM12. If no port appears, the usual causes are a charge-only cable or the missing CP210x driver. Chapter 9 has the full list.
Note
How to leave and restart
Ctrl+C leaves the monitor, and the program keeps running on the board. The EN or reset button restarts the program from its first line.
The shape every program shares
Look at any program in this book and you will see the same frame. The details differ, but the frame does not. Learn it once here.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let p = esp_hal::init(config);
loop {
// the program's own work goes here
}
}
Line
What it says in plain words
#![no_std]#![no_main]
"This program lives on bare metal. There is no operating system, and I start from my own main."
use esp_backtrace as _;
"If I crash, tell me where." The underscore means we only need the crate to be present.
esp_app_desc!();
A small label that the bootloader expects at the front of every app.
#[main] fn main() -> !
The front door. The ! after the arrow promises that main never returns.
init_logger_from_env()
Switches on the info! printing, so the board can talk to your screen.
esp_hal::init(config)
Wakes the chip at full speed and hands back every pin in one bundle, called the peripherals.
The one rule to remember about pins
Each pin has exactly one owner. When you write Output::new(p.GPIO2, …), the pin moves out of the bundle and into your led. Nobody else can use it now. That is how Rust stops two parts of a program from fighting over one wire.
Chapter 2
Hello: the watchman's call
See it first: the serial monitora model of what your screen shows
Notice two things. A line appears every half second without anyone asking. And when you leave the monitor, the board does not stop. Only you stopped looking. Press EN and the program starts again from its first line.
Picture this
A night watchman walks the compound. Every half hour he calls out, "All is well." He does not do it because anyone asked. He does it so that everyone who is awake knows he is awake too. If the calling stops, something is wrong.
The "Hello world!" program is the watchman's call. It does nothing useful. It proves that the chip is alive, that your program is running on it, and that the road back to your screen works. When the call arrives, the whole chain is good: the toolchain, the cable, the driver, the flash, the monitor.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{
clock::CpuClock,
main,
time::{Duration, Instant},
};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let _peripherals = esp_hal::init(config);
loop {
info!("Hello world!");
let delay_start = Instant::now();
while delay_start.elapsed() < Duration::from_millis(500) {}
}
}
Reconstructed from esp-generate's template. Yours may differ in small details, such as comments. When the generator's own file and this one disagree, trust the generator's, because it was written for the exact library versions you installed.
The only new lines
info!("Hello world!"); is a macro call. The exclamation mark after info tells you so. It prints one line to the monitor.
The next two lines are a wait made by hand. Instant::now() notes the time on the chip's clock. delay_start.elapsed() asks "how long since then?". The while loop has an empty body {}, so it does nothing but keep asking, over and over, until the answer reaches 500 milliseconds. Then the outer loop goes round again.
A wait made by looking at a clock is like a cook watching the pot and checking his watch. A wait made with Delay, which you meet in the next chapter, is like a cook setting a kitchen timer. Both give the same half second. The first shows you how time is measured, and the second is shorter to write.
Piece
Meaning
Instant::now()
→
Read the chip's clock right now
.elapsed()
→
How long has passed since that moment?
Duration::from_millis(500)
→
A length of time: 500 milliseconds, which is half a second
while … < … {}
→
Keep checking until the time is up
let _peripherals
→
We take the bundle of pins but use none of them. The leading underscore says "I know it is unused".
Your turn
Change the message to your own name and run it again. The first time you see your own words come back from a chip you flashed, something shifts. It is a very small program, and it is the first proof that you are in control of the machine.
Chapter 3
Blink: a tap you open and close
See it first: pin 2 togglingthe program's own loop
500 ms
Slide the wait down to 50 milliseconds. The light seems to stay on all the time, with just a hint of flicker. The eye cannot follow twenty changes a second. That is also how a dimmed light works, but that story is for another day.
Picture this
A water tap has two states: open and closed. A pin set as an output is a tap on the electric supply. Open it, and 3.3 volts flows out of the pin. Close it, and the flow stops. An LED on that pin is the glass that fills when the tap is open and goes empty when it is closed.
Blinking is turning the tap one way, waiting, turning it the other way, waiting, and doing that for ever.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{
clock::CpuClock,
delay::Delay,
gpio::{Level, Output, OutputConfig},
main,
};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
// Pin 2 drives the little blue LED on many ESP32 boards.letmut led = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
let delay = Delay::new();
loop {
led.toggle();
if led.is_set_high() {
info!("LED on");
} else {
info!("LED off");
}
delay.delay_millis(500);
}
}
Reading it
Output::new(…)
Makes pin 2 an output. The three things inside the brackets are: which pin, which level to start at (Level::Low means the tap starts closed), and the electrical settings (OutputConfig::default() is the ordinary choice).
let mut led
The word mut means "this thing will change". Without it Rust refuses to let you toggle the pin. A tap that cannot be turned is not much of a tap.
led.toggle()
If the tap is open, close it. If closed, open it. One call does both, so we do not need to remember which way it was.
led.is_set_high()
Asks the pin "which way did we leave you?". The program uses the answer to print "LED on" or "LED off", so the log tells the truth even when no light is visible.
delay.delay_millis(500)
Waits half a second. Delay::new() made the kitchen timer, and delay_millis sets it.
Honest note: what the night actually showed
The program uses pin 2 because many ESP32 boards put a small blue LED there. The board on the bench had no LED on pin 2, so nothing was seen to blink. The program still ran perfectly, and the monitor printed "LED on" and "LED off" every half second. That is worth remembering: a program can be right when the light is not there. The log is your second pair of eyes. To see a real blink, put an outside LED with a resistor of a few hundred ohms in series on a free pin, and change GPIO2 to that pin. A resistor is always needed, or the LED can burn out.
Make the light stay on for a quarter second and off for three-quarters, by toggling twice per loop with two different waits.
Print how many times the light has toggled, using a u32 counter like the one in Chapter 4.
Chapter 4
The button and the spring door
See it first: hold the pad downtouch, click, or press Space
3.3 VPin 0 sees
falsedown (is_low)
falsewas_down
0presses counted (the program)
0if it counted the holding
Hold the pad for two seconds. The honest program counts one press. The naive program, which counts every time it looks and finds the button down, counts a hundred or more. The whole chapter is about the difference.
Picture this
A door with a spring keeps itself closed. When nobody pushes, the spring holds it shut. Push it open and it swings. The BOOT button is the door, and the pull-up resistor is the spring. When nobody presses, the spring holds the pin up at 3.3 volts. When you press, the button joins the pin to ground and pulls it down to zero.
Notice the surprise: pressed means low. The button does not send a signal. It cuts the spring's hold and lets the pin drop. That is why the program says is_low() for pressed.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{
clock::CpuClock,
delay::Delay,
gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull},
main,
};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
letmut led = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
// The BOOT button joins pin 0 to ground when pressed,// so we switch on the chip's own pull-up to keep it high otherwise.let button = Input::new(peripherals.GPIO0, InputConfig::default().with_pull(Pull::Up));
let delay = Delay::new();
letmut presses: u32 = 0;
letmut was_down = false;
loop {
let down = button.is_low();
if down {
led.set_high();
} else {
led.set_low();
}
// Count once, at the moment the button goes down.if down && !was_down {
presses += 1;
info!("press number {}", presses);
}
was_down = down;
delay.delay_millis(10);
}
}
Input::new(GPIO0, … Pull::Up)
Makes pin 0 an input and switches on the chip's own pull-up spring. Without the spring, an unconnected pin floats and reads random noise, like a door hanging off its hinges.
button.is_low()
True while the button is held, for the reason above. Pressed pulls the pin down to zero.
was_down
Remembers what the last look saw. A press is counted only at the moment the answer changes from "up" to "down". Without it, holding for a second would print a hundred presses.
down && !was_down
Read it aloud: "it is down now, and it was not down before". Both must be true. The && means "and", and the ! in front of a true or false value means "not".
The 10 ms wait
A switch does not close cleanly. For a thousandth of a second or two it flickers, like a coin settling on a table. Looking only every ten milliseconds ignores most of that chatter.
u32
A counter that can hold up to about four billion. Whole numbers only, no negatives. A button will not be pressed that many times in a lifetime.
The night's lesson
There are two small buttons on the board, one on each side of the USB socket. When the first press on the left button printed a screenful of strange lines, it was not an error. Pressing the left one, RST or EN, restarts the chip, and as the chip wakes it prints a report on itself: the bootloader, the partition table, "Loaded app from partition". The button the program listens to is BOOT, on the other side of the socket.
There was a second lesson in that moment. After a reset, the press counter began again at zero. Anything held only in the program's variables is wiped by a restart. To keep a number across a restart, it has to be saved in flash, and that is a later book.
Careful with pin 0
Pin 0 also decides how the chip starts. Using it as a button after the program is running is fine. Do not hold BOOT down while pressing RST unless you want to enter the flashing mode on purpose.
Make each press toggle the LED, so it stays on until the next press.
Print how many milliseconds each press lasted, using Instant from Chapter 2.
Wire an outside button between pin 18 and GND, switch on the pull-up, and read it the same way.
Chapter 5
The knob, part one: the idea
See it first: step the knob a quarter at a timethe program's own table
CLK (pin 25)
DT (pin 26)
11last (CLK, DT)
11now
15(last << 2) | now
0STEP answer
0acc (needs 4 or −4)
0position
The sixteen answers. The lit one is the one just used.
Press "¼ step clockwise" four times. The two pins go through four patterns and come back to where they started. Each quarter step scored +1. The accumulator reached 4, so the position rose by one and the accumulator reset. Now try "Switch bounce": the pins jump in a way that cannot happen in a real turn, and the table answers 0. The glitch is simply ignored.
Picture this
A revolving door at a hotel has two sensors on the floor, side by side. A guest walking in steps on sensor A first and then sensor B. A guest walking out steps on B first and then A. The door does not need to see the guest's face to know the direction. It only needs to know which sensor fired first.
The rotary knob is that door. It has two switches, called CLK and DT, wired so that they close in a fixed order as you turn. Turn one way, and DT changes first. Turn the other way, and CLK changes first. Your program watches both and notes the order.
Each switch is either high (1) or low (0), so together they make a two-bit number from 0 to 3. As you turn the knob clockwise, that number goes through four patterns in this order, and then repeats:
Quarter step
CLK
DT
The two bits
Decimal
Resting
1
1
11
3
1 (clockwise)
1
1→0
10
2
2
1→0
0
00
0
3
0
0→1
01
1
4 (back to resting)
0→1
1
11
3
Turning the other way plays the same four patterns in the opposite order: 11, 01, 00, 10, 11. There is a nice property here. Between two neighbouring patterns, only one bit changes. This is called Gray code, and it is exactly why the knob is trustworthy: two switches never change at the same instant in a clean turn.
Where the sixteen answers come from
Take the pattern we saw last time and the pattern we see now. Each is two bits, so together they make a four-bit number, from 0 to 15. That is sixteen possible pairs, and for each pair there is one right answer: did we move one step forward (+1), one step back (−1), or not at all or impossibly (0)?
You could write that as a long chain of if statements. A much tidier way is to write the answers down once, in a table, and let the program look it up. A table is a row of pigeonholes, each with a note inside:
// The sixteen-answer table.// index = (last << 2) | now value = +1, -1 or 0constSTEP: [i8; 16] = [0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0];
The program builds the index like this: shift last two places left to make it the high half, then join now as the low half. For example, last = 11 and now = 10 gives 1110, which is 14, and STEP[14] is +1. Check it in the widget above: that is the first clockwise quarter step.
The impossible pairs, such as 11 jumping straight to 00, have 0 in their pigeonholes. A noisy contact that flickers cannot add or subtract anything by accident. That is the quiet strength of a lookup table.
Why four quarters make one click
One tick of the knob you feel in your fingers is a full cycle of the four patterns. The program adds up the quarter steps in a variable called acc (accumulator, a running total). When it reaches +4, one full click has been turned clockwise. When it reaches −4, one click anticlockwise. Then acc goes back to zero. If your knob has a different number of patterns per click, the 4 changes to a 2. The night's note said: "if one click counts twice, change the 4 to a 2".
Chapter 6
The knob, part two: the program
Here is the whole program as it ran on the board. The knob's three wires go to P25 (CLK), P26 (DT) and P27 (SW, the push switch). The knob's + goes to 3V3 and its GND to GND. Everything else you have already met.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{
clock::CpuClock,
delay::Delay,
gpio::{Input, InputConfig, Pull},
main,
};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
// The sixteen-answer table.constSTEP: [i8; 16] = [0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0];
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let p = esp_hal::init(config);
let clk = Input::new(p.GPIO25, InputConfig::default().with_pull(Pull::Up));
let dt = Input::new(p.GPIO26, InputConfig::default().with_pull(Pull::Up));
let sw = Input::new(p.GPIO27, InputConfig::default().with_pull(Pull::Up));
let delay = Delay::new();
letmut last: u8 = ((clk.is_high() asu8) << 1) | (dt.is_high() asu8);
letmut acc: i8 = 0;
letmut position: i32 = 0;
letmut sw_was = false;
loop {
let now = ((clk.is_high() asu8) << 1) | (dt.is_high() asu8);
acc += STEP[((last << 2) | now) asusize];
last = now;
if acc >= 4 {
acc = 0;
position += 1;
info!("position {}", position);
} elseif acc <= -4 {
acc = 0;
position -= 1;
info!("position {}", position);
}
let down = sw.is_low();
if down && !sw_was {
position = 0;
info!("zeroed");
}
sw_was = down;
delay.delay_millis(1);
}
}
The two lines that do the thinking
let now = ((clk.is_high() asu8) << 1) | (dt.is_high() asu8);
acc += STEP[((last << 2) | now) asusize];
clk.is_high() as u8
True or false becomes 1 or 0. as u8 is pouring one kind of value into another cup.
<< 1
Slide the bits one place to the left. 1 becomes 10 (two). It makes room for the second bit beside it.
|
The bitwise OR. It merges two bit patterns, and since the spaces do not overlap, it works like gluing them together. 10 OR 01 gives 11.
as usize
A table is indexed with usize, so our number is poured into that cup before the lookup.
acc += STEP[…]
Add whatever the table says, +1, −1 or 0, to the running total.
last = now
Tomorrow's "last" is today's "now". This is the memory that makes comparison possible.
Variable
Type
Job
last
u8
The two-bit pattern seen on the previous look
acc
i8
The quarter-step total. It needs a sign, so it is an i8 where the i means "signed".
position
i32
The click count. Signed too, so it can go negative.
sw_was
bool
Was the push switch down last time? The same trick as was_down in Chapter 4.
Then the switch. sw.is_low() means pressed, down && !sw_was means "just now pressed", and the program sets position = 0 and prints "zeroed". The loop ends with a one millisecond wait, so the knob is looked at about a thousand times a second. That is fast enough that a quick turn cannot slip between two looks.
What the night showed
The first run printed nothing at all. The cure was a test with one jumper wire: put it between P27 and a GND terminal, which does by hand what the knob's push switch does. Joining pin 27 to ground should print "zeroed". If it does, the program and the pin are fine and the fault is on the knob side.
It was on the knob side, in the simplest way: the board had been seated on the wrong side of the breadboard. After it was turned round, the numbers climbed 2, 3, 4, 5, then fell 4, 3, 2, 1, 0, and kept going into the negatives, −1, −2, all the way to −18. The sixteen answers had been written for a different board, and they worked unchanged here.
A debugging tool worth keeping
When a program prints nothing, you cannot tell whether the program is wrong or the wires are. The fix is to add a heartbeat that prints the raw pins once a second. Add two lines before loop, and a block inside it:
// 1. just before the line "loop {":info!("knob test running");
letmut ticks: u32 = 0;
// 2. inside the loop, just above "delay.delay_millis(1);":
ticks += 1;
if ticks % 1000 == 0 {
info!("clk={} dt={} sw={}", clk.is_high() asu8, dt.is_high() asu8, sw.is_high() asu8);
}
You do
Healthy line shows
If it does not
Leave the knob alone
clk=1 dt=1 sw=1
All zeros or random: the knob's + or GND is missing
Press the knob down
sw=0 while held
Still 1: the SW wire is not reaching P27
Turn slowly
clk and dt flicker between 0 and 1
Still 1: the CLK or DT wire is loose
Jumper P27 to GND
sw=0
If this works but the knob does not, the fault is the knob side
Two honest fixes to remember
If the number goes the wrong way, swap the CLK and DT wires, or swap the two pin numbers in the program. If one click moves the number by two, change the 4 to a 2. Each is a one-character mistake, and neither means the program is wrong.
Chapter 7
The display that counts
See it first: the counterten steps a second, like the program
0(n / 1000) % 10
0(n / 100) % 10
0(n / 10) % 10
0n % 10
n = 0
Each line of arithmetic picks one digit out of the number, and the table turns each digit into a byte of lit bars. Jump to 9995 and watch the number roll over from 9999 to 0000. The % 10000 in the program is what does that.
Picture this
The shop boy and his blackboard from Book 1 again. He cannot write a whole number on the board. He can only write one digit at a time, each in its own box, and for each digit he needs the chalk pattern. So to show 1234 he must first work out that the digits are 1, 2, 3, 4, and then copy the right chalk pattern into each box, in order.
The display is a four-digit module with a chip called TM1637 on its back. It talks over two wires: CLK, the beat, and DIO, the data. You hand it four bytes and it lights the bars. Book 1, Chapters 5 to 7, take you through those two wires one pulse at a time. Here is the short version.
The step
What the program sends
In words
1
start, 0x40, stop
"I will send the digits in order."
2
start, 0xC0, four bytes, stop
"Starting at the first digit, here are four."
3
start, 0x88 | brightness, stop
"Switch on, at this brightness (0 to 7)."
Here is the whole program, as it ran. The first sixty or so lines, from struct Tm to the end of impl Tm, are the display driver, written by hand with no library. You can copy the program with the button.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{
clock::CpuClock,
delay::Delay,
gpio::{Level, Output, OutputConfig},
main,
};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
// Which segments to light for each digit 0 to 9.constDIGITS: [u8; 10] = [0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F];
structTm {
clk: Output<'static>,
dio: Output<'static>,
delay: Delay,
}
implTm {
fn wait(&self) {
self.delay.delay_micros(5);
}
fn start(&mutself) {
self.clk.set_high();
self.dio.set_high();
self.wait();
self.dio.set_low();
self.wait();
self.clk.set_low();
}
fn stop(&mutself) {
self.clk.set_low();
self.dio.set_low();
self.wait();
self.clk.set_high();
self.wait();
self.dio.set_high();
self.wait();
}
fn write_byte(&mutself, byte: u8) {
for i in0..8 {
self.clk.set_low();
if (byte >> i) & 1 == 1 {
self.dio.set_high();
} else {
self.dio.set_low();
}
self.wait();
self.clk.set_high();
self.wait();
}
// The ninth tick: the chip answers "got it" by pulling the wire low.// We hold it low as well, so the two of us never fight.self.clk.set_low();
self.dio.set_low();
self.wait();
self.clk.set_high();
self.wait();
self.clk.set_low();
}
fn show(&mutself, digits: [u8; 4], brightness: u8) {
self.start();
self.write_byte(0x40); // "I will send the digits in order"self.stop();
self.start();
self.write_byte(0xC0); // "starting from the first digit"for d in digits {
self.write_byte(d);
}
self.stop();
self.start();
self.write_byte(0x88 | (brightness & 7)); // "switch on, at this brightness"self.stop();
}
}
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let p = esp_hal::init(config);
letmut tm = Tm {
clk: Output::new(p.GPIO18, Level::High, OutputConfig::default()),
dio: Output::new(p.GPIO19, Level::High, OutputConfig::default()),
delay: Delay::new(),
};
let delay = Delay::new();
letmut n: u32 = 0;
loop {
let d = [
DIGITS[((n / 1000) % 10) asusize],
DIGITS[((n / 100) % 10) asusize],
DIGITS[((n / 10) % 10) asusize],
DIGITS[(n % 10) asusize],
];
tm.show(d, 4);
if n % 10 == 0 {
info!("showing {}", n);
}
n = (n + 1) % 10000;
delay.delay_millis(100);
}
}
The driver is Book 1's subject. What is new here is only main, so look at it on its own:
Ten bytes, one per digit. Each bit is one bar of the seven-segment shape. 0x06 lights only the two right-hand bars, which is a 1. 0x3F lights six bars and leaves the middle one dark, which is a 0.
(n / 1000) % 10
Divide by 1000 and throw away the remainder, to get the thousands. Then % 10 keeps only the last digit of that. For 1234 you get 1. Each line below does the same with a smaller divisor.
as usize
The table is indexed with usize, so the digit is poured in first, as in Chapter 6.
n = (n + 1) % 10000
Add one, and wrap round to 0 after 9999, because only four digits fit on the board.
if n % 10 == 0
Print a line to the monitor once for every ten steps, which is once a second. Printing on every step would flood the screen.
delay_millis(100)
Ten steps a second. The last digit spins, and the others change slowly.
What the night showed
This is the program that would not flash. The wiring on paper was right: CLK to P18, DIO to P19, the display's own VCC and GND. But the display is a 5 volt part, and when its VCC was taken from the board's 5V terminal, flashing failed with the error espflash::flash_connect. The knob program, which has no display, flashed every time. That was the clue that pointed at the display's wiring and not at the cable or the driver.
The working arrangement: the display runs on its own 5 volt supply, with its ground joined to the board's ground so both sides agree on what zero is. Flash with the display unplugged, then plug it back in, and it runs. The honest part: the exact reason the display on the board's 5V line broke flashing was never fully found. The fix is solid and repeatable, but the explanation is still an open question.
Why the grounds must be joined
Voltage is a difference, like a height. A signal is "high" only compared with some zero. If the display's zero and the board's zero are different, the display cannot tell high from low. Two people can only agree on how tall a wall is if they measure from the same floor.
Make the counter count down instead, wrapping from 0000 to 9999. Think about what n - 1 does to an unsigned number at zero.
Change the brightness argument from 4 to 0, and then to 7. What do you see?
Make the colon blink, using the idea from Book 1, Chapter 5: add 0x80 to the second digit.
Chapter 8
The digital dial
See it first: the dialknob in, display out
0position
nochanged this round
digital dial runninglast monitor line
Turn left at zero. The knob-only program of Chapter 6 would have gone to −1, −2. Here the number stays at 0. A line in the dial program stops it, and you meet that line below.
Picture this
A radio with a volume dial and a little number window beside it. You turn the dial, and the number moves to match. You press the dial in, and the volume goes back to zero. Inside, there are exactly two jobs: one part feels your hand, and one part writes the number. A third part, a single variable called position, sits between them and holds the number. The dial writes it, the window reads it.
That is what Chapter 6 and Chapter 7 already built. Chapter 8 is the plumbing between them.
#![no_std]#![no_main]use esp_backtrace as _;
use esp_hal::{
clock::CpuClock,
delay::Delay,
gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull},
main,
};
use log::info;
esp_bootloader_esp_idf::esp_app_desc!();
// The sixteen-answer table from the knob.constSTEP: [i8; 16] = [0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0];
// Which segments to light for each digit 0 to 9.constDIGITS: [u8; 10] = [0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F];
structTm {
clk: Output<'static>,
dio: Output<'static>,
delay: Delay,
}
implTm {
fn wait(&self) {
self.delay.delay_micros(5);
}
fn start(&mutself) {
self.clk.set_high();
self.dio.set_high();
self.wait();
self.dio.set_low();
self.wait();
self.clk.set_low();
}
fn stop(&mutself) {
self.clk.set_low();
self.dio.set_low();
self.wait();
self.clk.set_high();
self.wait();
self.dio.set_high();
self.wait();
}
fn write_byte(&mutself, byte: u8) {
for i in0..8 {
self.clk.set_low();
if (byte >> i) & 1 == 1 {
self.dio.set_high();
} else {
self.dio.set_low();
}
self.wait();
self.clk.set_high();
self.wait();
}
// The ninth tick: the chip answers "got it" by pulling the wire low.// We hold it low as well, so the two of us never fight.self.clk.set_low();
self.dio.set_low();
self.wait();
self.clk.set_high();
self.wait();
self.clk.set_low();
}
fn show_number(&mutself, n: u32, brightness: u8) {
let d = [
DIGITS[((n / 1000) % 10) asusize],
DIGITS[((n / 100) % 10) asusize],
DIGITS[((n / 10) % 10) asusize],
DIGITS[(n % 10) asusize],
];
self.start();
self.write_byte(0x40);
self.stop();
self.start();
self.write_byte(0xC0);
for x in d {
self.write_byte(x);
}
self.stop();
self.start();
self.write_byte(0x88 | (brightness & 7));
self.stop();
}
}
#[main]fn main() -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let p = esp_hal::init(config);
letmut tm = Tm {
clk: Output::new(p.GPIO18, Level::High, OutputConfig::default()),
dio: Output::new(p.GPIO19, Level::High, OutputConfig::default()),
delay: Delay::new(),
};
let clk = Input::new(p.GPIO25, InputConfig::default().with_pull(Pull::Up));
let dt = Input::new(p.GPIO26, InputConfig::default().with_pull(Pull::Up));
let sw = Input::new(p.GPIO27, InputConfig::default().with_pull(Pull::Up));
let delay = Delay::new();
letmut last: u8 = ((clk.is_high() asu8) << 1) | (dt.is_high() asu8);
letmut acc: i8 = 0;
letmut position: i32 = 0;
letmut sw_was = false;
tm.show_number(0, 4);
info!("digital dial running");
loop {
let now = ((clk.is_high() asu8) << 1) | (dt.is_high() asu8);
acc += STEP[((last << 2) | now) asusize];
last = now;
letmut changed = false;
if acc >= 4 {
acc = 0;
position += 1;
changed = true;
} elseif acc <= -4 {
acc = 0;
position -= 1;
changed = true;
}
let down = sw.is_low();
if down && !sw_was {
position = 0;
changed = true;
}
sw_was = down;
if changed {
position = position.clamp(0, 9999);
tm.show_number(position asu32, 4);
info!("position {}", position);
}
delay.delay_millis(1);
}
}
The knob and display parts are exactly as they ran. The use lines at the top were assembled from the two earlier programs, because the dial needs both inputs and outputs.
What is new
The dial's loop is the knob loop from Chapter 6 with three changes. Read them as a short list:
letmut changed = false; // new: the sticky noteif acc >= 4 {
acc = 0;
position += 1;
changed = true;
} elseif acc <= -4 {
acc = 0;
position -= 1;
changed = true;
}
let down = sw.is_low();
if down && !sw_was {
position = 0;
changed = true; // new: zeroing needs a redraw too
}
sw_was = down;
if changed {
position = position.clamp(0, 9999); // new: stay inside the walls
tm.show_number(position asu32, 4); // new: write to the displayinfo!("position {}", position);
}
1
A changed flag
Redrawing the display means a couple of hundred pin changes on the two wires. We do that only when the number actually changed, so the knob is looked at again straight away. A flag is a sticky note saying "something happened this round".
2
Press sets changed too
Zeroing the position is a change, so the display must be redrawn. Forgetting this line is the classic bug: the number resets inside the program, but the screen still shows the old value.
3
position.clamp(0, 9999)
Keep the number inside the walls. If the knob takes it to −1, it is put back to 0. If it goes past 9999, it is put back to 9999. The display can only show four unsigned digits, so the number must stay in the range it can show.
4
position as u32
The knob works in signed whole numbers, i32. The display wants an unsigned u32. After the clamp it is certain to be zero or more, so the pour is safe.
There is one more thing to notice, and it is easy to miss. show_number is the old digit-picking code of Chapter 7, tidied into a method of Tm. A method is a verb that belongs to a noun. Instead of writing the digit arithmetic inside main, we write tm.show_number(position as u32, 4), and read it as "display, show this number at brightness four".
fn show_number(&mutself, n: u32, brightness: u8) {
let d = [
DIGITS[((n / 1000) % 10) asusize],
DIGITS[((n / 100) % 10) asusize],
DIGITS[((n / 10) % 10) asusize],
DIGITS[(n % 10) asusize],
];
self.start();
self.write_byte(0x40);
self.stop();
self.start();
self.write_byte(0xC0);
for x in d {
self.write_byte(x);
}
self.stop();
self.start();
self.write_byte(0x88 | (brightness & 7));
self.stop();
}
What the night showed
The video of the finished dial showed the display starting at 0000, rising as the knob was turned, then reading 19, 16, 15 and finally 9 as it was turned back. The number followed the hand in both directions, so it was not just a counter running by itself. Pressing the knob zeroed it. The verdict at the bench was: "zero works, everything works."
What the video could not show is whether one click moves the number by exactly one on every knob. If yours moves by two, change the 4 to a 2 in the two comparisons.
What you now own
A dial that sets a value and a display that shows it. This is the shape of a setpoint knob on the front of any machine that has one. Everything you build later with a knob and a number starts from this program.
And the clock?
The program in Book 1, The Clock That Counts, uses the same display, the same Tm toolbox and the same wiring. It replaces the knob with a count of seconds. The only things it adds are the time arithmetic and the blinking colon. If the dial works, the clock will, and the other way round. Remember that the clock forgets the time when the power goes, because the board has no battery clock.
Make the knob count in tens: add 10 instead of 1 per click. What does the clamp do near the top?
Make the number wrap round instead of stopping: from 9999 going up to 0, and from 0 going down to 9999. Replace clamp with an if that sets the opposite end.
Show the position as a percentage by dividing by 100, stopping at 100.
Chapter 9
Pins, power, and when it goes wrong
This is the chapter to keep open on the bench. First the wiring that worked, then the rules for choosing pins, then a table of the things that actually went wrong on the night and what fixed them.
P0 · BOOTInput with pull-up. Pressed = low. Chapter 4.
P25 · knob CLKInput with pull-up. Chapter 6.
P26 · knob DTInput with pull-up. Chapter 6.
P27 · knob SWInput with pull-up. Pressed = low.
Knob + → 3V3The knob is powered from the board's 3.3 volt rail.
Knob GND → GNDShared ground.
P18 · display CLKOutput. Chapter 7.
P19 · display DIOOutput. Chapter 7.
Display VCC → own 5 VSeparate 5 volt supply. Not from the board's 5V terminal.
Display GND → GNDMust be joined to the board's ground.
Choosing pins on the ESP32
These rules come from general ESP32 knowledge, not from the night. Check them against your own board's pin chart.
Pins
Rule
Why
GPIO 6 to 11
Never use
They are wired to the chip's own flash memory
GPIO 1 and 3
Avoid
They carry the serial monitor, the road back to your screen
GPIO 0, 2, 12, 15
Careful
Their level at power-up decides how the chip starts. Use them only when you know the reason.
GPIO 34 to 39
Inputs only
No output driver, and no internal pull-up spring
GPIO 18, 19, 25, 26, 27
Free to use
These are the ones this book uses, and they did not collide on the night
Try it: choose what you seefrom the night's real faults
Symptom
Cause
Fix
No serial port appears
Charge-only cable, or the CP210x driver is missing
Use a data cable and install the CP210x driver, then run espflash board-info
"Connecting…" for ever
The board did not enter flashing mode
Hold BOOT while the dots appear, then release
Wrong port chosen
Several ports are listed
Pick the CP210x one (COM12 on the night)
unknown option --chip
Old guide, esp-generate 2.0.0
Use -o esp32 -o esp32-wroom-32
Boot banner full of odd lines
You pressed RST (the left button), not BOOT
That is normal. Press the other button.
No light blinks, but the log says "LED on"
There is no LED on pin 2 on that board
Use an outside LED with a resistor on another pin
Knob prints nothing
Board seated on the wrong side, or a loose screw terminal
Do the P27 to GND jumper test, then check each wire
Knob counts the wrong way
CLK and DT swapped
Swap the two wires or the two pin numbers
One click moves two steps
This knob gives 2 quarter-steps per click
Change 4 to 2 in the program
espflash::flash_connect only with the display attached
Display VCC taken from the board's 5V terminal
Use a separate 5 V supply, join the grounds, flash with the display unplugged
Display lit but scrambled
CLK and DIO swapped, or a loose wire
Check P18 is CLK and P19 is DIO
The method behind every row
Split the problem in half. Is it the program or the wires? A jumper test, a heartbeat print, or flashing with a part unplugged each cut the problem in two. Then split the surviving half again. This is faster than staring.
Chapter 10
Now test yourself
You have been taught. Now the questions, and they are fair, because everything in them was in the chapters above. Tap an answer. You will see at once whether it is right, and why.
0 / 10
Answered correctly
What comes next
The road ahead
Two displays at once
The eight-digit module on the same table could show a second number beside the dial's number, such as a target and a reading.
The gauge needle
The knob turns the small stepper motor, so a pointer on its shaft agrees with the number on the display.
One knob, two boards
Send the knob's number through the air from one board to another. A first version of this program was written, but it has not been run on the boards yet, so it is a plan, not a result.
Serial and Modbus
The serial loopback and a Modbus device were written in the earlier book, but not run on the boards on the night. They come next in the careful order: run, then trust.
At the back
Words to keep
GPIO
General-purpose input/output: a pin the program can drive or read.
Output / Input
A pin you drive, or a pin you listen to.
High / Low
3.3 volts, or zero. Written 1 and 0.
Pull-up
A small resistor, here inside the chip, that holds a pin high until something pulls it down.
Edge detection
Reacting to the change, not the state. down && !was_down.
Debounce
Ignoring the tiny flickers a switch makes as it settles.
Quadrature
Two signals a quarter-cycle apart, whose order tells the direction.
Gray code
A sequence where only one bit changes between neighbours.
Lookup table
A row of pre-written answers, indexed by a number.
Accumulator
A running total, here the quarter steps toward one click.
Detent
One felt click of the knob.
Clamp
Force a number to stay between a floor and a ceiling.
TM1637
The chip on the four-digit module. Two data wires, no library needed.
Segment
One lit bar of a digit. Seven bars and a dot per digit.
Flag
A true or false variable that remembers that something happened.
Method
A function that belongs to a struct, such as tm.show_number.
Common ground
Joining the zero volt points of two supplies so they agree on what zero is.
Monitor
The window where info! lines appear, opened by cargo run.