Skip to content
Level 3 · AdvancedBuildPart 17 · page 4 of 7240 minSafety level A · Standard home darkroomCraftScience£
240Minutes
6Sources
ASafety level

Safety level A, standard home darkroom. Suitable with ordinary darkroom controls: nitrile gloves, eye protection, a well-ventilated room, dedicated utensils and correct labelling.

Build: The Timer Firmware

To write the firmware that makes the hardware an instrument, and to establish the one property that distinguishes an instrument from a device: that every exposure it makes is recorded, with the interval it achieved beside the interval it was asked for.

Three things come out of this session. Firmware in full, published and runnable, implementing the f-stop arithmetic of the lesson two pages back. A measurement of the firmware’s own overshoot, made with a second board that does nothing but timestamp two edges. And a log format shared with the sensitometer and the densitometer, so that a printing session, an exposure series and a set of densities can sit in the same spreadsheet.

By the end you should be able to:

  • explain why a sleep is not a timer, and why a deadline computed once from a start tick is;
  • say what the RP2 port actually offers in place of a hardware timer, and design around it;
  • implement a geometric exposure series in integer milliseconds with an explicit rounding rule, and say what the rule buys;
  • draw the state machine, name what the foot switch does in each state, and justify the rule that a second start aborts rather than restarts;
  • schedule a metronome that cannot drift, inside a loop that must not delay the end of an exposure;
  • state exactly what a watchdog protects against on this board, and what actually releases the output when the firmware fails;
  • test the arithmetic before any paper is exposed, and measure the pulse rather than believing it.

The timer hardware, built and passing its input test — this page assumes the pin assignment that page fixed. The f-stop lesson, whose arithmetic this firmware implements and whose worked table the unit tests check against. Part XIV’s electronics primer for MicroPython, ticks_us, the busy-wait and the gate pull-down, none of which are re-taught here.

Level A. No new physical hazard is introduced by writing firmware, and the rubric’s Level A criteria apply unchanged: purchased certified equipment only, no mains construction, extra-low voltage throughout.

What is not a hazard here, and why — and the one thing that is. There is nothing to spill, nothing to inhale and nothing at mains potential; the whole session is a computer, a USB lead and two Picos, and ventilation is not among its controls because nothing produces a vapour. What is real is peculiar to software: the firmware commands a light source, and its worst failure mode is an exposure that never ends. A hung loop with the output asserted will run a UV array or a high-output LED head indefinitely, which is a burn hazard and a fire risk rather than a spoiled print.

Three controls answer it and all three are in the design rather than in a warning. The output pin is released in a finally clause on every path. The gate carries the hardware pull-down Part XIV specified, so an undriven pin means an off lamp. And a physical master switch sits within reach, removing power from the output channel independently of the software — which is the only control on the list that does not depend on the thing that has just failed.

Bench-test with an indicator LED as the load, never with the UV unit. A fault found on a 20 mA LED is a fault; the same fault found on a UVA array is an eye injury and a scorched lid.

  • An exposure that does not end. Controls above. Do not defeat the master switch by tidying it away behind the bench.
  • A bright emitter at close range if you test with the real head fitted. Part XVI’s rule stands: do not look into the emitter, and the UV array’s interlock is never bypassed to make the firmware work.
  • Soldering, only if you are still changing the hardware, under Part XIV’s controls unchanged.
  • The foot-switch cable on a dark floor, as on the hardware page.

Nothing on this page produces waste beyond a few centimetres of wire.

None is required for writing and testing firmware, and saying so is a statement about this activity rather than a general one: the controls here are the master switch, the indicator-LED load and the interlock, which are engineering controls rather than personal protection. Eye protection goes on the moment a real emitter is connected for a live test, and Part XVI’s rules for the UV unit apply in full from that moment.

Ventilation is not among the controls for this session, because nothing here produces a vapour; it returns the moment an iron is picked up.

Part Quantity What it does
The timer hardware from the previous page 1 The instrument under test
A second Raspberry Pi Pico 1 The probe: an independent stopwatch that does nothing else
USB leads 2 One per board; the probe needs its own serial console
Indicator LED and a 330 Ω resistor 1 each The bench load, standing in for the head
Jumper wires 2 Output to probe input, and a shared ground
Master switch, in line with the output channel’s supply 1 The control that does not depend on software

A computer with two serial terminals open at once, a text editor, a way of copying files to a board, and a multimeter. Nothing else.

Cost band £. The only new item is the second Pico, and if you built the sensitometer or the densitometer you have one already — the probe borrows a board for half an hour and gives it back.

No price is quoted, because the planner carries no electronics line at all and a figure invented here would be a guess wearing a quotation’s clothes. The specification is the durable part: any board that can timestamp an edge to the microsecond and print over a serial link will do the probe’s job, and it does not have to be the same family as the timer.

Nothing in this session is consumed. Every item in the parts table is capital, the two boards are re-used, and the bench load’s LED outlives the instrument.

Consumed This session Sourced price Cost this session
Nothing £0.00

That row is not a joke and it is not padding: it is the answer the consumables calculator needs, and a page that invented a figure to avoid an empty table would be putting noise into the one product that depends on this section. If you change the hardware while debugging, the consumables are the hardware page’s — solder, sleeving, wire — and they are priced there, or rather they are named there as unpriced.

Equipment is excluded as everywhere: the two boards, the multimeter and the master switch are not consumed by a session.

Nine stages, four hours. Stage 0 is the one that decides what the rest of the file is for.

Stage 0 — Three exposures, and only one of them is yours (20 minutes)

Section titled “Stage 0 — Three exposures, and only one of them is yours (20 minutes)”

Write these on a card and keep it beside the screen.

  1. The commanded exposure. The number the printer set and the firmware was asked for.
  2. The delivered interval. How long the output was actually asserted, which differs from the first by the loop’s overhead and by whatever else the interpreter did.
  3. The received exposure. The light the paper actually integrated, which differs from the second by the lamp’s rise and fall and is the only one the print knows about.

This page owns the first two and can measure the gap between them. It owns none of the third: the lamp’s behaviour is measured at the easel by the calibration page, which produces a number the firmware then carries as OFFSET_MS. Until that page has been run on your build, the constant is zero, and zero is an admission rather than a value.

The glossary keeps the three apart under commanded exposure, and the reason the distinction is worth this much fuss is that every argument about a timer that “runs fast” is really an argument about which of the three somebody meant.

Where each of the three numbers is decided, and who measures it

  1. Commanded, in integer millisecondsthe f-stop engine computes it from the base, the divisions and the band. This file owns it and the unit tests check it.
  2. Delivered, measured by the firmware itselfticks_us at both ends of the exposure loop. Reported on every log line, never assumed.
  3. Delivered, measured independentlya second Pico timestamping both edges. The probe exists because a routine that measures itself can only find some kinds of error.
  4. Received by the paperthe area under the lamp's light-against-time curve, measured at the easel with the photodiode head on the calibration page. Not this page.

Stage 1 — Why a sleep is not a timer, and what this port actually gives (30 minutes)

Section titled “Stage 1 — Why a sleep is not a timer, and what this port actually gives (30 minutes)”

Three facts from the documentation, and the design falls out of them.

There is no hardware timer here. MicroPython’s machine.Timer documentation says hardware timers may be more accurate for very fine sub-millisecond timing, and that most ports support them except Zephyr and RP2, which support only virtual timers. The RP2 quick reference agrees from the other side: the RP2040’s system timer provides a global microsecond timebase, and what is exposed through machine.Timer is a software timer whose callback runs as a soft interrupt unless hard=True is passed — and a soft-interrupt callback can be delayed by garbage collection.

Ticks wrap, and only two operations are valid on them. ticks_ms and ticks_us count up from an arbitrary origin to a value the port does not publish and then start again. The documentation is blunt that ordinary subtraction and comparison are wrong, and that ticks_diff and ticks_add are the operations available. Code that computes end - start works for hours and then hands you a negative exposure once.

A sum of sleeps accumulates and a deadline does not. Sleeping 500 ms forty times does not take 20 s; it takes 20 s plus forty times whatever each sleep overshot by, and the error is one-directional. Computing every deadline from a single start tick makes each one independent of every other, so a late beat cannot push the next beat later.

Sum of sleeps: t_n = Σ (Δ + εᵢ) Deadline: t_n = t_start + nΔ
The two ways of scheduling, and why only one of them survives

Δ is the intended interval, εᵢ the overshoot of the i-th sleep, and n the count. In the first form the errors add up; in the second they cannot, because nΔ is computed from a number that was taken once. Every interval in the firmware below is the second form.

Stage 2 — The f-stop engine, and the rounding rule made concrete (40 minutes)

Section titled “Stage 2 — The f-stop engine, and the rounding rule made concrete (40 minutes)”

The engine is pure arithmetic: no pins, no clock, no printing. That is deliberate, and it is what makes the unit tests runnable before any hardware exists.

Four rules govern it.

Every term is computed from the base, never from its predecessor. Iterating a multiplication by 2^(1/n) accumulates the floating-point error term by term; computing 2^(k/n) once for each k cannot. It costs one exponential per band and buys an error that does not grow with the length of the strip.

Everything is committed to integer milliseconds immediately. A float that lives on is a float that can be rounded twice.

Round the cumulative and difference the rounded values. This is the f-stop lesson’s rule at a thousand times finer a quantum, and strip_additions takes the cumulative list as its argument rather than computing increments itself, so the rule is enforced by the shape of the function and cannot be forgotten by a caller.

A tenth of a second is a different amount of exposure at every base. This is the number that decides how the encoder should behave, and it is worth having in front of you:

Base exposure One 0.1 s step, in stops A twelfth-stop detent
1.0 s 0.1375 0.0833
1.68 s 0.0833 0.0833
5.0 s 0.0286 0.0833
30.0 s 0.0048 0.0833

The two are equal at about 1.68 s. Below that a tenth of a second is a coarser adjustment than a twelfth of a stop, and above it a finer one — which is why the firmware edits the base in stops below 1.68 s and in tenths of a second above it, and why an instrument that only ever offered tenths would be clumsy at exactly the exposures a bright LED head produces.

Stage 3 — The state machine (40 minutes)

Section titled “Stage 3 — The state machine (40 minutes)”

Six states, and the value of writing them down is that it makes the illegal transitions visible.

State Output What START does What the foot switch does
idle off Begin a timed exposure The same
focus on, continuously Return to idle The same
armed off Expose the next strip band The same
exposing on Abort, and log the abort The same
paused off Expose the next band, once SET has armed it The same
burn off Expose the increment The same

The foot switch does whatever START does, in every state, always. That is the whole rule and it is what makes a pedal usable without looking: there is nothing to remember, and no state in which the pedal means something the button does not.

A second start during an exposure aborts and logs it; it does not silently restart. The reasoning is that the printer who presses START again during an exposure has almost always seen something wrong — a card in the wrong place, the wrong negative, a hand in the beam — and wants it to stop. An implementation that restarted would double the exposure of the sheet under the lens and give no indication. So the abort is a first-class outcome with its own flag in the log, and a sheet that carries an aborted exposure can be identified afterwards from the record rather than from memory.

The abort is edge detected, which matters more than it sounds. START is nearly always still held down at the instant an exposure begins, because pressing it is what began the exposure; a level test would abort every exposure immediately. So expose records which controls are down when it starts, waits for each to be released, and only then treats a press as an abort.

The state machine, with the transitions that are refused

idle1focus2ENC PUSHarmed3SETexposing4STARTelapsedpaused5bands leftSETburn6START7START while exposing aborts and logsOutput on in two states only: focus and exposing. The pedal does whatever START does, everywhere.
  1. idle — output off; the encoder edits the base
  2. focus — output on continuously; the metronome runs so you can count while composing
  3. armed — the next strip band is queued; move the card, then START
  4. exposing — the only state that asserts the output for a computed interval
  5. paused — between bands; SET arms the next one
  6. burn — an increment on the remembered base, in stops
  7. refused — a start while the output is already asserted: the interlock, logged not silent
Six states, two of which assert the output. Everything the instrument refuses to do is drawn as well as everything it does.

Stage 4 — A metronome that cannot drift, inside a loop that must not be delayed (25 minutes)

Section titled “Stage 4 — A metronome that cannot drift, inside a loop that must not be delayed (25 minutes)”

The beat is scheduled against the exposure’s own start tick: the k-th beat is due at k × 500 ms after the start, computed from the same tick the deadline was computed from. A beat that fires late therefore does not push the next one late, which is the whole property the design needs.

Three details make it usable rather than merely correct.

The tone is non-blocking. click() starts the PWM and records the millisecond at which it must stop; service_tone() is called from every loop and ends it when that moment comes. A sleep_ms(12) inside the exposure loop would be twelve milliseconds during which the deadline is not being checked, and twelve milliseconds is more than a hundredth of a stop at a half-second exposure.

Three pitches, and the third is the one that matters. A click on the half second, a higher tone on the second, and a distinct lower tone through the final second, so that the end of an exposure is something you hear coming rather than something that happens. A printer holding a dodging card needs a second’s warning to get their hand clear.

The display goes dark for the exposure. BLANK_WHILE_EXPOSING defaults to true, and it is a fog control rather than a nicety: the paper is out and receiving light for exactly the interval in which the instrument has no need to show anything. This is why the metronome is not a convenience — with the display blank, the beat is the only feedback there is.

Stage 5 — Interface behaviour in a room with no light in it (25 minutes)

Section titled “Stage 5 — Interface behaviour in a room with no light in it (25 minutes)”

Encoder acceleration. Detents arriving less than 60 ms apart are multiplied by eight, so a slow turn adjusts finely and a fast turn crosses the range. The acceleration is applied in the interrupt handler, where the timestamp is, and the multiplier is a constant you can change; without it, moving from 3 s to 60 s is a wrist-aching four hundred detents.

Long press means “go somewhere”, short press means “do something”. Holding the encoder for 600 ms opens the settings walk — divisions per stop, dry-down offset, display brightness — and pressing it briefly toggles the focus lamp. One control, two meanings, distinguished by a duration your hand can produce reliably in the dark.

Every accepted action makes a sound. A short click for a detent, a longer confirmation for a mode change or a setting. In a blacked-out room with the display blanked, an action that produces no feedback is an action you cannot know was received, and the printer’s response to silence is to press again.

The dry-down offset is applied and logged separately. apply_drydown subtracts the stored correction before the exposure, and the log carries both the base the printer set and the exposure that was actually commanded. A firmware that silently delivered a different number from the one on the display would be a convenience for one session and an unexplained systematic error for every session after it.

Stage 6 — Output control, the interlock, and what a watchdog actually protects (30 minutes)

Section titled “Stage 6 — Output control, the interlock, and what a watchdog actually protects (30 minutes)”

One output pin, GP15, driving the gate resistor of the switched output. It is created with value=0 and is asserted in exactly one function.

The interlock. expose refuses to start if the output is already asserted, and prints the refusal rather than swallowing it. That covers the case that actually happens: a focus lamp left on, then a strip band started, which would otherwise give the band an exposure beginning at an unknown time.

A range check. Below MIN_MS and above MAX_MS the routine refuses. The upper bound is not timidity; ten minutes is longer than any print exposure this course teaches, and a request for an hour is a bug in the caller or a slipped encoder.

The watchdog, stated precisely. The RP2 quick reference documents machine.WDT: a countdown timer that restarts parts of the chip if it reaches zero, with a minimum timeout of 1 s and a maximum of 8388 ms. That maximum is shorter than many print exposures, so the exposure loop must feed the watchdog from inside itself — and that has a consequence worth stating plainly.

One line per exposure, comma-separated, with # beginning every comment, so a captured terminal session is already a data file. The columns are the same shape as the sensitometer’s and the densitometer’s — a serial number, a kind, a millisecond tick, then the payload — so the three instruments’ logs open in the same spreadsheet:

n,kind,ticks_ms,mode,base_ms,divisions,band,stops,commanded_ms,delivered_ms,offset_ms,flag

commanded_ms beside delivered_ms is the point of the whole file. A session’s log is then a data set rather than a memory, and the break/fix page is tractable precisely because the log exists: an argument about whether the prints drifted becomes a column you can plot.

Save the listing as timer.py on the board, then at the REPL:

import timer
timer.selftest() # proves the wiring; exposes nothing
timer.test() # the arithmetic, against the printed table
timer.session() # the instrument

Eight constants near the top are meant to be changed, along with the pin numbers, and the rest is not: OFFSET_MS, DRYDOWN_STOPS, DIVISIONS, BANDS, MIN_MS, MAX_MS, PIEZO_HZ and COUNTS_PER_DETENT. The values in the listing are provisional starting points rather than measurements, and the calibration page replaces the first two with your own.

"""
timer.py - Pure Silver, Part XVII: the darkroom timer firmware.
Target Raspberry Pi Pico (RP2040), MicroPython v1.24.1 for RPI_PICO.
Install copy to the board as timer.py, then at the REPL:
import timer
timer.selftest() # proves the wiring, exposes nothing
timer.test() # the arithmetic unit tests
timer.session() # the instrument
Licence CC BY-SA 4.0, as the rest of this course's content.
Everything printed is comma-separated, with '#' beginning any line that is a
comment, so a captured terminal session is already the printing log. The
column order is the one Parts XIV and XV already use: a serial number, a kind,
a millisecond tick, then the payload.
Three rules govern this file and none of them are stylistic.
1. The output pin is switched off in a `finally` clause on every path, and
its hardware pull-down holds the gate low whenever the pin is not being
driven. Software is the second line of defence here, not the first.
2. Every interval is a DEADLINE computed once from a start tick, never a sum
of sleeps. Deadlines do not accumulate error; sums do.
3. The routine reports the interval it ACHIEVED beside the one it was asked
for. A timer that prints only its request is not an instrument.
"""
import gc
import machine
import math
import micropython
import sys
import time
micropython.alloc_emergency_exception_buf(100)
VERSION = "1.0"
# --- Wiring. The same nine numbers as the hardware page's input test. -------
PIN_ENC_A = 16
PIN_ENC_B = 17
PIN_ENC_SW = 18
PIN_START = 19
PIN_MODE = 20
PIN_SET = 21
PIN_BUZZER = 22
PIN_FOOT = 14
PIN_OUT = 15
PIN_SCL = 5
PIN_SDA = 4
# --- Settings the calibration experiment replaces with measured ones. -------
OFFSET_MS = 0 # lamp rise/fall correction; measured, not guessed
DRYDOWN_STOPS = 0.0 # subtracted from every commanded exposure; yours
DIVISIONS = 3 # divisions per stop for the strip and for burns
BANDS = 5 # bands in a test strip
MIN_MS = 100 # shortest exposure the interface will let you set
MAX_MS = 600000 # ten minutes: the interlock's upper bound
# --- Interface constants. ---------------------------------------------------
DEBOUNCE_MS = 25
LONG_PRESS_MS = 600
ACCEL_MS = 60 # detents closer together than this are accelerated
ACCEL_FACTOR = 8
COUNTS_PER_DETENT = 4
PIEZO_HZ = 2400 # provisional: your transducer's resonant frequency
PIEZO_DUTY = 32768
BEAT_MS = 500 # metronome half-second beat
BEEP_MS = 12 # length of one metronome click
TONE_HZ = 2400 # the half-second click
SECOND_HZ = 3200 # the on-the-second tone, a distinct pitch
FINAL_HZ = 1600 # the last second, distinct again
WDT_MS = 5000 # 1000 is the minimum and 8388 the maximum on RP2
BLANK_WHILE_EXPOSING = True # the fog rule: the display goes dark to expose
COLUMNS = ("n,kind,ticks_ms,mode,base_ms,divisions,band,stops,"
"commanded_ms,delivered_ms,offset_ms,flag")
IDLE, FOCUS, ARMED, EXPOSING, PAUSED, BURN = range(6)
STATE_NAME = ("idle", "focus", "armed", "exposing", "paused", "burn")
# Quadrature transition table, indexed by (previous << 2) | current, where a
# state is (A << 1) | B. Identical to the hardware page's input test: the
# eight legal transitions give +1 or -1 and everything else gives 0.
_STEP = (0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0)
out = machine.Pin(PIN_OUT, machine.Pin.OUT, value=0)
enc_a = machine.Pin(PIN_ENC_A, machine.Pin.IN, machine.Pin.PULL_UP)
enc_b = machine.Pin(PIN_ENC_B, machine.Pin.IN, machine.Pin.PULL_UP)
enc_sw = machine.Pin(PIN_ENC_SW, machine.Pin.IN, machine.Pin.PULL_UP)
btn_start = machine.Pin(PIN_START, machine.Pin.IN, machine.Pin.PULL_UP)
btn_mode = machine.Pin(PIN_MODE, machine.Pin.IN, machine.Pin.PULL_UP)
btn_set = machine.Pin(PIN_SET, machine.Pin.IN, machine.Pin.PULL_UP)
foot = machine.Pin(PIN_FOOT, machine.Pin.IN, machine.Pin.PULL_UP)
buzzer = machine.PWM(machine.Pin(PIN_BUZZER))
buzzer.duty_u16(0)
_counts = 0
_bad = 0
_state = (enc_a.value() << 1) | enc_b.value()
_last_edge_us = 0
_accel = 1
_foot_released = 1
_n = 0
_display = None
_wdt = None
def _feed():
"""Feed the watchdog from every loop that can run for longer than WDT_MS.
The RP2040's watchdog has a maximum period of 8388 ms, which is shorter
than many print exposures, so a long exposure has to feed it from inside
the exposure loop. That is worth being clear about: the watchdog catches a
loop that has STOPPED, not a loop that is taking too long, and what
actually releases the output on a reset is the gate pull-down of Part XIV
rather than anything in this file.
"""
if _wdt is not None:
_wdt.feed()
def _on_quadrature(pin):
"""Hard-IRQ handler: table lookup and integer arithmetic only, no alloc."""
global _state, _counts, _bad, _last_edge_us, _accel
now = (enc_a.value() << 1) | enc_b.value()
step = _STEP[(_state << 2) | now]
if step:
t = time.ticks_us()
if time.ticks_diff(t, _last_edge_us) < ACCEL_MS * 1000:
_accel = ACCEL_FACTOR
else:
_accel = 1
_last_edge_us = t
_counts += step * _accel
elif now != _state:
_bad += 1
_state = now
enc_a.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING,
handler=_on_quadrature, hard=True)
enc_b.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING,
handler=_on_quadrature, hard=True)
# --- The f-stop engine. Pure arithmetic: no pins, no clock, no printing. ----
# Everything below is testable on a desktop Python as well as on the board,
# which is why test() can run before any hardware exists.
def stops_to_factor(stops):
"""A change of `stops` stops, as a multiplier of exposure."""
return math.pow(2.0, stops)
def factor_to_stops(factor):
"""A multiplier of exposure, as a change in stops."""
return math.log(factor) / math.log(2.0)
def series_ms(base_ms, divisions, k):
"""Term k of a geometric series about base_ms, in whole milliseconds.
Every term is computed FROM THE BASE rather than from its predecessor.
Iterating a multiplication would accumulate the float error term by term;
computing each term once cannot. The result is committed to an integer
immediately, so the rounding happens once per term and never compounds.
"""
return int(round(base_ms * stops_to_factor(float(k) / divisions)))
def strip_bands(base_ms, divisions=DIVISIONS, bands=BANDS, centred=True):
"""The cumulative exposures of a test strip, in milliseconds.
Centred on the base by default, so a five-band third-stop strip about
12.0 s runs from two thirds of a stop under to two thirds over.
"""
first = -(bands // 2) if centred else 0
return [series_ms(base_ms, divisions, first + i) for i in range(bands)]
def strip_additions(cumulative):
"""What to ADD at each step of an additive strip.
The rule the f-stop lesson derives, and the reason this function takes the
cumulative list rather than computing increments itself: round the
CUMULATIVE and difference the rounded values, never round the increments
and add them up. Done this way each band's error is bounded by the single
rounding of its own term; done the other way every band inherits every
earlier rounding.
"""
adds = []
prev = 0
for c in cumulative:
adds.append(c - prev)
prev = c
return adds
def burn_ms(base_ms, stops):
"""The EXTRA exposure for a burn of `stops` stops on a base."""
return int(round(base_ms * (stops_to_factor(stops) - 1.0)))
def dodge_ms(base_ms, stops):
"""The exposure WITHHELD for a dodge of `stops` stops on a base."""
return int(round(base_ms * (1.0 - stops_to_factor(-stops))))
def apply_drydown(base_ms, stops=None):
"""The base, less the dry-down correction, as an integer millisecond."""
s = DRYDOWN_STOPS if stops is None else stops
return int(round(base_ms * stops_to_factor(-s)))
def setting_granularity_stops(base_ms, step_ms=100):
"""How coarse one step of the seconds-mode encoder is, in stops.
This is the number that decides which editing mode the interface should be
in: a tenth of a second is 0.137 stops on a one-second base and 0.005 on a
thirty-second one, while a twelfth-stop detent is 0.083 stops everywhere.
"""
return factor_to_stops((base_ms + step_ms) / float(base_ms))
# --- Sound. Non-blocking: the exposure loop cannot afford to sleep. ---------
_tone_off_at = None
def tone_on(hz):
buzzer.freq(hz)
buzzer.duty_u16(PIEZO_DUTY)
def tone_off():
global _tone_off_at
buzzer.duty_u16(0)
_tone_off_at = None
def click(hz=TONE_HZ, ms=BEEP_MS):
"""Start a click and record when it must stop. Never blocks."""
global _tone_off_at
tone_on(hz)
_tone_off_at = time.ticks_add(time.ticks_ms(), ms)
def service_tone():
"""Called from every loop: ends a click when its moment arrives."""
if _tone_off_at is not None and time.ticks_diff(_tone_off_at, time.ticks_ms()) <= 0:
tone_off()
def confirm(ok=True):
"""A blocking confirmation, used only where blocking is harmless."""
tone_on(SECOND_HZ if ok else FINAL_HZ)
time.sleep_ms(40 if ok else 160)
tone_off()
# --- Display. Absent by default; the instrument runs headless. -------------
def attach_display():
"""Import a driver if one is present. The course publishes no driver."""
global _display
try:
import ssd1306
i2c = machine.I2C(0, scl=machine.Pin(PIN_SCL), sda=machine.Pin(PIN_SDA))
_display = ssd1306.SSD1306_I2C(128, 64, i2c)
return True
except Exception as exc:
print("# no display ({}); running headless".format(exc))
_display = None
return False
def show(line1, line2="", bright=None):
if _display is None:
return
try:
if bright is not None:
_display.contrast(bright)
_display.fill(0)
_display.text(line1, 0, 16)
_display.text(line2, 0, 40)
_display.show()
except Exception:
pass
def blank():
"""Black the panel. Called before every exposure when the fog rule is on."""
if _display is None:
return
try:
_display.fill(0)
_display.show()
except Exception:
pass
# --- Inputs. ---------------------------------------------------------------
def learn_foot_polarity():
"""Take whatever the pedal reads at boot as its released state."""
global _foot_released
n = 0
total = 0
end = time.ticks_add(time.ticks_ms(), 200)
while time.ticks_diff(end, time.ticks_ms()) > 0:
total += foot.value()
n += 1
time.sleep_ms(5)
_foot_released = 1 if total * 2 > n else 0
return _foot_released
def foot_pressed():
return foot.value() != _foot_released
def detents():
return _counts // COUNTS_PER_DETENT
def take_detents():
"""Detents turned since the last call, and reset.
Interrupts are masked across the read-modify-write, because the encoder's
handler writes the same variable and a detent lost between the read and
the subtraction is a number the printer set and the instrument did not.
"""
global _counts
mask = machine.disable_irq()
try:
d = _counts // COUNTS_PER_DETENT
_counts -= d * COUNTS_PER_DETENT
finally:
machine.enable_irq(mask)
return d
def pressed(pin, since):
"""True once per debounced press. `since` holds each pin's last edge.
`ticks_diff` is the only valid arithmetic on a tick value, and it may only
be given values that came from `ticks_ms` - which is why a pin that has
never been pressed is stored as None rather than as a sentinel number.
"""
key = id(pin)
down = pin.value() == 0
last = since.get(key)
if not down:
since[key] = None
return False
if last is None:
since[key] = time.ticks_ms()
return True
return False
def held_for(pin, ms):
"""Block until the pin is released, and report whether it was held `ms`."""
start = time.ticks_ms()
while pin.value() == 0:
_feed()
if time.ticks_diff(time.ticks_ms(), start) > ms:
return True
time.sleep_ms(5)
return False
# --- Logging. --------------------------------------------------------------
def _log(kind, mode, base_ms, band, stops, commanded, delivered, offset, flag):
global _n
_n += 1
print("{},{},{},{},{},{},{},{:.4f},{},{},{},{}".format(
_n, kind, time.ticks_ms(), mode, base_ms, DIVISIONS, band, stops,
commanded, delivered, offset, flag))
# --- The exposure. This is the only place the output is ever asserted. ------
def expose(commanded_ms, mode="timed", base_ms=0, band=0, stops=0.0,
metronome=True):
"""Assert the output for commanded_ms and report what actually happened.
Returns (delivered_ms, flag). The flag is 'ok', or 'abort' if a second
START or a foot press ended it early.
Three properties are deliberate.
The interval is a DEADLINE measured from one start tick, so nothing that
happens inside the loop can move the end. The deadline is tested FIRST in
every pass, before the metronome and the buttons, so housekeeping can
delay the end by at most one pass of the loop and never by a whole beat.
The loop is not a pure busy-wait, unlike the sensitometer's, because this
instrument has to beat time and to watch for an abort while it exposes.
That costs some microseconds of overshoot per pass, which is why the
routine measures the interval it achieved instead of asserting the one it
was asked for, and why loopback() exists.
The output is released in a `finally` clause on every path, including a
keyboard interrupt at the REPL.
The abort is EDGE detected. START is almost always still down at the
moment the exposure begins, because pressing it is what began the
exposure; a level test would abort every exposure instantly. So the
routine records which controls are down when it starts, waits for each to
go up, and only then treats a press as an abort.
"""
if out.value():
print("# REFUSED: the output is already asserted. Interlock.")
return 0, "interlock"
if commanded_ms < MIN_MS or commanded_ms > MAX_MS:
print("# REFUSED: {} ms is outside {} to {} ms.".format(
commanded_ms, MIN_MS, MAX_MS))
return 0, "range"
gc.collect() # so a collection is unlikely mid-exposure
if BLANK_WHILE_EXPOSING:
blank()
target_us = commanded_ms * 1000
beat = 1
flag = "ok"
start_armed = btn_start.value() == 0 # down already: not an abort yet
foot_armed = foot_pressed()
start = time.ticks_us()
out.value(1)
try:
while True:
now = time.ticks_us()
elapsed = time.ticks_diff(now, start)
if elapsed >= target_us:
break
_feed()
if metronome:
due = beat * BEAT_MS * 1000
if elapsed >= due:
remaining = target_us - elapsed
if remaining <= 1000000:
click(FINAL_HZ)
elif beat % 2 == 0:
click(SECOND_HZ)
else:
click(TONE_HZ)
beat += 1
service_tone()
start_down = btn_start.value() == 0
foot_down = foot_pressed()
if not start_down:
start_armed = False
if not foot_down:
foot_armed = False
if (start_down and not start_armed) or (foot_down and not foot_armed):
flag = "abort"
break
finally:
out.value(0)
delivered = time.ticks_diff(time.ticks_us(), start)
tone_off()
delivered_ms = delivered // 1000
_log("exposure", mode, base_ms, band, stops,
commanded_ms, delivered_ms, OFFSET_MS, flag)
return delivered_ms, flag
# --- The state machine. ----------------------------------------------------
def session(base_ms=12000, wdt_enabled=True):
"""Warm the instrument up, then run until interrupted.
States: idle, focus, armed, exposing, paused, burn.
START idle -> exposing, armed -> exposing, burn -> exposing;
during an exposure it ABORTS and the abort is logged.
MODE cycles timed / strip / burn.
SET long press enters the settings walk; short press arms the
next band of a strip.
ENC_PUSH idle -> focus and back; the lamp is on continuously in focus
and the metronome runs so you can count while you compose.
FOOT whatever START does in the current state. That is the whole
rule, and it is what makes the pedal usable without looking.
"""
global _wdt
_wdt = machine.WDT(timeout=WDT_MS) if wdt_enabled else None
header()
learn_foot_polarity()
attach_display()
print(COLUMNS)
state = IDLE
mode = "timed"
strip = []
band = 0
since = {}
foot_was = foot_pressed()
show("READY", "{:.1f}s".format(base_ms / 1000.0))
try:
while True:
_feed()
service_tone()
d = take_detents()
if d and state in (IDLE, PAUSED):
if base_ms >= 1682:
base_ms = max(MIN_MS, min(MAX_MS, base_ms + d * 100))
else:
base_ms = max(MIN_MS, min(MAX_MS, series_ms(base_ms, 12, d)))
show("{:.1f}s".format(base_ms / 1000.0), mode)
click(TONE_HZ, 6)
if pressed(btn_mode, since):
mode = {"timed": "strip", "strip": "burn",
"burn": "timed"}[mode]
strip = []
band = 0
state = IDLE
confirm()
show(mode, "{:.1f}s".format(base_ms / 1000.0))
if pressed(enc_sw, since):
if held_for(enc_sw, LONG_PRESS_MS):
settings()
show("READY", "{:.1f}s".format(base_ms / 1000.0))
elif state == FOCUS:
out.value(0)
state = IDLE
show("READY", "{:.1f}s".format(base_ms / 1000.0))
confirm()
elif state == IDLE:
state = FOCUS
out.value(1)
show("FOCUS", "encoder to end")
confirm()
if pressed(btn_set, since) and mode == "strip":
if not strip:
strip = strip_additions(strip_bands(apply_drydown(base_ms)))
band = 0
state = ARMED
show("BAND {}".format(band + 1),
"+{:.1f}s".format(strip[band] / 1000.0))
confirm()
foot_now = foot_pressed()
foot_edge = foot_now and not foot_was
foot_was = foot_now
if pressed(btn_start, since) or foot_edge:
if state == FOCUS:
out.value(0)
state = IDLE
elif mode == "strip" and state == ARMED:
state = EXPOSING
expose(strip[band], "strip", base_ms, band + 1,
float(band - BANDS // 2) / DIVISIONS)
band += 1
if band >= len(strip):
strip = []
band = 0
state = IDLE
show("STRIP DONE", "{:.1f}s".format(base_ms / 1000.0))
else:
state = PAUSED
show("MOVE CARD", "then SET")
elif mode == "burn":
stops = 0.5
state = EXPOSING
expose(burn_ms(apply_drydown(base_ms), stops), "burn",
base_ms, 0, stops)
state = IDLE
show("BURN DONE", "+{:.2f} stop".format(stops))
else:
state = EXPOSING
expose(apply_drydown(base_ms), "timed", base_ms, 0,
-DRYDOWN_STOPS)
state = IDLE
show("DONE", "{:.1f}s".format(base_ms / 1000.0))
while foot_pressed():
_feed()
time.sleep_ms(10)
foot_was = False
time.sleep_ms(2)
finally:
out.value(0)
tone_off()
print("# session ended with the output off")
def settings():
"""A three-item walk: divisions per stop, dry-down, display brightness."""
global DIVISIONS, DRYDOWN_STOPS
items = ("divisions", "drydown", "brightness")
i = 0
bright = 128
since = {}
show("SETTINGS", items[i])
confirm()
while True:
_feed()
service_tone()
d = take_detents()
if d:
if items[i] == "divisions":
DIVISIONS = max(1, min(12, DIVISIONS + d))
show("divisions", str(DIVISIONS))
elif items[i] == "drydown":
DRYDOWN_STOPS = max(0.0, min(1.0, DRYDOWN_STOPS + d * 0.02))
show("drydown", "{:.2f} stop".format(DRYDOWN_STOPS))
else:
bright = max(1, min(255, bright + d * 8))
show("brightness", str(bright), bright)
click(TONE_HZ, 6)
if pressed(btn_mode, since):
i = (i + 1) % len(items)
show("SETTINGS", items[i])
confirm()
if pressed(enc_sw, since):
print("# settings: divisions {}, drydown {:.2f} stop, bright {}"
.format(DIVISIONS, DRYDOWN_STOPS, bright))
confirm()
return
time.sleep_ms(5)
def header():
print("# Pure Silver timer firmware v{}".format(VERSION))
print("# {}".format(sys.implementation))
print("# divisions {}, bands {}, offset {} ms, drydown {:.2f} stop"
.format(DIVISIONS, BANDS, OFFSET_MS, DRYDOWN_STOPS))
print("# blank display while exposing: {}".format(BLANK_WHILE_EXPOSING))
# --- Proving it, before it is trusted with paper. --------------------------
def selftest():
"""Every input at rest, the output low, one audible confirmation."""
header()
out.value(0)
learn_foot_polarity()
print("# foot released state {}".format(_foot_released))
print("# resting: enc_sw {} start {} mode {} set {}".format(
enc_sw.value(), btn_start.value(), btn_mode.value(), btn_set.value()))
if 0 in (btn_start.value(), btn_mode.value(), btn_set.value()):
print("# FAIL: a button reads pressed with nothing touching it.")
return False
if out.value():
print("# FAIL: the output is asserted at rest.")
return False
confirm()
print("# PASS: inputs at rest, output low.")
return True
def test():
"""Unit tests for the arithmetic, against the table on the f-stop page.
Runs on the board and on a desktop Python alike, because nothing it calls
touches a pin or a clock. Run it after any change to the engine, and run
it before you believe a single exposure.
"""
fails = 0
def check(name, got, want):
if got != want:
print("# FAIL {}: got {} want {}".format(name, got, want))
return 1
return 0
cum = strip_bands(12000, 3, 5)
fails += check("strip cumulative", cum, [7560, 9524, 12000, 15119, 19049])
fails += check("strip additions", strip_additions(cum),
[7560, 1964, 2476, 3119, 3930])
fails += check("additions sum to the last band",
sum(strip_additions(cum)), cum[-1])
tenths = [int(round(c / 100.0)) for c in cum]
fails += check("as displayed, tenths", tenths, [76, 95, 120, 151, 190])
fails += check("one whole stop up", series_ms(12000, 1, 1), 24000)
fails += check("one whole stop down", series_ms(12000, 1, -1), 6000)
fails += check("half stop", series_ms(12000, 2, 1), 16971)
fails += check("burn half a stop", burn_ms(12000, 0.5), 4971)
fails += check("burn one stop is the base again", burn_ms(12000, 1.0), 12000)
fails += check("dodge one stop is half the base", dodge_ms(12000, 1.0), 6000)
fails += check("dodge half a stop", dodge_ms(12000, 0.5), 3515)
fails += check("drydown of zero changes nothing",
apply_drydown(12000, 0.0), 12000)
fails += check("drydown of a third of a stop",
apply_drydown(12000, 1.0 / 3.0), 9524)
g1 = setting_granularity_stops(1000)
g30 = setting_granularity_stops(30000)
if not (0.137 < g1 < 0.138 and 0.0047 < g30 < 0.0049):
print("# FAIL granularity: {:.4f} at 1 s, {:.4f} at 30 s".format(g1, g30))
fails += 1
print("# granularity of a 0.1 s step: {:.4f} stops at 1 s, "
"{:.4f} stops at 30 s".format(g1, g30))
print("# a twelfth-stop detent is 0.0833 stops at every base;")
print("# the two are equal at about 1.68 s, which is why the encoder")
print("# edits in stops below that and in tenths above it.")
if fails:
print("# {} TEST(S) FAILED".format(fails))
else:
print("# all arithmetic tests passed")
return fails
def loopback(n=20, ms=1000):
"""Measure the pulse this firmware actually produces, without a lamp.
Run timer_probe.py on a second board with its input on the output pin and
a shared ground. This routine makes n exposures at a fixed cadence and
prints its own view; the probe prints the independent one. The difference
between the two columns is what this instrument may claim.
"""
print("# loopback: {} exposures of {} ms".format(n, ms))
print(COLUMNS)
over = []
for _ in range(n):
delivered, flag = expose(ms, "loopback", ms, 0, 0.0, metronome=False)
if flag == "ok":
over.append(delivered - ms)
time.sleep_ms(500)
if over:
worst = max(over)
mean = sum(over) / len(over)
print("# overshoot: mean {:.1f} ms, worst {} ms over {} runs".format(
mean, worst, len(over)))
print("# as a fraction of a stop at this exposure: {:.5f}".format(
factor_to_stops((ms + worst) / float(ms))))
return over
def soak(n=100, ms=2000, cadence_ms=3000):
"""A hundred exposures, logged, so the outliers can be looked for.
The point is not the mean. It is whether any single exposure is far from
it, because one bad exposure in a hundred is one ruined print in a
hundred, and a mean will hide it completely.
"""
print("# soak: {} exposures of {} ms at {} ms cadence".format(
n, ms, cadence_ms))
print(COLUMNS)
delivered = []
for _ in range(n):
d, flag = expose(ms, "soak", ms, 0, 0.0, metronome=False)
if flag == "ok":
delivered.append(d)
time.sleep_ms(cadence_ms)
if len(delivered) > 1:
mean = sum(delivered) / len(delivered)
var = sum((x - mean) ** 2 for x in delivered) / (len(delivered) - 1)
sd = math.sqrt(var)
print("# delivered mean {:.1f} ms, sd {:.2f} ms, min {}, max {}".format(
mean, sd, min(delivered), max(delivered)))
print("# spread as stops: {:.5f}".format(
factor_to_stops(max(delivered) / float(min(delivered)))))
return delivered

The second board. It does nothing but timestamp two edges, which is exactly why it can be believed about a quantity the first board cannot measure about itself.

"""
timer_probe.py - Pure Silver, Part XVII: an independent stopwatch.
Target a SECOND Raspberry Pi Pico, MicroPython v1.24.1 for RPI_PICO.
Wiring probe GP16 <- timer GP15 (the output pin, before the gate resistor)
probe GND <- timer GND (one wire, one connection, essential)
Install copy to the second board as timer_probe.py, then at its REPL:
import timer_probe as p
p.watch(20)
Licence CC BY-SA 4.0, as the rest of this course's content.
Why a second board rather than the datasheet: the quantity in question is not
the crystal's accuracy, which Part XIV showed is 150 times better than
anything here needs. It is the interpreter's loop overhead, the interrupt
latency and whatever the garbage collector does, and none of those is in any
datasheet. A board that does nothing but timestamp two edges measures all
three at once.
The probe's own overhead is its interrupt latency, which is small, constant
and in the same direction on both edges - so it very nearly cancels out of a
pulse width. It does not cancel out of a delay between boards, which is why
this file measures widths and not offsets.
"""
import machine
import micropython
import sys
import time
micropython.alloc_emergency_exception_buf(100)
PIN_WATCH = 16
ACTIVE_HIGH = True # the timer asserts its output high to switch the lamp
watch_pin = machine.Pin(PIN_WATCH, machine.Pin.IN, machine.Pin.PULL_DOWN)
_rise = None
_widths = []
def _edge(pin):
"""Hard-IRQ handler: one tick read, one comparison, no allocation."""
global _rise
t = time.ticks_us()
if pin.value() == (1 if ACTIVE_HIGH else 0):
_rise = t
elif _rise is not None:
_widths.append(time.ticks_diff(t, _rise))
_rise = None
watch_pin.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING,
handler=_edge, hard=True)
def watch(n=20, timeout_s=600):
"""Report the width of the next n pulses, in microseconds.
Start this first, then run loopback() or soak() on the timer. Every line
is comma-separated so a captured session goes straight into a spreadsheet
beside the timer's own log, and the two columns can be subtracted.
"""
print("# Pure Silver timer probe, watching GP{}".format(PIN_WATCH))
print("# {}".format(sys.implementation))
print("pulse,width_us,width_ms")
_widths.clear()
seen = 0
end = time.ticks_add(time.ticks_ms(), timeout_s * 1000)
while seen < n and time.ticks_diff(end, time.ticks_ms()) > 0:
while seen < len(_widths):
w = _widths[seen]
seen += 1
print("{},{},{:.3f}".format(seen, w, w / 1000.0))
time.sleep_ms(20)
if seen < 2:
print("# FAIL: {} pulse(s) seen. Check the signal wire and the".format(seen))
print("# shared ground, and that ACTIVE_HIGH matches the output.")
return []
got = _widths[:seen]
mean = sum(got) / len(got)
spread = max(got) - min(got)
print("# {} pulses: mean {:.1f} us, min {}, max {}, spread {} us".format(
len(got), mean, min(got), max(got), spread))
print("# spread as a fraction of the mean: {:.5f}".format(spread / mean))
return got

Five tests, in order, and the fifth is the one that decides what the instrument may claim.

T1 — The arithmetic, before any hardware

Section titled “T1 — The arithmetic, before any hardware”

timer.test(). It touches no pin and reads no clock, so it runs on the board and on a desktop Python alike. Every value it checks comes from the worked table on the f-stop lesson, which is the point: the firmware is checked against the arithmetic a reader can do by hand, not against itself.

Run it after every change to the engine, and run it before you believe a single exposure.

T2 — Every input at rest, and the output low

Section titled “T2 — Every input at rest, and the output low”

timer.selftest(). It prints the resting state of every control, learns the foot switch’s polarity, and fails if any button reads pressed with nothing touching it or if the output is asserted at rest.

T3 — The pulse, measured by something else

Section titled “T3 — The pulse, measured by something else”

Wire the probe: its GP16 to the timer’s GP15, and one ground wire between the two boards. Start p.watch(20) on the probe, then timer.loopback(20, 1000) on the timer, and put the two logs side by side.

What you are looking for is not agreement to the microsecond. It is three numbers:

  • the mean overshoot, which is the loop’s own cost. Part XIV measured a pure busy-wait’s overhead at a few tens of microseconds, and this loop does three things per pass rather than one, so expect more than that — but the number you write down is the one you measured, not the one you expected;
  • the worst overshoot over twenty runs, which is the number that goes on the certificate;
  • the spread, which is what turns into stops and therefore into visible density.

Repeat at 200 ms and at 10 s. The overshoot should be roughly constant in absolute terms, which means it is a much larger fraction of a short exposure — and that, not the crystal, is what sets the shortest exposure this firmware can honestly command.

T4 — A hundred exposures, looking for the one that is wrong

Section titled “T4 — A hundred exposures, looking for the one that is wrong”

timer.soak(100, 2000). The mean is not the point and neither is the standard deviation. Sort the delivered column and look at the extremes, because one bad exposure in a hundred is one ruined print in a hundred, and a mean will hide it completely. A single outlier of tens of milliseconds is worth chasing: the likeliest cause is a garbage collection that fell inside the exposure.

T5 — What has been verified, and what has not

Section titled “T5 — What has been verified, and what has not”

This is a test of the page rather than of the firmware, and it belongs in the record.

Verified. The listing imports cleanly and its arithmetic unit tests pass, run for this page with the hardware layer stubbed out: the five-band third-stop strip, the additive increments, the identity that the increments sum to the last band, the display rounding to tenths, whole and half stops, burn and dodge in both directions, the dry-down application, and the granularity figures. selftest passes against stubbed pins, expose honours its deadline, the interlock and the range check refuse as designed, and a START held down at the start of an exposure does not abort it.

Not verified. No part of this file has been run on a Raspberry Pi Pico by this course, because no Pure Silver timer has been built yet. Every timing figure in it is therefore a design intention rather than a measurement, OFFSET_MS is zero because nothing has measured it, and the interrupt handler’s behaviour under a real encoder’s bounce is the hardware page’s open question rather than a settled one. The version stated in the header is the interpreter the course standardises on across Parts XIV, XV and XVII, not a version this listing has been observed to run under.

That paragraph is the honest form of what other projects write as “tested and working”. When somebody builds the instrument and runs T3 and T4, this section gets shorter.

Symptom Likely cause Test that separates it
Every exposure aborts the instant it starts An abort detected on a level rather than an edge, or a foot switch whose polarity was learned with your foot on it Reboot with your foot off the pedal; selftest() prints the polarity it learned
# REFUSED: the output is already asserted The focus lamp was left on Press the encoder to leave focus; the interlock is working as designed
The delivered interval exceeds the commanded one by milliseconds rather than microseconds Something inside the loop is blocking — a sleep_ms, a display write, a print Comment out the metronome and re-run loopback(); the difference is the cost of the thing you removed
One exposure in a hundred is tens of milliseconds long A garbage collection inside the exposure Re-run soak() immediately after a reset; if the outliers move to later in the run, the heap is the cause
The board resets a few seconds after Ctrl-C The watchdog is doing its job session(wdt_enabled=False) for bench work
The encoder jumps eight at a time Acceleration, working Turn it slowly; if it still jumps, lower ACCEL_FACTOR or raise ACCEL_MS
The strip’s bands are not evenly spaced on the print Could be the firmware, the lamp or the paper test() proves the arithmetic; loopback() proves the pulse; anything left belongs to the calibration page
A negative exposure or an interval of about 71 minutes appears in the log Tick arithmetic done with - instead of ticks_diff somewhere Search the file for a subtraction of two tick values; there should be none
The metronome drifts behind over a long exposure Beats counted with sleeps rather than scheduled against the start tick The published loop schedules; a modified one may not
  1. Explain, in terms of the two forms in stage 1’s equation, why a metronome built on sleep_ms(500) drifts and one built on deadlines does not — and say what the drift would be over a two-minute exposure if each sleep overshot by 3 ms.
  2. strip_additions takes the cumulative list as its argument instead of computing increments from the base itself. Say what error that shape makes impossible, and why a comment saying “round the cumulative” would have been a weaker guarantee.
  3. The exposure loop tests the deadline before it services the metronome and the buttons. State what would change if the order were reversed, and estimate the size of the effect.
  4. Your loopback() reports a worst overshoot of 380 µs. Express that as a fraction of a stop at a commanded 0.2 s and at a commanded 20 s, and say which of those two exposures the figure constrains.
  5. A watchdog is described in a forum post as “making sure the lamp cannot stay on”. Correct the statement precisely, naming what actually releases the output and what the watchdog does contribute.
  6. The firmware applies a dry-down offset and logs the base and the commanded exposure separately. Construct the failure that would follow from applying it silently, and say why it would take months to notice.
  7. You change DIVISIONS from 3 to 6 and the strip now has bands that differ by less than your instrument’s measured spread. Say how you would know, from the numbers you already have, and what you would do about it.

Measure the loop’s cost directly. Add a counter to the exposure loop that increments on every pass, and print passes-per-second beside the delivered interval. The reciprocal is the granularity of the deadline test, and it is the tightest bound on the overshoot you can state without the probe.

Test the drift hypothesis on the metronome. Rewrite expose to count beats with sleep_ms(500) instead of scheduling them, run a two-minute exposure with the probe watching, and measure how far behind the last beat lands. That number is what the deadline design is worth, and it is more convincing measured than argued.

Find your own crossover. The 1.68 s at which a tenth of a second and a twelfth of a stop are equal falls out of the arithmetic, but the useful crossover depends on your instrument’s measured spread. Run loopback() at eight exposures from 0.2 s to 30 s, convert each spread to stops, and plot it: where the curve crosses your smallest intended step is the shortest exposure at which your steps mean anything.

Log a whole printing session and read it as data. Make a real print with the log captured, then plot delivered_ms against n. Anything that is not a flat line with the strip’s steps in it is a question, and the break/fix page is where those questions get answered.

Check your understanding

Question 1. Why does this firmware compute every term of an exposure series from the base rather than by repeatedly multiplying the previous term by 2 to the power 1/n?
Show the answer and why

Answer: Because the floating-point error of a repeated multiplication accumulates term by term, while computing 2 to the power k/n once for each band cannot accumulate anything

Each multiplication carries its own small rounding, and iterating them compounds those roundings along the series, so the twentieth band of a strip is worse than the second. Computing the term directly costs one exponential per band and gives an error that is the same size at every band. Speed is not the consideration — a strip has a handful of bands and is computed once — and MicroPython has both an operator and math.pow.

Question 2. The exposure loop checks its deadline before it services the metronome and before it reads the buttons. What does that ordering buy?
Show the answer and why

Answer: It bounds the delay the housekeeping can add to the end of the exposure at one pass of the loop, rather than allowing a beat or a button read to sit between the deadline arriving and the output being released

The loop can only end on an iteration, so the exposure always overshoots by up to one pass. Testing the deadline first makes that pass as short as possible for the purposes of ending: nothing in this iteration will run between the test succeeding and the pin going low. If the order were reversed, a click starting and a button being read would fall inside the exposure after it should have ended. The metronome is unaffected either way, because its beats are scheduled from the start tick rather than from the loop.

Question 3. A second START press during an exposure aborts it and writes a line to the log. Why is that better than silently restarting the exposure?
Show the answer and why

Answer: Because a printer pressing START mid-exposure has almost always seen something wrong and wants it stopped; a silent restart would double the sheet's exposure with no record, whereas an abort leaves an identifiable line in the log

The design question is what the press most likely means, and in a darkroom it means stop: a card in the wrong place, a hand in the beam, the wrong negative. A restart would give the sheet the aborted exposure plus a full one and leave nothing behind to say so, which is a print you cannot diagnose afterwards. Making the abort a flagged outcome in the log means the sheet can be identified from the record. Note also that the abort is edge detected, because START is still down at the moment the exposure begins.

Question 4. The RP2040 watchdog's maximum timeout is 8388 ms and print exposures can be longer, so the exposure loop feeds it. What follows?
Show the answer and why

Answer: The watchdog catches a loop that has stopped, not one that is taking too long — and what releases the output on a reset is the gate pull-down rather than any software, because the port documentation does not state the GPIO behaviour during a watchdog reset

A loop that is feeding the watchdog is by definition still running, so the watchdog can only catch a hang. It has no notion of the intended exposure length and cannot police it. The important half of the answer is what happens on the reset it does cause: the quick reference documents the countdown timer and its 1 s to 8388 ms range but not the state of the GPIO during a reset, so the course relies on the 100 kΩ gate pull-down of Part XIV, which holds the MOSFET off whenever the pin is not driven and can be verified with a meter. The third control, and the only one that does not depend on the failing thing, is the physical master switch.

Question 5. Below about 1.68 s the firmware edits the base in twelfths of a stop and above it in tenths of a second. Where does 1.68 s come from?
Show the answer and why

Answer: It is the base at which a 0.1 s step and a twelfth-stop step are the same size: 0.1 divided by (2 to the power 1/12, minus 1)

Setting log2((t + 0.1)/t) equal to 1/12 gives 0.1/t = 2^(1/12) − 1, so t = 1.68 s. Below that a tenth of a second is a coarser adjustment than a twelfth of a stop and above it a finer one, so the interface switches units at the crossing and the printer never has an adjustment that is clumsier than the alternative. It has nothing to do with the hardware's shortest exposure, which the calibration page measures, and nothing to do with paper reciprocity, for which this course has no published figure at all.

Question 6. This page states that no part of the firmware has been run on a Pico by the course. Why is that in the Testing section rather than left unsaid?
Show the answer and why

Answer: Because a published timing claim that has not been measured is exactly the kind of thing the course refuses elsewhere, and the honest form is to say which properties were verified — the arithmetic, under a stubbed run — and which were not

The course's first rule forbids stating something it has not verified, and a firmware page is not exempt because the subject is software rather than chemistry. What was verified is specific and checkable: the module imports, the arithmetic unit tests pass against the table a reader can compute by hand, the interlock and range check refuse, and a held START does not abort. What was not is equally specific: no board, no measured overshoot, and an OFFSET_MS of zero that is an admission rather than a value. The statement will indeed get shorter when somebody builds one, which is a reason to write it down now rather than a reason not to.

Sources for this page

6 cited · checked 2026-09-05

  1. 01Quick reference for the RP2, MicroPython documentationDamien P. George, Paul Sokolovsky and contributors§ Timers - the RP2040's hardware timers are not exposed and machine.Timer provides virtual timers whose callbacks are prone to garbage-collection jitter and delays unless hard=True is passed; Delay and timing, with time.ticks_ms and time.ticks_diff; WDT, the watchdog timer, enabled with a timeout of which 1 s is the minimum and 8388 ms the maximum; Pins and GPIO, the internal pull-up and Pin.irq; hardware I2C, where machine.I2C(0) takes the port default of scl on Pin 5 and sda on Pin 4docs.micropython.org/en/latest/rp2/quickref.htmltier 1, primary2026-09-05
  2. 02class Timer, control hardware timers, MicroPython library documentationDamien P. George, Paul Sokolovsky and contributors§ class Timer - the distinction between virtual and hardware timers, the note that most ports support hardware timers except Zephyr and RP2 which support only virtual timers, and the hard keyword, where False schedules the callback as a soft interrupt allowing allocation but possibly introducing garbage-collection delays and jitterdocs.micropython.org/en/latest/library/machine.Timer.htmltier 1, primary2026-09-05
  3. 03time, time related functions, MicroPython library documentationDamien P. George, Paul Sokolovsky and contributors§ ticks_ms, ticks_us and ticks_cpu - the values wrap around and only ticks_diff and ticks_add are valid on them, with the documented patterns for polling with a timeout and for scheduling events against a deadlinedocs.micropython.org/en/latest/library/time.htmltier 1, primary2026-09-05
  4. 04Raspberry Pi Pico Datasheet: An RP2040-based microcontroller boardRaspberry Pi Ltd§ Section 1, key features - a dual-core Cortex-M0+ at up to 133 MHz, 26 multi-function 3.3 V GPIO and one timer with four alarms; section 4.2, general purpose IOdatasheets.raspberrypi.com/pico/pico-datasheet.pdftier 1, primary2026-09-05
  5. 05RP2040 Datasheet: A microcontroller by Raspberry PiRaspberry Pi Ltd§ Section 2.19, GPIO - output drive strength settable to 2 mA, 4 mA, 8 mA or 12 mA; and the pad's behaviour as an input when the pin is not drivendatasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdftier 1, primary2026-09-05
  6. 06MULTIGRADE RC Papers, technical informationHARMAN technology Limited (ILFORD Photo), 2020§ ISO Range (R) - the table of range figures to ISO 6846:1992, used here only to convert a rounding error into the fraction of a paper's scale it representsilfordphoto.com/wp/wp-content/uploads/2021/01/MULTIGRADE-RC-Papers-J20.pdftier 1, primary2026-09-05

Formulas, hazard statements, historical dates and process descriptions on this page were checked against the sources above on the date shown. Safety data changes: obtain the current safety data sheet for the product you actually buy before you open it.