A C++ application for the Raspberry Pi 5 that drives three displays:
- 16x2 character LCD showing current weather (temperature + conditions)
- 0.96" SSD1306 OLED showing the current time
- 16-pixel WS2812 NeoPixel ring showing the sun's position by day and the moon's illumination by night
Single self-contained binary, runs as a systemd service, no Python.
| Component | Interface | Address / Pin |
|---|---|---|
| Raspberry Pi 5 | - | - |
| LCD1602 + I2C bp | I2C bus 1 | 0x27 |
| SSD1306 OLED | I2C bus 1 | 0x3C |
| WS2812 ring (16) | SPI0 MOSI | GPIO 10 / header pin 19 |
| 5V power | Pi 5V | header pin 2 or 4 |
| Ground | Pi GND | any GND pin |
The LCD and OLED share the I2C bus (different addresses). The NeoPixel ring is driven over SPI rather than a regular GPIO because the Pi 5 cannot reliably bit-bang the WS2812 protocol from userspace; SPI MOSI clocked at 6.4 MHz is used to synthesize the WS2812 bit timing.
One C++ binary (weather_display) running a 1 Hz event loop:
| Subsystem | Driver | Update frequency |
|---|---|---|
| OLED time | oled.cpp |
every second |
| LED ring | ring.cpp |
every minute |
| LCD weather | lcd.cpp |
every UPDATE_INTERVAL seconds (default 600) |
All three subsystems share the main thread. Weather fetches use libcurl with a 15s total timeout so a hung HTTP call cannot stall the OLED tick.
Sunrise/sunset/moon math is computed locally (NOAA Solar Calculator algorithm + synodic-month moon phase). No external service is called for celestial data.
Logging uses a simple LOG_INFO / LOG_WARN / LOG_ERROR macro set defined in include/log.h. Output format is [HH:MM:SS] [LEVEL] message. INFO goes to stdout; WARN and ERROR go to stderr. systemd's journal adds its own absolute timestamp on top.
sudo apt update
sudo apt install -y build-essential cmake git i2c-tools libcurl4-openssl-dev wiringpi
# If `gpio -v` fails on Pi 5, install from the maintained fork instead:
# https://github.com/WiringPi/WiringPi
# Enable I2C and SPI
sudo raspi-config nonint do_i2c 0
sudo raspi-config nonint do_spi 0
sudo usermod -aG spi $USER
# Log out and back in (or reboot) for the spi group membership to apply.
git clone https://github.com/GageLawton/WeatherDisplay.git
cd WeatherDisplay
makeThe binary lands at build/bin/weather_display.
The project compiles on macOS (Apple Silicon, Intel, both fine) using mock backends for I2C, the LCD, and SPI. Useful for iterating on logic without hardware.
make -f test/Makefile.mac run-localThis builds the binary, sources .env automatically (skipped gracefully if absent), and runs it. The mock build prints what would have been written to the LCD, runs the real weather fetch via libcurl, and silently exercises the OLED + ring code paths.
You can also run without env loading (e.g. for quick smoke tests):
make -f test/Makefile.mac runPer-subsystem test programs live under test/ and have their own makefiles. Each writes visualizations into test_output/ as PPM or PGM files openable in Preview.
# Sunrise/sunset/moon math against current date in Westmont
make -f test/Makefile.celestial && ./test_celestial
# OLED rendering at multiple sizes and formats
make -f test/Makefile.oled && ./test_oled
open test_output/*.pgm
# NeoPixel driver + orientation transform visualizer
make -f test/Makefile.neopixel && ./test_neopixel
open test_output/ring_*.ppm
# Ring controller (sun-by-day, moon-by-night) across a simulated day
make -f test/Makefile.ring && ./test_ring
open test_output/ring_day_*.ppmTwo layers, in order of precedence:
-
Environment variables (highest priority). Loaded by systemd from
.envin the project root, or set manually in the shell:Variable Purpose WEATHER_API_KEYweatherapi.com API key (required) WEATHER_LOCATIONCity for weather lookup UNITSForC(defaultF)UPDATE_INTERVALSeconds between weather fetches OLED_FORMATOLED time format string OLED_SCALEOLED text scale ( autoor1-4)ENGINEERING_MODE1to silence all outbound email (see Engineering mode) -
config.jsonin the project root:{ "location": { "latitude": 41.7958, "longitude": -87.9756 }, "led": { "spi_device": "/dev/spidev0.0", "count": 16, "brightness": 0.05, "offset": 0, "clockwise": false, "sleep_start": 22, "sleep_end": 7 }, "oled": { "format": "II:MM AP", "scale": "auto", "i2c_address": "0x3C" }, "weather": { "engineering_mode": false }, "UNITS": "F", "UPDATE_INTERVAL": 600 }
When you're SSH'd in doing active development, weather API outages are often self-inflicted (restarts, redeploys). Engineering mode silences all outbound email so you don't receive alerts for disruptions you caused.
Enable it without editing .env:
ENGINEERING_MODE=1 ./build/bin/weather_displayOr set it persistently in config.json:
"weather": {
"engineering_mode": true
}When active, the startup banner shows Engineering mode: ON and any email that would have been sent is logged as Alerter [engineering mode]: would have sent: "..." instead.
The alerter sends one email per API outage episode — when the weather API has been unreachable for more than 25 minutes (configurable via alert_after_failures). The email is suppressed for subsequent ticks of the same outage and resets when the API recovers. Weather alerts (severe weather, heavy rain, temperature swings) appear on the LCD and drive the ring color but do not generate emails.
Email credentials are loaded from env vars (typically via .env):
| Variable | Purpose |
|---|---|
GMAIL_USER |
Sender Gmail address |
GMAIL_APP_PASSWORD |
16-character Google App Password |
ALERT_RECIPIENT |
Destination address for alert emails |
If any of these are absent the alerter is silently disabled — the rest of the app runs normally.
| Placeholder | Example |
|---|---|
HH:MM:SS |
13:45:22 |
HH:MM |
13:45 |
II:MM:SS |
01:45:22 |
II:MM |
01:45 |
II:MM AP |
01:45 PM |
II:MM:SS AP |
01:45:22 PM |
SS |
22 |
scale can be "auto" (largest size that fits the display width) or "1" through "4".
offset is the physical pixel that should be treated as "logical 0" (useful for putting noon at the top regardless of how the ring is mounted). clockwise: false reverses the direction of increasing indices around the ring. Tune these two values together until the sun's position visually matches the time of day (rising on the left, peaking at top, setting on the right).
sleep_start and sleep_end define a local-time window (0–23, 24-hour clock) during which the ring is turned off completely. The window wraps midnight correctly, so sleep_start: 22 / sleep_end: 7 means off from 10 pm through 7 am.
"led": {
"sleep_start": 22,
"sleep_end": 7
}Set either value to -1 to disable the feature (ring stays on at all hours). When active, the startup log shows LED sleep hours: 22:00 - 7:00 (ring off).
# Set secrets
cp .env.example .env
# Edit .env and fill in real values.
chmod 600 .env
# Install
sudo make install-service
# Verify
systemctl status weather-display
journalctl -u weather-display -f # Ctrl+C to exit log tailThe service is enabled on boot and restarts on failure with a 10-second backoff. The unit file is systemd/weather-display.service.
sudo systemctl stop weather-display
sudo systemctl disable weather-display
sudo rm /etc/systemd/system/weather-display.service
sudo rm /usr/local/bin/weather_display
sudo systemctl daemon-reload.
├── CMakeLists.txt # Pi production build
├── Makefile # thin wrapper around CMake (only top-level Makefile)
├── README.md
├── config.json # runtime configuration
├── docs/
│ └── device.jpeg # hero photo
├── include/
│ ├── celestial.h # sunrise/sunset/moon math
│ ├── config.h
│ ├── i2c_bus.h # I2C abstraction (real wiringPi / mock)
│ ├── json.hpp # nlohmann/json single-header
│ ├── lcd.h
│ ├── log.h # LOG_INFO/WARN/ERROR macros
│ ├── neopixel.h # WS2812 driver (real spidev / mock)
│ ├── oled.h, oled_font.h
│ ├── ring.h # high-level ring controller
│ ├── temperature.h # cToF / fToC / toUnits helpers
│ └── weather.h
├── src/
│ ├── celestial.cpp
│ ├── config.cpp
│ ├── i2c_bus.cpp
│ ├── lcd.cpp # real (wiringPi) - excluded from Mac build
│ ├── lcd_mock.cpp # mock - excluded from Pi build via CMake
│ ├── main.cpp
│ ├── neopixel.cpp
│ ├── oled.cpp, oled_font.cpp
│ ├── ring.cpp
│ └── weather.cpp
├── systemd/
│ └── weather-display.service
└── test/
├── Makefile.mac # full binary build with mock backends
├── Makefile.celestial # celestial math unit test
├── Makefile.neopixel # NeoPixel driver + visualizer
├── Makefile.oled # OLED renderer
├── Makefile.ring # ring controller across a simulated day
├── test_celestial.cpp
├── test_neopixel.cpp
├── test_oled.cpp
└── test_ring.cpp
lcd_mock.cppis excluded from the CMake build vialist(REMOVE_ITEM SOURCES ...)to avoid duplicate-symbol errors. The Mac build (test/Makefile.mac) substitutes it forlcd.cppexplicitly.- The I2C and SPI backends are swapped at compile time via
-DI2C_REALand-DNEOPIXEL_REALrespectively. Both are set by the CMake build; neither is set by the Mac dev makefiles. - The LCD's contrast is set by a trim potentiometer on the back of the I2C backpack — not a software setting. If text appears as garbled symbols or invisible, this is almost always the cause.
- The NeoPixel ring's data wire must be on GPIO 10 (SPI0 MOSI, header pin 19), not a generic GPIO. The Pi 5 cannot bit-bang WS2812 reliably from userspace.
The contrast is set by a trim potentiometer on the back of the I2C backpack. Turn it until text becomes visible. This is a hardware-only setting with no software equivalent.
The weather fetch is failing. Check:
journalctl -u weather-display -n 50Common causes:
WEATHER_API_KEYis missing or invalid — verify it in.envWEATHER_LOCATIONis not set — the app has no city to look up- No network connectivity on the Pi
sudo i2cdetect -y 1- Expected:
0x27(LCD backpack) and0x3C(OLED) visible - If absent: check wiring and confirm I2C is enabled (
sudo raspi-config nonint do_i2c 0, then reboot) - On Pi 5, the I2C bus may be
/dev/i2c-1or/dev/i2c-0— try both
- The data wire must be on GPIO 10 (SPI0 MOSI, header pin 19) — any other pin will not work
- Confirm SPI is enabled:
sudo raspi-config nonint do_spi 0 - Confirm the
spigroup membership:id $USERshould includespi. If not, runsudo usermod -aG spi $USERand log out/in
- Verify
GMAIL_USER,GMAIL_APP_PASSWORD, andALERT_RECIPIENTin.env GMAIL_APP_PASSWORDmust be a 16-character Google App Password, not your regular password — generate one at https://myaccount.google.com/apppasswords- Use
ENGINEERING_MODE=1during development to suppress outbound email while you iterate
systemctl status weather-display
journalctl -u weather-display -b- Confirm the binary is at
/usr/local/bin/weather_display(sudo make install-serviceinstalls it there) - Confirm
.envis at the project root and readable by the service user
wiringPi(I2C access on the Pi)libcurl(HTTP for weather API)nlohmann/json(vendored asinclude/json.hpp)- Linux
spidev(kernel SPI driver) - A free weatherapi.com API key
MIT
