Display experiment · Raspberry Pi · recorded history
A display that still says “RUN A” after its program exits is showing a past request. It is not evidence that the process, sensor or network is healthy. This experiment sends one deliberately historical image to an exact Waveshare display, watches a bounded acknowledgement, and then closes the software interfaces without pretending to know the pixels.
An electrophoretic display moves charged pigment particles to make an image. Its bistability means that a visible pattern can persist without continuous refreshing. That useful property also makes a stale status page look reassuring. We put “HISTORY ONLY” and a manually chosen run label in the image so that the experiment never presents an unverified green health indicator.
The target is a Raspberry Pi 4 Model B running Linux with CPython 3 and the Waveshare 2.13inch e-Paper HAT V4, the black-and-white 250 by 122 pixel model. A Hardware Attached on Top (HAT) is the complete accessory board, including its interface components. This lesson uses its separate eight-pin connector; do not stack its 40-pin header at the same time. V2, V3, three-colour and differently sized panels require different drivers and are outside this procedure.
The Serial Peripheral Interface (SPI) carries commands and image bytes. General-purpose input/output (GPIO) pins select command or data and reset the controller. Broadcom (BCM) numbers identify the Pi signals; physical numbers identify header positions. Master Out Slave In (MOSI) sends data, Serial Clock (SCLK) clocks it, and Chip Enable 0 (CE0) selects the accessory. Data/Command (DC), Reset (RST) and BUSY are the remaining control signals. BUSY HIGH means the controller is occupied; it is not a pixel comparison result.
A frame buffer is the intended image stored in software. An acknowledgement is evidence that a protocol condition was observed. Neither establishes that the panel physically looks correct. The program returns a digest of the intended buffer, a hexadecimal Secure Hash Algorithm 256-bit (SHA-256) fingerprint, and a software receipt. It never reads the pixels or returns a current sensor measurement.
Shut down the Pi, disconnect its Universal Serial Bus Type-C (USB-C) power supply and verify that no other supply powers the HAT before wiring. Use an official-quality 5.1 volt (V), 3 ampere (A) Pi supply. Connect the HAT only to the Pi's 3.3 V output through P3 as drawn. The whole HAT accepts the documented module supply; that does not authorize applying its input voltage to a bare display or a Pi signal.

| Pi header | HAT P3 terminal | Role |
|---|---|---|
| Physical 1 / 3.3 V | 1 VCC | Module supply |
| Physical 6 / ground | 2 GND | Shared return |
| BCM10 / physical19 | 3 DIN | Serial data into HAT |
| BCM11 / physical23 | 4 CLK | Serial clock |
| BCM8 / physical24 | 5 CS | Hardware CE0 only |
| BCM25 / physical22 | 6 DC | Command/data output |
| BCM17 / physical11 | 7 RST | Reset output |
| BCM24 / physical18 | 8 BUSY | Controller output to Pi input |
Match the connector's numbered terminals with the actual board documentation and continuity checks while unpowered; do not infer them from cable colours. The SPI return-data signal, Master In Slave Out (MISO), is unused. BCM18 at physical12 is reserved by this software backend but has no wire in this eight-wire fixture. Its software LOW does not prove that a physical HAT supply was removed. Do not attach another process, overlay or device to these reserved pins or SPI0 chip-select0.
The panel specification's bare-panel supply and input thresholds are separate from the complete HAT interface. Its 3.5 milliampere (mA) typical panel current and approximately 3 second typical full refresh at 25 degrees Celsius are not maximum HAT current or a guaranteed completion time. Keep this initial bench experiment dry and near room temperature; qualify actual supply loading and the connected module before interpreting a physical run.

At process start the visible state is unknown even when an old label remains readable. Sending image bytes enters REFRESH_PENDING. Only an observed HIGH followed by LOW after the full-refresh activation changes the software receipt to BUSY_CYCLE_OBSERVED. A new process must not restore that receipt as a fresh physical claim. It has no read-back channel for the pigment pattern.
A panel may finish so quickly, or the operating system may pause polling so long, that the program misses the HIGH phase. It then rejects the run rather than guessing that a continuously LOW wire proves success. A stuck LOW, disconnected BUSY wire biased LOW, or a missed acknowledgement therefore cannot silently become a successful receipt. A stuck HIGH also fails. These are software observations, not a guarantee that every electrical fault is diagnosable.
The driver calls its portrait buffer 122 pixels wide and 250 high. Each row occupies a whole number of eight-bit bytes: rounding 122/8 upward gives 16 bytes. Thus a full buffer is 16 × 250 = 4000 bytes, including padding bits. Our landscape 250 by 122 image is converted by the exact driver's supported rotation path; it is not flattened with a guessed stride.
bytes per portrait row = ceil(122 / 8) = 16 full intended frame = 16 × 250 = 4000 bytes SPI clock request = 4,000,000 cycles/second, mode 0
That clock request is a bus setting, not a refresh-speed promise. Moving 4000 bytes and moving pigment are different operations. The full-refresh command sequence is the vendor's 0x24 image transfer followed by 0x22 with 0xF7 and activation 0x20. We do not substitute a partial-refresh waveform or extrapolate the panel's typical timing into a maximum electrical specification.
Enable SPI in the Pi's supported configuration tool and reboot if required. Use a dedicated session with no other display service. Install the operating system's supported gpiozero, spidev and Pillow packages; record their actual versions from the final report. Pillow creates the monochrome image, gpiozero owns the non-SPI pins and spidev talks to the kernel SPI device. Permission or missing-device errors are failures, not reasons to run an unreviewed competing GPIO backend.
The supplied manufacturer driver source is byte-pinned to commit a794fbc39656b0f93938d1ffb3fdc77eaed9e9fc and retains its licence. Keep it beside the program. The loader checks its fingerprint before executing it, supplies a small explicit backend and replaces the manufacturer's unbounded BUSY loop. It does not import the manufacturer's global board-detection backend, which could acquire pins as a side effect. Hardware CE0 owns chip select; vendor attempts to write that same GPIO become deliberate no-ops.
python3 epaper_history.py RUN_A --hat-v4-confirmed
The flag is an operator acknowledgement of the physical revision, not electronic identification. The program requires a Pi4 Linux model string, records installed package versions and prints only after interface cleanup. This article supplies complete target code but has not executed it on a Pi or panel. Host tests below exercise the same manufacturer Python driver with simulated pins and command capture; they do not establish actual refresh performance.
Download the full program, the adjacent pinned driver, or the complete experiment archive. Extract everything together. The backend reserves GPIO ownership before initialization and retains successfully created handles so a later construction failure can still attempt to release them.
"""One full refresh on Waveshare 2.13inch HAT V4, never a live-state guarantee."""
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import time
import types
VENDOR_SHA='9f5df7236029898f06426dfd61b3a4361c3cfafad3835d1db28ffcd377a3a00c'
VENDOR_COMMIT='a794fbc39656b0f93938d1ffb3fdc77eaed9e9fc'
def wait_busy(read, clock=time.monotonic_ns, sleep=time.sleep, cycle=False):
start=clock();last=start;seen=False
if type(start)is not int or start<0:raise ValueError('CLOCK')
for _ in range(1500):
level=read();now=clock()
if type(level)is not int or level not in (0,1):raise ValueError('BUSY_LEVEL')
if type(now)is not int or now<last or now-start>10_000_000_000:raise TimeoutError('BUSY_OR_CLOCK')
last=now
if level==1:seen=True
if level==0 and (seen or not cycle):return {'observed_busy_high':seen,'elapsed_ns':now-start}
sleep(.01)
raise TimeoutError('FINITE_POLL_BUDGET')
class RuntimeBackend:
RST_PIN=17;DC_PIN=25;CS_PIN=8;BUSY_PIN=24;PWR_PIN=18
def __init__(self):
self.outputs={};self.busy=None;self.spi=None;self.closed=False
try:
import gpiozero,spidev
for pin in (17,25,18):self.outputs[pin]=gpiozero.DigitalOutputDevice(pin,initial_value=False)
self.busy=gpiozero.DigitalInputDevice(24,pull_up=False)
self.spi=spidev.SpiDev()
except BaseException:
self.module_exit();raise
def module_init(self):
self.outputs[18].on();self.spi.open(0,0);self.spi.max_speed_hz=4_000_000;self.spi.mode=0
return 0
def digital_write(self,pin,value):
# CE0 is owned by the SPI controller; vendor GPIO-CS calls are no-ops.
if pin==8:return
if pin not in self.outputs or value not in (0,1):raise ValueError('PIN_OWNER')
self.outputs[pin].value=value
def digital_read(self,pin):
if pin!=24:raise ValueError('ONLY_BUSY_INPUT')
return int(self.busy.value)
def delay_ms(self,value):time.sleep(value/1000)
def spi_writebyte(self,data):self.spi.writebytes(data)
def spi_writebyte2(self,data):self.spi.writebytes2(data)
def module_exit(self):
if self.closed:return
errors=[]
if self.spi is not None:
try:self.spi.close()
except BaseException as e:errors.append(type(e).__name__)
for output in self.outputs.values():
for action in ('off','close'):
try:getattr(output,action)()
except BaseException as e:errors.append(type(e).__name__)
if self.busy is not None:
try:self.busy.close()
except BaseException as e:errors.append(type(e).__name__)
self.closed=True
if errors:raise RuntimeError('RESOURCE_RELEASE_UNKNOWN '+repr(errors))
def load_driver(path,backend,clock=time.monotonic_ns,sleep=time.sleep):
path=Path(path)
if hashlib.sha256(path.read_bytes()).hexdigest()!=VENDOR_SHA:raise ValueError('PINNED_VENDOR_BYTES')
package_name='_lesson109_vendor';full=package_name+'.epd2in13_V4';config_name=package_name+'.epdconfig'
package=types.ModuleType(package_name);package.__path__=[]
config=types.ModuleType(config_name)
for name in ('RST_PIN','DC_PIN','CS_PIN','BUSY_PIN','module_init','module_exit','digital_write','digital_read','delay_ms','spi_writebyte','spi_writebyte2'):
setattr(config,name,getattr(backend,name))
old={name:sys.modules.get(name)for name in (package_name,config_name,full)}
try:
sys.modules[package_name]=package;sys.modules[config_name]=config
spec=importlib.util.spec_from_file_location(full,path);module=importlib.util.module_from_spec(spec);sys.modules[full]=module;spec.loader.exec_module(module)
finally:
for name,value in old.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
class BoundedEPD(module.EPD):
def ReadBusy(self):return wait_busy(lambda:backend.digital_read(24),clock,sleep)
def TurnOnDisplay(self):
self.send_command(0x22);self.send_data(0xf7);self.send_command(0x20)
self.refresh_receipt=wait_busy(lambda:backend.digital_read(24),clock,sleep,cycle=True)
return BoundedEPD()
def make_history_image(label):
from PIL import Image,ImageDraw
if not isinstance(label,str)or not 1<=len(label)<=16 or not label.isascii()or not all(c.isalnum()or c in '-_'for c in label):raise ValueError('SHORT_ASCII_RUN_LABEL')
image=Image.new('1',(250,122),255);draw=ImageDraw.Draw(image)
draw.rectangle((0,0,249,121),outline=0,width=2)
for y,text in [(10,'HISTORY ONLY'),(35,'RUN '+label),(60,'NOT A LIVE SENSOR'),(85,'PIXELS MAY REMAIN')]:draw.text((10,y),text,fill=0)
return image
def refresh_once(backend,driver_path,label,clock=time.monotonic_ns,sleep=time.sleep):
state='VISIBLE_UNKNOWN';primary=None;cleanup=None;receipt=None
try:
image=make_history_image(label)
epd=load_driver(driver_path,backend,clock,sleep)
if epd.init()!=0:raise RuntimeError('INIT_FAILED')
buffer=epd.getbuffer(image)
if len(buffer)!=4000:raise ValueError('V4_BUFFER_SIZE')
buffer_sha=hashlib.sha256(bytes(buffer)).hexdigest()
state='REFRESH_PENDING'
epd.display(buffer)
receipt=epd.refresh_receipt
state='BUSY_CYCLE_OBSERVED'
# Only successful, idle refresh enters deep sleep. No extra commands after timeout.
epd.sleep()
except BaseException as exc:primary=exc;state='VISIBLE_UNKNOWN'
finally:
try:backend.module_exit()
except BaseException as exc:cleanup=exc;state='VISIBLE_UNKNOWN'
if primary is not None:raise primary
if cleanup is not None:raise cleanup
return {'state':state,'run_label':label,'vendor_commit':VENDOR_COMMIT,
'refresh_receipt':receipt,'intended_buffer_sha256':buffer_sha,'buffer_bytes':4000,'visible_pixels_verified':False,
'sensor_reading':None,'live_status':None,
'scope':'Historical software request/observed BUSY cycle; verify actual panel by sight. Closed resources do not erase pixels.'}
def main():
if len(sys.argv)!=3 or sys.argv[2]!='--hat-v4-confirmed':raise SystemExit('python3 epaper_history.py SHORT_RUN_LABEL --hat-v4-confirmed')
import platform,importlib.metadata
if platform.system()!='Linux' or 'Raspberry Pi 4 Model B' not in Path('/proc/device-tree/model').read_text():raise RuntimeError('PI4_LINUX_REQUIRED')
versions={n:importlib.metadata.version(n)for n in ('gpiozero','spidev','Pillow')}
backend=RuntimeBackend()
result=refresh_once(backend,Path(__file__).with_name('vendor_epd2in13_V4.py'),sys.argv[1])
result['runtime']={'python':platform.python_version(),'platform':platform.platform(),'packages':versions}
print(json.dumps(result,indent=2))
if __name__=='__main__':main()
wait_busy rejects non-binary levels, reversing or malformed clocks, a returned wait beyond ten seconds, and exhaustion of 1500 polls even if a mock clock never advances. These bounds cannot interrupt a hung operating-system call. Scheduling gaps can also miss a short BUSY transition. The accepted receipt describes the observed polling trace, never a certified maximum physical refresh duration.
After a successful cycle the driver sends its documented deep-sleep command and closes resources. On an error it skips further display commands and still attempts to release the backend. Closing SPI and GPIO does not remove the Pi's 3.3 V supply, erase the pixels or certify a low-power current. An exception, interruption or cleanup error prevents a successful final report. If the process is killed or power disappears, Python finalizers need not run and no new receipt is implied.
First inspect the physical V4 marking, eight connections, supply and exclusive pin ownership while power is off. Power the Pi, observe whatever image is already visible, and write that observation without treating it as a current result. Choose a new short label, run the command once and record the receipt, intended-buffer digest and visible label by sight. The program deliberately contains no sensor and no green “healthy” page.
After normal exit inspect the display again. Start a fresh terminal or reboot the Pi only through its supported shutdown procedure, then observe the retained label before issuing another request. If you also study an unpowered panel, shut down and disconnect the complete Pi supply; never unplug individual live signal wires to create a back-power experiment. Record the actual power action separately from software closure.
Use the blank comma-separated values (CSV) worksheet for your own observations. No rows are prefilled. Repeat with a different label only after the first run has closed and the board's recommended rest conditions are respected. Never hammer repeated full or partial refreshes to turn this history example into a speed test.
Run test_epaper.py from the extracted directory using Python3 and Pillow. The tests execute the actual pinned driver, inspect its command stream and buffer size, and inject stuck HIGH/LOW BUSY, missed-cycle, frozen/reversing/late clocks, initialization, transfer and cleanup faults. They also verify that a wrong vendor file is rejected and that the temporary import namespace is restored. They do not replace the signal with a fabricated physical timing result.
For a physical fault exercise, stop, shut down and rewire only while unpowered. Do not short an output to ground or drive BUSY from a second output. A deliberately disconnected BUSY input may be rejected as continuously LOW, but its resistor bias and actual level must be checked before power is applied. Keep the experiment on this low-voltage module; it does not qualify exposed panel internals, hot-plugging or industrial isolation.
The display is readable after a restart and the old receipt is on disk. Can a fresh process report that the room is still safe?
No. The panel and saved receipt are historical. A fresh process has not acquired a new physical measurement or compared the pixels. Keep live status unknown, show the old run label as history and require a separately qualified acquisition and completed update before making any new application claim. Even a newly observed BUSY cycle does not certify the room or the pixel pattern.
Use the exact V4 specification, HAT schematic and pinned driver. The official manual's ordinary page request was denied during preparation; the indexed official connector table was checked against the archived schematic and exact driver. No access workaround or claim that an unexamined errata page is clear is made.
Continue with the electronics course and the live category menu. This lesson prepares a bounded experiment and a blank record. It does not claim a tested prototype, measured refresh time, guaranteed retained image, current consumption certificate or a production safety display.