Get up-to-date weather direct to your desktop, including meterological data and week-ahead predictions.
The OpenWeatherMap API
Requests to the API can take a few moments to complete. If we perform these in the main application loop this will cause our app to hang while waiting for data. To avoid this we perform all requests in seperate worker threads,
This worker collects both the current weather and a forecast, and returns this to the main thread to update the UI.
First we define a number of custom signals which the worker can emit. These include finished a generic signal for the worker completing, error which emits an
Exception message should an error occur and result which returns the result of the API call. The data is returned as two separate
one representing the current weather and one for the forecast.
:::python class WorkerSignals(QObject): ''' Defines the signals available from a running worker thread. ''' finished = pyqtSignal() error = pyqtSignal(str) result = pyqtSignal(dict, dict)
WeatherWorker runnable handles the actual requests to the API. It is initialized with a single parameter
location which gives the location that the worker will retrieve the weather data for. Each worker performs two requests, one for the weather, and one for the forecast, receiving a JSON strings from the OpenWeatherMap.org. These are then unpacked into
dict objects and emitted using the
:::python class WeatherWorker(QRunnable): ''' Worker thread for weather updates. ''' signals = WorkerSignals() is_interrupted = False def __init__(self, location): super(WeatherWorker, self).__init__() self.location = location @pyqtSlot() def run(self): try: params = dict( q=self.location, appid=OPENWEATHERMAP_API_KEY ) url = 'http://api.openweathermap.org/data/2.5/weather?%s&units=metric' % urlencode(params) r = requests.get(url) weather = json.loads(r.text) # Check if we had a failure (the forecast will fail in the same way). if weather['cod'] != 200: raise Exception(weather['message']) url = 'http://api.openweathermap.org/data/2.5/forecast?%s&units=metric' % urlencode(params) r = requests.get(url) forecast = json.loads(r.text) self.signals.result.emit(weather, forecast) except Exception as e: self.signals.error.emit(str(e)) self.signals.finished.emit()
The User Interface
The Raindar UI was created using Qt Designer, and saved as
file, which is available for download. This was converted to an importable Python file using
With the main window layout defined in Qt Designer. To create the mainwindow we simply create a subclass of
QMainWindow) and call
self.setupUi(self) as normal.
To trigger the request for weather data using the push button we connect it's pressed signal to our custom
Finally we create our thread pool class, to handle running our workers and show the main window.
:::python class MainWindow(QMainWindow, Ui_MainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setupUi(self) self.pushButton.pressed.connect(self.update_weather) self.threadpool = QThreadPool() self.show()
Requesting and Refreshing data
Pressing the button triggers the
update_weather slot method. This creates a new
WeatherWorker instance, passing in the currently set location from the
lineEdit box. The result and error signals of the worker are connected up to the
weather_result handler, and to our custom
alert handler respectively.
alert handler uses
QMessageBox to display a message box window, containing the error from the worker.
:::python def update_weather(self): worker = WeatherWorker(self.lineEdit.text()) worker.signals.result.connect(self.weather_result) worker.signals.error.connect(self.alert) self.threadpool.start(worker) def alert(self, message): alert = QMessageBox.warning(self, "Warning", message)
Handling the result
The weather and forecast
dict objects returned by the workers are emitted through the result signal. This signal is connected to our custom slot
weather_result, which receives the two
dict objects. This method is responsible for updating the UI with the result returned, showing both the numeric data and updating the weather icons.
The weather results are updated to the UI by
setText on the defined
QLabels, formatted to decimal places where appropriate.
:::python def weather_result(self, weather, forecasts): self.latitudeLabel.setText("%.2f °" % weather['coord']['lat']) self.longitudeLabel.setText("%.2f °" % weather['coord']['lon']) self.windLabel.setText("%.2f m/s" % weather['wind']['speed']) self.temperatureLabel.setText("%.1f °C" % weather['main']['temp']) self.pressureLabel.setText("%d" % weather['main']['pressure']) self.humidityLabel.setText("%d" % weather['main']['humidity']) self.weatherLabel.setText("%s (%s)" % ( weather['weather']['main'], weather['weather']['description'] )
The timestamps are processed using a custom
from_ts_to_time_of_day function to return a user-friendlier time of day in am/pm format with no leading zero.
:::python def from_ts_to_time_of_day(ts): dt = datetime.fromtimestamp(ts) return dt.strftime("%I%p").lstrip("0") self.sunriseLabel.setText(from_ts_to_time_of_day(weather['sys']['sunrise']))
The OpenWeatherMap.org has a custom mapping for icons, with each weather state indicated by a specific number — the full mapping is available here. We're using the free fugue icon set, which has a pretty complete set of weather-related icons. To simplify the mapping between the OpenWeatherMap.org and the icon set, the icons have been renamed to their respective OpenWeatherMap.org numeric code.
:::python def set_weather_icon(self, label, weather): label.setPixmap( QPixmap( os.path.join('images', "%s.png" % weather['icon']) ) )
First we set the current weather icon, from the
weather dict, then iterate over the first 5 of the provided forecasts. The forecast icons, times and temperature labels were defined in Qt Designer with the names
forecastTemp<n>, making it simple to iterate over them in turn and retrieve them using
getattr with the current iteration index.
:::python self.set_weather_icon(self.weatherIcon, weather['weather']) for n, forecast in enumerate(forecasts['list'][:5], 1): getattr(self, 'forecastTime%d' % n).setText(from_ts_to_time_of_day(forecast['dt'])) self.set_weather_icon(getattr(self, 'forecastIcon%d' % n), forecast['weather']) getattr(self, 'forecastTemp%d' % n).setText("%.1f °C" % forecast['main']['temp'])
The full source is available on Github.
A few simple ways you could extend this application —
- Eliminate repeated requests for the data, by using
request_cache. This will persist the request data between runs.
- Support for multiple locations.
- Configurable forecast length.
- Make the current weather available on a toolbar icon while running.