Skip to content

System-Level Configuration

This section covers getting the OS imaged to the SSD, initial setup, the scripts and processes for items such as fans and OLED, configuration of the COM port, and covers the LTE connection configuration plus some other system tweaks. I decided on RaspiOS for this final build after originally planning to use TwisterOS. TwisterOS felt better out of the box, included some better theme options, had all the pieces I needed to get the LTE modem running, and runs X11 so some pieces were less buggy but it had a lot of pre-installed applications. In the end I decided on RaspiOS because of the healthy community support. I have a GH repo for the final files for this project here inclusive of scripts, assets, and 3D files.

Imaging the Drive & OS Choice

Imaging the drive was performed on another system, so I used my desktop running Pop_OS (Debian Linux) with the Nomad drive plugged in via USB to SATA adapter, so sudo apt install rpi-imagercould be run from a terminal to install the imaging software and and executed with rpi-imager. There is also an AppImage and Windows installers available through https://www.raspberrypi.com/software/. I selected the device model, in my case Pi4B. Then, selecting the OS image, I decided on RaspiOS - Full (this variant is the same as RaspiOS - Desktop with some additional software pre-installed). Selected the drive. Lastly, it asked if I wanted to customize the image (user names, WiFi, etc.). I chose not to.

Screenshot_2026-08-17_13-06-14 Screenshot_2026-08-17_13-07-07
Screenshot_2026-08-17_13-08-05 Screenshot_2026-08-17_13-08-12

First Boot & Hostname Changing

After placing the drive in Nomad and booting, I followed the startup wizard prompts and created user1. After I booted into the DE, I opened up a terminal (Ctrl+Alt+T) and changed the hostname by typing sudo raspi-config in the terminal and selecting System Options -> Hostname. By default, it will be raspberrypi. In my case, I changed it to nomad1. Then reboot.

20260817_13h53m13s_grim

After a reboot and back to the DE, I connected to WiFi via the network tray, opened up the terminal (Ctrl+Alt+T), then ran

sudo apt update && sudo apt upgrade -y

to update the system packages. While that was running, I went to Preferences -> Control Center -> Interfaces and enabled SSH and VNC so I could connect remotely. I also enabled Serial Console and Serial Login so I could configure our RS232 module. Lastly, I enabled I2C so I could interface with the OLED and UPS.

20260817_13h54m33s_grim

20260817_13h55m49s_grim

After that, another reboot.

Configuring WWAN Modem & Network Tray

The first big configuration item was getting the LTE connection up. I used a Sierra Wireless EM7455 modem on a M.2 Key-B to USB adapter plugged directly into my internal USB hub. I am US based and ended up going with a Verizon pre-paid SIM slotted into the M.2 modem adapter. I could probably write a book on the headaches I had getting this to work. The short of it is, make sure you find a modem that people have had some luck using on Raspi devices and confirm the IMEI on the modem is green lit by the carrier you are going to choose before you commit to a carrier. If you are purchasing modems second hand, make sure they work on a known compatible system (Windows in my case) prior to going straight to configuration on a Raspi. Not every PiOS image has modemmanager installed to configure these types of devices so I needed to run

sudo apt install modemmanager

then

systemctl status ModemManager

to confirm it is running. A reboot is usually required after this part. Upon login I ran mmcli -L. This will bring up a list of modems available.

Screenshot_2026-08-17_14-07-39

Screenshot_2026-08-17_14-08-16

Screenshot_2026-08-17_14-11-18

Then I grabbed the interface name (it should be cdc-wdm0 but lets run ls /dev/cdc-wdm* to confirm). I added this connection with the terminal for now with:

 nmcli connection add type gsm ifname cdc-wdm0 con-name "Verizon-LTE" apn vzwinternet

The APN of vzwinternet is specific to my carrier. We can get the connection up with

nmcli connection up "Verizon-LTE"

and check its status and more details with

mmcli -m 0

You can confirm there is a connection established in the Status section after running this command.

Screenshot_2026-08-17_14-13-16

From here, I found the default RaspiOS network tray was not showing anything regarding this LTE connection, just WiFi connections.

Screenshot_2026-08-17_14-14-14

I tested out quite a few other tray solutions with mixed results in Wayland sessions. I settled on network-manager-gnome which uses nm-applet so we can run

sudo apt install network-manager-gnome

followed by

sudo apt install gir1.2-modemmanager-1.0

and then reboot. The latter of the two above is what allows nm-applet to query modem manager and report more details in the tray. I now saw two network trays in the task bar, with the newly added one having more options over the Raspi network tray. You can see below I have the LTE connection listed as well as the WiFI networks and some other switches.

Screenshot_2026-08-17_14-17-32

Then after this, I hid the default RaspiOS network tray by Right Clicking Taskbar -> Add/RemovePlugins... and removing the Network plugin (the default tray), which left me with a slightly more robust network tray.

Screenshot_2026-08-17_14-17-45

Configuring RS232 Serial Module

My next step was to configure the serial port. I have read a lot of mixed information about the proper way to configure these serial ports on Raspi devices. What I did and show below might not be necessary on a Pi4 per some forum goers. But since I did not use Bluetooth in this build, I went with the safe route. So I popped into the config.txt with nano using

sudo nano /boot/firmware/config.txt

and under the [All] section confirmed enable_uart=1 is present. It should already be there and also add dtoverlay=disable-bt Crtl+O, Enter, Ctrl+X to save and exit.

Screenshot_2026-08-17_14-19-16

This disables Bluetooth and frees up the PL011 UART as the on-board Bluetooth usually claims this for use. So instead of using mini-UART which can be hit or miss based on system load due to clock speeds on the system we now have full usage of the PL011 UART. We can do a reboot and run

 ls -l /dev/serial0

it should point to ttyAMA0 (PL011 UART) not ttyS0 (mini-UART).

Screenshot_2026-08-17_14-21-10

Now its just up to a quick test. I have a cheap DB9 to USB cable to my laptop. You can run screen or minicom on your external device. I went with screen and ran sudo screen /dev/ttyUSB0 115200 (ttyUSB0 being the device on my laptops end and 115200 to match the Pi’s default baud rate) and I can now login to the device over this serial port. This was a late add to this project and actually proved to be pretty useful in the end.

Screenshot from 2026-08-19 10-40-40

Screenshot from 2026-08-19 10-41-30

UPS HAT (E) - Battery Firmware & System Tray

I used a Waveshare UPS Hat (E) in this build as the meat and potatoes of the power system. They have a well documented wiki for this device www.waveshare.com/wiki/UPS_HAT_(E)#INA219_Baterry_Level_Detection. I transcribed some of the wiki here and also kept all of the custom items in clean directories located in /opt/. The following shell commands were used to install the UPS Hat (E) firmware:

sudo mkdir -p /opt/nomad/battery #creates a battery directory for this device
cd /opt/nomad/battery #changes to that directory
sudo apt-get install python3-smbus #install teh smbus3 library per Waveshare guidance
sudo wget https://files.waveshare.com/wiki/UPS-HAT-E/UPS_HAT_E.zip #downloads Waveshare UPS zip files
sudo unzip UPS_HAT_E.zip #unzips files
cd UPS_HAT_E #changes to the unzipped directory
python3 ups.py #runs the Waveshare python script for output information

Screenshot_2026-08-17_14-26-43

This results in live polling data in the terminal with the ups.py script running.

Screenshot_2026-08-17_14-27-00

Ctrl+C to terminate the script. The installation also comes with a battery tray for our task bar, and this can be tested by running the batteryTray.py script with

cd /opt/nomad/battery/UPS_HAT_E
DISPLAY=':0.0' python3 batteryTray.py

The terminal should be spitting out some battery data as well as a battery tray show up in the task bar on the top right. Ctrl+C to exit.

Screenshot_2026-08-17_14-28-39

Obviously we want this running every time the DE loads in, so I made an autostart directory and scripted to run this when I load in by doing the following

mkdir -p ~/.config/autostart 
nano ~/.config/autostart/battery-tray.desktop

and adding the below

[Desktop Entry]
Type=Application
Name=Battery Tray
Exec=bash -c "sleep 10 && cd /opt/nomad/battery/UPS_HAT_E && python3 batteryTray.py"
X-GNOME-Autostart-enabled=true

The sleep 10 was added so the tray didn’t try to load in prematurely before the DE loaded and the cd portion was added in because sometimes the battery logo was not loading. This happened to fix the issue by specifying the directory.

Then Ctrl+O, Enter, Ctrl+X to save and exit. Good idea to reboot and confirm it runs.

OLED Display

Next, configured the front OLED on this build to report battery percentage, charging status, CPU temp, CPU utilization and RAM utilization. I used luma.oled which provides a Python interface to the OLED display. This section was slightly out of my wheelhouse but I was able to iron out a script with a few passes through Claude. I made two more directories for assets and oled items and then installed luma.oled using the following

sudo mkdir -p /opt/nomad/oled /opt/nomad/assets
sudo pip3 install luma.oled --break-system-packages

I decided to go with the following font called PixelOperator-Bold.tty which can be found here https://www.dafont.com/pixel-operator.font, unzipped and dropped it into the /opt/nomad/assets folder. This will likely error due to permissions if you GUI drag and drop, but you can also extract anywhere and do a sudo cp /home/user1/Desktop/nomad_items/PixelOperator-Bold.ttf /opt/nomad/assets/PixelOperator-Bold.ttf to copy it over. You’ll see me reference that /Desktop/nomad_items path a few times in this walk-through. I dumped a folder on the Desktop from a thumb drive with the majority of prepared files for this final PiOS setup, and I constantly copied files from that directory.

Now, I created an empty .py script with

sudo nano /opt/nomad/oled/oled_status.py

and pasted in the following

#!/usr/bin/env python3
"""NOMAD OLED status display — UPS HAT (E) @ 0x2D, SSD1306 @ 0x3C"""

import time
import re
import signal
import sys
import subprocess
import smbus

from luma.core.interface.serial import i2c
from luma.oled.device import ssd1306
from luma.core.render import canvas
from PIL import ImageFont

UPS_ADDR = 0x2D
POLL_SECONDS = 2
FONT_PATH = "/opt/nomad/assets/PixelOperator-Bold.ttf"
FONT_SIZE = 18  # off PixelOperator's native 16pt grid — if it looks soft
                # on real hardware, drop back to 16 (and y-values to 0/22/44)

bus = smbus.SMBus(1)
serial = i2c(port=1, address=0x3C)
device = ssd1306(serial, rotate=2)

try:
    font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
except OSError:
    print(f"Font not found at {FONT_PATH}, falling back to default")
    font = ImageFont.load_default()


def handle_shutdown(signum, frame):
    try:
        device.clear()
        device.hide()
    except Exception:
        pass
    sys.exit(0)


signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)


def draw_centered(draw, y, text, font):
    width = draw.textlength(text, font=font)
    x = (device.width - width) / 2
    draw.text((x, y), text, font=font, fill="white")


def read_battery():
    status_byte = bus.read_i2c_block_data(UPS_ADDR, 0x02, 0x01)[0]
    fast_charging = bool(status_byte & 0x40)

    data = bus.read_i2c_block_data(UPS_ADDR, 0x20, 0x0C)
    percent = data[4] | (data[5] << 8)
    current = data[2] | (data[3] << 8)
    if current > 0x7FFF:
        current -= 0xFFFF

    if current < 0:
        symbol = "-"
    elif fast_charging:
        symbol = "++"
    else:
        symbol = "+"

    return percent, symbol


def read_cpu_temp():
    out = subprocess.run(
        ["vcgencmd", "measure_temp"], capture_output=True, text=True, check=True
    ).stdout
    match = re.search(r"temp=([\d.]+)", out)
    return float(match.group(1)) if match else 0.0


def read_ram_percent():
    meminfo = {}
    with open("/proc/meminfo") as f:
        for line in f:
            parts = line.split(":")
            if len(parts) == 2:
                meminfo[parts[0].strip()] = int(parts[1].strip().split()[0])
    total = meminfo.get("MemTotal", 1)
    available = meminfo.get("MemAvailable", total)
    return (total - available) / total * 100


def read_cpu_percent(prev_idle, prev_total):
    with open("/proc/stat") as f:
        fields = [int(x) for x in f.readline().split()[1:]]
    idle = fields[3]
    total = sum(fields)
    idle_delta = idle - prev_idle
    total_delta = total - prev_total
    cpu_percent = 0.0 if total_delta == 0 else (1 - idle_delta / total_delta) * 100
    return cpu_percent, idle, total


def main():
    with open("/proc/stat") as f:
        fields = [int(x) for x in f.readline().split()[1:]]
    prev_idle, prev_total = fields[3], sum(fields)

    while True:
        try:
            percent, symbol = read_battery()
            cpu_temp = read_cpu_temp()
            ram_percent = read_ram_percent()
            cpu_percent, prev_idle, prev_total = read_cpu_percent(prev_idle, prev_total)

            with canvas(device) as draw:
                draw_centered(draw, 0, f"BATT {percent}% {symbol}", font)
                draw_centered(draw, 21, f"CPU {cpu_temp:.0f}C", font)
                draw_centered(draw, 42, f"C{cpu_percent:.0f}% R{ram_percent:.0f}%", font)

        except OSError as e:
            print(f"I2C read error, skipping this cycle: {e}")

        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    main()

Ctrl+O, Enter, Ctrl+X to save and exit. I want to mention we are using rotate=2 in this script because my OLED was technically mounted upside down due to clearance issues with its pins.

Instead of running this as a startup script similar to battery tray, I wanted to deploy this as a system-wide process with systemd so the OLED will still work even if I am not logged in or if there is no DE loaded up. So I started by making a process with

sudo nano /etc/systemd/system/nomad-oled.service

and dropping in the following

[Unit]
Description=NOMAD OLED Status Display
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/nomad/oled/oled_status.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Ctrl+O, Save, Ctrl-X to save and exit. We can then run

sudo systemctl daemon-reload
sudo systemctl enable --now nomad-oled.service

to bring the process up. The OLED should be 100% good to go now.

IMG_20260817_143529002 IMG_20260817_143628845

Fan Control

RaspiOS supports fan control out of the box both in raspi-config and directly in the Control Center menus. My problem with the default behavior of the fans was that they require an "on" threshold, for example 60c, and when the fans turn on they will not turn back off until a -10c delta is met. In this case it would need to be 50c before the fan clicks off. I did some Googling and saw some folks talking about making a custom script where you can tighten up the on/off thresholds. Also, unlike the Pi5, the Pi4 doesn't have a dedicated fan controller or fan headers. The Pi4 does in fact support PWM though over GPIO 18 as well as GPIO12, 13, and 19 so there could be some granular control baked into this script I think, but a simple on/off for this build is adequate. Below is the script I used as well as the systemd setup to have a simple on/off configuration of the fan.

I started by making a new directory with

sudo mkdir -p /opt/nomad/fan

and then

sudo nano /opt/nomad/fan/fan_control.py

to create an empty .py script, and pasted in the following:

#!/usr/bin/env python3
"""NOMAD fan control — custom hysteresis, replaces gpio-fan overlay's fixed 10C gap."""

import time
import re
import signal
import sys
import subprocess

from gpiozero import DigitalOutputDevice

FAN_PIN = 18  # this needs to match the GPIO pin used by your fan (14 by system default)
ON_TEMP = 62.0   # fan turns ON at or above this temp (C)
OFF_TEMP = 58.0  # fan turns OFF at or below this temp (C)
POLL_SECONDS = 5

fan = DigitalOutputDevice(FAN_PIN, initial_value=False)
fan_on = False


def handle_shutdown(signum, frame):
    fan.off()
    sys.exit(0)


signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)


def read_cpu_temp():
    out = subprocess.run(
        ["vcgencmd", "measure_temp"], capture_output=True, text=True, check=True
    ).stdout
    match = re.search(r"temp=([\d.]+)", out)
    return float(match.group(1)) if match else 0.0


def main():
    global fan_on
    while True:
        temp = read_cpu_temp()

        if not fan_on and temp >= ON_TEMP:
            fan.on()
            fan_on = True
            print(f"Fan ON at {temp:.1f}C")
        elif fan_on and temp <= OFF_TEMP:
            fan.off()
            fan_on = False
            print(f"Fan OFF at {temp:.1f}C")

        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    main()

Ctrl+O, Enter, Ctrl+X to save and exit. A couple things to note with that script: My fan is coming off GPIO 18. PiOS by default points to GPIO 14 according to Control Center. It is just important to make sure that the FAN_PIN variable is set correctly and then the ON_TEMP and OFF_TEMP variables can be set to user-defined thresholds. Any default PiOS fan related items need to be disabled; anything in the Control Center or in raspi-config or in the config.txt regarding dtoverlay=gpio-fan need to be disabled or they will fight this script.

Similar to the OLED section, I set up a process in the terminal with

sudo nano /etc/systemd/system/nomad-fan-control.service

and dropped in the following

[Unit]
Description=NOMAD Custom Fan Control
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/nomad/fan/fan_control.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

then

sudo systemctl daemon-reload
sudo systemctl enable --now nomad-fan-control.service

to bring up the service. That concludes this section.

Screen Power Off, Idle Timers, & Screen Lock

This is an interesting aspect of this project that I thought would “just work” and it did not. When I sampled TwisterOS for this build, I stepped away from the machine for a while, came back, the screen was off. It woke up with a key stroke, and put me at a login screen. For whatever reason, PiOS did not have this screen idle feature out of the box. There is a very simple "screen blanking" toggle inside their Control Center, which has no default options or timers. My assumption was that this was a Wayland quirk or that I just didn’t understand how to configure this with the GUI. In any case, I wanted for the screen to idle after 15 minutes to save on power, for the screen to wake up to a login screen, and to be able to apply a custom background to the login screen. I eventually achieved this functionality through a combination of multiple LLMs and scrubbing through random user posts, but my solution is likely very fragile and may break with software updates down the road. It will be something to keep an eye on.

I started by bringing in my lockscreen.png image I created into the /opt/nomad/assets folder with

sudo cp /home/user1/Desktop/nomad_items/lockscreen.png /opt/nomad/assets/lockscreen.png

Then hopped into cmdline.txt with nano and appended vc4.force_hotplug=1 to the end

sudo nano /boot/firmware/cmdline.txt

Screenshot_2026-08-17_14-42-35

Ctrl+O, Enter, Ctrl+X to save and exit. This is apparently a fix so the screen wont randomly turn on again after it has shut off without some form of user input per some forum goers.

I then made some changes to swayidle which handles actions like screen locking or monitor control after idle and is part of the RaspiOS Wayland compositor labwc. So I ran

sudo nano ~/.config/labwc/autostart

and pasted in

swayidle -w timeout 900 'wlr-randr --output HDMI-A-1 --off; swaylock -f -p -i /opt/nomad/assets/lockscreen.png' resume 'wlr-randr --output HDMI-A-1 --on' &

Screenshot_2026-08-17_14-43-20

timeout 900 is in seconds and is 15m, wlr-randr is the Wayland equivalent of xrandr which controls display outputs, HDMI-A-1 is the physical HDMI port I’m using on the Pi (this port is the one closest to the USB-C port on the device), -i /opt/nomad/assets/lockscreen.png is the lock screen background itself. This seems to work right now, but it is something I will just need to keep an eye on after running updates. We Ctrl+O, Enter, Ctrl+X to save and exit this now. Below is a picture of the lock screen.

IMG_20260819_102158543

Boot Timing

I had an ongoing issue when starting this PC from cold. The Pi would fail to find a boot drive. I would end up at a screen similar to the one below.

IMG_20260816_133133440

My guess is the USB to SATA adapter I used is failing to “spin up” fast enough before the Pi goes looking for a drive. The easy workaround was a simple restart. I found a forum user that had a similar issue and they did the following as a fix

sudo rpi-eeprom-config --edit

and under [All] set the following to

USB_MSD_STARTUP_DELAY=15000 
USB_MSD_DISCOVER_TIMEOUT=25000

Screenshot_2026-08-17_14-44-23

Ctrl+O, Enter, Ctrl+X to save and exit then reboot. This will add a startup delay of 15 seconds so the Pi will wait 15 seconds before looking for a drive to boot from and the second will give the Pi 25 seconds total to find, scan, and connect to a USB drive. I have not cycled the machine enough to confirm this works (it was an inconsistent problem), but I have not seen it since making this change.

Edit: After some more use with this device it did not fix my problem. Will need to investigate further.