Weather Station – Complete Setup Documentation

Raspberry Pi Pico sensor node → Raspberry Pi (MariaDB + Apache CGI dashboard) → DDNS → Internet

Contents
  1. System Overview
  2. Hardware
  3. Pico Setup
  4. Raspberry Pi Setup
  5. MariaDB Configuration
  6. Python Logger
  7. systemd Service
  8. Apache + CGI Dashboard
  9. Firewall (ufw)
  10. Dynamic DNS (ddclient)
  11. Router Port Forwarding
  12. HTTPS (optional)
  13. Daily Operations
  14. Troubleshooting
  15. Rebuild Checklist
  16. Key Lessons
Placeholders used in this document:

1. System Overview

┌─────────────────────┐   USB    ┌──────────────────────────────────────┐
│  Raspberry Pi Pico  │ ───────► │  Raspberry Pi (Server)               │
│                     │          │                                      │
│  main.py:           │          │  sensor-logger.service               │
│  - DHT11            │          │    → reads /dev/ttyACM0              │
│  - BMP280           │          │    → writes to MariaDB (sensor_db)   │
│  - ADC (light)      │          │                                      │
│  - SSD1306 OLED     │          │  Apache + CGI (weather.py)           │
│                     │          │    → reads MariaDB                   │
│  Sends every 4 s:   │          │    → renders plots as PNG (base64)   │
│  (t,p,t,h,light)    │          │    → serves HTML dashboard           │
└─────────────────────┘          │                                      │
                                 │  ddclient → DDNS provider            │
                                 │  ufw → firewall                      │
                                 └──────────────────────────────────────┘

Data flow: Pico sensors → USB serial → Python logger → MariaDB → CGI script → Apache → Browser

Data format on serial:

(bmp_temp, pressure_hPa, dht_temp, humidity, light_raw)
e.g. (21.8, 1007.82, 22.0, 68, 12345)

2. Hardware

ComponentConnection
Raspberry Pi PicoUSB to PiServer
DHT11 (temp + humidity)GP15
BMP280 (temp + pressure)I2C0: SDA=GP0, SCL=GP1
SSD1306 OLED (128×64)I2C0 (shared with BMP280)
Light sensor (LDR)ADC0 = GP26

3. Raspberry Pi Pico Setup

3.1 Required MicroPython libraries

On the Pico, install:

3.2 main.py on the Pico

from machine import Pin, I2C, ADC
import time
from dht import DHT11
import bmp280
from ssd1306 import SSD1306_I2C

# ---------- Hardware ----------
adc = ADC(Pin(26))
dht = DHT11(Pin(15))
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
bmp = bmp280.BMP280(i2c)

WIDTH, HEIGHT = 128, 64
oled = SSD1306_I2C(WIDTH, HEIGHT, i2c)

# ---------- Sensor read ----------
def read_sensor():
    try:
        dht.measure()
        bmp_temp = bmp.temperature
        bmp_press = bmp.pressure / 100.0     # Pa -> hPa
        dht_temp = dht.temperature()
        dht_hum = dht.humidity()
        light = adc.read_u16()
        return bmp_temp, bmp_press, dht_temp, dht_hum, light
    except Exception as e:
        print(f"# sensor error: {e}")
        return 0.0, 0.0, 0.0, 0.0, 0

# ---------- Main loop ----------
while True:
    data = read_sensor()
    print(str(data))

    oled.fill(0)
    oled.text("Weather Station:", 0, 0)
    oled.hline(0, 15, 128, 1)
    oled.text("Temp:", 0, 17)
    oled.text(str(round((data[0] + data[2]) / 2, 1)) + " C", 32, 30)
    oled.text("Pressure:", 0, 42)
    oled.text(str(round(data[1], 2)) + " hPa", 28, 55)
    oled.show()
    time.sleep(2)

    oled.fill(0)
    oled.text("Weather Station:", 0, 0)
    oled.hline(0, 15, 128, 1)
    oled.text("Humidity:", 0, 17)
    oled.text(str(data[3]) + " %", 50, 30)
    oled.text("Light:", 0, 42)
    oled.text(str(data[4]), 40, 55)
    oled.show()
    time.sleep(2)

    oled.fill(0)

3.3 Critical rules

4. Raspberry Pi (Server) Setup

Assume user YOUR_USER on Debian 12 (Bookworm).

4.1 System packages

sudo apt update
sudo apt install -y \
    python3-pip python3-dev python3-venv python3-full \
    python3-serial python3-matplotlib \
    mariadb-server apache2 \
    curl git nano ufw

4.2 Serial port access

sudo usermod -a -G dialout YOUR_USER
sudo reboot

Verify:

groups          # must include dialout
ls -l /dev/ttyACM0

4.3 Disable ModemManager (frees the serial port)

sudo systemctl disable --now ModemManager

4.4 Install mysql-connector-python system-wide

Debian 12 does not ship python3-mysql.connector. Use pip with the PEP 668 override.
sudo pip install mysql-connector-python --break-system-packages

Verify that Apache's user can import it:

sudo -u www-data python3 -c "import mysql.connector; print('OK')"
# Must print: OK

5. MariaDB Configuration

5.1 Create database and user

sudo mysql
CREATE DATABASE sensor_db;
CREATE USER 'YOUR_DB_USER'@'localhost' IDENTIFIED BY 'YOUR_DB_PASSWORD';
GRANT ALL PRIVILEGES ON sensor_db.* TO 'YOUR_DB_USER'@'localhost';
FLUSH PRIVILEGES;
EXIT;

5.2 Create the table

mysql -u YOUR_DB_USER -p sensor_db
CREATE TABLE sensor_data (
    id INT AUTO_INCREMENT PRIMARY KEY,
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    temperatur FLOAT,
    luftdruck FLOAT,
    luftfeuchtigkeit FLOAT
);
EXIT;

5.3 Useful DB commands

# Count rows
mysql -u YOUR_DB_USER -p sensor_db -e "SELECT COUNT(*) FROM sensor_data;"

# Last 10 rows
mysql -u YOUR_DB_USER -p sensor_db -e "SELECT * FROM sensor_data ORDER BY id DESC LIMIT 10;"

# Fix old rows that stored Pa instead of hPa
mysql -u YOUR_DB_USER -p sensor_db -e "UPDATE sensor_data SET luftdruck = luftdruck/100 WHERE luftdruck > 2000;"

6. Python Logger on the Pi

6.1 Location

/home/YOUR_USER/projects/weather/data_logger.py

6.2 Content

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import glob
import time
import serial
import mysql.connector

def find_serial_port():
    candidates = glob.glob('/dev/serial/by-id/*')
    return candidates[0] if candidates else '/dev/ttyACM0'

PORT = find_serial_port()
BAUDRATE = 115200

DB_CONFIG = {
    'host': 'localhost',
    'user': 'YOUR_DB_USER',
    'password': 'YOUR_DB_PASSWORD',
    'database': 'sensor_db',
    'autocommit': False,
}
TABLE_NAME = 'sensor_data'

SQL_INSERT = (
    f"INSERT INTO {TABLE_NAME} "
    f"(temperatur, luftdruck, luftfeuchtigkeit) VALUES (%s, %s, %s)"
)

def open_serial():
    while True:
        try:
            ser = serial.Serial(PORT, BAUDRATE, timeout=1)
            print(f"Opened serial port {PORT} @ {BAUDRATE} baud", flush=True)
            return ser
        except serial.SerialException as e:
            print(f"Serial port not available ({e}); retrying in 5 s...", flush=True)
            time.sleep(5)

def connect_db():
    while True:
        try:
            db = mysql.connector.connect(**DB_CONFIG)
            print("Connected to database", flush=True)
            return db
        except mysql.connector.Error as e:
            print(f"DB connection failed ({e}); retrying in 5 s...", flush=True)
            time.sleep(5)

def ensure_db_alive(db):
    try:
        db.ping(reconnect=True, attempts=3, delay=2)
        return db
    except mysql.connector.Error:
        return connect_db()

def parse_line(raw):
    line = raw.strip()
    if not line or not line.startswith('('):
        return None
    inner = line.strip('()')
    parts = [p.strip() for p in inner.split(',')]
    if len(parts) < 4:
        return None
    if any(p.upper() == "ERR" for p in parts):
        return None
    try:
        return [float(p) for p in parts[:4]]
    except ValueError:
        return None

def main():
    ser = open_serial()
    db = connect_db()
    cursor = db.cursor()
    try:
        while True:
            try:
                raw = ser.readline().decode('utf-8', errors='ignore')
                if not raw:
                    continue
                print(f"RAW: {raw.strip()!r}", flush=True)

                values = parse_line(raw)
                if values is None:
                    print("  -> skipped (malformed)", flush=True)
                    continue

                bmp_temp, bmp_press, dht_temp, dht_hum = values
                temperatur = round((bmp_temp + dht_temp) / 2, 1)
                luftdruck = round(bmp_press, 2)
                luftfeuchtigkeit = round(dht_hum, 1)

                db = ensure_db_alive(db)
                cursor = db.cursor()
                cursor.execute(SQL_INSERT, (temperatur, luftdruck, luftfeuchtigkeit))
                db.commit()
                cursor.close()
                print(f"  -> stored: {temperatur} C, {luftdruck} hPa, {luftfeuchtigkeit} %", flush=True)

            except mysql.connector.Error as e:
                print(f"DB error: {e}", flush=True)
                time.sleep(2)
                db = connect_db()
                cursor = db.cursor()
            except serial.SerialException as e:
                print(f"Serial error: {e}", flush=True)
                try: ser.close()
                except Exception: pass
                ser = open_serial()
            except KeyboardInterrupt:
                break
            except Exception as e:
                print(f"Unexpected error: {e}", flush=True)
    finally:
        try: cursor.close()
        except Exception: pass
        try: db.close()
        except Exception: pass
        try: ser.close()
        except Exception: pass

if __name__ == '__main__':
    main()

6.3 Test manually

python3 /home/YOUR_USER/projects/weather/data_logger.py

You should see RAW: and -> stored: lines every 4 s. Stop with Ctrl+C.

7. systemd Service

7.1 Create the unit

sudo nano /etc/systemd/system/sensor-logger.service
[Unit]
Description=Weather Station Sensor Logger
After=network.target mariadb.service

[Service]
Type=simple
User=YOUR_USER
Group=dialout
WorkingDirectory=/home/YOUR_USER/projects/weather
ExecStart=/usr/bin/python3 -u /home/YOUR_USER/projects/weather/data_logger.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

7.2 Critical points

7.3 Enable and start

sudo systemctl daemon-reload
sudo systemctl enable sensor-logger.service
sudo systemctl restart sensor-logger.service
sudo systemctl status sensor-logger.service
journalctl -u sensor-logger.service -f

7.4 Common error codes

CodeMeaningFix
217/USERUser does not existCorrect User=
200/CHDIRWorking directory missing/inaccessibleCorrect WorkingDirectory=
203/EXECExecStart path wrongCorrect ExecStart=
No log outputPython bufferingAdd -u

8. Apache + CGI Dashboard

8.1 Enable CGI

sudo a2enmod cgi
sudo systemctl restart apache2

8.2 Create /usr/lib/cgi-bin/weather.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import cgi
import cgitb
import os
import io
import base64

os.environ['MPLCONFIGDIR'] = '/tmp/matplotlib'
os.makedirs('/tmp/matplotlib', exist_ok=True)

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

import mysql.connector

cgitb.enable()

DB_CONFIG = {
    'host': 'localhost',
    'user': 'YOUR_DB_USER',
    'password': 'YOUR_DB_PASSWORD',
    'database': 'sensor_db'
}
TABLE_NAME = 'sensor_data'

def fetch_data():
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor()
    try:
        cursor.execute(f"SELECT timestamp, temperatur, luftdruck, luftfeuchtigkeit "
                       f"FROM {TABLE_NAME} ORDER BY timestamp")
        data = cursor.fetchall()
        ts, t, p, h = [], [], [], []
        for row in data:
            ts.append(row[0]); t.append(float(row[1]))
            p.append(float(row[2])); h.append(float(row[3]))
        return ts, t, p, h
    finally:
        cursor.close(); conn.close()

def create_plot(x, y, title, ylabel, color='blue'):
    plt.figure(figsize=(10, 5))
    plt.plot(x, y, color=color)
    plt.title(title); plt.xlabel('Time'); plt.ylabel(ylabel)
    plt.grid(True); plt.xticks(rotation=45)
    buf = io.BytesIO()
    plt.savefig(buf, format='png', bbox_inches='tight')
    plt.close()
    return base64.b64encode(buf.getvalue()).decode('utf-8')

def main():
    print("Content-type: text/html\n")
    print("""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Weather Data</title>
    <style>body{font-family:Arial;margin:20px}
    .plot-container{margin:30px 0}
    img{max-width:100%;height:auto;border:1px solid #ddd}
    .error{color:red}</style></head><body>
    <h1>Weather Data Visualization</h1>""")
    try:
        ts, t, p, h = fetch_data()
        for values, title, ylabel, color in [
            (t, 'Temperature Over Time', 'Temperature (C)', 'red'),
            (p, 'Air Pressure Over Time', 'Pressure (hPa)', 'green'),
            (h, 'Humidity Over Time', 'Humidity (%)', 'blue'),
        ]:
            img = create_plot(ts, values, title, ylabel, color)
            print(f'<div class="plot-container"><h2>{title}</h2>'
                  f'<img src="data:image/png;base64,{img}"></div>')
    except Exception as e:
        print(f'<div class="error"><h2>Error:</h2><p>{e}</p></div>')
    print("</body></html>")

if __name__ == '__main__':
    main()

8.3 Permissions

sudo chmod +x /usr/lib/cgi-bin/weather.py
sudo chown www-data:www-data /usr/lib/cgi-bin/weather.py

8.4 Test

curl -s http://localhost/cgi-bin/weather.py | head -20

Browser: http://<pi-ip>/cgi-bin/weather.py

8.5 Common CGI errors

SymptomFix
500 Internal Server Errorsudo tail -30 /var/log/apache2/error.log
ModuleNotFoundError: mysql.connectorsudo pip install mysql-connector-python --break-system-packages
403 Forbiddensudo chmod +x /usr/lib/cgi-bin/weather.py
404 Not Foundsudo a2enmod cgi && sudo systemctl restart apache2
Missing pltEnsure import matplotlib.pyplot as plt

9. Firewall (ufw)

sudo ufw allow ssh
sudo ufw allow 'Apache Full'
sudo ufw enable
sudo ufw status verbose

Expected:

22/tcp    ALLOW IN    Anywhere
80/tcp    ALLOW IN    Anywhere
443/tcp   ALLOW IN    Anywhere
ufw does not affect serial ports or local file permissions.

10. Dynamic DNS (ddclient)

10.1 Install ddclient 3.11.2

Debian 12 ships ddclient 3.10.0, which has known bugs (protocol <undefined>, curl not found). Upgrade to 3.11.2.
wget http://ftp.de.debian.org/debian/pool/main/d/ddclient/ddclient_3.11.2-2_all.deb
sudo dpkg -i ddclient_3.11.2-2_all.deb
sudo apt --fix-broken install

10.2 /etc/ddclient.conf

protocol=dyndns2
usev4=webv4, webv4=ipify-ipv4
server=dyndns.example-provider.com/nic/update
login=example.com
password='YOUR_DDNS_PASSWORD'
weather.example.com

10.3 Restart & verify

sudo systemctl restart ddclient
sudo journalctl -u ddclient -n 20 --no-pager

Success looks like:

WARNING: updating weather.example.com: nochg: No update required

nochg = IP already correct, this is a success.

10.4 DNS provider settings

Verify DNS resolution:

getent hosts weather.example.com
# Must return your public IPv4

11. Router Port Forwarding

  1. Open the router web UI (e.g. http://fritz.box).
  2. Go to Internet → Freigaben → Portfreigaben.
  3. New rule:
  4. Ensure no other rule uses port 80 (e.g. remote access).
  5. Reboot the router if the rule doesn't activate.

Verify from outside

Use a mobile phone on cellular data:

http://YOUR_PUBLIC_IP

Or a port checker like portchecker.co.

CGNAT / DS-Lite check

Compare the IPv4 in Router → Internet → Online-Monitor with curl -4 ifconfig.me on the Pi. If they differ, you're behind CGNAT and port forwarding won't work — you'll need a public IPv4 from your ISP or IPv6.

12. HTTPS (optional)

sudo apt install certbot python3-certbot-apache -y
sudo certbot --apache -d weather.example.com

Follow the prompts. Certbot handles renewal automatically.

13. Daily Operations

Start / stop / restart

sudo systemctl restart sensor-logger.service
sudo systemctl restart apache2
sudo systemctl restart mariadb
sudo systemctl restart ddclient

Live logs

journalctl -u sensor-logger.service -f
journalctl -u ddclient -f
sudo tail -f /var/log/apache2/error.log

Database quick-checks

mysql -u YOUR_DB_USER -p sensor_db -e "SELECT COUNT(*) FROM sensor_data;"
mysql -u YOUR_DB_USER -p sensor_db -e "SELECT * FROM sensor_data ORDER BY id DESC LIMIT 5;"

When working on the Pico

Stop the logger first — the serial port can only be opened by one process.
sudo systemctl stop sensor-logger.service
# ... work in Thonny ...
sudo systemctl start sensor-logger.service

14. Troubleshooting Cheat Sheet

SymptomCauseFix
Resource temporarily unavailable on /dev/ttyACM0Another process holds the portsudo fuser -k /dev/ttyACM0; stop Thonny/miniterm
Logger runs, no journal outputPython bufferingAdd -u to ExecStart
Logger crashes on IndexErrorMalformed line from PicoHandled by parse_line() — check Pico output
Errno 13 Permission denied on /dev/ttyACM0User not in dialoutsudo usermod -a -G dialout $USER, reboot
Service fails with 217/USERUser= doesn't existCorrect username in unit file
Service fails with 200/CHDIRWorkingDirectory= missingPoint to real directory or remove line
Access denied for userWrong password or pluginsudo mysqlALTER USER ...
protocol <undefined> from ddclientddclient 3.10.0 bugUpgrade to 3.11.2, use usev4=webv4, webv4=ipify-ipv4
curl not found from ddclientddclient 3.10.0 bugSame — upgrade
CGI returns 500Python tracebackCheck /var/log/apache2/error.log
Domain shows provider pageDNS not yet updatedWait for propagation; check DynDNS enabled
Website not reachable from outsidePort forwarding or CGNATCheck router rule; compare WAN IP with public IP

15. Complete Rebuild Checklist

  1. Pico: flash MicroPython, install bmp280 + ssd1306, save main.py, close Thonny.
  2. Pi users & groups: sudo usermod -a -G dialout YOUR_USER, reboot.
  3. Packages: apt install python3-pip python3-serial python3-matplotlib mariadb-server apache2 curl ufw
  4. Python module: sudo pip install mysql-connector-python --break-system-packages
  5. MariaDB: create sensor_db, user, table sensor_data.
  6. Logger: create data_logger.py, test manually.
  7. Service: create /etc/systemd/system/sensor-logger.service with correct user, directory, and -u.
  8. Apache CGI: enable cgi, create /usr/lib/cgi-bin/weather.py, chmod +x, chown www-data.
  9. Firewall: ufw allow ssh, ufw allow 'Apache Full', ufw enable.
  10. DDNS: install ddclient 3.11.2, configure, enable DynDNS at provider.
  11. Router: forward TCP 80 → Pi's local IP.
  12. Test: local curl, LAN browser, external browser, mobile data.

16. Key Lessons Learned

  1. Debian 12 (Bookworm) blocks global pip installs (PEP 668). Use --break-system-packages for the MySQL connector so www-data can import it.
  2. ddclient 3.10.0 is broken for many DDNS providers. Version 3.11.2 with usev4=webv4, webv4=ipify-ipv4 works.
  3. Serial port is exclusive — one process at a time.
  4. systemd User= and WorkingDirectory= must be real — otherwise 217/USER or 200/CHDIR.
  5. Python buffers stdout when not on a TTY — always use python3 -u in services.
  6. DynDNS and manual A-record are mutually exclusive at most providers.
  7. Pico main.py must not contain terminal banners or >>> prompts.
  8. bmp.pressure returns Pa, not hPa — divide by 100.
  9. cgitb.enable() in CGI scripts shows tracebacks in the browser — invaluable for debugging.
  10. getent hosts <domain> verifies DNS without installing extra tools.

End of documentation · Generated for a Raspberry Pi weather station project