Compare commits
7 Commits
d6b718a8ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0285fde580 | |||
| 511cc31dc0 | |||
| c3614ebab0 | |||
| c6c3f4cf37 | |||
| cff7f80463 | |||
| 5f516f5dd4 | |||
| 2907f5de18 |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# MQTT Broker Konfiguration
|
||||||
|
MQTT_HOST=rexfue.de
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_TOPIC=vantage/live
|
||||||
|
MQTT_USER=your_mqtt_username
|
||||||
|
MQTT_PASSWORD=your_mqtt_password
|
||||||
|
|
||||||
|
# Datenbank
|
||||||
|
DB_FILE=wetterdaten.db
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ env/
|
|||||||
# Database
|
# Database
|
||||||
wetterdaten.db
|
wetterdaten.db
|
||||||
*.db
|
*.db
|
||||||
|
*.sdb
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
129
DOCKER_README.md
Normal file
129
DOCKER_README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Wetterstation Docker Setup
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
Die Wetterstation besteht aus zwei unabhängigen Services:
|
||||||
|
|
||||||
|
1. **Ingestion Service** (Port 5004): Empfängt POST-Daten und schreibt in die Datenbank
|
||||||
|
2. **Web Service** (Port 5003): Stellt das Web-Interface und Lese-APIs bereit
|
||||||
|
|
||||||
|
Beide Services teilen sich eine gemeinsame SQLite-Datenbank via Volume.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Docker und Docker Compose installiert
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. `.env` Datei erstellen (optional)
|
||||||
|
Erstelle eine `.env` Datei für benutzerdefinierte Konfiguration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_FILE=wetterdaten.db
|
||||||
|
HTTP_PORT=5003
|
||||||
|
INGESTION_PORT=5004
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Container starten
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Services laufen dann unter:
|
||||||
|
- Web-Interface: `http://localhost:5003`
|
||||||
|
- Ingestion API: `http://localhost:5004/api/data/upload`
|
||||||
|
|
||||||
|
### 3. Container verwalten
|
||||||
|
```bash
|
||||||
|
# Logs anschauen (beide Services)
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Logs nur Ingestion Service
|
||||||
|
docker-compose logs -f wetterstation-ingestion
|
||||||
|
|
||||||
|
# Logs nur Web Service
|
||||||
|
docker-compose logs -f wetterstation-web
|
||||||
|
|
||||||
|
# Container stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Container neustarten
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Nur Ingestion Service neustarten
|
||||||
|
docker-compose restart wetterstation-ingestion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenverwaltung
|
||||||
|
Die SQLite-Datenbank (`wetterdaten.db`) wird als Volume persistiert und bleibt erhalten, auch wenn die Container gelöscht werden.
|
||||||
|
|
||||||
|
## Services im Detail
|
||||||
|
|
||||||
|
### Ingestion Service
|
||||||
|
- **Port**: 5004
|
||||||
|
- **Endpoints**:
|
||||||
|
- `POST /api/data/upload` - Empfängt Wetterdaten
|
||||||
|
- `GET /health` - Health-Check
|
||||||
|
- **Zweck**: Schreibt Daten in die Datenbank
|
||||||
|
|
||||||
|
### Web Service
|
||||||
|
- **Port**: 5003
|
||||||
|
- **Endpoints**:
|
||||||
|
- `GET /` - Web-Interface
|
||||||
|
- `GET /api/data/day` - Daten der letzten 24h
|
||||||
|
- `GET /api/data/week` - Daten der letzten Woche
|
||||||
|
- `GET /health` - Health-Check
|
||||||
|
- **Zweck**: Visualisierung und Datenabruf
|
||||||
|
|
||||||
|
## Externe Zugriffe
|
||||||
|
|
||||||
|
### Daten senden (von außen)
|
||||||
|
```bash
|
||||||
|
curl -X POST http://your-server-ip:5004/api/data/upload \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"dateTime": "2026-01-27 12:00:00",
|
||||||
|
"barometer": 1013.2,
|
||||||
|
"outTemp": 5.6,
|
||||||
|
"outHumidity": 72,
|
||||||
|
"windSpeed": 3.2,
|
||||||
|
"windDir": 180,
|
||||||
|
"windGust": 5.0,
|
||||||
|
"rainRate": 0.0,
|
||||||
|
"rain": 0.0
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web-Interface aufrufen
|
||||||
|
Öffne im Browser: `http://your-server-ip:5003`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Datenbank-Fehler
|
||||||
|
Falls die Datenbank beschädigt ist:
|
||||||
|
```bash
|
||||||
|
rm wetterdaten.db
|
||||||
|
docker-compose restart wetterstation-ingestion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container neu bauen
|
||||||
|
Nach Code-Änderungen:
|
||||||
|
```bash
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port-Konflikte
|
||||||
|
Falls Ports bereits belegt sind, passe die Ports in `docker-compose.yml` an:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "NEUER_PORT:5003" # für Web Service
|
||||||
|
- "NEUER_PORT:5004" # für Ingestion Service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health-Checks
|
||||||
|
Überprüfe, ob Services laufen:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5003/health
|
||||||
|
curl http://localhost:5004/health
|
||||||
|
```
|
||||||
|
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Multi-stage build: Leichtgewichtiger Container
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Setze Arbeitsverzeichnis
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installiere Dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopiere die Anwendung
|
||||||
|
COPY wetterstation.py .
|
||||||
|
COPY static/ static/
|
||||||
|
COPY templates/ templates/
|
||||||
|
|
||||||
|
# Exponiere Port
|
||||||
|
EXPOSE 5003
|
||||||
|
|
||||||
|
# Starten Sie die Anwendung
|
||||||
|
CMD ["python", "wetterstation.py"]
|
||||||
18
Dockerfile.ingestion
Normal file
18
Dockerfile.ingestion
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Multi-stage build: Leichtgewichtiger Container für Ingestion Service
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Setze Arbeitsverzeichnis
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installiere Dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopiere die Anwendung
|
||||||
|
COPY wetterstation_ingestion.py .
|
||||||
|
|
||||||
|
# Exponiere Port
|
||||||
|
EXPOSE 5004
|
||||||
|
|
||||||
|
# Starten Sie die Anwendung
|
||||||
|
CMD ["python", "wetterstation_ingestion.py"]
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
services:
|
||||||
|
# Ingestion Service - empfängt POST-Daten
|
||||||
|
wetterstation-ingestion:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.ingestion
|
||||||
|
container_name: wetterstation-ingestion
|
||||||
|
ports:
|
||||||
|
- "5004:5004"
|
||||||
|
volumes:
|
||||||
|
- ./wetterdaten.db:/app/wetterdaten.db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- INGESTION_PORT=5004
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- wetterstation_network
|
||||||
|
|
||||||
|
# Web Service - stellt UI und Lese-APIs bereit
|
||||||
|
wetterstation-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: wetterstation-web
|
||||||
|
ports:
|
||||||
|
- "5003:5003"
|
||||||
|
volumes:
|
||||||
|
- ./wetterdaten.db:/app/wetterdaten.db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- wetterstation_network
|
||||||
|
depends_on:
|
||||||
|
- wetterstation-ingestion
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wetterstation_network:
|
||||||
|
driver: bridge
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
flask==3.0.0
|
flask==3.0.0
|
||||||
paho-mqtt==1.6.1
|
python-dotenv==1.0.0
|
||||||
89
static/css/style.css
Normal file
89
static/css/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
static/js/app.js
Normal file
225
static/js/app.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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 Timestamps in Millisekunden
|
||||||
|
const timestamps = data.map(d => d.dateTime)
|
||||||
|
const rainTimestamps = rainData.map(d => new Date(d.hour).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 (Fahrenheit -> Celsius umrechnen)
|
||||||
|
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,
|
||||||
|
gridLineWidth: 1
|
||||||
|
},
|
||||||
|
yAxis: { title: { text: '°C' } },
|
||||||
|
legend: { enabled: true },
|
||||||
|
series: [{
|
||||||
|
name: 'Temperatur',
|
||||||
|
data: data.map((d, i) => [d.dateTime*1000, (d.outTemp - 32) * 5/9]),
|
||||||
|
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,
|
||||||
|
gridLineWidth: 1
|
||||||
|
},
|
||||||
|
yAxis: { title: { text: '%' } },
|
||||||
|
legend: { enabled: true },
|
||||||
|
series: [{
|
||||||
|
name: 'Luftfeuchtigkeit',
|
||||||
|
data: data.map((d, i) => [d.dateTime*1000, d.outHumidity]),
|
||||||
|
color: '#4ecdc4',
|
||||||
|
lineWidth: 2
|
||||||
|
}],
|
||||||
|
credits: { enabled: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Luftdruck (inHg -> hPa umrechnen)
|
||||||
|
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,
|
||||||
|
gridLineWidth: 1
|
||||||
|
},
|
||||||
|
yAxis: { title: { text: 'hPa' } },
|
||||||
|
legend: { enabled: true },
|
||||||
|
series: [{
|
||||||
|
name: 'Luftdruck',
|
||||||
|
data: data.map((d, i) => [d.dateTime*1000, d.barometer * 33.8639]),
|
||||||
|
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,
|
||||||
|
gridLineWidth: 1
|
||||||
|
},
|
||||||
|
yAxis: { title: { text: 'mm' } },
|
||||||
|
legend: { enabled: false },
|
||||||
|
series: [{
|
||||||
|
name: 'Regen',
|
||||||
|
data: rainData.map((d, i) => [d.dateTime*1000, d.rain]),
|
||||||
|
color: '#3498db'
|
||||||
|
}],
|
||||||
|
credits: { enabled: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Windgeschwindigkeit (mph -> km/h umrechnen)
|
||||||
|
Highcharts.chart('wind-speed-chart', {
|
||||||
|
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
|
||||||
|
title: { text: '💨 Windgeschwindigkeit (km/h)' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'datetime',
|
||||||
|
title: { text: 'Zeit' },
|
||||||
|
labels: { format: '{value:%H}' },
|
||||||
|
tickInterval: FOUR_HOURS,
|
||||||
|
gridLineWidth: 1
|
||||||
|
},
|
||||||
|
yAxis: { title: { text: 'km/h' } },
|
||||||
|
legend: { enabled: true },
|
||||||
|
series: [{
|
||||||
|
name: 'Windgeschwindigkeit',
|
||||||
|
data: data.map((d, i) => [d.dateTime*1000, d.windSpeed * 1.60934]),
|
||||||
|
color: 'blue',
|
||||||
|
lineWidth: 2
|
||||||
|
}, {
|
||||||
|
name: 'Böen',
|
||||||
|
data: data.map((d, i) => [d.dateTime*1000, d.windGust * 1.60934]),
|
||||||
|
color: 'red',
|
||||||
|
lineWidth: 2,
|
||||||
|
}],
|
||||||
|
credits: { enabled: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Windrichtung
|
||||||
|
Highcharts.chart('wind-dir-chart', {
|
||||||
|
chart: { type: 'scatter', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
|
||||||
|
title: { text: '🧭 Windrichtung (°)' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'datetime',
|
||||||
|
title: { text: 'Zeit' },
|
||||||
|
labels: { format: '{value:%H}' },
|
||||||
|
tickInterval: FOUR_HOURS,
|
||||||
|
gridLineWidth: 1
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
title: { text: 'Richtung (°)' },
|
||||||
|
min: 0,
|
||||||
|
max: 360,
|
||||||
|
// tickPositions: [0, 90, 180, 270, 360]
|
||||||
|
tickInterval: 90,
|
||||||
|
labels: {
|
||||||
|
formatter: function() {
|
||||||
|
// Windrichtungen zuordnen
|
||||||
|
const directions = {
|
||||||
|
0: 'Nord',
|
||||||
|
90: 'Ost',
|
||||||
|
180: 'Süd',
|
||||||
|
270: 'West',
|
||||||
|
360: 'Nord'
|
||||||
|
};
|
||||||
|
return directions[this.value] || this.value + '°';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: { enabled: true },
|
||||||
|
series: [{
|
||||||
|
name: 'Windrichtung',
|
||||||
|
data: data.map((d, i) => [d.dateTime*1000, d.windDir || 0]),
|
||||||
|
color: '#f39c12',
|
||||||
|
marker: {
|
||||||
|
radius: 2
|
||||||
|
}
|
||||||
|
// lineWidth: 2
|
||||||
|
}],
|
||||||
|
credits: { enabled: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiales Laden
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
// Auto-Refresh alle 5 Minuten
|
||||||
|
setInterval(loadData, 5 * 60 * 1000);
|
||||||
191
tools/sqlite_copy.py
Normal file
191
tools/sqlite_copy.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite Datenkopier-Programm
|
||||||
|
Kopiert ausgewählte Spalten von einer SQLite-Datenbank in eine andere
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Zu kopierende Spalten
|
||||||
|
COLUMNS = [
|
||||||
|
'dateTime',
|
||||||
|
'barometer',
|
||||||
|
'outTemp',
|
||||||
|
'outHumidity',
|
||||||
|
'windSpeed',
|
||||||
|
'windDir',
|
||||||
|
'windGust',
|
||||||
|
'rainRate',
|
||||||
|
'rain'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime(timestamp):
|
||||||
|
"""Konvertiert Unix-Timestamp in lesbares Format"""
|
||||||
|
try:
|
||||||
|
if timestamp is None:
|
||||||
|
return 'NULL'
|
||||||
|
return datetime.fromtimestamp(int(timestamp)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return str(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_name(cursor):
|
||||||
|
"""Ermittelt den Namen der Quelltabelle"""
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
|
||||||
|
if not tables:
|
||||||
|
print("Fehler: Keine Tabellen in der Quelldatenbank gefunden!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if len(tables) == 1:
|
||||||
|
return tables[0][0]
|
||||||
|
|
||||||
|
print("\nVerfügbare Tabellen:")
|
||||||
|
for i, (table,) in enumerate(tables, 1):
|
||||||
|
print(f"{i}. {table}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = int(input("\nWählen Sie die Nummer der Quelltabelle: "))
|
||||||
|
if 1 <= choice <= len(tables):
|
||||||
|
return tables[choice - 1][0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
print("Ungültige Eingabe. Bitte erneut versuchen.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_columns(cursor, table_name, columns):
|
||||||
|
"""Prüft, welche Spalten in der Tabelle existieren"""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
available = [col for col in columns if col in existing_cols]
|
||||||
|
missing = [col for col in columns if col not in existing_cols]
|
||||||
|
|
||||||
|
return available, missing
|
||||||
|
|
||||||
|
|
||||||
|
def copy_data(source_db, target_db, source_table, target_table, columns):
|
||||||
|
"""Kopiert die Daten von der Quell- zur Zieldatenbank"""
|
||||||
|
|
||||||
|
# Verbindungen herstellen
|
||||||
|
conn_source = sqlite3.connect(source_db)
|
||||||
|
conn_target = sqlite3.connect(target_db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor_source = conn_source.cursor()
|
||||||
|
cursor_target = conn_target.cursor()
|
||||||
|
|
||||||
|
# Quelltabelle ermitteln
|
||||||
|
if source_table is None:
|
||||||
|
source_table = get_table_name(cursor_source)
|
||||||
|
|
||||||
|
print(f"\nQuelltabelle: {source_table}")
|
||||||
|
|
||||||
|
# Verfügbare Spalten prüfen
|
||||||
|
available_cols, missing_cols = check_columns(cursor_source, source_table, columns)
|
||||||
|
|
||||||
|
if missing_cols:
|
||||||
|
print(f"\nWarnung: Folgende Spalten existieren nicht in der Quelltabelle: {', '.join(missing_cols)}")
|
||||||
|
|
||||||
|
if not available_cols:
|
||||||
|
print("Fehler: Keine der gewünschten Spalten gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Zu kopierende Spalten: {', '.join(available_cols)}")
|
||||||
|
|
||||||
|
# Zieltabelle erstellen
|
||||||
|
columns_def = ', '.join([f"{col} REAL" if col != 'dateTime' else f"{col} INTEGER PRIMARY KEY"
|
||||||
|
for col in available_cols])
|
||||||
|
cursor_target.execute(f"CREATE TABLE IF NOT EXISTS {target_table} ({columns_def})")
|
||||||
|
|
||||||
|
# Daten zählen
|
||||||
|
cursor_source.execute(f"SELECT COUNT(*) FROM {source_table}")
|
||||||
|
total_rows = cursor_source.fetchone()[0]
|
||||||
|
print(f"\nGesamtanzahl Datensätze: {total_rows}")
|
||||||
|
|
||||||
|
# Vorschau der ersten Zeile
|
||||||
|
cursor_source.execute(f"SELECT {columns_str} FROM {source_table} LIMIT 1")
|
||||||
|
first_row = cursor_source.fetchone()
|
||||||
|
if first_row:
|
||||||
|
print("\nVorschau erste Zeile:")
|
||||||
|
for i, col in enumerate(available_cols):
|
||||||
|
value = first_row[i]
|
||||||
|
if col == 'dateTime':
|
||||||
|
print(f" {col}: {format_datetime(value)} (Timestamp: {value})")
|
||||||
|
else:
|
||||||
|
print(f" {col}: {value}")
|
||||||
|
|
||||||
|
# Daten kopieren
|
||||||
|
columns_str = ', '.join(available_cols)
|
||||||
|
placeholders = ', '.join(['?' for _ in available_cols])
|
||||||
|
|
||||||
|
cursor_source.execute(f"SELECT {columns_str} FROM {source_table}")
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
copied = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = cursor_source.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
cursor_target.executemany(
|
||||||
|
f"INSERT OR REPLACE INTO {target_table} ({columns_str}) VALUES ({placeholders})",
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
copied += len(rows)
|
||||||
|
print(f"Kopiert: {copied}/{total_rows} Datensätze...", end='\r')
|
||||||
|
|
||||||
|
conn_target.commit()
|
||||||
|
print(f"\n\n✓ Erfolgreich {copied} Datensätze kopiert!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nFehler beim Kopieren: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn_source.close()
|
||||||
|
conn_target.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("SQLite Datenkopier-Programm")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Eingaben
|
||||||
|
if len(sys.argv) >= 3:
|
||||||
|
source_db = sys.argv[1]
|
||||||
|
target_db = sys.argv[2]
|
||||||
|
source_table = sys.argv[3] if len(sys.argv) >= 4 else None
|
||||||
|
target_table = sys.argv[4] if len(sys.argv) >= 5 else 'weather_data'
|
||||||
|
else:
|
||||||
|
source_db = input("\nPfad zur Quelldatenbank: ").strip()
|
||||||
|
target_db = input("Pfad zur Zieldatenbank: ").strip()
|
||||||
|
source_table = input("Name der Quelltabelle (leer=automatisch ermitteln): ").strip() or None
|
||||||
|
target_table = input("Name der Zieltabelle [weather_data]: ").strip() or 'weather_data'
|
||||||
|
|
||||||
|
# Validierung
|
||||||
|
if not Path(source_db).exists():
|
||||||
|
print(f"\nFehler: Quelldatenbank '{source_db}' nicht gefunden!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Kopieren
|
||||||
|
success = copy_data(source_db, target_db, source_table, target_table, COLUMNS)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\nZieldatenbank: {target_db}")
|
||||||
|
print(f"Zieltabelle: {target_table}")
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
168
tools/sqlite_copy_1.py
Normal file
168
tools/sqlite_copy_1.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite Datenkopier-Programm
|
||||||
|
Kopiert ausgewählte Spalten von einer SQLite-Datenbank in eine andere
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Zu kopierende Spalten
|
||||||
|
COLUMNS = [
|
||||||
|
'dateTime',
|
||||||
|
'barometer',
|
||||||
|
'outTemp',
|
||||||
|
'outHumidity',
|
||||||
|
'windSpeed',
|
||||||
|
'windDir',
|
||||||
|
'windGust',
|
||||||
|
'rainRate',
|
||||||
|
'rain'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_name(cursor):
|
||||||
|
"""Ermittelt den Namen der Quelltabelle"""
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
|
||||||
|
if not tables:
|
||||||
|
print("Fehler: Keine Tabellen in der Quelldatenbank gefunden!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if len(tables) == 1:
|
||||||
|
return tables[0][0]
|
||||||
|
|
||||||
|
print("\nVerfügbare Tabellen:")
|
||||||
|
for i, (table,) in enumerate(tables, 1):
|
||||||
|
print(f"{i}. {table}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = int(input("\nWählen Sie die Nummer der Quelltabelle: "))
|
||||||
|
if 1 <= choice <= len(tables):
|
||||||
|
return tables[choice - 1][0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
print("Ungültige Eingabe. Bitte erneut versuchen.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_columns(cursor, table_name, columns):
|
||||||
|
"""Prüft, welche Spalten in der Tabelle existieren"""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
available = [col for col in columns if col in existing_cols]
|
||||||
|
missing = [col for col in columns if col not in existing_cols]
|
||||||
|
|
||||||
|
return available, missing
|
||||||
|
|
||||||
|
|
||||||
|
def copy_data(source_db, target_db, source_table, target_table, columns):
|
||||||
|
"""Kopiert die Daten von der Quell- zur Zieldatenbank"""
|
||||||
|
|
||||||
|
# Verbindungen herstellen
|
||||||
|
conn_source = sqlite3.connect(source_db)
|
||||||
|
conn_target = sqlite3.connect(target_db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor_source = conn_source.cursor()
|
||||||
|
cursor_target = conn_target.cursor()
|
||||||
|
|
||||||
|
# Quelltabelle ermitteln
|
||||||
|
if source_table is None:
|
||||||
|
source_table = get_table_name(cursor_source)
|
||||||
|
|
||||||
|
print(f"\nQuelltabelle: {source_table}")
|
||||||
|
|
||||||
|
# Verfügbare Spalten prüfen
|
||||||
|
available_cols, missing_cols = check_columns(cursor_source, source_table, columns)
|
||||||
|
|
||||||
|
if missing_cols:
|
||||||
|
print(f"\nWarnung: Folgende Spalten existieren nicht in der Quelltabelle: {', '.join(missing_cols)}")
|
||||||
|
|
||||||
|
if not available_cols:
|
||||||
|
print("Fehler: Keine der gewünschten Spalten gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Zu kopierende Spalten: {', '.join(available_cols)}")
|
||||||
|
|
||||||
|
# Zieltabelle erstellen
|
||||||
|
columns_def = ', '.join([f"{col} REAL" if col != 'dateTime' else f"{col} INTEGER PRIMARY KEY"
|
||||||
|
for col in available_cols])
|
||||||
|
cursor_target.execute(f"CREATE TABLE IF NOT EXISTS {target_table} ({columns_def})")
|
||||||
|
|
||||||
|
# Daten zählen
|
||||||
|
cursor_source.execute(f"SELECT COUNT(*) FROM {source_table}")
|
||||||
|
total_rows = cursor_source.fetchone()[0]
|
||||||
|
print(f"\nGesamtanzahl Datensätze: {total_rows}")
|
||||||
|
|
||||||
|
# Daten kopieren
|
||||||
|
columns_str = ', '.join(available_cols)
|
||||||
|
placeholders = ', '.join(['?' for _ in available_cols])
|
||||||
|
|
||||||
|
cursor_source.execute(f"SELECT {columns_str} FROM {source_table}")
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
copied = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = cursor_source.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
cursor_target.executemany(
|
||||||
|
f"INSERT OR REPLACE INTO {target_table} ({columns_str}) VALUES ({placeholders})",
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
copied += len(rows)
|
||||||
|
print(f"Kopiert: {copied}/{total_rows} Datensätze...", end='\r')
|
||||||
|
|
||||||
|
conn_target.commit()
|
||||||
|
print(f"\n\n✓ Erfolgreich {copied} Datensätze kopiert!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nFehler beim Kopieren: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn_source.close()
|
||||||
|
conn_target.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("SQLite Datenkopier-Programm")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Eingaben
|
||||||
|
if len(sys.argv) >= 3:
|
||||||
|
source_db = sys.argv[1]
|
||||||
|
target_db = sys.argv[2]
|
||||||
|
source_table = sys.argv[3] if len(sys.argv) >= 4 else None
|
||||||
|
target_table = sys.argv[4] if len(sys.argv) >= 5 else 'weather_data'
|
||||||
|
else:
|
||||||
|
source_db = input("\nPfad zur Quelldatenbank: ").strip()
|
||||||
|
target_db = input("Pfad zur Zieldatenbank: ").strip()
|
||||||
|
source_table = input("Name der Quelltabelle (leer=automatisch ermitteln): ").strip() or None
|
||||||
|
target_table = input("Name der Zieltabelle [weather_data]: ").strip() or 'weather_data'
|
||||||
|
|
||||||
|
# Validierung
|
||||||
|
if not Path(source_db).exists():
|
||||||
|
print(f"\nFehler: Quelldatenbank '{source_db}' nicht gefunden!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Kopieren
|
||||||
|
success = copy_data(source_db, target_db, source_table, target_table, COLUMNS)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\nZieldatenbank: {target_db}")
|
||||||
|
print(f"Zieltabelle: {target_table}")
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
193
tools/sqlite_query.py
Normal file
193
tools/sqlite_query.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite Query Tool mit formatierter DateTime-Ausgabe
|
||||||
|
Zeigt dateTime als 'YYYY-MM-DD HH:MM:SS' statt Unix-Timestamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime(timestamp):
|
||||||
|
"""Konvertiert Unix-Timestamp in lesbares Format"""
|
||||||
|
try:
|
||||||
|
if timestamp is None:
|
||||||
|
return 'NULL'
|
||||||
|
return datetime.fromtimestamp(int(timestamp)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return str(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def format_value(value):
|
||||||
|
"""Formatiert Werte für die Ausgabe"""
|
||||||
|
if value is None:
|
||||||
|
return 'NULL'
|
||||||
|
if isinstance(value, float):
|
||||||
|
return f'{value:.2f}'
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_query(db_path, query=None, limit=None):
|
||||||
|
"""Führt eine SELECT-Abfrage aus und zeigt Ergebnisse formatiert an"""
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Fehler: Datenbank '{db_path}' nicht gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Wenn keine Query angegeben, zeige verfügbare Tabellen
|
||||||
|
if not query:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
|
||||||
|
if not tables:
|
||||||
|
print("Keine Tabellen gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\nVerfügbare Tabellen:")
|
||||||
|
for table, in tables:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
print(f" - {table} ({count} Datensätze)")
|
||||||
|
|
||||||
|
# Erste Tabelle als Standard verwenden
|
||||||
|
default_table = tables[0][0]
|
||||||
|
query = f"SELECT * FROM {default_table}"
|
||||||
|
if limit:
|
||||||
|
query += f" LIMIT {limit}"
|
||||||
|
else:
|
||||||
|
query += " LIMIT 10"
|
||||||
|
|
||||||
|
print(f"\nStandard-Abfrage: {query}\n")
|
||||||
|
|
||||||
|
# Query ausführen
|
||||||
|
cursor.execute(query)
|
||||||
|
|
||||||
|
# Spaltennamen holen
|
||||||
|
columns = [description[0] for description in cursor.description]
|
||||||
|
|
||||||
|
# Prüfen, ob dateTime-Spalte vorhanden ist
|
||||||
|
datetime_index = None
|
||||||
|
if 'dateTime' in columns:
|
||||||
|
datetime_index = columns.index('dateTime')
|
||||||
|
|
||||||
|
# Daten abrufen
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print("Keine Ergebnisse gefunden.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f"Anzahl Ergebnisse: {len(rows)}\n")
|
||||||
|
|
||||||
|
# Spaltenbreiten berechnen
|
||||||
|
col_widths = []
|
||||||
|
for i, col in enumerate(columns):
|
||||||
|
if i == datetime_index:
|
||||||
|
max_width = max(len(col), 19) # 'YYYY-MM-DD HH:MM:SS' = 19 Zeichen
|
||||||
|
else:
|
||||||
|
max_width = len(col)
|
||||||
|
for row in rows[:100]: # Nur erste 100 Zeilen für Breitenberechnung
|
||||||
|
max_width = max(max_width, len(format_value(row[i])))
|
||||||
|
col_widths.append(min(max_width, 30)) # Max 30 Zeichen pro Spalte
|
||||||
|
|
||||||
|
# Header ausgeben
|
||||||
|
header = ' | '.join(col.ljust(col_widths[i]) for i, col in enumerate(columns))
|
||||||
|
print(header)
|
||||||
|
print('-' * len(header))
|
||||||
|
|
||||||
|
# Daten ausgeben
|
||||||
|
for row in rows:
|
||||||
|
formatted_row = []
|
||||||
|
for i, value in enumerate(row):
|
||||||
|
if i == datetime_index:
|
||||||
|
formatted_row.append(format_datetime(value).ljust(col_widths[i]))
|
||||||
|
else:
|
||||||
|
formatted_row.append(format_value(value).ljust(col_widths[i]))
|
||||||
|
print(' | '.join(formatted_row))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Datenbankfehler: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_mode(db_path):
|
||||||
|
"""Interaktiver Modus für mehrere Abfragen"""
|
||||||
|
print("=" * 70)
|
||||||
|
print("SQLite Query Tool - Interaktiver Modus")
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nTipps:")
|
||||||
|
print(" - Geben Sie SQL-Abfragen direkt ein")
|
||||||
|
print(" - Drücken Sie Enter ohne Eingabe für Standard-Abfrage (erste 10 Zeilen)")
|
||||||
|
print(" - Geben Sie 'exit' oder 'quit' zum Beenden ein")
|
||||||
|
print(" - dateTime wird automatisch als 'YYYY-MM-DD HH:MM:SS' angezeigt\n")
|
||||||
|
|
||||||
|
execute_query(db_path) # Zeige verfügbare Tabellen und Standard-Abfrage
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
query = input("\nSQL> ").strip()
|
||||||
|
|
||||||
|
if query.lower() in ('exit', 'quit', 'q'):
|
||||||
|
print("Auf Wiedersehen!")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
# Standard-Abfrage
|
||||||
|
execute_query(db_path)
|
||||||
|
else:
|
||||||
|
execute_query(db_path, query)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nUnterbrochen. Auf Wiedersehen!")
|
||||||
|
break
|
||||||
|
except EOFError:
|
||||||
|
print("\nAuf Wiedersehen!")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Verwendung:")
|
||||||
|
print(" python sqlite_query.py <datenbank.db> # Interaktiver Modus")
|
||||||
|
print(" python sqlite_query.py <datenbank.db> '<SQL-Query>' # Einzelne Abfrage")
|
||||||
|
print(" python sqlite_query.py <datenbank.db> <tabelle> [limit] # Tabelle anzeigen")
|
||||||
|
print("\nBeispiele:")
|
||||||
|
print(" python sqlite_query.py wetter.db")
|
||||||
|
print(" python sqlite_query.py wetter.db 'SELECT * FROM weather_data WHERE outTemp > 20'")
|
||||||
|
print(" python sqlite_query.py wetter.db weather_data 50")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if len(sys.argv) == 2:
|
||||||
|
# Interaktiver Modus
|
||||||
|
interactive_mode(db_path)
|
||||||
|
elif len(sys.argv) == 3:
|
||||||
|
# Einzelne Query oder Tabellenname
|
||||||
|
arg = sys.argv[2]
|
||||||
|
if arg.upper().startswith('SELECT') or arg.upper().startswith('WITH'):
|
||||||
|
execute_query(db_path, arg)
|
||||||
|
else:
|
||||||
|
# Als Tabellenname interpretieren
|
||||||
|
execute_query(db_path, f"SELECT * FROM {arg} LIMIT 10")
|
||||||
|
elif len(sys.argv) == 4:
|
||||||
|
# Tabelle mit Limit
|
||||||
|
table = sys.argv[2]
|
||||||
|
limit = sys.argv[3]
|
||||||
|
execute_query(db_path, f"SELECT * FROM {table} LIMIT {limit}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
520
wetterstation.py
520
wetterstation.py
@@ -1,81 +1,31 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Wetterstation - MQTT Datenempfang und Web-Visualisierung
|
Wetterstation Web-Interface - Visualisierung und API
|
||||||
|
Stellt das Web-Interface und Lese-APIs für historische Daten bereit
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
import threading
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Flask, render_template, jsonify
|
from flask import Flask, render_template, jsonify
|
||||||
import paho.mqtt.client as mqtt
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Konfiguration
|
# Lade Umgebungsvariablen aus .env Datei
|
||||||
MQTT_HOST = "rexfue.de"
|
load_dotenv()
|
||||||
MQTT_PORT = 1883
|
|
||||||
MQTT_TOPIC = "vantage/live" # Bitte anpassen!
|
|
||||||
MQTT_USER = "stzuhr" # Bitte anpassen!
|
|
||||||
MQTT_PASSWORD = "74chQCYb" # Bitte anpassen!
|
|
||||||
|
|
||||||
DB_FILE = "wetterdaten.db"
|
# Konfiguration aus Umgebungsvariablen
|
||||||
|
DB_FILE = os.getenv("DB_FILE", "wetterdaten.db")
|
||||||
|
HTTP_PORT = int(os.getenv("HTTP_PORT", 5003))
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WetterDB:
|
class WetterDB:
|
||||||
"""Klasse für Datenbankoperationen"""
|
"""Klasse für Datenbankoperationen (nur Lesezugriff)"""
|
||||||
|
|
||||||
def __init__(self, db_file):
|
def __init__(self, db_file):
|
||||||
self.db_file = db_file
|
self.db_file = db_file
|
||||||
self.init_db()
|
|
||||||
|
|
||||||
def init_db(self):
|
|
||||||
"""Datenbank initialisieren"""
|
|
||||||
conn = sqlite3.connect(self.db_file)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS wetterdaten (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
datetime TEXT NOT NULL,
|
|
||||||
pressure REAL,
|
|
||||||
wind_gust REAL,
|
|
||||||
wind_speed REAL,
|
|
||||||
wind_dir REAL,
|
|
||||||
rain_rate REAL,
|
|
||||||
rain REAL,
|
|
||||||
humidity INTEGER,
|
|
||||||
temperature REAL
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_datetime ON wetterdaten(datetime)
|
|
||||||
''')
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def save_data(self, data):
|
|
||||||
"""Wetterdaten speichern"""
|
|
||||||
conn = sqlite3.connect(self.db_file)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT INTO wetterdaten
|
|
||||||
(datetime, pressure, wind_gust, wind_speed, wind_dir,
|
|
||||||
rain_rate, rain, humidity, temperature)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (
|
|
||||||
data['datetime'],
|
|
||||||
data.get('pressure'),
|
|
||||||
data.get('wind_gust'),
|
|
||||||
data.get('wind_speed'),
|
|
||||||
data.get('wind_dir'),
|
|
||||||
data.get('rain_rate'),
|
|
||||||
data.get('rain'),
|
|
||||||
data.get('humidity'),
|
|
||||||
data.get('temperature')
|
|
||||||
))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print(f"Daten gespeichert: {data['datetime']}")
|
|
||||||
|
|
||||||
def get_data(self, hours=24):
|
def get_data(self, hours=24):
|
||||||
"""Daten der letzten X Stunden abrufen"""
|
"""Daten der letzten X Stunden abrufen"""
|
||||||
@@ -83,12 +33,12 @@ class WetterDB:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
time_threshold = (datetime.now() - timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S')
|
time_threshold = int((datetime.now() - timedelta(hours=hours)).timestamp())
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT * FROM wetterdaten
|
SELECT * FROM wetterdaten
|
||||||
WHERE datetime >= ?
|
WHERE dateTime >= ?
|
||||||
ORDER BY datetime ASC
|
ORDER BY dateTime ASC
|
||||||
''', (time_threshold,))
|
''', (time_threshold,))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
@@ -101,14 +51,14 @@ class WetterDB:
|
|||||||
conn = sqlite3.connect(self.db_file)
|
conn = sqlite3.connect(self.db_file)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
time_threshold = (datetime.now() - timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S')
|
time_threshold = int((datetime.now() - timedelta(hours=hours)).timestamp())
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT
|
SELECT
|
||||||
strftime('%Y-%m-%d %H:00:00', datetime) as hour,
|
strftime('%Y-%m-%d %H:00:00', datetime(dateTime, 'unixepoch', 'localtime')) as hour,
|
||||||
SUM(rain_rate) as total_rain
|
SUM(rainRate) as total_rain
|
||||||
FROM wetterdaten
|
FROM wetterdaten
|
||||||
WHERE datetime >= ?
|
WHERE dateTime >= ?
|
||||||
GROUP BY hour
|
GROUP BY hour
|
||||||
ORDER BY hour ASC
|
ORDER BY hour ASC
|
||||||
''', (time_threshold,))
|
''', (time_threshold,))
|
||||||
@@ -123,104 +73,6 @@ class WetterDB:
|
|||||||
db = WetterDB(DB_FILE)
|
db = WetterDB(DB_FILE)
|
||||||
|
|
||||||
|
|
||||||
class MQTTClient:
|
|
||||||
"""MQTT Client für Datenempfang"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.client = mqtt.Client()
|
|
||||||
self.client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
|
|
||||||
# Stabilere Verbindungen bei Abbrüchen
|
|
||||||
try:
|
|
||||||
self.client.reconnect_delay_set(min_delay=1, max_delay=120)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Optionale Protokollierung (hilfreich beim Debuggen)
|
|
||||||
try:
|
|
||||||
self.client.enable_logger()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.client.on_connect = self.on_connect
|
|
||||||
self.client.on_message = self.on_message
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _sanitize_data(payload: dict) -> dict:
|
|
||||||
"""Payload robust in erwartetes Format wandeln.
|
|
||||||
- Fehlende `datetime` wird mit aktueller Zeit ergänzt
|
|
||||||
- Felder in richtige Typen konvertieren
|
|
||||||
"""
|
|
||||||
def to_float(x):
|
|
||||||
try:
|
|
||||||
return float(x) if x is not None else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def to_int(x):
|
|
||||||
try:
|
|
||||||
return int(x) if x is not None else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Zeitstempel: wenn vorhanden, in akzeptables Format bringen, sonst jetzt
|
|
||||||
dt = payload.get('datetime')
|
|
||||||
if isinstance(dt, (int, float)):
|
|
||||||
# Epoch -> ISO
|
|
||||||
dt = datetime.fromtimestamp(dt).strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
elif isinstance(dt, str):
|
|
||||||
# Versuchen, ISO-Varianten in 'YYYY-MM-DD HH:MM:SS' zu überführen
|
|
||||||
# Entferne ggf. 'T' oder 'Z'
|
|
||||||
dt_clean = dt.replace('T', ' ').replace('Z', '').strip()
|
|
||||||
# Falls Millisekunden enthalten, abschneiden
|
|
||||||
if '.' in dt_clean:
|
|
||||||
dt_clean = dt_clean.split('.')[0]
|
|
||||||
# Bei zu kurzem String: fallback auf jetzt
|
|
||||||
if len(dt_clean) < 16:
|
|
||||||
dt = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
else:
|
|
||||||
dt = dt_clean
|
|
||||||
else:
|
|
||||||
dt = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'datetime': dt,
|
|
||||||
'pressure': to_float(payload.get('pressure')),
|
|
||||||
'wind_gust': to_float(payload.get('wind_gust')),
|
|
||||||
'wind_speed': to_float(payload.get('wind_speed')),
|
|
||||||
'wind_dir': to_float(payload.get('wind_dir')),
|
|
||||||
'rain_rate': to_float(payload.get('rain_rate')),
|
|
||||||
'rain': to_float(payload.get('rain')),
|
|
||||||
'humidity': to_int(payload.get('humidity')),
|
|
||||||
'temperature': to_float(payload.get('temperature')),
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_connect(self, client, userdata, flags, rc):
|
|
||||||
"""Callback bei Verbindung"""
|
|
||||||
if rc == 0:
|
|
||||||
print(f"Mit MQTT Broker verbunden: {MQTT_HOST}")
|
|
||||||
client.subscribe(MQTT_TOPIC)
|
|
||||||
print(f"Topic abonniert: {MQTT_TOPIC}")
|
|
||||||
else:
|
|
||||||
print(f"Verbindungsfehler: {rc}")
|
|
||||||
|
|
||||||
def on_message(self, client, userdata, msg):
|
|
||||||
"""Callback bei empfangener Nachricht"""
|
|
||||||
try:
|
|
||||||
raw = json.loads(msg.payload.decode())
|
|
||||||
data = self._sanitize_data(raw)
|
|
||||||
print(f"Empfangen und gespeichert: {data}")
|
|
||||||
db.save_data(data)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Fehler beim Verarbeiten der Nachricht: {e}")
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""MQTT Client starten"""
|
|
||||||
try:
|
|
||||||
self.client.connect(MQTT_HOST, MQTT_PORT, 60)
|
|
||||||
self.client.loop_start()
|
|
||||||
print("MQTT Client gestartet")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"MQTT Verbindungsfehler: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Flask Routes
|
# Flask Routes
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
@@ -228,9 +80,15 @@ def index():
|
|||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
"""Health-Check Endpoint"""
|
||||||
|
return jsonify({'status': 'ok', 'service': 'web'}), 200
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/data/<period>')
|
@app.route('/api/data/<period>')
|
||||||
def get_data(period):
|
def get_historical_data(period):
|
||||||
"""API Endpoint für Wetterdaten"""
|
"""API Endpoint für historische Wetterdaten"""
|
||||||
hours = 24 if period == 'day' else 168 # 168h = 1 Woche
|
hours = 24 if period == 'day' else 168 # 168h = 1 Woche
|
||||||
data = db.get_data(hours)
|
data = db.get_data(hours)
|
||||||
rain_data = db.get_hourly_rain(hours)
|
rain_data = db.get_hourly_rain(hours)
|
||||||
@@ -241,330 +99,16 @@ def get_data(period):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def create_html_template():
|
|
||||||
"""HTML Template erstellen"""
|
|
||||||
import os
|
|
||||||
os.makedirs('templates', exist_ok=True)
|
|
||||||
|
|
||||||
html_content = '''<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Wetterstation</title>
|
|
||||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🌤️ Wetterstation Dashboard</h1>
|
|
||||||
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab active" onclick="switchPeriod('day')">Tag (24h)</button>
|
|
||||||
<button class="tab" onclick="switchPeriod('week')">Woche (7 Tage)</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="loading" id="loading">Lade Daten...</div>
|
|
||||||
|
|
||||||
<div class="charts-grid" id="charts" style="display: none;">
|
|
||||||
<div class="chart-container">
|
|
||||||
<div id="temp-chart"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<div id="humidity-chart"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<div id="pressure-chart"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<div id="rain-chart"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<div id="wind-speed-chart"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<div id="wind-dir-chart"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentPeriod = 'day';
|
|
||||||
const DEFAULT_CHART_HEIGHT = 360;
|
|
||||||
const POLAR_CHART_HEIGHT = 420;
|
|
||||||
const plotConfig = { responsive: true, displayModeBar: false };
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const timestamps = data.map(d => d.datetime);
|
|
||||||
|
|
||||||
// Temperatur
|
|
||||||
Plotly.newPlot('temp-chart', [{
|
|
||||||
x: timestamps,
|
|
||||||
y: data.map(d => d.temperature),
|
|
||||||
type: 'scatter',
|
|
||||||
mode: 'lines',
|
|
||||||
name: 'Temperatur',
|
|
||||||
line: {color: '#ff6b6b', width: 3}
|
|
||||||
}], {
|
|
||||||
title: '🌡️ Temperatur (°C)',
|
|
||||||
xaxis: {title: 'Zeit'},
|
|
||||||
yaxis: {title: '°C'},
|
|
||||||
margin: {t: 50, b: 60, l: 60, r: 30, pad: 0},
|
|
||||||
legend: {orientation: 'h', y: -0.2},
|
|
||||||
height: DEFAULT_CHART_HEIGHT
|
|
||||||
}, plotConfig);
|
|
||||||
|
|
||||||
// Luftfeuchtigkeit
|
|
||||||
Plotly.newPlot('humidity-chart', [{
|
|
||||||
x: timestamps,
|
|
||||||
y: data.map(d => d.humidity),
|
|
||||||
type: 'scatter',
|
|
||||||
mode: 'lines',
|
|
||||||
name: 'Luftfeuchtigkeit',
|
|
||||||
line: {color: '#4ecdc4', width: 3}
|
|
||||||
}], {
|
|
||||||
title: '💧 Luftfeuchtigkeit (%)',
|
|
||||||
xaxis: {title: 'Zeit'},
|
|
||||||
yaxis: {title: '%'},
|
|
||||||
margin: {t: 50, b: 60, l: 60, r: 30, pad: 0},
|
|
||||||
legend: {orientation: 'h', y: -0.2},
|
|
||||||
height: DEFAULT_CHART_HEIGHT
|
|
||||||
}, plotConfig);
|
|
||||||
|
|
||||||
// Luftdruck
|
|
||||||
Plotly.newPlot('pressure-chart', [{
|
|
||||||
x: timestamps,
|
|
||||||
y: data.map(d => d.pressure),
|
|
||||||
type: 'scatter',
|
|
||||||
mode: 'lines',
|
|
||||||
name: 'Luftdruck',
|
|
||||||
line: {color: '#95e1d3', width: 3}
|
|
||||||
}], {
|
|
||||||
title: '🎈 Luftdruck (hPa)',
|
|
||||||
xaxis: {title: 'Zeit'},
|
|
||||||
yaxis: {title: 'hPa'},
|
|
||||||
margin: {t: 50, b: 60, l: 60, r: 30, pad: 0},
|
|
||||||
legend: {orientation: 'h', y: -0.2},
|
|
||||||
height: DEFAULT_CHART_HEIGHT
|
|
||||||
}, plotConfig);
|
|
||||||
|
|
||||||
// Regenmenge pro Stunde
|
|
||||||
Plotly.newPlot('rain-chart', [{
|
|
||||||
x: rainData.map(d => d.hour),
|
|
||||||
y: rainData.map(d => d.rain),
|
|
||||||
type: 'bar',
|
|
||||||
name: 'Regen',
|
|
||||||
marker: {color: '#3498db'}
|
|
||||||
}], {
|
|
||||||
title: '🌧️ Regenmenge pro Stunde (mm)',
|
|
||||||
xaxis: {title: 'Zeit'},
|
|
||||||
yaxis: {title: 'mm'},
|
|
||||||
margin: {t: 50, b: 60, l: 60, r: 30, pad: 0},
|
|
||||||
legend: {orientation: 'h', y: -0.2},
|
|
||||||
height: DEFAULT_CHART_HEIGHT
|
|
||||||
}, plotConfig);
|
|
||||||
|
|
||||||
// Windgeschwindigkeit
|
|
||||||
Plotly.newPlot('wind-speed-chart', [{
|
|
||||||
x: timestamps,
|
|
||||||
y: data.map(d => d.wind_speed),
|
|
||||||
type: 'scatter',
|
|
||||||
mode: 'lines',
|
|
||||||
name: 'Windgeschwindigkeit',
|
|
||||||
line: {color: '#f38181', width: 3}
|
|
||||||
}, {
|
|
||||||
x: timestamps,
|
|
||||||
y: data.map(d => d.wind_gust),
|
|
||||||
type: 'scatter',
|
|
||||||
mode: 'lines',
|
|
||||||
name: 'Böen',
|
|
||||||
line: {color: '#aa96da', width: 2, dash: 'dash'}
|
|
||||||
}], {
|
|
||||||
title: '💨 Windgeschwindigkeit (m/s)',
|
|
||||||
xaxis: {title: 'Zeit'},
|
|
||||||
yaxis: {title: 'm/s'},
|
|
||||||
margin: {t: 50, b: 60, l: 60, r: 30, pad: 0},
|
|
||||||
legend: {orientation: 'h', y: -0.2},
|
|
||||||
height: DEFAULT_CHART_HEIGHT
|
|
||||||
}, plotConfig);
|
|
||||||
|
|
||||||
// Windrichtung (Polarplot)
|
|
||||||
Plotly.newPlot('wind-dir-chart', [{
|
|
||||||
r: data.map(d => d.wind_speed),
|
|
||||||
theta: data.map(d => d.wind_dir),
|
|
||||||
mode: 'markers',
|
|
||||||
type: 'scatterpolar',
|
|
||||||
marker: {
|
|
||||||
size: 8,
|
|
||||||
color: data.map(d => d.wind_speed),
|
|
||||||
colorscale: 'Viridis',
|
|
||||||
showscale: true,
|
|
||||||
colorbar: {title: 'm/s', orientation: 'h', y: -0.25}
|
|
||||||
}
|
|
||||||
}], {
|
|
||||||
title: '🧭 Windrichtung',
|
|
||||||
polar: {
|
|
||||||
radialaxis: {title: 'Geschwindigkeit (m/s)'},
|
|
||||||
angularaxis: {direction: 'clockwise'}
|
|
||||||
},
|
|
||||||
margin: {t: 50, b: 80, l: 60, r: 60, pad: 0},
|
|
||||||
legend: {orientation: 'h', y: -0.2},
|
|
||||||
height: POLAR_CHART_HEIGHT
|
|
||||||
}, plotConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiales Laden
|
|
||||||
loadData();
|
|
||||||
|
|
||||||
// Auto-Refresh alle 5 Minuten
|
|
||||||
setInterval(loadData, 5 * 60 * 1000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>'''
|
|
||||||
|
|
||||||
with open('templates/index.html', 'w', encoding='utf-8') as f:
|
|
||||||
f.write(html_content)
|
|
||||||
|
|
||||||
print("HTML Template erstellt: templates/index.html")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Hauptprogramm"""
|
"""Hauptprogramm"""
|
||||||
print("Wetterstation wird gestartet...")
|
print("Wetterstation Web-Interface wird gestartet...")
|
||||||
|
print(f"\nWeb-Interface verfügbar unter: http://0.0.0.0:{HTTP_PORT}")
|
||||||
# HTML Template erstellen
|
print(f"API Endpoints:")
|
||||||
create_html_template()
|
print(f" - http://0.0.0.0:{HTTP_PORT}/api/data/day")
|
||||||
|
print(f" - http://0.0.0.0:{HTTP_PORT}/api/data/week")
|
||||||
# MQTT Client starten
|
print(f"Health-Check: http://0.0.0.0:{HTTP_PORT}/health")
|
||||||
mqtt_client = MQTTClient()
|
|
||||||
mqtt_client.start()
|
|
||||||
|
|
||||||
# Flask Server starten
|
|
||||||
print("\nWeb-Interface verfügbar unter: http://localhost:5003")
|
|
||||||
print("Drücke CTRL+C zum Beenden\n")
|
print("Drücke CTRL+C zum Beenden\n")
|
||||||
app.run(host='0.0.0.0', port=5003, debug=False)
|
app.run(host='0.0.0.0', port=HTTP_PORT, debug=False)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
122
wetterstation_ingestion.py
Normal file
122
wetterstation_ingestion.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Wetterstation Ingestion Service - HTTP-POST Datenempfang
|
||||||
|
Empfängt Wetterdaten via POST und speichert sie in der Datenbank
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Lade Umgebungsvariablen aus .env Datei
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Konfiguration aus Umgebungsvariablen
|
||||||
|
DB_FILE = os.getenv("DB_FILE", "wetterdaten.db")
|
||||||
|
HTTP_PORT = int(os.getenv("INGESTION_PORT", 5004))
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.url_map.strict_slashes = False
|
||||||
|
|
||||||
|
|
||||||
|
class WetterDB:
|
||||||
|
"""Klasse für Datenbankoperationen"""
|
||||||
|
|
||||||
|
def __init__(self, db_file):
|
||||||
|
self.db_file = db_file
|
||||||
|
self.init_db()
|
||||||
|
|
||||||
|
def init_db(self):
|
||||||
|
"""Datenbank initialisieren"""
|
||||||
|
conn = sqlite3.connect(self.db_file)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
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
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dateTime ON wetterdaten(dateTime)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_data(self, data):
|
||||||
|
"""Wetterdaten speichern"""
|
||||||
|
conn = sqlite3.connect(self.db_file)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO wetterdaten
|
||||||
|
(dateTime, barometer, outTemp, outHumidity, windSpeed, windDir, windGust, rainRate, rain)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
data.get('dateTime'),
|
||||||
|
data.get('barometer'),
|
||||||
|
data.get('outTemp'),
|
||||||
|
data.get('outHumidity'),
|
||||||
|
data.get('windSpeed'),
|
||||||
|
data.get('windDir'),
|
||||||
|
data.get('windGust'),
|
||||||
|
data.get('rainRate'),
|
||||||
|
data.get('rain')
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Daten gespeichert: {data.get('dateTime')}")
|
||||||
|
|
||||||
|
|
||||||
|
# Globale Datenbankinstanz
|
||||||
|
db = WetterDB(DB_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
# Flask Routes
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
"""Health-Check Endpoint"""
|
||||||
|
return jsonify({'status': 'ok', 'service': 'ingestion'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# @app.route('/api/data/upload', methods=['POST'])
|
||||||
|
@app.route('/api/data/upload/', methods=['POST'])
|
||||||
|
def upload_data():
|
||||||
|
"""HTTP-POST Endpoint für Wetterdaten"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'Keine Daten empfangen'}), 400
|
||||||
|
|
||||||
|
# Daten speichern
|
||||||
|
db.save_data(data)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Daten empfangen und gespeichert'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fehler beim Verarbeiten der POST-Anfrage: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Hauptprogramm"""
|
||||||
|
print("Wetterstation Ingestion Service wird gestartet...")
|
||||||
|
print(f"\nHTTP-POST Endpoint: http://0.0.0.0:{HTTP_PORT}/api/data/upload")
|
||||||
|
print(f"Health-Check: http://0.0.0.0:{HTTP_PORT}/health")
|
||||||
|
print("Drücke CTRL+C zum Beenden\n")
|
||||||
|
app.run(host='0.0.0.0', port=HTTP_PORT, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user