First commit

This commit is contained in:
rxf
2026-01-28 20:01:48 +01:00
commit ec4b1a250d
13 changed files with 3553 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Datenbank
DB_FILE=<database_name>
# HTTP-Port
HTTP_PORT=<port>

88
.gitignore vendored Normal file
View File

@@ -0,0 +1,88 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
.venv/
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment variables
.env
.env.local
.env.*.local
# Database
wetterdaten.db
*.db
*.sdb
# Logs
*.log
logs/
# Node.js
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
lerna-debug.log
.pnpm-debug.log
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
pids
*.pid
*.seed
*.pid.lock
.npm/
# Cache
.pytest_cache/
.coverage
htmlcov/
# Temporary files
*.tmp
*.bak
*.swp
# Flask
instance/
.webassets-cache
templates/index.html
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes

61
DOCKER_README.md Normal file
View File

@@ -0,0 +1,61 @@
# Wetterstation Docker Setup
## Voraussetzungen
- Docker und Docker Compose installiert
- MQTT Broker Zugang (Host, Port, Benutzername, Passwort)
## Installation
### 1. `.env` Datei erstellen
Kopiere `.env.example` zu `.env` und fülle deine Daten ein:
```bash
cp .env.example .env
```
Bearbeite `.env` mit deinen MQTT-Credentials:
```
MQTT_HOST=dein_broker.com
MQTT_PORT=1883
MQTT_TOPIC=vantage/live
MQTT_USER=dein_benutzer
MQTT_PASSWORD=dein_passwort
DB_FILE=wetterdaten.db
```
### 2. Container starten
```bash
docker-compose up -d
```
Die Anwendung läuft dann unter `http://localhost:5003`
### 3. Container verwalten
```bash
# Logs anschauen
docker-compose logs -f
# Container stoppen
docker-compose down
# Container neustarten
docker-compose restart
```
## Datenverwaltung
Die SQLite-Datenbank (`wetterdaten.db`) wird als Volume persistiert und bleibt erhalten, auch wenn der Container gelöscht wird.
## Troubleshooting
### Datenbank-Fehler
Falls die Datenbank beschädigt ist, kannst du sie löschen und neu erstellen:
```bash
rm wetterdaten.db
docker-compose restart
```
### MQTT-Verbindungsfehler
Überprüfe deine `.env` Datei auf korrekte Credentials:
```bash
docker-compose logs wetterstation | grep -i mqtt
```

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Multi-stage build: Leichtgewichtiger Container
FROM node:18-alpine
# Setze Arbeitsverzeichnis
WORKDIR /app
# Installiere Dependencies
COPY package.json .
RUN npm install --production
# Kopiere die Anwendung
COPY server.js .
COPY static/ static/
COPY views/ views/
# Exponiere Port
EXPOSE 5003
# Starten Sie die Anwendung
CMD ["node", "server.js"]

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
wetterstation:
build: .
container_name: wetterstation
ports:
- "5003:5003"
volumes:
- ./wetterdaten.db:/app/wetterdaten.db
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped
networks:
- wetterstation_network
networks:
wetterstation_network:
driver: bridge

90
docs/README.md Normal file
View File

@@ -0,0 +1,90 @@
# Wetterstation - MQTT zu Web Dashboard
## Installation
1. **Python-Pakete installieren:**
```bash
pip install -r requirements.txt
```
## Konfiguration
Öffne `wetterstation.py` und passe folgende Zeilen an:
```python
MQTT_TOPIC = "wetter/daten" # Dein MQTT Topic
MQTT_USER = "username" # Dein MQTT Benutzername
MQTT_PASSWORD = "password" # Dein MQTT Passwort
```
## Verwendung
1. **Programm starten:**
```bash
python wetterstation.py
```
2. **Browser öffnen:**
- Öffne http://localhost:5000
- Du siehst das Dashboard mit allen Grafiken
## Funktionen
### MQTT Datenempfang
- Verbindet sich automatisch mit rexfue.de
- Empfängt Wetterdaten im JSON-Format
- Speichert alle Daten in SQLite Datenbank
### Web-Dashboard
- **6 Grafiken:**
- Temperatur über Zeit
- Luftfeuchtigkeit über Zeit
- Luftdruck über Zeit
- Regenmenge pro Stunde (Balkendiagramm)
- Windgeschwindigkeit + Böen
- Windrichtung (Polarplot)
- **Zwei Ansichten:**
- Tag: Letzte 24 Stunden
- Woche: Letzte 7 Tage
- Umschaltbar per Tab
- **Auto-Refresh:** Aktualisiert sich alle 5 Minuten automatisch
## Datenbank
Die Wetterdaten werden in `wetterdaten.db` gespeichert (SQLite).
## Erwartetes MQTT Datenformat
```json
{
"datetime": "2026-01-24 16:05:00",
"pressure": 1002.0,
"wind_gust": 5.0,
"wind_speed": 3.0,
"wind_dir": 45.0,
"rain_rate": 0.0,
"rain": 0.0,
"humidity": 80,
"temperature": 0.7
}
```
## Problemlösung
**MQTT verbindet nicht:**
- Prüfe MQTT_USER und MQTT_PASSWORD
- Prüfe MQTT_TOPIC
- Stelle sicher, dass rexfue.de erreichbar ist
**Keine Daten im Dashboard:**
- Warte bis erste MQTT Nachricht empfangen wurde
- Prüfe die Konsole auf Fehlermeldungen
- Überprüfe ob `wetterdaten.db` erstellt wurde
**Port 5000 bereits belegt:**
Ändere in `wetterstation.py` die letzte Zeile:
```python
app.run(host='0.0.0.0', port=8080, debug=False)
```

2713
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "wetterstation",
"version": "1.0.0",
"description": "Wetterstation Web-Visualisierung (Node.js Port)",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"pug": "^3.0.2",
"express": "^4.18.2",
"sqlite3": "^5.0.2"
},
"engines": {
"node": ">=18.0.0"
}
}

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
flask==3.0.0
paho-mqtt==1.6.1
python-dotenv==1.0.0

197
server.js Normal file
View File

@@ -0,0 +1,197 @@
import 'dotenv/config';
import express from 'express';
import sqlite3Pkg from 'sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
const sqlite3 = sqlite3Pkg.verbose();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
// Configuration
const DB_FILE = process.env.DB_FILE || 'wetterdaten.db';
const HTTP_PORT = parseInt(process.env.HTTP_PORT || '5003', 10);
// View engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
// Middleware
app.use(express.json());
app.use('/static', express.static(path.join(__dirname, 'static')));
// Database handling class
class WetterDB {
constructor(dbFile) {
this.dbFile = dbFile;
this.initDb();
}
initDb() {
const db = new sqlite3.Database(this.dbFile);
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS wetterdaten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateTime INTEGER NOT NULL,
barometer REAL,
outTemp REAL,
outHumidity INTEGER,
windSpeed REAL,
windDir REAL,
windGust REAL,
rainRate REAL,
rain REAL
)
`);
db.run(`
CREATE INDEX IF NOT EXISTS idx_dateTime ON wetterdaten(dateTime)
`);
});
db.close();
}
saveData(data) {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(this.dbFile);
const sql = `
INSERT INTO wetterdaten
(dateTime, barometer, outTemp, outHumidity, windSpeed, windDir, windGust, rainRate, rain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const params = [
data.dateTime,
data.barometer,
data.outTemp,
data.outHumidity,
data.windSpeed,
data.windDir,
data.windGust,
data.rainRate,
data.rain
];
db.run(sql, params, function(err) {
db.close();
if (err) {
console.error("Error saving data:", err);
reject(err);
} else {
console.log(`Daten gespeichert: ${data.dateTime}`);
resolve(this.lastID);
}
});
});
}
getData(hours = 24) {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(this.dbFile);
// Calculate timestamp threshold (current time - hours) in seconds (Unix Timestamp)
const timeThreshold = Math.floor((Date.now() - hours * 60 * 60 * 1000) / 1000);
const sql = `
SELECT * FROM wetterdaten
WHERE dateTime >= ?
ORDER BY dateTime ASC
`;
db.all(sql, [timeThreshold], (err, rows) => {
db.close();
if (err) reject(err);
else resolve(rows);
});
});
}
getHourlyRain(hours = 24) {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(this.dbFile);
// Calculate timestamp threshold (current time - hours) in seconds (Unix Timestamp)
const timeThreshold = Math.floor((Date.now() - hours * 60 * 60 * 1000) / 1000);
const sql = `
SELECT
strftime('%Y-%m-%d %H:00:00', datetime(dateTime, 'unixepoch', 'localtime')) as hour,
SUM(rainRate) as total_rain
FROM wetterdaten
WHERE dateTime >= ?
GROUP BY hour
ORDER BY hour ASC
`;
db.all(sql, [timeThreshold], (err, rows) => {
db.close();
if (err) {
reject(err);
} else {
const result = rows.map(row => ({
hour: row.hour,
rain: row.total_rain || 0
}));
resolve(result);
}
});
});
}
}
// Global DB instance
const db = new WetterDB(DB_FILE);
// Routes
app.get('/', (req, res) => {
res.render('index');
});
app.post('/api/data/upload', async (req, res) => {
try {
const data = req.body;
if (!data || Object.keys(data).length === 0) {
return res.status(400).json({ error: 'Keine Daten empfangen' });
}
await db.saveData(data);
res.status(200).json({
status: 'success',
message: 'Daten empfangen und gespeichert'
});
} catch (e) {
console.error(`Fehler beim Verarbeiten der POST-Anfrage: ${e}`);
res.status(400).json({ error: e.toString() });
}
});
app.get('/api/data/:period', async (req, res) => {
const period = req.params.period;
const hours = period === 'day' ? 24 : 168; // 168h = 1 week
try {
const [data, rainData] = await Promise.all([
db.getData(hours),
db.getHourlyRain(hours)
]);
res.json({
data: data,
rain_hourly: rainData
});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// Start server
app.listen(HTTP_PORT, '0.0.0.0', () => {
console.log("Wetterstation wird gestartet...");
console.log(`\nWeb-Interface verfügbar unter: http://localhost:${HTTP_PORT}`);
console.log(`HTTP-POST Endpoint: http://localhost:${HTTP_PORT}/api/data/upload`);
console.log("Drücke CTRL+C zum Beenden\n");
});

89
static/css/style.css Normal file
View File

@@ -0,0 +1,89 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 2.5em;
}
.tabs {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 30px;
}
.tab {
padding: 15px 40px;
background: #f0f0f0;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 18px;
transition: all 0.3s;
font-weight: 600;
}
.tab:hover {
background: #e0e0e0;
transform: translateY(-2px);
}
.tab.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-top: 20px;
}
.chart-container {
background: #f9f9f9;
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
/* feste Mindesthöhe sorgt für konsistente Layouts */
min-height: 420px;
}
/* Stellt sicher, dass Charts nicht breiter als ihre Container werden */
#charts { width: 100%; }
.chart-container > div { width: 100%; max-width: 100%; }
.loading {
text-align: center;
padding: 50px;
font-size: 20px;
color: #666;
}
@media (max-width: 1024px) {
.charts-grid {
grid-template-columns: 1fr;
}
}

213
static/js/app.js Normal file
View File

@@ -0,0 +1,213 @@
let currentPeriod = 'day';
const DEFAULT_CHART_HEIGHT = 360;
const POLAR_CHART_HEIGHT = 420;
// HighCharts globale Einstellungen
Highcharts.setOptions({
lang: {
months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
shortMonths: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
weekdays: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
shortWeekdays: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
}
});
function switchPeriod(period) {
currentPeriod = period;
// Tab-Status aktualisieren
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
event.target.classList.add('active');
loadData();
}
function loadData() {
document.getElementById('loading').style.display = 'block';
document.getElementById('charts').style.display = 'none';
fetch(`/api/data/${currentPeriod}`)
.then(response => response.json())
.then(data => {
renderCharts(data);
document.getElementById('loading').style.display = 'none';
document.getElementById('charts').style.display = 'grid';
})
.catch(error => {
console.error('Fehler beim Laden:', error);
document.getElementById('loading').innerHTML = 'Fehler beim Laden der Daten';
});
}
function renderCharts(apiData) {
const data = apiData.data;
const rainData = apiData.rain_hourly;
// Konvertiere Unix Timestamps (Sekunden) in Millisekunden für JS
const timestamps = data.map(d => {
// Wenn es schon Millisekunden sind (sehr große Zahl, > 10^11), lassen, sonst * 1000
return d.dateTime > 10000000000 ? d.dateTime : d.dateTime * 1000;
});
const rainTimestamps = rainData.map(d => {
// rainData.hour kommt aus SQLite "strftime" als String "YYYY-MM-DD HH:00:00"
// da wir unixepoch verwendet haben, müssen wir prüfen, was der Server liefert.
// Server liefert "YYYY-MM-DD HH:00:00". Das müssen wir parsen.
const [date, time] = d.hour.split(' ');
return new Date(date + 'T' + time).getTime();
});
// Berechne Zeitbereich für die Achsen
const minTime = Math.min(...timestamps, ...rainTimestamps);
const maxTime = Math.max(...timestamps, ...rainTimestamps);
// 1-Stunden-Intervalle für die Achsen-Labels (3600000 ms = 1 Stunde)
const ONE_HOUR = 3600000;
const FOUR_HOURS = ONE_HOUR * 4;
// Temperatur
Highcharts.chart('temp-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🌡️ Temperatur (°C)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
},
yAxis: { title: { text: '°C' } },
legend: { enabled: true },
series: [{
name: 'Temperatur',
data: data.map((d, i) => [timestamps[i], d.outTemp]),
color: '#ff6b6b',
lineWidth: 2
}],
credits: { enabled: false }
});
// Luftfeuchtigkeit
Highcharts.chart('humidity-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '💧 Luftfeuchtigkeit (%)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
},
yAxis: { title: { text: '%' } },
legend: { enabled: true },
series: [{
name: 'Luftfeuchtigkeit',
data: data.map((d, i) => [timestamps[i], d.outHumidity]),
color: '#4ecdc4',
lineWidth: 2
}],
credits: { enabled: false }
});
// Luftdruck
Highcharts.chart('pressure-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🎈 Luftdruck (hPa)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
},
yAxis: { title: { text: 'hPa' } },
legend: { enabled: true },
series: [{
name: 'Luftdruck',
data: data.map((d, i) => [timestamps[i], d.barometer]),
color: '#95e1d3',
lineWidth: 2
}],
credits: { enabled: false }
});
// Regenmenge pro Stunde
Highcharts.chart('rain-chart', {
chart: { type: 'column', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🌧️ Regenmenge pro Stunde (mm)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
},
yAxis: { title: { text: 'mm' } },
legend: { enabled: false },
series: [{
name: 'Regen',
data: rainData.map((d, i) => [rainTimestamps[i], d.rain]),
color: '#3498db'
}],
credits: { enabled: false }
});
// Windgeschwindigkeit
Highcharts.chart('wind-speed-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '💨 Windgeschwindigkeit (m/s)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
},
yAxis: { title: { text: 'm/s' } },
legend: { enabled: true },
series: [{
name: 'Windgeschwindigkeit',
data: data.map((d, i) => [timestamps[i], d.windSpeed]),
color: '#f38181',
lineWidth: 2
}, {
name: 'Böen',
data: data.map((d, i) => [timestamps[i], d.windGust]),
color: '#aa96da',
lineWidth: 2,
dashStyle: 'dash'
}],
credits: { enabled: false }
});
// Windrichtung
Highcharts.chart('wind-dir-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🧭 Windrichtung (°)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
},
yAxis: {
title: { text: 'Richtung (°)' },
min: 0,
max: 360,
tickPositions: [0, 90, 180, 270, 360]
},
legend: { enabled: true },
series: [{
name: 'Windrichtung',
data: data.map((d, i) => [timestamps[i], d.windDir || 0]),
color: '#f39c12',
lineWidth: 2
}],
credits: { enabled: false }
});
}
// Initiales Laden
loadData();
// Auto-Refresh alle 5 Minuten
setInterval(loadData, 5 * 60 * 1000);

35
views/index.pug Normal file
View File

@@ -0,0 +1,35 @@
doctype html
html(lang="de")
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title Wetterstation
link(rel="stylesheet", href="/static/css/style.css")
script(src="https://code.highcharts.com/highcharts.js")
script(src="https://code.highcharts.com/modules/exporting.js")
script(src="https://code.highcharts.com/modules/export-data.js")
body
.container
h1 🌤️ Wetterstation Dashboard
.tabs
button.tab.active(onclick="switchPeriod('day')") Tag (24h)
button.tab(onclick="switchPeriod('week')") Woche (7 Tage)
#loading.loading Lade Daten...
#charts.charts-grid(style="display: none;")
.chart-container
#temp-chart
.chart-container
#humidity-chart
.chart-container
#pressure-chart
.chart-container
#rain-chart
.chart-container
#wind-speed-chart
.chart-container
#wind-dir-chart
script(src="/static/js/app.js")