Rıza KorkusuzTechnical Support
0 / 13 reviewed
← Back to Portfolio
Tutorial + lab projects · Raspberry Pi

Set it up right. Then make it report.

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 sections 5 lab projects Checked against official docs Synthetic data only Troubleshooting tables
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.

← Back to Portfolio

Part A · Tutorial

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

ModelRecommended supply (official docs)
Raspberry Pi 55 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 B5 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.

2

Flash Raspberry Pi OS with Raspberry Pi Imager

TUTORIAL 02 · ~15 min

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

  1. 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).
  2. Device tab: select your Raspberry Pi model.
  3. OS tab: choose Raspberry Pi OS Lite (64-bit) for a headless box.
  4. Storage tab: pick the microSD card. Keep Exclude system drives selected so you can’t wipe your laptop’s disk.
  5. Customization (recommended): fill in the subtabs below, then review the summary and write.

Customization — what to set

SettingWhat to enterWhy
Hostnamee.g. pi-lab-01 — letters, numbers, hyphensReach it as pi-lab-01.local via mDNS instead of hunting for the IP
LocalisationTime zone + keyboardCorrect timestamps in logs and telemetry
UserYour own username + strong passwordNo shared default accounts
Wi-FiSSID, password, Wi-Fi country (skip if using Ethernet)Boots straight onto the network
Remote accessEnable SSH → public key authentication, paste your public keyKey-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.

3

Headless first boot: find it and SSH in

TUTORIAL 03 · ~10 min

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

SymptomLikely causeWhat to check
Could not resolve hostnamemDNS not working on laptop / different subnetUse the IP from the router lease table
Connection refused on port 22SSH not enabled in ImagerRe-flash with SSH on, or enable via raspi-config with a screen
Permission denied (publickey)Wrong key / wrong usernamessh -v to see which key is offered; username is the one set in Imager
REMOTE HOST IDENTIFICATION HAS CHANGEDYou re-flashed the card; new host keyRemove the old entry: ssh-keygen -R pi-lab-01.local
No lease in router at allPower, bad card, Wi-Fi details/country wrongTry 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.

4

Updates and Python packages

TUTORIAL 04 · Routine

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.

Check yourself

Answer out loud first, then open the card.

pip install flask says externally-managed-environment. Fix?

Create and activate a venv (python3 -m venv .venv) and install there, or use the apt package if one exists.

When is rpi-update appropriate?

Almost never in production — only for testing pre-release firmware or when Raspberry Pi engineers ask. Routine updates use APT.

5

Stable IP and basic network checks

TUTORIAL 05 · Network

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

CommandQuestion it answers
ip -br addr / ip aDo I have an IP on the right interface? (no IP = DHCP/link problem)
ip routeWhat is my default gateway?
ping -c 4 <gateway>Can I reach the local router?
ping -c 4 1.1.1.1Can I reach the internet by IP (past the modem)?
ping -c 4 example.comDoes DNS work? (IP works + name fails = DNS)
nmcli device statusIs NetworkManager connected on eth0 / wlan0?
sudo ss -tulpnWhich services listen on which ports (and which process)?
journalctl -u NetworkManager -bWhat did the network stack log since boot?
journalctl -b -p warningAny warnings or errors this boot?

How it breaks, and what to check

SymptomLikely causeWhat to check
No IPv4 on eth0Cable/switch port, DHCP not answeringip -br link (UP?), switch LEDs, router DHCP
Gateway ping failsWrong subnet/static typo, VLAN, Wi-Fi associationip route, nmcli device status
Gateway OK, 1.1.1.1 failsModem/WAN, SIM/signal, ISP, firewall upstreamModem status page, other devices on same LAN
1.1.1.1 OK, names failDNS server wrong/unreachablenmcli device show | grep DNS
Service unreachable from LANNot listening, bound to 127.0.0.1, or firewallsudo 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.

6

Secure it: users, SSH keys, firewall, updates

TUTORIAL 06 · Hardening

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).

7

Run scripts as systemd services

TUTORIAL 07 · Operations

A script in a terminal dies when you log out. A systemd service starts at boot, restarts on failure, and logs to the journal.

Unit file: /etc/systemd/system/meter-reader.service

[Unit]
Description=Lab Modbus reader (synthetic data)
After=network-online.target
Wants=network-online.target

[Service]
User=<name>
WorkingDirectory=/home/<name>/lab
Environment=MODBUS_PORT=/dev/ttyUSB0
ExecStart=/home/<name>/lab/.venv/bin/python reader.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Operate it

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.

8

Troubleshooting checklist

TUTORIAL 08 · Reference

Work bottom-up: power, storage, network, service, application. Write down each result.

The ladder

1

Power & health

vcgencmd get_throttled (0x0 = clean) · vcgencmd measure_temp · uptime (unexpected reboots?)

read-only
2

Storage

df -h (full disk breaks logging and databases) · free -h

read-only
3

Network

ip -br addr → ip route → ping gateway → ping 1.1.1.1 → ping a name

read-only
4

Service

systemctl status <svc> · journalctl -u <svc> -n 50 · systemctl --failed

read-only
5

Devices

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
SymptomLikely causeWhat to check
Random rebootsUndervoltage / weak supply or cablevcgencmd get_throttled, try the recommended PSU
Read-only filesystem / corrupt filesSD card wear or power loss during writessudo dmesg for mmc errors; back up and replace card
Service up but no dataWrong serial port, unit ID, or baudService logs; /dev/serial/by-id/; settings vs device
Data stops at night onlySource device sleeps (e.g. no PV production)Expected? Compare with device behavior before escalating
Timestamps wrongTime zone/NTP not syncedtimedatectl

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.

Part B · Lab projects

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

  1. Plug in the adapter and find a stable name: ls -l /dev/serial/by-id/ (better than /dev/ttyUSB0, which can change order).
  2. Wire per the device’s and adapter’s manuals. A/B (or D+/D−) naming differs between vendors — follow the documentation, don’t guess.
  3. 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.
  4. 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.
  5. 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:

socat -d pty,raw,echo=0,link=/tmp/ttyLAB0 pty,raw,echo=0,link=/tmp/ttyLAB1 &
python lab_sim.py /tmp/ttyLAB1 &
MODBUS_PORT=/tmp/ttyLAB0 python reader.py
# {'ts': 1791246786, 'power_w': 3638, 'energy_wh': 1250005, 'voltage_v': 240.5}
# 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

SymptomLikely causeWhat to check
“No response received” (timeout)Wrong unit ID, baud/parity mismatch, A/B swapped, no power on deviceTry the simulator first to prove the Pi side; then compare settings with the device display/manual
CRC / garbled framesNoise, missing termination, extra terminators, long stubsTermination at both ends only, twisted pair, shield per site practice
Values off by 10×/1000×Scaling factor not appliedRegister map: units and scale
Huge or negative 32-bit valuesWord order wrongSwap word_order big/little and compare with device display
Reads the wrong quantityRegister offset (docs “40001” vs protocol address 0)Try address −1; confirm with a known value
Permission denied on the portUser not in dialoutgroups; add and re-login
10

Local telemetry buffer that survives outages

ProjectLAB 02 · SQLite

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

SymptomLikely causeWhat to check
Portal shows a gap, then data “back-fills”Uplink outage; buffer drained afterwardsExpected behavior — confirm with the uptime log (Lab 03)
Backlog keeps growingUplink down, or receiver rejecting dataForwarder logs; network ladder (section 5)
database is lockedLong-held write transaction from another processKeep transactions short; WAL mode; one writer
Disk fullNo pruning, verbose logsdf -h; prune sent rows; journal size
Duplicate points in the portalAt-least-once redeliveryDe-duplicate on device + timestamp at the receiver
11

Network / modem uptime monitor

ProjectLAB 03 · ping logging

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

VerdictMeaningWho/what next
LAN/gateway downPi can’t reach its first hopCable, switch, Wi-Fi, router power — local site
WAN down past gatewayLAN fine, both public IPs failModem status, SIM/signal, ISP / carrier
DNS failureIPs reachable, names don’t resolveDNS servers in use; router DNS relay
OKAll layers passIf 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

SymptomLikely causeWhat to check
Gateway always fails, everything else worksRouter drops ICMP to itselfPick another LAN target (e.g. modem LAN IP) or test TCP instead
Short single missesNormal packet loss / busy linkAlert on N consecutive failures, not one
All targets fail at once, every nightScheduled modem reboot or power scheduleCompare with modem logs / schedule
Log stops entirelyService died or disk fullsystemctl status, df -h
12

MQTT telemetry publisher

ProjectLAB 04 · paho-mqtt 2.x

Publish readings to a broker with QoS 1 and a retained online/offline status, so a dashboard knows when the device disappears.

GoalSend synthetic readings to an MQTT broker and announce device status with a Last Will.
Parts
  • Raspberry Pi
  • Mosquitto broker (local or on the LAN)
  • paho-mqtt in the venv
Support skills
  • Topics & QoS
  • Last Will / retained status
  • Broker vs client faults

Setup (lab broker on the Pi)

sudo apt install mosquitto mosquitto-clients
mosquitto_sub -h localhost -t 'lab/#' -v      # watch everything under lab/
# in another terminal:
MQTT_HOST=localhost python publisher.py
lab/lab-site-01/status online
lab/lab-site-01/meter1/telemetry {"ts": 1791246877, "power_w": 3933}
lab/lab-site-01/status offline
Lab onlyA default local broker is for bench testing. Anything beyond the LAN needs authentication and TLS — don’t expose port 1883 to the internet.

Code — publisher.py

# publisher.py - publish synthetic readings over MQTT (paho-mqtt 2.x)
import json, os, random, time
import paho.mqtt.client as mqtt

BROKER = os.getenv("MQTT_HOST", "localhost")
SITE = "lab-site-01"                               # synthetic site id
TOPIC = f"lab/{SITE}/meter1/telemetry"
STATUS = f"lab/{SITE}/status"

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"{SITE}-pub")
client.will_set(STATUS, "offline", qos=1, retain=True)   # broker announces if we vanish

def on_connect(c, userdata, flags, reason_code, properties):
    print("connected:", reason_code)
    c.publish(STATUS, "online", qos=1, retain=True)

client.on_connect = on_connect
client.connect(BROKER, 1883, keepalive=30)
client.loop_start()                                # network loop + auto-reconnect thread

try:
    while True:
        reading = {"ts": int(time.time()), "power_w": random.randint(3200, 4100)}
        info = client.publish(TOPIC, json.dumps(reading), qos=1)
        if info.rc != mqtt.MQTT_ERR_SUCCESS:
            print("publish not queued:", mqtt.error_string(info.rc))  # keep it in the SQLite buffer
        time.sleep(10)
finally:
    client.publish(STATUS, "offline", qos=1, retain=True).wait_for_publish(2)
    client.loop_stop(); client.disconnect()

Support / troubleshooting notes

SymptomLikely causeWhat to check
Connection refusedBroker not running / wrong host or portsystemctl status mosquitto, sudo ss -tulpn | grep 1883
Connects, then “not authorised”Broker requires credentials/ACLBroker config and user permissions
Status stuck on “online” after unpluggingKeepalive not expired yetLWT fires after ~1.5× keepalive; wait, then check
Subscriber sees nothingTopic typo or wrong wildcardSubscribe to lab/# to see everything
Gaps during outagesNo local bufferCombine with Lab 02: publish from the outbox, mark sent on success
13

Small Flask dashboard

ProjectLAB 05 · Flask

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

SymptomLikely causeWhat to check
Page loads on the Pi but not from a laptopBound to 127.0.0.1 or firewallsudo ss -tulpn | grep 5000, sudo ufw status
/health returns 503 STALEReader stopped or device silentReader service logs, Modbus timeouts (Lab 01)
NO DATAEmpty or wrong database pathWorking directory in the unit file
Backlog high but state OKLocal reads fine, uplink downUptime 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.