weather-packing-bot
Studio Climate Monitor
LAN-only temperature and humidity monitor for a studio: mold risk + PLA filament climate.
Raspberry Pi (Python): KY-015 / DHT11 collector → SQLite → ntfy alerts → FastAPI on port
8787PC (Node.js): local Vite/React dashboard for charts and remote band settings
Outside conditions: hourly average of three weather services, for indoor/outdoor comparison and a 4-hour outlook
Nothing is exposed to the public internet. The PC talks to the Pi over the same Wi‑Fi.
Hardware (Joy-IT KY-015)
Sensor | Raspberry Pi |
Signal | GPIO 23 (physical pin 16) |
+V | 3.3 V (pin 1) |
GND | GND (pin 6) |
Docs: KY-015 SensorKit
Quick start — Raspberry Pi
cd pi
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install adafruit-circuitpython-dht adafruit-blinka
# DHT on Pi often also needs:
sudo apt-get install -y libgpiod2
cp config.example.toml config.toml
# Edit config.toml:
# mock_sensor = false
# api_token = "a-long-secret"
# ntfy.topic = "your-private-topic"
python -m studio_climate allAPI (same Wi‑Fi): http://<pi-hostname>.local:8787 or http://<pi-lan-ip>:8787
systemd
Edit paths/user in pi/studio-climate.service, then:
sudo cp studio-climate.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now studio-climatentfy
Install the ntfy app on your phone.
Subscribe to a hard-to-guess topic.
Set
ntfy.topicinconfig.toml(or via the dashboard Target bands form).Alerts fire after
sustain_minutesoutside the band (default 30), withalert_cooldown_minutesbetween repeats.
Quiet hours
Notifications are held back during the [quiet_hours] window in config.toml (default 23:00–08:00, so nothing buzzes overnight):
[quiet_hours]
enabled = true
start = "23:00"
end = "08:00"
# IANA zone, e.g. "Europe/Berlin". Empty = the Pi's own local time.
timezone = ""Readings and breach tracking continue as normal — only the ntfy push is skipped. If a breach is still active when the window ends, the alert goes out on the next sample. The window may wrap past midnight, and it is editable from the dashboard.
Outside conditions
Outdoor temperature and humidity come from three services queried in parallel and averaged, so one flaky or offline service degrades the reading instead of losing it:
Service | Key needed | Notes |
no | Includes DWD ICON; free for non-commercial use | |
yes | Skipped when | |
no | Official DWD data (MOSMIX + SYNOP observations) |
Numeric values are averaged over whichever services answered; the weather condition is a majority vote, with the more disruptive condition winning a tie. Bright Sky's hourly forecast reports no relative humidity, so it is derived from its dew point via the Magnus formula.
The collector polls on its own thread every poll_interval_minutes (default 60) and stores both the averaged reading and a 12-hour hourly outlook. An outgoing alert also carries the outside conditions: it reuses the stored reading when it is younger than alert_max_age_minutes, otherwise it fetches fresh before sending.
Because indoor and outdoor dew points are both known, alerts and the dashboard say whether opening a window would actually help:
Humidity high — mold risk: 68.0% RH for 90 min (threshold 55% RH). Studio climate out of target band.
Outside: 20.4°C, 58% RH, cloudy (dew point 11.8°C). Outside air is drier — dew point 3.5°C below inside. Airing out removes moisture.Configure the location and services in config.toml:
[weather]
enabled = true
latitude = 51.2277
longitude = 6.7735
poll_interval_minutes = 60
# On an alert, reuse the stored reading if younger than this; else refetch.
alert_max_age_minutes = 30
forecast_hours = 12
request_timeout_seconds = 10
# Optional — leave empty to average over Open-Meteo and Bright Sky only.
openweathermap_api_key = ""API keys stay in config.toml and are never returned by the API. Set enabled = false for a Pi without internet access; the dashboard then hides the outdoor cards.
The dashboard shows an Outside card beside Now, a Next 4 hours strip highlighting the hour the weather is expected to turn, and dashed outdoor lines on the chart. Click a legend entry to hide a series.
Quick start — PC dashboard
cd pc-dashboard
cp .env.example .env
# Set VITE_PI_API_BASE to your Pi, e.g. http://raspberrypi.local:8787
# Set VITE_API_TOKEN to the same value as pi config api_token
npm install
npm run devAPI
Method | Path | Notes |
GET |
| Liveness |
GET |
| Latest reading + in/out of band |
GET |
| History (ISO timestamps) |
GET |
| min/max/avg |
GET |
| Averaged outdoor reading, indoor/outdoor comparison, next 4 h outlook |
GET |
| Outdoor history |
POST |
| Force a poll now; send header |
GET |
| Bands, ntfy, intervals, quiet hours |
PUT |
| Update bands; send header |
Default bands: humidity 40–55% RH, temperature 18–24 °C (editable).
Local smoke test (no Pi / no sensor)
On this PC:
cd pi
python -m venv .venv
# Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp config.example.toml config.toml
# Set mock_sensor = true and optionally api_token in config.toml
python -m studio_climate allOptional DB/sensor self-check:
python scripts/selfcheck.pyIn another terminal:
cd pc-dashboard
cp .env.example .env
npm install
npm run devSD card backup
Frequent SQLite writes wear SD cards. Periodically copy the DB off the Pi:
# From the PC (PowerShell example)
scp pi@raspberrypi.local:~/studio-climate/pi/data/climate.db .\backups\climate-$(Get-Date -Format yyyyMMdd).dbOr rsync/cron on the Pi to a NAS. Prefer keeping data/ on a USB disk if you have one.
Project layout
studio-climate/
pi/ Python collector + API
pc-dashboard/ Node.js Vite + React charts