A practical Raspberry Pi guide — from flashing the card to a hardened, headless box running services — followed by five lab projects that mirror a solar-monitoring support path: meter → RS-485 → logger → modem → cloud.
8 tutorial sections5 lab projectsChecked against official docsSynthetic data onlyTroubleshooting tables
Pick any stop on the path, or scroll in order. Progress is saved only in this browser.
Tutorial (1–8)Hardware, Imager, headless SSH, updates, networking, security, systemd and a troubleshooting checklist — commands verified against Raspberry Pi’s documentation.
Projects (9–13)Lab guides with goal, parts, steps, code and support notes. Built around the data path a monitoring desk supports.
Honest scopeThese are bench prototypes with synthetic data, not systems deployed at a job. Real hardware follows the manufacturer’s manuals.
From an empty microSD card to a secure, headless Raspberry Pi running your scripts as services.
1
Choose the hardware
TUTORIAL 01 · Plan
Pick a board, power supply, storage and adapters that match the job — a headless monitoring box does not need a desktop kit.
For a small monitoring or lab box, the board matters less than the boring parts: a correct power supply, reliable storage, and the right adapters. Most “random reboot” and “SD card died” stories start there.
What to buy
Shopping list for a headless lab box
Board: any current Raspberry Pi with Ethernet works for these labs (for example Raspberry Pi 4 or 5). Wired Ethernet is easier to troubleshoot than Wi-Fi.
Storage: a microSD card. Raspberry Pi recommends at least 8 GB for Raspberry Pi OS Lite and 32 GB for the desktop image; more gives room for logs.
OS choice:Raspberry Pi OS Lite (no desktop) is the recommended choice for headless setups.
Case + cooling so it can sit in a cabinet without throttling.
For the projects: a USB-to-RS-485 adapter, short twisted-pair cable, and (optional) a second adapter so you can simulate a meter on the bench.
Power: use the recommended supply
Model
Recommended supply (official docs)
Raspberry Pi 5
5 V at 5 A — 27 W USB-C supply. At 5 V/3 A, USB peripherals are limited to 600 mA.
Raspberry Pi 4 Model B
5 V at 3 A — 15 W USB-C supply
Raspberry Pi 3 (all)
5 V at 2.5 A — 12.5 W micro-USB supply
Why this mattersAn undersized supply or thin cable causes undervoltage: random reboots, USB adapters dropping off, corrupted SD cards. vcgencmd get_throttled reports it (bit 0x1 = undervoltage now, 0x10000 = undervoltage has occurred since boot).
Support mindsetWhen a field box “randomly reboots,” ask about the power supply and check get_throttled before blaming software.
Check yourself
Answer out loud first, then open the card.
A Pi 5 runs fine on a 3 A phone charger, but the USB-RS485 adapter keeps disconnecting. First suspicion?
Power. On 5 V/3 A, a Pi 5 limits USB peripherals to 600 mA; also check vcgencmd get_throttled for undervoltage bits.
Which OS image for a headless monitoring box?
Raspberry Pi OS Lite — no desktop, smaller, recommended for headless setups.
Imager downloads the OS, writes the card, and pre-configures hostname, user, Wi-Fi and SSH so the Pi boots straight onto the network.
Steps
Install Raspberry Pi Imager on your laptop (Windows/macOS installer from raspberrypi.com, AppImage on Linux, or sudo apt install rpi-imager on Raspberry Pi OS).
Device tab: select your Raspberry Pi model.
OS tab: choose Raspberry Pi OS Lite (64-bit) for a headless box.
Storage tab: pick the microSD card. Keep Exclude system drives selected so you can’t wipe your laptop’s disk.
Customization (recommended): fill in the subtabs below, then review the summary and write.
Customization — what to set
Setting
What to enter
Why
Hostname
e.g. pi-lab-01 — letters, numbers, hyphens
Reach it as pi-lab-01.local via mDNS instead of hunting for the IP
Localisation
Time zone + keyboard
Correct timestamps in logs and telemetry
User
Your own username + strong password
No shared default accounts
Wi-Fi
SSID, password, Wi-Fi country (skip if using Ethernet)
Boots straight onto the network
Remote access
Enable SSH → public key authentication, paste your public key
Key-only login from day one
TipIf you already have ~/.ssh/id_rsa.pub, Imager pre-fills it. For an Ed25519 key, browse to ~/.ssh/id_ed25519.pub (see section 6 to create one).
Check yourself
Answer out loud first, then open the card.
Why keep “Exclude system drives” enabled?
It stops Imager from listing your computer’s own disks, so you can’t accidentally erase them.
What do you pre-configure for a headless box?
Hostname, time zone, user, Wi-Fi (if needed) and SSH — ideally with public key authentication.
Power on, give it a minute, then connect from your laptop by hostname or IP.
Connect
# from your laptop (mDNS name you set in Imager)
ssh <username>@pi-lab-01.local
# or by IP address
ssh <username>@192.168.1.50
# first time: confirm the host key fingerprint, then you get a prompt
<username>@pi-lab-01:~ $
Can’t find it? Find the IP
Router / DHCP lease table: look for the hostname you set.
On the Pi (if you have a screen):hostname -I or nmcli device show.
From another Linux machine: a ping scan of your subnet, e.g. nmap -sn 192.168.1.0/24 (only on networks you’re allowed to scan).
.local not resolving? mDNS needs support on your laptop (built in on macOS and most Linux desktops; Windows support varies). Fall back to the IP.
How it breaks, and what to check
Symptom
Likely cause
What to check
Could not resolve hostname
mDNS not working on laptop / different subnet
Use the IP from the router lease table
Connection refused on port 22
SSH not enabled in Imager
Re-flash with SSH on, or enable via raspi-config with a screen
Permission denied (publickey)
Wrong key / wrong username
ssh -v to see which key is offered; username is the one set in Imager
REMOTE HOST IDENTIFICATION HAS CHANGED
You re-flashed the card; new host key
Remove the old entry: ssh-keygen -R pi-lab-01.local
No lease in router at all
Power, bad card, Wi-Fi details/country wrong
Try Ethernet; check the green activity LED; re-flash
Check yourself
Answer out loud first, then open the card.
You re-flashed the card and SSH now warns the host identification changed. Safe fix?
Expected after a re-flash: remove the stale entry with ssh-keygen -R <host>, reconnect and verify the new fingerprint. If you did NOT re-flash, treat it as suspicious.
Fastest way to find a headless Pi’s IP?
Router DHCP lease table by hostname; otherwise a ping scan of the subnet you’re authorized to scan.
Keep the OS patched with APT and keep project libraries inside a virtual environment.
Update the OS with APT
sudo apt update # refresh package lists
sudo apt full-upgrade # install updates (Raspberry Pi recommends full-upgrade)
df -h # check free space if the upgrade asks for more
sudo reboot # after kernel/firmware updates
Firmware and kernel updates arrive through APT. rpi-update installs pre-release firmware — not for routine use; only if a Raspberry Pi engineer asks you to.
Python libraries go in a venv
Since Raspberry Pi OS Bookworm, system-wide pip install fails with externally-managed-environment (PEP 668). Use a virtual environment per project:
mkdir -p ~/lab && cd ~/lab
python3 -m venv .venv
source .venv/bin/activate
pip install pymodbus pyserial paho-mqtt flask
pip freeze > requirements.txt # pin what you tested
Support habitBefore and after any update, note uname -a, the date, and what changed. When something breaks next week, that note is the timeline.
Give the Pi a predictable address, then learn the handful of commands that prove where a network problem lives.
Stable address: reservation first
Option A — DHCP reservation (recommended)
On the router, reserve an IP for the Pi’s MAC address. IP management stays central and nothing on the Pi can be mistyped. Get the MAC with ip link show eth0.
Option B — static IP on the Pi (NetworkManager)
Raspberry Pi OS uses NetworkManager. Only do this if a reservation isn’t possible, and pick an address outside the router’s DHCP pool.
nmcli connection show # find the connection NAME for eth0
sudo nmcli connection modify "<NAME>" \
ipv4.method manual \
ipv4.addresses 192.168.1.50/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "192.168.1.1"
sudo nmcli connection up "<NAME>" # apply (you may lose SSH if wrong)
Lockout riskChanging the IP over SSH drops your session. Do it with console access, or be sure of the values and reconnect on the new address. To revert: sudo nmcli connection modify "<NAME>" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "".
Checks that answer one question each
Command
Question it answers
ip -br addr / ip a
Do I have an IP on the right interface? (no IP = DHCP/link problem)
ip route
What is my default gateway?
ping -c 4 <gateway>
Can I reach the local router?
ping -c 4 1.1.1.1
Can I reach the internet by IP (past the modem)?
ping -c 4 example.com
Does DNS work? (IP works + name fails = DNS)
nmcli device status
Is NetworkManager connected on eth0 / wlan0?
sudo ss -tulpn
Which services listen on which ports (and which process)?
journalctl -u NetworkManager -b
What did the network stack log since boot?
journalctl -b -p warning
Any warnings or errors this boot?
How it breaks, and what to check
Symptom
Likely cause
What to check
No IPv4 on eth0
Cable/switch port, DHCP not answering
ip -br link (UP?), switch LEDs, router DHCP
Gateway ping fails
Wrong subnet/static typo, VLAN, Wi-Fi association
ip route, nmcli device status
Gateway OK, 1.1.1.1 fails
Modem/WAN, SIM/signal, ISP, firewall upstream
Modem status page, other devices on same LAN
1.1.1.1 OK, names fail
DNS server wrong/unreachable
nmcli device show | grep DNS
Service unreachable from LAN
Not listening, bound to 127.0.0.1, or firewall
sudo ss -tulpn, sudo ufw status
Check yourself
Answer out loud first, then open the card.
Ping to 1.1.1.1 works, ping to example.com fails. Where’s the fault?
Name resolution (DNS). Routing to the internet is fine; check the DNS servers the Pi is using.
Why prefer a DHCP reservation over a static IP on the Pi?
Central management on the router, no risk of a typo or address conflict on the device — it’s what Raspberry Pi’s docs recommend.
Small, boring steps that stop most problems: personal accounts, key-only SSH, a default-deny firewall, and regular updates.
Users
sudo adduser <name> # create a personal account
sudo usermod -aG sudo,dialout <name> # admin + serial ports (USB-RS485)
passwd # change your own password
sudo deluser --remove-home <oldname> # remove accounts nobody should use
SSH keys (on your laptop)
ssh-keygen -t ed25519 # creates ~/.ssh/id_ed25519(.pub)
ssh-copy-id <name>@pi-lab-01.local # installs the public key on the Pi
ssh <name>@pi-lab-01.local # should log in without a password
Once key login works, limit who can log in and turn off password logins (OpenSSH settings) in /etc/ssh/sshd_config:
AllowUsers <name>
PasswordAuthentication no
sudo sshd -t # syntax check before restarting
sudo systemctl restart ssh # keep your current session open while you test a new one
Firewall with UFW (allow SSH before enabling)
sudo apt install ufw
sudo ufw default deny incoming
sudo ufw allow ssh # or: sudo ufw limit ssh/tcp
sudo ufw allow from 192.168.1.0/24 to any port 5000 proto tcp # LAN-only dashboard
sudo ufw enable
sudo ufw status verbose
Don’t lock yourself outEnable UFW only after the SSH rule exists, and test key login in a second terminal before closing the first one.
OptionalRaspberry Pi’s docs also cover Fail2Ban, which watches logs and bans IPs after repeated failed logins.
Check yourself
Answer out loud first, then open the card.
Order of operations to enable UFW over SSH?
Install → default deny incoming → allow ssh → enable → status verbose. Never enable before the SSH rule.
Your script gets “Permission denied: /dev/ttyUSB0”. Fix?
Add the user to dialout (sudo usermod -aG dialout <name>), then log out and back in (or restart the service).
sudo systemctl daemon-reload # after creating/editing a unit
sudo systemctl enable --now meter-reader # start now + at every boot
systemctl status meter-reader # state, PID, last log lines
journalctl -u meter-reader -f # follow its logs live
journalctl -u meter-reader --since "1 hour ago"
sudo systemctl restart meter-reader
systemctl --failed # anything crashed?
Why support people like this“Is it running, since when, and what did it last say?” — systemctl status + journalctl -u answers all three without guessing.
Check yourself
Answer out loud first, then open the card.
You edited the unit file but nothing changed. What did you forget?
sudo systemctl daemon-reload, then restart the service.
Service shows activating (auto-restart) over and over. Next step?
journalctl -u <service> -n 50 to read the error — wrong path, missing venv package, or serial permission are common.
ls -l /dev/serial/by-id/ · sudo dmesg | tail after plugging in the USB adapter · groups (dialout?)
read-only
6
One controlled change
Restart the service, then re-test. Note time and result before trying anything else.
change
7
Reboot / re-flash
Only after evidence is captured. A reboot erases the clues in RAM and the current boot’s state.
last
Symptom
Likely cause
What to check
Random reboots
Undervoltage / weak supply or cable
vcgencmd get_throttled, try the recommended PSU
Read-only filesystem / corrupt files
SD card wear or power loss during writes
sudo dmesg for mmc errors; back up and replace card
Service up but no data
Wrong serial port, unit ID, or baud
Service logs; /dev/serial/by-id/; settings vs device
Data stops at night only
Source device sleeps (e.g. no PV production)
Expected? Compare with device behavior before escalating
Timestamps wrong
Time zone/NTP not synced
timedatectl
Check yourself
Answer out loud first, then open the card.
Why capture evidence before rebooting?
A reboot clears the current state (memory, current-boot journal context, transient errors). Capture status, logs and readings first so the root cause can still be found.
get_throttled returns 0x50000. Meaning?
Bits 16 and 18 set: undervoltage has occurred and throttling has occurred since boot — a power problem, even if it looks fine right now.
Prototypes for a solar-monitoring style telemetry path.
Lab guides, not deployments. Every project below uses synthetic data and was built as a learning prototype — tested on Linux with a virtual serial link and a local MQTT broker. Register addresses are placeholders; no vendor-specific wiring, terminal maps or schematics are implied.
9
RS-485 / Modbus RTU meter reader
ProjectLAB 01 · pymodbus
Poll a meter-style device over a USB-RS485 adapter and print readings — the same modem → logger → RS-485 → meter path a monitoring desk supports.
GoalRead power, energy and voltage from one Modbus RTU device and log errors clearly.
Parts
Raspberry Pi + USB-RS485 adapter
2-wire twisted pair
A Modbus RTU device or a second adapter running the simulator below
Support skills
Serial settings (baud/parity/stop)
Unit IDs
Timeouts vs CRC errors
Steps
Plug in the adapter and find a stable name: ls -l /dev/serial/by-id/ (better than /dev/ttyUSB0, which can change order).
Wire per the device’s and adapter’s manuals. A/B (or D+/D−) naming differs between vendors — follow the documentation, don’t guess.
Bus basics: daisy-chain (no star), 120 Ω termination at the two physical ends only, every device on the same baud/parity/stop bits, each with a unique unit ID.
Take register addresses, data types, scaling and word order from the device’s register map. The addresses in the code are placeholders for the simulator.
Install in a venv (section 4), run it, then wrap it in the systemd unit from section 7.
Code — reader.py
# reader.py - poll one Modbus RTU device over a USB-RS485 adapter
# Register addresses are PLACEHOLDERS for the lab simulator.
# On real hardware, take addresses, scaling and word order from the
# meter's own register map - they differ between vendors and models.
import os, sys, time
from pymodbus.client import ModbusSerialClient
from pymodbus.exceptions import ModbusException
PORT = os.getenv("MODBUS_PORT", "/dev/ttyUSB0")
UNIT = int(os.getenv("MODBUS_UNIT", "1"))
client = ModbusSerialClient(port=PORT, baudrate=9600, bytesize=8,
parity="N", stopbits=1, timeout=1, retries=2)
def read_once():
rr = client.read_holding_registers(0, count=4, device_id=UNIT)
if rr.isError():
raise IOError(f"Modbus error from unit {UNIT}: {rr}")
regs = rr.registers
energy = client.convert_from_registers(
regs[1:3], data_type=client.DATATYPE.UINT32, word_order="big")
return {"ts": int(time.time()), "power_w": regs[0],
"energy_wh": energy, "voltage_v": regs[3] / 10}
if __name__ == "__main__":
if not client.connect():
sys.exit(f"cannot open {PORT} - check cable, permissions (dialout), port name")
try:
while True:
try:
print(read_once(), flush=True)
except (ModbusException, IOError) as exc:
print(f"WARN {exc}", file=sys.stderr, flush=True)
time.sleep(5)
finally:
client.close()
Bench simulator — no meter needed
A tiny synthetic “meter” that answers function 03 with random values. Run it on a second adapter, or on Linux use a virtual serial pair:
# lab_sim.py - tiny synthetic Modbus RTU "meter" (function 03 only) for bench practice.
# Pair it with a virtual serial link (socat) or a second USB-RS485 adapter.
import random, struct, sys, serial
def crc16(frame: bytes) -> bytes:
crc = 0xFFFF
for b in frame:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return struct.pack("<H", crc) # CRC is sent low byte first
UNIT, energy_wh = 1, 1_250_000
port = serial.Serial(sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB1",
9600, bytesize=8, parity="N", stopbits=1, timeout=0.05)
buf = b""
while True:
buf = (buf + port.read(64))[-8:]
if len(buf) < 8 or crc16(buf[:6]) != buf[6:]:
continue # wait for a complete, valid request
unit, func, start, count = struct.unpack(">BBHH", buf[:6]); buf = b""
if unit != UNIT or func != 3:
continue # not for us: stay silent, like a real slave
power_w = random.randint(3200, 4100) # synthetic values only
energy_wh += power_w // 720
regs = [power_w, energy_wh >> 16, energy_wh & 0xFFFF, 2405] + [0] * 60
data = struct.pack(f">{count}H", *regs[start:start + count])
reply = struct.pack(">BBB", UNIT, 3, len(data)) + data
port.write(reply + crc16(reply))
Support / troubleshooting notes
Symptom
Likely cause
What to check
“No response received” (timeout)
Wrong unit ID, baud/parity mismatch, A/B swapped, no power on device
Try the simulator first to prove the Pi side; then compare settings with the device display/manual
CRC / garbled frames
Noise, missing termination, extra terminators, long stubs
Termination at both ends only, twisted pair, shield per site practice
Values off by 10×/1000×
Scaling factor not applied
Register map: units and scale
Huge or negative 32-bit values
Word order wrong
Swap word_order big/little and compare with device display
Reads the wrong quantity
Register offset (docs “40001” vs protocol address 0)
Store every reading locally first, forward later. When the modem or uplink drops, nothing is lost — the backlog just grows and drains when the link returns.
GoalNever lose readings during connectivity outages; forward oldest-first when the link is back.
Parts
Raspberry Pi (SQLite is built into Python)
Readings from Lab 01 or synthetic data
Support skills
Store-and-forward
Backlog as a health signal
Disk space hygiene
Design
Write locally first: each reading becomes a row in an outbox table with sent = 0.
Forward in batches: oldest unsent rows first; mark them sent only after the upload succeeds.
At-least-once: if the upload succeeds but the Pi loses power before marking rows, they are re-sent. The receiver should de-duplicate (e.g. by device + timestamp).
Prune old sent rows so the SD card doesn’t fill up.
Code — buffer.py
# buffer.py - store-and-forward telemetry buffer (SQLite, survives reboots and outages)
import json, sqlite3, time
db = sqlite3.connect("telemetry.db")
db.execute("PRAGMA journal_mode=WAL") # safer concurrent reads/writes
db.execute("""CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
payload TEXT NOT NULL,
sent INTEGER NOT NULL DEFAULT 0)""")
def store(reading: dict):
with db: # commit (or roll back) atomically
db.execute("INSERT INTO outbox (ts, payload) VALUES (?, ?)",
(reading["ts"], json.dumps(reading)))
def forward(send, batch=100) -> int:
"""Send oldest unsent rows first; mark sent only after send() succeeds."""
rows = db.execute("SELECT id, payload FROM outbox WHERE sent = 0 "
"ORDER BY id LIMIT ?", (batch,)).fetchall()
if not rows:
return 0
send([json.loads(p) for _, p in rows]) # raises on failure -> rows stay unsent
with db:
db.executemany("UPDATE outbox SET sent = 1 WHERE id = ?",
[(i,) for i, _ in rows])
return len(rows)
def backlog() -> int:
return db.execute("SELECT COUNT(*) FROM outbox WHERE sent = 0").fetchone()[0]
def prune(days=7):
with db: # keep the SD card from filling up
db.execute("DELETE FROM outbox WHERE sent = 1 AND ts < ?",
(int(time.time()) - days * 86400,))
Test: simulate an outage
link_up = False
def send(batch):
if not link_up:
raise ConnectionError("uplink down")
print("sent", len(batch))
for i in range(5):
buffer.store({"ts": 1000 + i, "power_w": 3500 + i})
try:
buffer.forward(send)
except ConnectionError as e:
print("forward failed:", e, "backlog", buffer.backlog())
link_up = True
print("forwarded", buffer.forward(send), "backlog", buffer.backlog())
forward failed: uplink down backlog 1
...
forward failed: uplink down backlog 5
sent 5
forwarded 5 backlog 0
Support / troubleshooting notes
Symptom
Likely cause
What to check
Portal shows a gap, then data “back-fills”
Uplink outage; buffer drained afterwards
Expected behavior — confirm with the uptime log (Lab 03)
Backlog keeps growing
Uplink down, or receiver rejecting data
Forwarder logs; network ladder (section 5)
database is locked
Long-held write transaction from another process
Keep transactions short; WAL mode; one writer
Disk full
No pruning, verbose logs
df -h; prune sent rows; journal size
Duplicate points in the portal
At-least-once redelivery
De-duplicate on device + timestamp at the receiver
Ping the gateway, two public IPs, and resolve a name every minute. The first layer that fails tells you who to call.
GoalLog layered reachability so an outage can be placed at LAN, WAN/modem, or DNS — with timestamps.
Parts
Raspberry Pi on the same LAN as the modem/router
Nothing else
Support skills
Layered isolation
Evidence with timestamps
Outage windows for tickets
Code — netwatch.py
# netwatch.py - layered reachability log: gateway -> modem/router -> internet -> DNS
# Targets are examples. Replace with your lab's gateway / modem LAN IP.
import csv, socket, subprocess, time
from datetime import datetime, timezone
TARGETS = [("gateway", "192.168.1.1"), # first hop (ip route | grep default)
("internet", "1.1.1.1"), # public IP: proves routing past the modem
("internet2", "8.8.8.8")] # second public IP: rules out one bad host
DNS_NAME = "example.com" # proves name resolution separately
def ping(host: str) -> float | None:
"""Return round-trip ms, or None on loss. One packet, 2 s deadline (Linux iputils)."""
r = subprocess.run(["ping", "-c", "1", "-W", "2", host],
capture_output=True, text=True)
if r.returncode != 0:
return None
for part in r.stdout.split():
if part.startswith("time="):
return float(part[5:])
return None
def dns_ok(name: str) -> bool:
try:
socket.getaddrinfo(name, 443)
return True
except socket.gaierror:
return False
def verdict(res: dict, dns: bool) -> str:
if res["gateway"] is None:
return "LAN/gateway down" # cable, Wi-Fi, switch, router power
if res["internet"] is None and res["internet2"] is None:
return "WAN down past gateway" # modem, SIM/signal, ISP
if not dns:
return "DNS failure" # IP works, names don't
return "OK"
with open("netwatch.csv", "a", newline="") as f:
log = csv.writer(f)
while True:
res = {name: ping(ip) for name, ip in TARGETS}
dns = dns_ok(DNS_NAME)
row = [datetime.now(timezone.utc).isoformat(timespec="seconds"),
*[res[n] if res[n] is not None else "" for n, _ in TARGETS],
int(dns), verdict(res, dns)]
log.writerow(row); f.flush()
print(row, flush=True)
time.sleep(60)
Reading the log
Verdict
Meaning
Who/what next
LAN/gateway down
Pi can’t reach its first hop
Cable, switch, Wi-Fi, router power — local site
WAN down past gateway
LAN fine, both public IPs fail
Modem status, SIM/signal, ISP / carrier
DNS failure
IPs reachable, names don’t resolve
DNS servers in use; router DNS relay
OK
All layers pass
If data is still missing, look at the app/device side
On a ticket“From 02:14 to 02:41 UTC the gateway answered but 1.1.1.1 and 8.8.8.8 didn’t — WAN outage past the router. Buffered data back-filled at 02:42.” That sentence is evidence, not a guess.
Support / troubleshooting notes
Symptom
Likely cause
What to check
Gateway always fails, everything else works
Router drops ICMP to itself
Pick another LAN target (e.g. modem LAN IP) or test TCP instead
A read-only LAN page plus a /health endpoint that returns 503 when data goes stale — easy for people and monitors to check.
GoalShow latest reading, data age and unsent backlog; expose machine-readable health.
Parts
Raspberry Pi
telemetry.db from Lab 02
Flask in the venv
Support skills
Stale-data detection
Health endpoints
LAN-only exposure
Code — dashboard.py
# dashboard.py - small read-only LAN dashboard over the SQLite buffer
import json, sqlite3, time
from flask import Flask, jsonify, render_template_string
app = Flask(__name__)
DB = "telemetry.db"
STALE_AFTER = 300 # seconds without data = stale
PAGE = """<!doctype html><meta name=viewport content="width=device-width">
<title>Lab telemetry</title><meta http-equiv=refresh content=30>
<h1>Lab telemetry <small>(synthetic)</small></h1>
<p>Status: <b>{{ s.state }}</b> · last reading {{ s.age_s }} s ago</p>
<p>Power: {{ s.latest.power_w }} W · Unsent backlog: {{ s.backlog }}</p>"""
def summary():
db = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) # read-only: can't corrupt the buffer
try:
row = db.execute("SELECT ts, payload FROM outbox ORDER BY id DESC LIMIT 1").fetchone()
backlog = db.execute("SELECT COUNT(*) FROM outbox WHERE sent = 0").fetchone()[0]
finally:
db.close()
if row is None:
return {"state": "NO DATA", "age_s": None, "latest": {}, "backlog": backlog}
age = int(time.time()) - row[0]
return {"state": "OK" if age <= STALE_AFTER else "STALE", "age_s": age,
"latest": json.loads(row[1]), "backlog": backlog}
@app.get("/")
def index():
return render_template_string(PAGE, s=summary())
@app.get("/health")
def health():
s = summary()
return jsonify(s), (200 if s["state"] == "OK" else 503)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000) # LAN only; firewall it (ufw) to your subnet
Run
python dashboard.py # dev server on port 5000
sudo ufw allow from 192.168.1.0/24 to any port 5000 proto tcp
curl -i http://pi-lab-01.local:5000/health
# HTTP/1.1 200 OK
# {"age_s":0,"backlog":1,"latest":{"power_w":3777,"ts":...},"state":"OK"}
Flask’s built-in server is for development. For anything longer-lived, run it under systemd (section 7) behind a production WSGI server.
Support / troubleshooting notes
Symptom
Likely cause
What to check
Page loads on the Pi but not from a laptop
Bound to 127.0.0.1 or firewall
sudo ss -tulpn | grep 5000, sudo ufw status
/health returns 503 STALE
Reader stopped or device silent
Reader service logs, Modbus timeouts (Lab 01)
NO DATA
Empty or wrong database path
Working directory in the unit file
Backlog high but state OK
Local reads fine, uplink down
Uptime log (Lab 03), forwarder logs
Sources
Commands in the tutorial were checked against the official Raspberry Pi documentation (October 2026). Project code was run on Linux with synthetic data: a virtual serial pair (socat) for Modbus RTU and a local Mosquitto broker for MQTT.
Scope: portfolio lab guides with synthetic data, not production deployments. For real sites, follow the equipment manufacturer’s wiring diagrams and register maps, site safety rules, and your employer’s procedures.