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!
# -*- 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.
#!/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 Dashboard. Ohne Gewähr © 2026 Hans Busche