Here is the failure that ends more installations than any other: a sensor comes loose. Not dramatically — a jumper wire works its way out of a breadboard, a JST connector half-unseats from vibration, a solder joint fatigues. The sensor stops answering, the read raises an exception, nothing catches it, and the program stops.
The piece is then dark until someone with a laptop turns up.
John Park’s CircuitPython Parsec on this is two and a half minutes long and it is one of the highest-value things a person building interactive hardware can watch.
The code example is on GitHub, in jedgarpark/parsec.
Why an I2C disconnect is fatal by default
I2C is an addressed bus with acknowledgement. The controller puts an address on the wire and waits for the target to pull SDA low in acknowledgement. If nothing acknowledges, the transaction fails and CircuitPython raises an OSError — typically [Errno 19] No such device or a remote I/O error.
In a naive program that read sits in the main loop with nothing around it:
while True:
temp = sensor.temperature # raises the moment the sensor is gone
display.show(temp)
time.sleep(0.1)
An uncaught exception in CircuitPython terminates code.py. The board drops to the REPL and sits there. Your LEDs freeze at whatever they were last told, your motor holds its last position, and the installation is over.
The crucial point is that the hardware is fine. The microcontroller is running, the display works, the network is up. One peripheral stopped answering and the entire program went with it.
The fix
while True:
try:
temp = sensor.temperature
last_good = temp
sensor_ok = True
except OSError:
sensor_ok = False
display.show(last_good if sensor_ok else "—")
time.sleep(0.1)
That is the whole idea. The read can fail; the loop cannot.
Three refinements worth adding for anything that will run for months:
Hold the last good value, and know that you are holding it. A sensor_ok flag lets the rest of your program behave differently when the data is stale, rather than silently acting on a frozen number. An installation that keeps reacting to a temperature from four hours ago is arguably worse than one that stops.
Try to re-initialise, not just re-read. A reseated sensor sometimes needs the driver object rebuilt, not just another read:
except OSError:
sensor_ok = False
try:
sensor = adafruit_bme280.Adafruit_BME280_I2C(i2c)
except OSError:
pass
Don’t re-initialise every loop. Rebuilding a driver object at 100Hz against absent hardware wastes time and can hang the bus. Count failures and retry on a timer — every five seconds is plenty.
The general principle, which is bigger than I2C
Any operation that touches the outside world can fail, and a long-running program must assume it will.
For creative hardware specifically, the list of things that will raise an exception at 2am in a gallery:
- I2C and SPI reads — loose connectors, EMI, a sensor browning out
- Network calls —
requests.get()on a flaky venue Wi-Fi. Wrap every one. - File operations — a full or corrupted filesystem, which happens when power is cut mid-write
- Parsing —
json.loads()on a truncated response,int()on a partial serial line - Serial reads — a USB device enumerating differently after a power cycle
- Audio and display init — a panel that did not come up in time
Each one is a try/except away from being survivable.
And the complementary habit: a watchdog. CircuitPython’s microcontroller.watchdog will reset the board if your loop stops feeding it, which catches the failures that try/except cannot — a genuine hang, a memory exhaustion, a hardware lockup. Try/except keeps the program running through expected faults; a watchdog recovers from unexpected ones. Unattended work wants both.
import microcontroller
from watchdog import WatchDogMode
w = microcontroller.watchdog
w.timeout = 10
w.mode = WatchDogMode.RESET
while True:
w.feed()
# ... your loop ...
The uncomfortable reframe
Most of us learn to code by writing things that run while we watch them. An exception is useful then — it stops immediately and tells you what broke, which is exactly what you want at a desk.
Unattended work inverts that. Nobody is watching, nobody will read the traceback, and a stopped program is the worst possible outcome. The goal stops being fail loudly and becomes degrade gracefully and keep going, with the failure logged for later.
That shift — from debugging posture to deployment posture — is the thing that separates a project that works at the demo from one that runs for six months. And it costs about twelve lines.