Wetter Dashboard.

Python PyQt6 Applikation

Zurück

Liebe Leserinnen und Leser, hallo du! Grüße aus dem PyQt6-Tornado (und warum ich das Wetter liebe). Du fragst dich sicher, warum ich mich seit Tagen im Keller verbarrikadiere. Die Antwort ist einfach: August 2026 und eine Temperatur von 32 Grad Celsius draußen. Du weißt ja, ich bin absolut wetterbegeistert. Das Wetter ist einfach die ehrlichste Sache der Welt. Es hält sich an keine Regeln, überrascht uns jeden Tag, und wenn es schlechte Laune hat, gibt es Hitze oder es regnet eben – ohne falsche Höflichkeit. Diese Faszination wollte ich jetzt in Python-PyQt6-Code gießen.

Diese Webseite stellt für den Quellcode Download- und Kopierfunktionen zur Verfügung. Das geht nur mit JavaScript. Wenn du mich für einen unsicheren Kantonisten hältst, verlasse diese Webseite. Alle Informationen und Angebote sind ohne Gewähr!

Python Code
# -*- coding: utf-8 -*- # ***************************************************************************** # Wiederverwendbare analoge Zeigerinstrumente (Gauges) für PyQt6. # AnalogGauge: Basisklasse (Skala, Zonen, Zeiger, Animation) # TemperatureGauge: vorkonfiguriertes Thermometer # HumidityGauge: vorkonfigurierte Luftfeuchte-Anzeige # PressureGauge: vorkonfigurierte Luftdruck-Anzeige # WindRoseGauge: Kompass/Windrose mit Richtungszeiger + Geschwindigkeit # Alle Klassen sind normale QWidget-Subklassen und lassen sich beliebig # in Layouts einbetten. Werte werden per set-Methode gesetzt und weich # animiert (QPropertyAnimation). (c) Hans Busche Datum: 01.08.2026 # ***************************************************************************** import math from PyQt6.QtCore import Qt, QRectF, QPointF, pyqtProperty, pyqtSignal, \ QPropertyAnimation, QEasingCurve from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPolygonF, \ QRadialGradient from PyQt6.QtWidgets import QWidget, QSizePolicy #****************************************************************************** # Punkt auf einem Kreis. Winkel im mathematischen Sinn (0°=Osten, # Gegenuhrzeigersinn), Bildschirm-Y ist gespiegelt (Y wächst nach unten). #****************************************************************************** def _point_at(angle_deg: float, radius: float) -> QPointF: rad = math.radians(angle_deg) return QPointF(radius * math.cos(rad), -radius * math.sin(rad)) #****************************************************************************** # Analoges Rundinstrument mit Zeiger. # Parameter: # min_value, max_value : Wertebereich der Skala # title : Beschriftung oben im Zifferblatt (z.B. "Temperatur") # unit : Einheit, die neben dem Digitalwert steht (z.B. "°C") # decimals : Nachkommastellen im Digitalwert # start_angle, span_angle : Start-/Sweep-Winkel der Skala in Grad # (Standard: 225° -> -270°, klassische Zeigerinstrument-Optik # mit Lücke unten) # major_ticks, minor_ticks : Anzahl Haupt-/Unterteilungsstriche # zones : Liste von (start, ende, farbe) für farbige Zonen # (z.B. grün/gelb/rot Bereiche) # needle_color : Farbe des Zeigers #****************************************************************************** class AnalogGauge(QWidget): valueChanged = pyqtSignal(float) def __init__(self, parent=None, *, min_value: float = 0.0, max_value: float = 100.0, title: str = "", unit: str = "", decimals: int = 1, start_angle: float = 225.0, span_angle: float = -270.0, major_ticks: int = 10, minor_ticks: int = 5, zones=None, needle_color: str = "#E74C3C", dial_color: str = "#1B2430", ring_color: str = "#0E1319"): super().__init__(parent) self._min = float(min_value) self._max = float(max_value) self._value = self._min self._display_value = self._min self.title = title self.unit = unit self.decimals = decimals self.start_angle = start_angle self.span_angle = span_angle self.major_ticks = max(1, major_ticks) self.minor_ticks = max(1, minor_ticks) self.zones = zones or [] # [(start, end, "#rrggbb"), ...] self.needle_color = QColor(needle_color) self.dial_color = QColor(dial_color) self.ring_color = QColor(ring_color) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setMinimumSize(180, 180) self._animation = QPropertyAnimation(self, b"displayValue") self._animation.setDuration(700) self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) # ---- animierte Qt-Property (intern, treibt das Neuzeichnen) -------- def _get_display_value(self): return self._display_value def _set_display_value(self, v): self._display_value = v self.update() displayValue = pyqtProperty(float, fget=_get_display_value, fset=_set_display_value) # ---- öffentliche API ------------------------------------------------- def value(self) -> float: return self._value def setRange(self, min_value: float, max_value: float): self._min, self._max = float(min_value), float(max_value) self._value = max(self._min, min(self._max, self._value)) self.update() def setZones(self, zones): self.zones = zones or [] self.update() def setValue(self, value: float, animate: bool = True): """Setzt den Zielwert des Zeigers (wird auf min/max begrenzt).""" value = max(self._min, min(self._max, float(value))) self._value = value if animate: self._animation.stop() self._animation.setStartValue(self._display_value) self._animation.setEndValue(value) self._animation.start() else: self._set_display_value(value) self.valueChanged.emit(value) def sizeHint(self): from PyQt6.QtCore import QSize return QSize(220, 220) # ---- Winkel-Hilfsfunktion ----------------------------------------------- def _angle_for_value(self, value: float) -> float: span = self._max - self._min if span == 0: frac = 0.0 else: frac = (value - self._min) / span return self.start_angle + frac * self.span_angle # ---- Zeichnen ----------------------------------------------------------- def paintEvent(self, event): side = min(self.width(), self.height()) if side <= 0: return painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.translate(self.width() / 2.0, self.height() / 2.0) scale = side / 220.0 painter.scale(scale, scale) self._draw_background() self._paint(painter) painter.end() def _draw_background(self): pass # Platzhalter, falls Subklassen vor dem Hauptzeichnen etwas brauchen def _paint(self, painter: QPainter): R = 100.0 # logischer Außenradius (Widget ist 220x220 logisch) # -- äußerer Ring + Zifferblatt --------------------------------------- gradient = QRadialGradient(0, 0, R) gradient.setColorAt(0.0, self.dial_color.lighter(115)) gradient.setColorAt(1.0, self.dial_color) painter.setPen(QPen(self.ring_color, 4)) painter.setBrush(QBrush(gradient)) painter.drawEllipse(QRectF(-R, -R, 2 * R, 2 * R)) # -- farbige Zonen (Bänder) ------------------------------------------- if self.zones: zone_radius = R - 10 pen = QPen() pen.setWidth(10) pen.setCapStyle(Qt.PenCapStyle.FlatCap) rect = QRectF(-zone_radius, -zone_radius, 2 * zone_radius, 2 * zone_radius) for start, end, color in self.zones: a0 = self._angle_for_value(start) a1 = self._angle_for_value(end) pen.setColor(QColor(color)) painter.setPen(pen) painter.drawArc(rect, int(a0 * 16), int((a1 - a0) * 16)) # -- Skalenstriche + Zahlen -------------------------------------- painter.setPen(QPen(QColor("#D6DEE8"), 2)) label_font = QFont("Sans Serif", 9) painter.setFont(label_font) fm = painter.fontMetrics() total_minor = self.major_ticks * self.minor_ticks for i in range(total_minor + 1): frac = i / total_minor value = self._min + frac * (self._max - self._min) angle = self.start_angle + frac * self.span_angle is_major = (i % self.minor_ticks == 0) outer = R - 12 inner = outer - (14 if is_major else 7) p_out = _point_at(angle, outer) p_in = _point_at(angle, inner) pen = QPen(QColor("#EAF0F6") if is_major else QColor("#7C8896")) pen.setWidthF(2.2 if is_major else 1.2) painter.setPen(pen) painter.drawLine(p_out, p_in) if is_major: label = f"{value:.0f}" label_pos = _point_at(angle, inner - 14) rect = fm.boundingRect(label) painter.setPen(QColor("#EAF0F6")) painter.drawText( QRectF(label_pos.x() - rect.width(), label_pos.y() - rect.height() / 2, 2 * rect.width(), rect.height()), Qt.AlignmentFlag.AlignCenter, label ) # -- Titel ------------------------------------------------------------ if self.title: painter.setFont(QFont("Sans Serif", 8, QFont.Weight.Bold)) painter.setPen(QColor("#EAF0F6")) painter.drawText(QRectF(-R, -R * 0.42, 2 * R, 20), Qt.AlignmentFlag.AlignCenter, self.title) # -- Zeiger ----------------------------------------------------------- angle = self._angle_for_value(self._display_value) needle_len = R - 30 tip = _point_at(angle, needle_len) left = _point_at(angle + 90, 3.2) right = _point_at(angle - 90, 3.2) tail = _point_at(angle + 180, 14) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(self.needle_color)) painter.drawPolygon(QPolygonF([tip, right, tail, left])) # -- Nabe ------------------------------------------------------------- painter.setBrush(QBrush(QColor("#2C3743"))) painter.setPen(QPen(QColor("#0E1319"), 2)) painter.drawEllipse(QRectF(-9, -9, 18, 18)) painter.setBrush(QBrush(self.needle_color)) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(QRectF(-4, -4, 8, 8)) # -- digitaler Wert unten --------------------------------------------- value_text = f"{self._display_value:.{self.decimals}f} {self.unit}".strip() painter.setFont(QFont("Sans Serif", 8, QFont.Weight.Bold)) painter.setPen(QColor("#FFFFFF")) painter.drawText(QRectF(-R, R * 0.45, 2 * R, 24), Qt.AlignmentFlag.AlignCenter, value_text) #****************************************************************************** # Vorkonfiguriertes Thermometer (-20 .. 50 °C). #****************************************************************************** class TemperatureGauge(AnalogGauge): def __init__(self, parent=None, min_value=-20.0, max_value=50.0, **kwargs): zones = kwargs.pop("zones", [ (min_value, 0, "#3498DB"), (0, 30, "#27AE60"), (30, max_value, "#E74C3C"), ]) kwargs.setdefault("title", "Temperatur") kwargs.setdefault("unit", "°C") kwargs.setdefault("decimals", 1) kwargs.setdefault("needle_color", "#FF5252") super().__init__(parent, min_value=min_value, max_value=max_value, zones=zones, **kwargs) #****************************************************************************** # Vorkonfigurierte Luftfeuchte-Anzeige (0 .. 100 %). #****************************************************************************** class HumidityGauge(AnalogGauge): def __init__(self, parent=None, min_value=0.0, max_value=100.0, **kwargs): zones = kwargs.pop("zones", [ (min_value, 30, "#E67E22"), (30, 70, "#27AE60"), (70, max_value, "#3498DB"), ]) kwargs.setdefault("title", "Luftfeuchte") kwargs.setdefault("unit", "%") kwargs.setdefault("decimals", 0) kwargs.setdefault("needle_color", "#4FC3F7") super().__init__(parent, min_value=min_value, max_value=max_value, zones=zones, **kwargs) #****************************************************************************** # Vorkonfigurierte Luftdruck-Anzeige (950 .. 1050 hPa). #****************************************************************************** class PressureGauge(AnalogGauge): def __init__(self, parent=None, min_value=950.0, max_value=1050.0, **kwargs): zones = kwargs.pop("zones", [ (min_value, 980, "#3498DB"), (980, 1020, "#27AE60"), (1020, max_value, "#E74C3C"), ]) kwargs.setdefault("title", "Luftdruck") kwargs.setdefault("unit", "hPa") kwargs.setdefault("decimals", 0) kwargs.setdefault("needle_color", "#B39DDB") super().__init__(parent, min_value=min_value, max_value=max_value, zones=zones, **kwargs) #****************************************************************************** # Windrose / Kompass mit Richtungszeiger und Geschwindigkeitsanzeige. # setDirection(grad): Windrichtung in Grad (0 = Nord, 90 = Ost, ...), animiert # auf kürzestem Drehweg. # setSpeed(wert): Windgeschwindigkeit, wird im Zentrum als Text angezeigt # (Einheit über 'speed_unit'). #****************************************************************************** class WindRoseGauge(QWidget): directionChanged = pyqtSignal(float) speedChanged = pyqtSignal(float) _COMPASS_LABELS = [ (0, "N"), (45, "NE"), (90, "O"), (135, "SE"), (180, "S"), (225, "SW"), (270, "W"), (315, "NW"), ] def __init__(self, parent=None, *, title="Windrichtung", speed_unit="km/h", needle_color="#E74C3C", dial_color="#1B2430", ring_color="#0E1319"): super().__init__(parent) self.title = title self.speed_unit = speed_unit self.needle_color = QColor(needle_color) self.dial_color = QColor(dial_color) self.ring_color = QColor(ring_color) self._direction = 0.0 # logischer Zielwert (kann > 360 / < 0 sein für Animation) self._display_direction = 0.0 self._speed = 0.0 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setMinimumSize(180, 180) self._animation = QPropertyAnimation(self, b"displayDirection") self._animation.setDuration(700) self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) # ---- animierte Property ------------------------------------------------ def _get_display_direction(self): return self._display_direction def _set_display_direction(self, v): self._display_direction = v self.update() displayDirection = pyqtProperty(float, fget=_get_display_direction, fset=_set_display_direction) # ---- öffentliche API ---------------------------------------------------- def direction(self) -> float: return self._direction % 360.0 def speed(self) -> float: return self._speed def setDirection(self, degrees: float, animate: bool = True): """Setzt die Windrichtung (0-360°). Dreht immer den kürzesten Weg.""" target = degrees % 360.0 current = self._display_direction % 360.0 diff = (target - current + 180) % 360 - 180 # kürzester Weg in [-180, 180) new_target = self._display_direction + diff self._direction = target if animate: self._animation.stop() self._animation.setStartValue(self._display_direction) self._animation.setEndValue(new_target) self._animation.start() else: self._set_display_direction(new_target) self.directionChanged.emit(target) def setSpeed(self, value: float): self._speed = float(value) self.update() self.speedChanged.emit(self._speed) def sizeHint(self): from PyQt6.QtCore import QSize return QSize(220, 220) # ---- Zeichnen ----------------------------------------------------------- def paintEvent(self, event): side = min(self.width(), self.height()) if side <= 0: return painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.translate(self.width() / 2.0, self.height() / 2.0) painter.scale(side / 220.0, side / 220.0) self._paint(painter) painter.end() def _paint(self, painter: QPainter): R = 100.0 gradient = QRadialGradient(0, 0, R) gradient.setColorAt(0.0, self.dial_color.lighter(115)) gradient.setColorAt(1.0, self.dial_color) painter.setPen(QPen(self.ring_color, 4)) painter.setBrush(QBrush(gradient)) painter.drawEllipse(QRectF(-R, -R, 2 * R, 2 * R)) # Gradstriche alle 10°, dicker alle 30° for deg in range(0, 360, 10): angle = 90 - deg # 0°=N=oben -> math. Winkel major = (deg % 30 == 0) outer = R - 10 inner = outer - (12 if major else 6) p_out = _point_at(angle, outer) p_in = _point_at(angle, inner) pen = QPen(QColor("#EAF0F6") if major else QColor("#7C8896")) pen.setWidthF(2.0 if major else 1.0) painter.setPen(pen) painter.drawLine(p_out, p_in) # Himmelsrichtungs-Labels painter.setFont(QFont("Sans Serif", 8, QFont.Weight.Bold)) fm = painter.fontMetrics() for deg, label in self._COMPASS_LABELS: angle = 90 - deg pos = _point_at(angle, R - 28) rect = fm.boundingRect(label) color = "#FF8A65" if label == "N" else "#EAF0F6" painter.setPen(QColor(color)) painter.drawText( QRectF(pos.x() - rect.width(), pos.y() - rect.height() / 2, 2 * rect.width(), rect.height()), Qt.AlignmentFlag.AlignCenter, label ) if self.title: painter.setFont(QFont("Sans Serif", 8, QFont.Weight.Bold)) painter.setPen(QColor("#9AA6B2")) painter.drawText(QRectF(-R, -R * 0.42, 2 * R, 16), Qt.AlignmentFlag.AlignCenter, self.title) # Zeiger (Richtungspfeil), 0°=N=oben angle = 90 - self._display_direction needle_len = R - 34 tip = _point_at(angle, needle_len) left = _point_at(angle + 90, 4.0) right = _point_at(angle - 90, 4.0) tail = _point_at(angle + 180, 16) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(self.needle_color)) painter.drawPolygon(QPolygonF([tip, right, tail, left])) painter.setBrush(QBrush(QColor("#2C3743"))) painter.setPen(QPen(QColor("#0E1319"), 2)) painter.drawEllipse(QRectF(-9, -9, 18, 18)) painter.setBrush(QBrush(self.needle_color)) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(QRectF(-4, -4, 8, 8)) # Zentrale Textanzeige: Richtung als Kompass-Kürzel + Geschwindigkeit compass_label = self._nearest_compass_label(self._direction) painter.setFont(QFont("Sans Serif", 8, QFont.Weight.Bold)) painter.setPen(QColor("#FFFFFF")) painter.drawText(QRectF(-R, R * 0.45, 2 * R, 20), Qt.AlignmentFlag.AlignCenter, f"{self._speed:.1f} {self.speed_unit}") painter.setFont(QFont("Sans Serif", 9)) painter.setPen(QColor("#9AA6B2")) painter.drawText(QRectF(-R, R * 0.45 + 18, 2 * R, 16), Qt.AlignmentFlag.AlignCenter, f"{compass_label} · {self._direction % 360:.0f}°") @staticmethod def _nearest_compass_label(deg: float) -> str: labels_16 = ["N", "NNO", "NO", "ONO", "O", "OSO", "SO", "SSO", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] idx = int(((deg % 360) + 11.25) // 22.5) % 16 return labels_16[idx] #****************************************************************************** # Ende #******************************************************************************


Die Grundidee!
Ein Zeigerinstrument besteht im Kern immer aus denselben Teilen: einem runden Zifferblatt, Strichen und Zahlen für die Skala, einem Zeiger, der auf einen Wert zeigt, und optional farbigen Zonen (grün/gelb/rot) zur schnellen Einordnung. Statt das für Temperatur, Luftfeuchte und Luftdruck viermal separat zu programmieren, gibt es eine gemeinsame Basisklasse AnalogGauge, Zeile 44 bis 215, die all das kann – und die konkreten Instrumente sind nur "vorausgefüllte" Varianten davon.

Wie ein Wert zur Zeigerstellung wird!
Jedes Gauge hat einen Wertebereich (z. B. -20 bis 50 °C) und einen Winkelbereich auf dem Zifferblatt (standardmäßig von 225° bis -45°, also unten eine kleine Lücke – die klassische Tacho-Optik). Wenn du setValue(23.4) aufrufst, rechnet die Klasse: "23.4 liegt bei X % zwischen -20 und 50" und übersetzt dieses Prozent in den passenden Winkel auf der Skala. Der Zeiger wird dann einfach als kleines Dreieck in diesem Winkel gezeichnet.

Die Animation!
Damit der Zeiger nicht ruckartig springt, sondern sanft "einschwingt", gibt es intern zwei Werte: den Zielwert (was du gesetzt hast) und den Anzeigewert (was gerade tatsächlich gezeichnet wird). Eine Qt-Animation bewegt den Anzeigewert über ca. 0,7 Sekunden vom alten zum neuen Zielwert, mit leichter Abbremsung am Ende. Bei jedem kleinen Zwischenschritt wird das Widget neu gezeichnet. So entsteht die weiche Bewegung.

Zeichnen mit QPainter!
Es gibt keine fertigen Bilder – alles wird bei jedem Neuzeichnen live berechnet: Kreis, Farbverläufe, Skalenstriche, Zahlen, Zeiger, Nabe, digitaler Wert unten. Das hat den Vorteil, dass die Instrumente sich beliebig skalieren lassen (großes Fenster, kleines Fenster – alles bleibt scharf) und dass man Farben, Zonen und Bereiche einfach über Parameter ändern kann, ohne Grafikdateien anzufassen.

Die vier konkreten Klassen!
TemperatureGauge, HumidityGauge, PressureGauge sind alle nur AnalogGauge mit fertig eingestelltem Wertebereich, Einheit und Farbzonen für den jeweiligen Zweck – du erbst quasi die ganze Zeichenlogik gratis. WindRoseGauge ist eine eigene Klasse, weil eine Windrose anders tickt: Statt eines Wertebereichs mit Minimum/Maximum gibt es eine Richtung von 0–360°, die sich im Kreis dreht. Die Besonderheit hier: Der Zeiger dreht immer den kürzeren Weg – von 350° nach 10° dreht er 20° vorwärts durch die Null, statt einmal fast ganz herum zu schwingen.


Python Code
#!/usr/bin/env python3 # -*- coding: utf-8 -*- # ***************************************************************************** # Beispiel-Dashboard, das die vier Gauge-Klassen aus gauges.py in ein # Hauptfenster integriert. Eingabefeld für einen Ort/Stadt + Start-Button. Ein # Klick sucht den Ort über die kostenlose Open-Meteo-API (kein API-Key nötig), # zeigt darunter Datum, Sonnenaufgang und Sonnenuntergang an und füllt die vier # Gauges mit den aktuellen Live-Messwerten für diesen Ort. Solange noch kein Ort # gesucht wurde, läuft ein QTimer im Hintergrund und zeigt simulierte Demo-Werte, # damit das Dashboard nicht leer aussieht. Sobald ein Ort erfolgreich geladen # wurde, stoppt der Demo-Timer. (c) Hans Busche Datum: 02.08.2026 # ***************************************************************************** import random, os import sys from datetime import datetime import requests from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextEdit ) from gauges import TemperatureGauge, HumidityGauge, PressureGauge, WindRoseGauge from PyQt6.QtGui import QIcon GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" FORECAST_URL = "https://api.open-meteo.com/v1/forecast" # Verzeichnis dieser Datei, damit das Icon unabhängig vom Startort gefunden wird BASE_DIR = os.path.dirname(os.path.abspath(__file__)) ICON_PATH = os.path.join(BASE_DIR, "Schaefer.ico") # ***************************************************************************** # Holt Ortskoordinaten + Wetterdaten in einem Hintergrund-Thread, damit die GUI # während des Netzwerkzugriffs nicht einfriert. WICHTIG: Die Ergebnis-Signale # heißen bewusst NICHT "finished" - QThread bringt bereits ein eingebautes, # parameterloses "finished"-Signal mit, das erst feuert, nachdem run() # vollständig zurückgekehrt ist. Ein gleichnamiges eigenes Signal würde dieses # überschreiben und zu einem Wettlauf beim Aufräumen führen (sporadisches # "QThread: Destroyed while thread is still running!"). # ***************************************************************************** class WeatherWorker(QThread): resultReady = pyqtSignal(dict) errorOccurred = pyqtSignal(str) def __init__(self, city: str, parent=None): super().__init__(parent) self.city = city def run(self): try: # 1) Ort in Koordinaten umwandeln geo_resp = requests.get( GEOCODING_URL, params={"name": self.city, "count": 1, "language": "de"}, timeout=10, ) geo_resp.raise_for_status() results = geo_resp.json().get("results") if not results: self.errorOccurred.emit(f"Ort „{self.city}“ wurde nicht gefunden.") return place = results[0] lat, lon = place["latitude"], place["longitude"] display_name = place.get("name", self.city) country = place.get("country") if country: display_name = f"{display_name}, {country}" # 2) Wetterdaten (aktuell + Tageswerte Sonnenauf-/-untergang) holen weather_resp = requests.get( FORECAST_URL, params={ "latitude": lat, "longitude": lon, "current_weather": "true", "hourly": "relative_humidity_2m,surface_pressure", "daily": "sunrise,sunset", "timezone": "auto", }, timeout=10, ) weather_resp.raise_for_status() data = weather_resp.json() current = data.get("current_weather", {}) or {} daily = data.get("daily", {}) or {} hourly = data.get("hourly", {}) or {} # passende Luftfeuchte/Luftdruck zur aktuellen Stunde suchen humidity = None pressure = None current_time = current.get("time") hourly_times = hourly.get("time", []) if current_time in hourly_times: idx = hourly_times.index(current_time) humidity = hourly.get("relative_humidity_2m", [None])[idx] pressure = hourly.get("surface_pressure", [None])[idx] result = { "city": display_name, "date": (daily.get("time") or [""])[0], "sunrise": (daily.get("sunrise") or [""])[0], "sunset": (daily.get("sunset") or [""])[0], "temperature": current.get("temperature"), "windspeed": current.get("windspeed"), "winddirection": current.get("winddirection"), "humidity": humidity, "pressure": pressure, "latitude": lat, "longitude": lon, } self.resultReady.emit(result) except requests.RequestException as exc: self.errorOccurred.emit(f"Netzwerkfehler: {exc}") except Exception as exc: # noqa: BLE001 - im UI-Thread abfangen und anzeigen self.errorOccurred.emit(f"Unerwarteter Fehler: {exc}") # ***************************************************************************** # 2026-08-01T06:03' -> '06:03 Uhr'. Bei Fehlern Originalstring zurückgeben. # ***************************************************************************** def _format_iso_time(iso_str: str) -> str: if not iso_str: return "–" try: return datetime.fromisoformat(iso_str).strftime("%H:%M Uhr") except ValueError: return iso_str # ***************************************************************************** # 2026-08-01' -> '01.08.2026'. # ***************************************************************************** def _format_iso_date(iso_str: str) -> str: if not iso_str: return "–" try: return datetime.fromisoformat(iso_str).strftime("%d.%m.%Y") except ValueError: return iso_str # ***************************************************************************** # Dashboard # ***************************************************************************** class DashboardWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Wetterstation – Dashboard") self.resize(900, 780) # Icon if os.path.exists(ICON_PATH): self.setWindowIcon(QIcon(ICON_PATH)) self._worker = None # hält Referenz auf laufenden WeatherWorker central = QWidget() self.setCentralWidget(central) outer = QVBoxLayout(central) outer.setContentsMargins(16, 16, 16, 16) outer.setSpacing(12) header = QLabel("Wetter-Dashboard") header.setStyleSheet("color: #EAF0F6; font-size: 20px; font-weight: 600;") outer.addWidget(header) # ---- Ortssuche: Eingabefeld + Start-Button -------------------------- search_row = QHBoxLayout() search_row.setSpacing(10) self.city_edit = QLineEdit() self.city_edit.setPlaceholderText("Ort oder Stadt eingeben, z. B. „Hannover“ …") self.city_edit.returnPressed.connect(self._start_search) self.search_button = QPushButton("Start") self.search_button.setFixedWidth(110) self.search_button.clicked.connect(self._start_search) search_row.addWidget(self.city_edit, 1) search_row.addWidget(self.search_button) outer.addLayout(search_row) # ---- Info-Feld: Datum, Sonnenaufgang, Sonnenuntergang --------------- self.info_text = QTextEdit() self.info_text.setReadOnly(True) self.info_text.setFixedHeight(90) self.info_text.setText( "Ort eingeben und auf „Start“ klicken, um Datum, Sonnenaufgang und " "Sonnenuntergang sowie aktuelle Messwerte zu laden." ) outer.addWidget(self.info_text) # ---- Gauge-Grid ------------------------------------------------------ grid = QGridLayout() grid.setSpacing(20) outer.addLayout(grid) self.temp_gauge = TemperatureGauge() self.humidity_gauge = HumidityGauge() self.pressure_gauge = PressureGauge() self.wind_gauge = WindRoseGauge() grid.addWidget(self.temp_gauge, 0, 0) grid.addWidget(self.humidity_gauge, 0, 1) grid.addWidget(self.pressure_gauge, 1, 0) grid.addWidget(self.wind_gauge, 1, 1) # Startwerte (Demo) self.temp_gauge.setValue(18.0, animate=False) self.humidity_gauge.setValue(45.0, animate=False) self.pressure_gauge.setValue(1013.0, animate=False) self.wind_gauge.setDirection(0.0, animate=False) self.wind_gauge.setSpeed(0.0) self._apply_dark_theme() # ---- Demo: simulierte Sensordaten, bis ein echter Ort geladen wird -- self._timer = QTimer(self) self._timer.timeout.connect(self.update_from_sensors) self._timer.start(2000) def closeEvent(self, event): """Verhindert den 'QThread: Destroyed while thread is still running'- Absturz auch dann, wenn das Fenster geschlossen wird, während gerade noch eine Wetterabfrage im Hintergrund läuft. try/except RuntimeError: sobald der Worker fertig ist, räumt deleteLater() das C++-Objekt irgendwann weg - self._worker zeigt dann noch auf die (bereits "tote") Python-Hülle. isRunning() auf so einem Objekt löst "wrapped C/C++ object has been deleted" aus. """ try: if self._worker is not None and self._worker.isRunning(): self._worker.quit() self._worker.wait(3000) except RuntimeError: pass # Worker war bereits fertig und wurde schon aufgeräumt super().closeEvent(event) def _apply_dark_theme(self): self.setStyleSheet(""" QMainWindow { background-color: #10151C; } QWidget { background-color: #10151C; } QLineEdit { background-color: #1B2430; color: #EAF0F6; border: 1px solid #2C3743; border-radius: 6px; padding: 8px 10px; font-size: 13px; } QLineEdit:focus { border: 1px solid #4FC3F7; } QPushButton { background-color: #3498DB; color: white; border: none; border-radius: 6px; padding: 8px 14px; font-size: 13px; font-weight: 600; } QPushButton:hover { background-color: #2E86DE; } QPushButton:disabled { background-color: #2C3743; color: #7C8896; } QTextEdit { background-color: #1B2430; color: #EAF0F6; border: 1px solid #2C3743; border-radius: 6px; padding: 8px 10px; font-size: 13px; } """) # ------------------------------------------------------------------ # Ortssuche # ------------------------------------------------------------------ def _start_search(self): city = self.city_edit.text().strip() if not city: self.info_text.setText("Bitte zuerst einen Ort eingeben.") return # Demo-Timer anhalten, während/nachdem echte Daten geladen werden self._timer.stop() self.search_button.setEnabled(False) self.search_button.setText("Lädt …") self.info_text.setText(f"Suche Wetterdaten für „{city}“ …") # parent=self: Qt hält damit selbst eine Referenz auf den Worker, # so kann Python das Objekt nicht vorzeitig einsammeln, während # der Thread noch läuft. self._worker = WeatherWorker(city, parent=self) self._worker.resultReady.connect(self._on_weather_loaded) self._worker.errorOccurred.connect(self._on_weather_error) # Das eingebaute QThread.finished-Signal feuert erst, NACHDEM run() # tatsächlich zurückgekehrt ist - erst dann darf das Objekt weg. self._worker.finished.connect(self._worker.deleteLater) self._worker.start() def _on_weather_loaded(self, result: dict): self.search_button.setEnabled(True) self.search_button.setText("Start") date_str = _format_iso_date(result["date"]) sunrise_str = _format_iso_time(result["sunrise"]) sunset_str = _format_iso_time(result["sunset"]) self.info_text.setText( f"Ort: {result['city']}, Breitengrad: {result['latitude']}, Längengrad: {result['longitude']}\n" f"Datum: {date_str}\n" f"Sonnenaufgang: {sunrise_str} Sonnenuntergang: {sunset_str}" ) # Gauges mit den echten Live-Werten befüllen (falls vorhanden) if result.get("temperature") is not None: self.temp_gauge.setValue(result["temperature"]) if result.get("humidity") is not None: self.humidity_gauge.setValue(result["humidity"]) if result.get("pressure") is not None: self.pressure_gauge.setValue(result["pressure"]) if result.get("winddirection") is not None: self.wind_gauge.setDirection(result["winddirection"]) if result.get("windspeed") is not None: self.wind_gauge.setSpeed(result["windspeed"]) def _on_weather_error(self, message: str): self.search_button.setEnabled(True) self.search_button.setText("Start") self.info_text.setText(f"⚠ {message}") # Demo-Timer wieder starten, damit das Dashboard nicht einfriert self._timer.start(2000) # ------------------------------------------------------------------ # Demo-Modus: simulierte Sensordaten, bis ein echter Ort geladen ist. # Im echten Projekt ersetzt man dies durch eigene Messwert-Quellen. # ------------------------------------------------------------------ def update_from_sensors(self): temp = self.temp_gauge.value() + random.uniform(-2.0, 2.0) humidity = self.humidity_gauge.value() + random.uniform(-5.0, 5.0) pressure = self.pressure_gauge.value() + random.uniform(-3.0, 3.0) direction = (self.wind_gauge.direction() + random.uniform(-40, 40)) % 360 speed = max(0.0, self.wind_gauge.speed() + random.uniform(-4.0, 4.0)) self.temp_gauge.setValue(temp) self.humidity_gauge.setValue(humidity) self.pressure_gauge.setValue(pressure) self.wind_gauge.setDirection(direction) self.wind_gauge.setSpeed(speed) # ***************************************************************************** # main # ***************************************************************************** def main(): app = QApplication(sys.argv) window = DashboardWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main() #****************************************************************************** # Ende #******************************************************************************


Das Grundgerüst!
DashboardWindow, Zeile 173 bis 317, ist das eigentliche Fenster deiner App. Es baut sich von oben nach unten auf: eine Überschrift, darunter die Ortssuche (Eingabefeld + Start-Button), dann das Infofeld für Datum/Sonnenauf-/-untergang, und ganz unten ein 2×2-Raster mit den vier Gauges aus gauges.py. Alles steckt in Layouts (senkrechte und waagerechte "Container"), die automatisch dafür sorgen, dass sich alles beim Vergrößern des Fensters vernünftig mit anpasst.

Der Demo-Modus!
Damit das Dashboard nicht leer und tot aussieht, solange du noch keinen Ort gesucht hast, läuft von Anfang an ein Timer im Hintergrund, der alle 2 Sekunden zufällige, plausible Werte erzeugt und in die Gauges schreibt – reine Kosmetik, um zu zeigen, dass die Instrumente "leben".

Was beim Klick auf "Start" passiert!
Sobald du einen Ort eingibst und auf Start klickst, passiert das in zwei Schritten, die beide über das Internet laufen: Erstens wird der Ortsname in Koordinaten übersetzt (Geokodierung), zweitens werden für diese Koordinaten die aktuellen Wetterdaten und die Sonnenzeiten geholt. Für beides ist die kostenlose Open-Meteo-API zuständig.

Warum ein eigener Thread nötig ist!
Netzwerkanfragen können eine Sekunde dauern oder auch mal hängen bleiben. Würde man das direkt im Hauptprogramm machen, würde die ganze Oberfläche für diese Zeit einfrieren – kein Zeiger würde sich mehr bewegen, das Fenster wäre nicht mehr klickbar. Deshalb übernimmt ein separater Hintergrund-Thread (WeatherWorker) die eigentliche Netzwerkarbeit. Er meldet sich am Ende über ein Signal zurück – entweder mit den fertigen Daten oder mit einer Fehlermeldung –, und erst dieses Signal löst dann im Hauptfenster die eigentliche Aktualisierung aus (Textfeld beschriften, Gauges auf die neuen Werte setzen). Diese Trennung – "arbeiten im Hintergrund, aber nur der Hauptthread darf die Oberfläche anfassen" – ist eine Grundregel bei praktisch jeder GUI-Programmierung, nicht nur bei Qt.

Fehlerfälle!
Findet die API den Ort nicht, oder gibt es kein Netz, kommt statt der Erfolgsmeldung eine Fehlermeldung im Infofeld an, der Button wird wieder aktiv, und der Demo-Timer springt vorsichtshalber wieder an, damit das Dashboard nicht "stehen bleibt".

Der saubere Abschluss!
Bevor das Fenster geschlossen wird, prüft das Programm, ob im Hintergrund noch eine Anfrage läuft, und wartet in diesem Fall geordnet auf deren Ende, statt den Thread mitten in der Arbeit abzuwürgen. Hier habe ich einiges falsch gemacht! Ich brauchte Hilfe! Anfrage an Stack Overflow: Wie beende ich einen Thread? Habe sporadischen Absturz!


PyQt6 Wetter
Zurück