Skip to content

Commit 959b200

Browse files
authored
Merge pull request #15 from ngrok/joelhans/persistent-oled
Show hostname, MAC, and IPs on the OLED from boot
2 parents 65b9162 + b0d0a59 commit 959b200

12 files changed

Lines changed: 511 additions & 17 deletions

File tree

image/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ file in place so you can fix it and reboot.
9191
with no prompt.
9292
- Confirm the networking tools are present: `which tcpdump tshark arping`.
9393
- Check the I2C bus (for the OLED): `i2cdetect -y 1`.
94+
- If an OLED is wired up, it shows the node's identity from boot — hostname in
95+
the yellow strip, eth0's MAC and each interface's IPv4 in the blue area — so
96+
a rack of identical Pis is tellable-apart at a glance (see
97+
[`tools/status-oled`](../tools/status-oled/README.md)). The on-demand OLED
98+
scripts (`~/oled-test`, `~/arp-oled`) borrow the panel while they run and the
99+
status display resumes when they exit.
94100
- Wi-Fi is management-only by design: you can SSH in and the node reaches the
95101
internet, but two nodes **can't reach each other over Wi-Fi**. The lessons run
96102
on a wired link instead. So if a second node seems unreachable _from the first
@@ -132,7 +138,9 @@ image/
132138
│ ├── 03-run.sh Lets the user run tshark unprivileged, pre-creates ~/cap, and sets COLORTERM for colored output over SSH.
133139
│ ├── 04-run.sh Builds /opt/little-internet/venv with luma.oled to drive the OLED displays.
134140
│ ├── 05-run.sh Installs the OLED test scripts into ~/oled-test (staged from tools/oled-test by build.sh).
135-
│ └── files/ eth-dhcp.nmconnection — the eth0 DHCP baseline keyfile (build.sh also stages oled-test/ here).
141+
│ ├── 06-run.sh Installs the on-demand ARP-state OLED viewer into ~/arp-oled (staged from tools/arp-oled by build.sh).
142+
│ ├── 07-run.sh Installs + enables the boot-time OLED status display, little-internet-oled.service (staged from tools/status-oled by build.sh).
143+
│ └── files/ eth-dhcp.nmconnection + little-internet-oled.service (build.sh also stages oled-test/, arp-oled/, and status-oled/ here).
136144
├── 01-firstboot-config/ First-boot hostname + Wi-Fi provisioner for flashed (released) images.
137145
│ ├── 00-run.sh Installs the provisioner script, service, and boot-partition template.
138146
│ └── files/ The script, systemd unit, and little-internet.txt.example.

image/build.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ arp_files="${PIGEN_DIR}/${STAGE_NAME}/00-net-tools/files/arp-oled"
154154
mkdir -p "${arp_files}"
155155
cp "${HERE}/../tools/arp-oled/arp_oled.py" "${arp_files}/"
156156

157+
# 3e. Stage the boot-time OLED status display (tools/status-oled is its source
158+
# of truth); 00-net-tools/07-run.sh installs it under /opt/little-internet
159+
# and enables it as a systemd service.
160+
status_files="${PIGEN_DIR}/${STAGE_NAME}/00-net-tools/files/status-oled"
161+
mkdir -p "${status_files}"
162+
cp "${HERE}/../tools/status-oled/status_oled.py" "${status_files}/"
163+
157164
# 4. Only export our final image, not the intermediate Lite image.
158165
touch "${PIGEN_DIR}/stage2/SKIP_IMAGES"
159166

image/stage-little-internet/00-net-tools/06-run.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
#
99
# Run it by hand against the venv built in 04-run.sh (no sudo: reading the
1010
# neighbour cache is unprivileged and the first user is already in the i2c
11-
# group). It deliberately is NOT a boot service — that would permanently claim
12-
# the OLED and fight the oled-test smoke scripts over the one I2C panel:
11+
# group). It deliberately is NOT a boot service — the panel's boot-time
12+
# resident is the status display (07-run.sh). While this script runs it pauses
13+
# that display via the claim file described in little-internet-oled.service,
14+
# and hands the panel back on exit:
1315
# /opt/little-internet/venv/bin/python3 ~/arp-oled/arp_oled.py
1416
#
1517
# Staged into files/arp-oled by build.sh from tools/arp-oled (source of truth);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/bash -e
2+
3+
# Install the boot-time OLED status display: hostname in the yellow strip,
4+
# eth0's MAC and each interface's IPv4 in the blue body, painted from boot so
5+
# a rack of identical Pis is tellable-apart at a glance. Runs as a systemd
6+
# service (little-internet-oled.service) against the venv built in 04-run.sh.
7+
#
8+
# The script lives in /opt/little-internet (not the user's home) because a
9+
# root service shouldn't execute user-editable files. The on-demand scripts
10+
# installed by 05-/06-run.sh share the panel with it through the claim file
11+
# described in the service unit.
12+
#
13+
# Staged into files/status-oled by build.sh from tools/status-oled (source of
14+
# truth).
15+
install -d -m 755 "${ROOTFS_DIR}/opt/little-internet/status-oled"
16+
install -m 755 files/status-oled/status_oled.py \
17+
"${ROOTFS_DIR}/opt/little-internet/status-oled/"
18+
19+
install -m 644 files/little-internet-oled.service \
20+
"${ROOTFS_DIR}/etc/systemd/system/little-internet-oled.service"
21+
22+
on_chroot << 'EOF'
23+
systemctl enable little-internet-oled.service
24+
EOF
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[Unit]
2+
Description=Show hostname, MAC, and IPv4 on the OLED (little-internet)
3+
# The boot-time resident of the panel: with a rack of identical Pis, the OLED
4+
# is what tells you which node is which. Ordered after the first-boot
5+
# provisioner so a hostname from little-internet.txt is already set when the
6+
# first frame paints. No network dependency — the script polls, so addresses
7+
# appear as DHCP hands them out.
8+
After=local-fs.target little-internet.service
9+
10+
[Service]
11+
Type=simple
12+
# Unbuffered stdout so the script's log lines reach the journal as they
13+
# happen, not when the buffer fills.
14+
Environment=PYTHONUNBUFFERED=1
15+
ExecStart=/opt/little-internet/venv/bin/python3 /opt/little-internet/status-oled/status_oled.py
16+
Restart=on-failure
17+
RestartSec=5
18+
# /run/little-internet is the handoff point for the one panel: on-demand OLED
19+
# scripts (~/arp-oled, ~/oled-test) create oled.claim there to pause this
20+
# display and remove it to resume. Group i2c + 0775 lets the unprivileged lab
21+
# user (already in i2c for the bus itself) create and remove that file.
22+
Group=i2c
23+
RuntimeDirectory=little-internet
24+
RuntimeDirectoryMode=0775
25+
26+
[Install]
27+
WantedBy=multi-user.target

tools/arp-oled/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ the cache is unprivileged, and the `pi` user is already in the `i2c` group):
1515

1616
By default it watches the other half of the `10.10.0.x` pair (on `.1` it watches
1717
`.2`, and vice versa); override with `--peer`. Ctrl-C clears the panel and exits.
18-
It's a script, not a service, so it won't fight the `oled-test` scripts for the
19-
display.
18+
On the image the panel normally shows the boot-time
19+
[status display](../status-oled/README.md) (hostname/MAC/IP); this script pauses
20+
it while it runs — via `/run/little-internet/oled.claim`, no sudo needed — and
21+
the status display repaints when this exits.
2022

2123
## Drive the state machine
2224

tools/arp-oled/arp_oled.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
The peer defaults to the other half of the 10.10.0.1 <-> 10.10.0.2 lab pair
1818
(auto-picked from this node's own address), or pass --peer.
1919
20+
On the little-internet image the panel normally shows the boot-time status
21+
display (hostname/MAC/IP); this script pauses it via a claim file while it
22+
runs and the status display resumes when this exits.
23+
2024
/opt/little-internet/venv/bin/python3 arp_oled.py
2125
arp_oled.py --peer 10.10.0.2 --interval 0.5
2226
arp_oled.py --address 0x3d --controller sh1106
@@ -25,6 +29,7 @@
2529
import argparse
2630
import json
2731
import os
32+
import signal
2833
import subprocess
2934
import sys
3035
import time
@@ -39,6 +44,33 @@
3944
PAIR = {"10.10.0.1": "10.10.0.2", "10.10.0.2": "10.10.0.1"}
4045
DEFAULT_PEER = "10.10.0.2"
4146

47+
# The boot-time status display (little-internet-oled.service on the image)
48+
# owns the panel; it pauses while this claim file exists and resumes when it's
49+
# removed. The claim directory is that service's RuntimeDirectory (root:i2c,
50+
# 0775), so no sudo is needed. On systems without the service, claiming just
51+
# fails quietly — there's nothing to pause.
52+
CLAIM_FILE = "/run/little-internet/oled.claim"
53+
54+
55+
def claim_panel():
56+
"""Pause the boot status display; True if the claim file was written."""
57+
try:
58+
with open(CLAIM_FILE, "w") as fh:
59+
fh.write(f"{os.getpid()}\n")
60+
except OSError:
61+
return False
62+
# Let an in-flight status frame land before we paint over it.
63+
time.sleep(0.5)
64+
return True
65+
66+
67+
def release_panel(claimed):
68+
if claimed:
69+
try:
70+
os.remove(CLAIM_FILE)
71+
except OSError:
72+
pass
73+
4274
# NUD states worth a one-glance read. Anything else is shown verbatim.
4375
KNOWN_STATES = {
4476
"REACHABLE", "STALE", "DELAY", "PROBE",
@@ -228,6 +260,10 @@ def main():
228260
print("Requested font unavailable; using the built-in bitmap font.")
229261

230262
print(f"Watching ARP state for {peer} (poll {args.interval}s). Ctrl-C to stop.")
263+
# Python dies on SIGTERM without running `finally`, which would strand the
264+
# claim file and leave the status display paused; exit cleanly instead.
265+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
266+
claimed = claim_panel()
231267
beat = False
232268
try:
233269
while True:
@@ -238,6 +274,9 @@ def main():
238274
except KeyboardInterrupt:
239275
device.clear()
240276
print("\nDone.")
277+
finally:
278+
# Hand the panel back: the status display repaints within a second.
279+
release_panel(claimed)
241280

242281

243282
if __name__ == "__main__":

tools/oled-test/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ Quick smoke tests for the **SSD1306 128×64** OLEDs over **I2C** (4-pin modules)
2727
> /opt/little-internet/venv/bin/python3 ~/oled-test/oled_shrimp.py
2828
> ```
2929
>
30+
> On the image the panel is normally driven by the boot-time
31+
> [status display](../status-oled/README.md) (hostname/MAC/IP). These scripts
32+
> pause it while they run and it repaints when they exit, so a working panel
33+
> already showing status is itself a passing smoke test.
34+
>
3035
> The steps below are for a stock Raspberry Pi OS where you set this up yourself.
3136
3237
```sh

tools/oled-test/oled_shrimp.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"""
1212
import argparse
1313
import base64
14+
import os
15+
import signal
1416
import sys
1517
import time
1618

@@ -19,6 +21,33 @@
1921
from luma.core.interface.serial import i2c
2022
from luma.oled.device import sh1106, ssd1306
2123

24+
# The boot-time status display (little-internet-oled.service on the image)
25+
# owns the panel; it pauses while this claim file exists and resumes when it's
26+
# removed. The claim directory is that service's RuntimeDirectory (root:i2c,
27+
# 0775), so no sudo is needed. On systems without the service, claiming just
28+
# fails quietly — there's nothing to pause.
29+
CLAIM_FILE = "/run/little-internet/oled.claim"
30+
31+
32+
def claim_panel():
33+
"""Pause the boot status display; True if the claim file was written."""
34+
try:
35+
with open(CLAIM_FILE, "w") as fh:
36+
fh.write(f"{os.getpid()}\n")
37+
except OSError:
38+
return False
39+
# Let an in-flight status frame land before we paint over it.
40+
time.sleep(0.5)
41+
return True
42+
43+
44+
def release_panel(claimed):
45+
if claimed:
46+
try:
47+
os.remove(CLAIM_FILE)
48+
except OSError:
49+
pass
50+
2251
# 64x64, 1-bit, MSB-first packed (PIL mode "1"). Phosphor "shrimp" (regular).
2352
SHRIMP_64_B64 = (
2453
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAADwAAAAAAAAAPA"
@@ -66,10 +95,17 @@ def main():
6695
x = (device.width - icon.width) // 2
6796
y = (device.height - icon.height) // 2
6897
frame.paste(icon, (x, y))
69-
device.display(frame)
70-
print(f"Shrimp on I2C {args.port} @ {hex(args.address)}. "
71-
f"Holding {args.hold}s... 🦐")
72-
time.sleep(args.hold)
98+
# Python dies on SIGTERM without running `finally`, which would strand the
99+
# claim file and leave the status display paused; exit cleanly instead.
100+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
101+
claimed = claim_panel()
102+
try:
103+
device.display(frame)
104+
print(f"Shrimp on I2C {args.port} @ {hex(args.address)}. "
105+
f"Holding {args.hold}s... 🦐")
106+
time.sleep(args.hold)
107+
finally:
108+
release_panel(claimed)
73109
print("Done.")
74110

75111

tools/oled-test/oled_test.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,42 @@
88
python3 oled_test.py --address 0x3d --hold 8
99
"""
1010
import argparse
11+
import os
12+
import signal
1113
import sys
1214
import time
1315

1416
from luma.core.interface.serial import i2c
1517
from luma.core.render import canvas
1618
from luma.oled.device import sh1106, ssd1306
1719

20+
# The boot-time status display (little-internet-oled.service on the image)
21+
# owns the panel; it pauses while this claim file exists and resumes when it's
22+
# removed. The claim directory is that service's RuntimeDirectory (root:i2c,
23+
# 0775), so no sudo is needed. On systems without the service, claiming just
24+
# fails quietly — there's nothing to pause.
25+
CLAIM_FILE = "/run/little-internet/oled.claim"
26+
27+
28+
def claim_panel():
29+
"""Pause the boot status display; True if the claim file was written."""
30+
try:
31+
with open(CLAIM_FILE, "w") as fh:
32+
fh.write(f"{os.getpid()}\n")
33+
except OSError:
34+
return False
35+
# Let an in-flight status frame land before we paint over it.
36+
time.sleep(0.5)
37+
return True
38+
39+
40+
def release_panel(claimed):
41+
if claimed:
42+
try:
43+
os.remove(CLAIM_FILE)
44+
except OSError:
45+
pass
46+
1847

1948
def main():
2049
p = argparse.ArgumentParser(description="Smoke test an I2C SSD1306 OLED.")
@@ -38,14 +67,21 @@ def main():
3867
"and that I2C is enabled.")
3968
sys.exit(1)
4069

41-
with canvas(device) as draw:
42-
draw.rectangle(device.bounding_box, outline="white")
43-
draw.text((6, 8), "OLED OK", fill="white")
44-
draw.text((6, 26), f"I2C {args.port} @ {hex(args.address)}", fill="white")
45-
draw.text((6, 44), f"{device.width}x{device.height}", fill="white")
46-
print(f"Drew test pattern on I2C {args.port} @ {hex(args.address)}. "
47-
f"Holding {args.hold}s...")
48-
time.sleep(args.hold)
70+
# Python dies on SIGTERM without running `finally`, which would strand the
71+
# claim file and leave the status display paused; exit cleanly instead.
72+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
73+
claimed = claim_panel()
74+
try:
75+
with canvas(device) as draw:
76+
draw.rectangle(device.bounding_box, outline="white")
77+
draw.text((6, 8), "OLED OK", fill="white")
78+
draw.text((6, 26), f"I2C {args.port} @ {hex(args.address)}", fill="white")
79+
draw.text((6, 44), f"{device.width}x{device.height}", fill="white")
80+
print(f"Drew test pattern on I2C {args.port} @ {hex(args.address)}. "
81+
f"Holding {args.hold}s...")
82+
time.sleep(args.hold)
83+
finally:
84+
release_panel(claimed)
4985
print("Done.")
5086

5187

0 commit comments

Comments
 (0)