Raspberry Pi Pico sensor node → Raspberry Pi (MariaDB + Apache CGI dashboard) → DDNS → Internet
┌─────────────────────┐ 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)
| Component | Connection |
|---|---|
| Raspberry Pi Pico | USB 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 |
On the Pico, install:
bmp280 (mip install micropython-bmp280)ssd1306 (built into most MicroPython builds)dht (built in)main.py on the Picofrom 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)
main.py in the Pico's root directory.>>> prompts may appear in the file.main.py automatically.Assume user YOUR_USER on Debian 12 (Bookworm).
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
sudo usermod -a -G dialout YOUR_USER
sudo reboot
Verify:
groups # must include dialout
ls -l /dev/ttyACM0
sudo systemctl disable --now ModemManager
mysql-connector-python system-widepython3-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
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;
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;
# 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;"
/home/YOUR_USER/projects/weather/data_logger.py
#!/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()
python3 /home/YOUR_USER/projects/weather/data_logger.py
You should see RAW: and -> stored: lines every 4 s. Stop with Ctrl+C.
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
User= must match the actual Linux user.WorkingDirectory= must point to an existing directory.-u after python3 disables buffering — without it, no log output appears.Group=dialout grants serial port access.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
| Code | Meaning | Fix |
|---|---|---|
217/USER | User does not exist | Correct User= |
200/CHDIR | Working directory missing/inaccessible | Correct WorkingDirectory= |
203/EXEC | ExecStart path wrong | Correct ExecStart= |
| No log output | Python buffering | Add -u |
sudo a2enmod cgi
sudo systemctl restart apache2
/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()
sudo chmod +x /usr/lib/cgi-bin/weather.py
sudo chown www-data:www-data /usr/lib/cgi-bin/weather.py
curl -s http://localhost/cgi-bin/weather.py | head -20
Browser: http://<pi-ip>/cgi-bin/weather.py
| Symptom | Fix |
|---|---|
500 Internal Server Error | sudo tail -30 /var/log/apache2/error.log |
ModuleNotFoundError: mysql.connector | sudo pip install mysql-connector-python --break-system-packages |
403 Forbidden | sudo chmod +x /usr/lib/cgi-bin/weather.py |
404 Not Found | sudo a2enmod cgi && sudo systemctl restart apache2 |
Missing plt | Ensure import matplotlib.pyplot as plt |
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.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
/etc/ddclient.confprotocol=dyndns2
usev4=webv4, webv4=ipify-ipv4
server=dyndns.example-provider.com/nic/update
login=example.com
password='YOUR_DDNS_PASSWORD'
weather.example.com
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.
Verify DNS resolution:
getent hosts weather.example.com
# Must return your public IPv4
http://fritz.box).Use a mobile phone on cellular data:
http://YOUR_PUBLIC_IP
Or a port checker like portchecker.co.
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.
sudo apt install certbot python3-certbot-apache -y
sudo certbot --apache -d weather.example.com
Follow the prompts. Certbot handles renewal automatically.
sudo systemctl restart sensor-logger.service
sudo systemctl restart apache2
sudo systemctl restart mariadb
sudo systemctl restart ddclient
journalctl -u sensor-logger.service -f
journalctl -u ddclient -f
sudo tail -f /var/log/apache2/error.log
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;"
sudo systemctl stop sensor-logger.service
# ... work in Thonny ...
sudo systemctl start sensor-logger.service
| Symptom | Cause | Fix |
|---|---|---|
Resource temporarily unavailable on /dev/ttyACM0 | Another process holds the port | sudo fuser -k /dev/ttyACM0; stop Thonny/miniterm |
| Logger runs, no journal output | Python buffering | Add -u to ExecStart |
Logger crashes on IndexError | Malformed line from Pico | Handled by parse_line() — check Pico output |
Errno 13 Permission denied on /dev/ttyACM0 | User not in dialout | sudo usermod -a -G dialout $USER, reboot |
Service fails with 217/USER | User= doesn't exist | Correct username in unit file |
Service fails with 200/CHDIR | WorkingDirectory= missing | Point to real directory or remove line |
Access denied for user | Wrong password or plugin | sudo mysql → ALTER USER ... |
protocol <undefined> from ddclient | ddclient 3.10.0 bug | Upgrade to 3.11.2, use usev4=webv4, webv4=ipify-ipv4 |
curl not found from ddclient | ddclient 3.10.0 bug | Same — upgrade |
| CGI returns 500 | Python traceback | Check /var/log/apache2/error.log |
| Domain shows provider page | DNS not yet updated | Wait for propagation; check DynDNS enabled |
| Website not reachable from outside | Port forwarding or CGNAT | Check router rule; compare WAN IP with public IP |
bmp280 + ssd1306, save main.py, close Thonny.sudo usermod -a -G dialout YOUR_USER, reboot.apt install python3-pip python3-serial python3-matplotlib mariadb-server apache2 curl ufwsudo pip install mysql-connector-python --break-system-packagessensor_db, user, table sensor_data.data_logger.py, test manually./etc/systemd/system/sensor-logger.service with correct user, directory, and -u.cgi, create /usr/lib/cgi-bin/weather.py, chmod +x, chown www-data.ufw allow ssh, ufw allow 'Apache Full', ufw enable.--break-system-packages for the MySQL connector so www-data can import it.usev4=webv4, webv4=ipify-ipv4 works.User= and WorkingDirectory= must be real — otherwise 217/USER or 200/CHDIR.python3 -u in services.main.py must not contain terminal banners or >>> prompts.bmp.pressure returns Pa, not hPa — divide by 100.cgitb.enable() in CGI scripts shows tracebacks in the browser — invaluable for debugging.getent hosts <domain> verifies DNS without installing extra tools.End of documentation · Generated for a Raspberry Pi weather station project