├── arial.ttf ├── Icons ├── fog.png ├── hail.png ├── rain.png ├── sleet.png ├── snow.png ├── temp.png ├── wind.png ├── E_absorb.png ├── E_yield.png ├── IAQ_bad.png ├── IAQ_good.png ├── P_feed.png ├── cloudy.png ├── fog-big.png ├── hail-big.png ├── pointer.png ├── rain-big.png ├── snow-big.png ├── wind-big.png ├── IAQ_medium.png ├── P_consume.png ├── P_generate.png ├── P_purchase.png ├── clear-day.png ├── cloudy-big.png ├── sleet-big.png ├── P_batcharge.png ├── S_batcharge.png ├── clear-day-big.png ├── clear-night.png ├── humidity_blue.png ├── humidity_grey.png ├── thunderstorm.png ├── P_batdischarge.png ├── clear-night-big.png ├── partly-cloudy-day.png ├── thunderstorm-big.png ├── partly-cloudy-night.png ├── pressure_tendency_0.png ├── pressure_tendency_1.png ├── pressure_tendency_2.png ├── pressure_tendency_3.png ├── pressure_tendency_4.png ├── pressure_tendency_5.png ├── pressure_tendency_6.png ├── pressure_tendency_7.png ├── pressure_tendency_8.png ├── partly-cloudy-day-big.png ├── partly-cloudy-day-rain.png ├── partly-cloudy-day-snow.png ├── partly-cloudy-night-big.png ├── partly-cloudy-night-rain.png ├── partly-cloudy-night-snow.png ├── partly-cloudy-day-rain-big.png ├── partly-cloudy-day-snow-big.png ├── partly-cloudy-night-rain-big.png └── partly-cloudy-night-snow-big.png ├── LD2410C.png ├── screenshot.png ├── composite_hx_test.hd5 ├── rain.py ├── README.md ├── RadarProcessor.py └── weatherclock_rpi.py /arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/arial.ttf -------------------------------------------------------------------------------- /Icons/fog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/fog.png -------------------------------------------------------------------------------- /LD2410C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/LD2410C.png -------------------------------------------------------------------------------- /Icons/hail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/hail.png -------------------------------------------------------------------------------- /Icons/rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/rain.png -------------------------------------------------------------------------------- /Icons/sleet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/sleet.png -------------------------------------------------------------------------------- /Icons/snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/snow.png -------------------------------------------------------------------------------- /Icons/temp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/temp.png -------------------------------------------------------------------------------- /Icons/wind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/wind.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/screenshot.png -------------------------------------------------------------------------------- /Icons/E_absorb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/E_absorb.png -------------------------------------------------------------------------------- /Icons/E_yield.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/E_yield.png -------------------------------------------------------------------------------- /Icons/IAQ_bad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/IAQ_bad.png -------------------------------------------------------------------------------- /Icons/IAQ_good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/IAQ_good.png -------------------------------------------------------------------------------- /Icons/P_feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/P_feed.png -------------------------------------------------------------------------------- /Icons/cloudy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/cloudy.png -------------------------------------------------------------------------------- /Icons/fog-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/fog-big.png -------------------------------------------------------------------------------- /Icons/hail-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/hail-big.png -------------------------------------------------------------------------------- /Icons/pointer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pointer.png -------------------------------------------------------------------------------- /Icons/rain-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/rain-big.png -------------------------------------------------------------------------------- /Icons/snow-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/snow-big.png -------------------------------------------------------------------------------- /Icons/wind-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/wind-big.png -------------------------------------------------------------------------------- /Icons/IAQ_medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/IAQ_medium.png -------------------------------------------------------------------------------- /Icons/P_consume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/P_consume.png -------------------------------------------------------------------------------- /Icons/P_generate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/P_generate.png -------------------------------------------------------------------------------- /Icons/P_purchase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/P_purchase.png -------------------------------------------------------------------------------- /Icons/clear-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/clear-day.png -------------------------------------------------------------------------------- /Icons/cloudy-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/cloudy-big.png -------------------------------------------------------------------------------- /Icons/sleet-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/sleet-big.png -------------------------------------------------------------------------------- /Icons/P_batcharge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/P_batcharge.png -------------------------------------------------------------------------------- /Icons/S_batcharge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/S_batcharge.png -------------------------------------------------------------------------------- /Icons/clear-day-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/clear-day-big.png -------------------------------------------------------------------------------- /Icons/clear-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/clear-night.png -------------------------------------------------------------------------------- /Icons/humidity_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/humidity_blue.png -------------------------------------------------------------------------------- /Icons/humidity_grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/humidity_grey.png -------------------------------------------------------------------------------- /Icons/thunderstorm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/thunderstorm.png -------------------------------------------------------------------------------- /composite_hx_test.hd5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/composite_hx_test.hd5 -------------------------------------------------------------------------------- /Icons/P_batdischarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/P_batdischarge.png -------------------------------------------------------------------------------- /Icons/clear-night-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/clear-night-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-day.png -------------------------------------------------------------------------------- /Icons/thunderstorm-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/thunderstorm-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-night.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_0.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_1.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_2.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_3.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_4.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_5.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_6.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_7.png -------------------------------------------------------------------------------- /Icons/pressure_tendency_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/pressure_tendency_8.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-day-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-day-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-day-rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-day-rain.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-day-snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-day-snow.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-night-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-night-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-night-rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-night-rain.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-night-snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-night-snow.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-day-rain-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-day-rain-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-day-snow-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-day-snow-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-night-rain-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-night-rain-big.png -------------------------------------------------------------------------------- /Icons/partly-cloudy-night-snow-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkgeiger/rpi-weather-clock/HEAD/Icons/partly-cloudy-night-snow-big.png -------------------------------------------------------------------------------- /rain.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from RadarProcessor import RadarProcessor 4 | from PIL import ImageTk 5 | import tkinter as tk 6 | from tkinter import ttk 7 | import threading 8 | import time 9 | 10 | radar = None # Global radar processor instance 11 | root = None 12 | canvas = None 13 | 14 | def update_image_in_gui(): 15 | global root 16 | global canvas 17 | """Update the displayed image in the GUI (thread-safe)""" 18 | try: 19 | new_pil_image = radar.create_smooth_heatmap_grid(sigma=1.5) 20 | photo = ImageTk.PhotoImage(new_pil_image) 21 | 22 | canvas.create_image(0, 0, anchor=tk.NW, image=photo) 23 | 24 | # Store references to prevent garbage collection 25 | root.photo = photo 26 | root.current_pil_image = new_pil_image 27 | except Exception as e: 28 | print(f"Error updating GUI: {e}") 29 | 30 | def auto_update_loop(): 31 | """Background thread for checking and updating when new radar data is available""" 32 | while True: 33 | time.sleep(60) 34 | 35 | # If still running after 60 seconds, check for new data 36 | has_new_data, server_modified = radar.check_for_new_data() 37 | if has_new_data: 38 | try: 39 | # Load fresh data from server with timestamp info 40 | if radar.load_and_process_data(use_local=False, server_modified=server_modified): 41 | # Schedule image generation in main thread to avoid matplotlib threading issues 42 | root.after(0, update_image_in_gui) 43 | else: 44 | print("Failed to fetch new radar data") 45 | except Exception as e: 46 | print(f"Error updating radar data: {e}") 47 | else: 48 | print("No new radar data, continuing to monitor...") 49 | 50 | def main(): 51 | global radar 52 | global root 53 | global canvas 54 | 55 | # Create radar processor 56 | radar = RadarProcessor( 57 | satellite_source='esri_topo', 58 | zoom_level=11, 59 | center_lon=8.862, # Heimsheim 60 | center_lat=48.806, 61 | image_width_pixels=512, 62 | image_height_pixels=512, 63 | cities={ 64 | 'Heimsheim': (8.862, 48.806, 'red'), 65 | 'Leonberg': (9.014, 48.798, 'green'), 66 | 'Rutesheim': (8.947, 48.808, 'green'), 67 | 'Renningen': (8.934, 48.765, 'green'), 68 | 'Weissach': (8.929, 48.847, 'green'), 69 | 'Friolzheim': (8.835, 48.836, 'green'), 70 | 'Wiernsheim': (8.851, 48.891, 'green'), 71 | 'Liebenzell': (8.732, 48.771, 'green'), 72 | 'Calw': (8.739, 48.715, 'green'), 73 | 'Weil der Stadt': (8.871, 48.750, 'green'), 74 | 'Böblingen': (9.011, 48.686, 'green'), 75 | 'Hochdorf': (9.002, 48.886, 'green'), 76 | 'Pforzheim': (8.704, 48.891, 'green'), 77 | 'Sindelfingen': (9.005, 48.709, 'green'), 78 | } 79 | ) 80 | 81 | # Show GUI 82 | root = tk.Tk() 83 | 84 | # Create canvas for image with exact PIL image size 85 | canvas = tk.Canvas(root, bg='black', width=512, height=512) 86 | canvas.pack() 87 | 88 | # Generate initial image 89 | radar.load_and_process_data(use_local=False) 90 | update_image_in_gui() 91 | 92 | # Start auto-update thread 93 | update_thread = threading.Thread(target=auto_update_loop, daemon=True) 94 | update_thread.start() 95 | 96 | root.mainloop() 97 | 98 | if __name__ == "__main__": 99 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weather Clock v2.0 for Raspberry PI 2 | This project is a dashboard with a Raspberry Pi to display weather data, rain radar, weather forecast, home automation data, etc. on a connected LCD written in Python. The grafical user interface (GIU) is diplayed in so called kiosk mode (no mouse cursor, no window bars, etc.). The data is collected from a local MQTT broker but also from Internet servers providing map data, weather forecast data, rain radar, etc. The rain radar supports actually only radar data from DWD (Deutscher Wetter Dienst). 3 | 4 | drawing 5 | 6 | ## Display 7 | I use a 7 inch IPS Waveshare display with 1024x600 pixels physical resolution, to which I adjusted the geometry of the weather clock GUI. It has and HDMI input and a low power consumption of only 1-2 Watt which is perfect for running 24/7. The power supply happens over the pin header of the Raspberry Pi. Detailed installation instructions you can find on the following Wiki page: 8 | 9 | https://www.waveshare.com/wiki/7inch_HDMI_LCD 10 | 11 | ## Raspberry Pi 12 | For version v2.0 I spent an upgrade because the radar overlay requires significantly more computing power: the Raspberry Pi 2 Zero W with its Quad-core ARMv8, but still with a low power consumption and an HDMI connector. Also it is possible to connect it directly to the backside of the Waveshare display. Only an additional mini-HDMI (type C) to normal-HDMI (type A) cable is required. The whole system (RPI + display) can be powered over the USB connector and consumes in total a maximum of only 5 Watt. 13 | 14 | ## Human Presence Sensor 15 | 16 | For version v2.0 I also spent an upgrade for the radar sensor because the previously used RCWL-0516 interfered a lot with the Wi-Fi frequencies and caused many false positive triggers: the **LD2410C** human presense sensor module, whose radar frequency operates much higher at 24 GHz, is used to switch off the backlight when no motion of human bodies has been detected for the last 5 minutes. This is mainly for saving energy when nobody is in the room to watch the dashboard. The backlight is switched on automatically when motion is detected. The **OUT-pin** of the module is connected to the **GPIO 16** of the Raspberry Pi. Configuration of the sensivity of this radar module can easily be done per Bluetooth (BLE). 17 | 18 | drawing 19 | 20 | ## Software on the Raspberry Pi 21 | * Use at least a **16 Gbyte SD card** and install the latest Raspberry Pi OS (arm64, trixie, not the Lite version) on it. Use the Raspberry Pi Imager for a quick and easy way to install the SD card. 22 | * Configure the graphics driver and resolution as described on https://www.waveshare.com/wiki/7inch_HDMI_LCD 23 | * Configure preferable a static IP within your Wi-Fi network 24 | * Activate **SSH** for remote configuration, SW update and maintainance 25 | * Configure autostart of the weather clock script by creating a **.desktop** file 26 | 27 | ### Install following packages used by the Python script: 28 | * Python 3 is typically installed already with version 3.15.x, so no need to upgrade this 29 | * sudo apt update 30 | * sudo apt install -y python3-h5py 31 | * sudo apt install -y python3-matplotlib 32 | * sudo apt install -y python3-pyproj 33 | * sudo apt install -y python3-paho-mqtt 34 | 35 | ## Weather Clock configuration 36 | ### Please change following variables according to your location and your preferred wishes. 37 | * longitude = "8.862" 38 | * latiude = "48.806" 39 | * timezone = "Europe/Berlin" 40 | * zoom = 11 [8...12] 41 | * radar_background = "esri_topo" ["esri_topo"|"esri_satellite"|"esri_street"|"osm"|"grid"|"topographic"|"simple"] 42 | 43 | The radar background (map) is downloaded as tiles in the desired zoom level when the weatherclock script is started the very first time. The tiles are stored in a tile cache and are loaded from there for all subsequent startups and draw updates of the rain radar. Only if there is a change to above listed configuration variables the background tiles need to be downloaded and cached again. This cache mechanism reduces internet traffic to a minimum. 44 | 45 | ### Please change following variables according to your MQTT settings: 46 | * mqtt_user = "*********" 47 | * mqtt_password = "***************" 48 | * mqtt_broker_address = "192.168.xxx.xxx" 49 | * mqtt_port = 1883 50 | * mqtt_topic_pressure = "/483fdabaceba/pressure" 51 | * mqtt_topic_outtemperature = "/483fdabaceba/temperature" 52 | * mqtt_topic_outhumidity = "/483fdabaceba/humidity" 53 | * mqtt_topic_intemperature = "/483fdabaceba/temperature" 54 | * mqtt_topic_inhumidity = "/483fdabaceba/humidity" 55 | * mqtt_topic_staticiaq = "/483fdaaaceba/staticiaq" 56 | * mqtt_topic_ppurchase = "/00d0935D9eb9/ppurchase" 57 | * mqtt_topic_pfeed = "/00d0935D9eb9/pfeed" 58 | * mqtt_topic_pconsume = "/00d0935D9eb9/pconsume" 59 | * mqtt_topic_pgenerate = "/00d0935D9eb9/pgenerate" 60 | * mqtt_topic_pdischarge = "/00d0935D9eb9/pdischarge" 61 | * mqtt_topic_pcharge = "/00d0935D9eb9/pcharge" 62 | * mqtt_topic_sbatcharge = "/00d0935D9eb9/sbatcharge" 63 | * mqtt_topic_eyield = "/00d0935D9eb9/eyield" 64 | * mqtt_topic_eabsorb = "/00d0935D9eb9/eabsorb" 65 | 66 | Hint: *You can deactivate the drawing of a widget if you don't need it by commenting out the responsible draw method. Feel free to implement your own widget.* 67 | 68 | ## Internet services used 69 | * Tiles for the map background from Openstreetmap: e.g. https://tile.openstreetmap.org/{z}/{x}/{y}.png 70 | * Tiles for the map background from Esri: e.g. https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}, https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}, https://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x} 71 | * DWD HX radar composite: https://opendata.dwd.de/weather/radar/composite/hx/composite_hx_LATEST-hd5 72 | * Actual weather and weather forecast from BrightSky (DWD open weather data): e.g. https://api.brightsky.dev/weather?lat=48.80&lon=8.90&date=2021-10-18&tz=Europe/Berlin 73 | 74 | ## The program 75 | Because rain radar processing and projection on a map background became with v2.0 much more complex as just downloading rain radar tiles from a tile server like from RainViewer, I decided to implement this stuff in a separate class in file **RadarProcessor.py**. The class is limited actually to support only DWD radar data, means covering only Germany. It uses the DWD HX radar composite product in HDF5 format, where the reflectivity is measured in dBZ and which is updated every 5 minutes. Also the class supports features like: 76 | * different map backgrounds 77 | * cities overlay 78 | * download only on new radar data (typically every 5 minutes) 79 | * original HX radar dBZ colors 80 | * heatmap gaussian filtering for smooth antialzed rain radar visualization 81 | * using exact projection which comes with DWD radar data 82 | * tile caching, to reduce the traffic with map servers to a minimum 83 | 84 | Also **weatherclock_rpi.py** itself has been improved to solve some known bugs, e.g. a flickering issue which was frequently observed when widgets were updated/redrawn and MQTT stability/reconnection. The support for downloading tiles from RainViewer has been replaced by downloading and processing rain radar data from DWD. 85 | 86 | Execute the script (for running on a Raspberry Pi) with: **python3 ./weatherclock_rpi.py** 87 | 88 | Execute following script for running the weather clock on a PC under Linux or Windows: **python3 ./weatherclock_pc.py** 89 | 90 | To test the rain radar stand-alone you can execute: **python3 ./rain.py** 91 | -------------------------------------------------------------------------------- /RadarProcessor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | RadarProcessor class for DWD radar data processing and visualization 5 | Requires pyproj for accurate coordinate transformations 6 | """ 7 | import requests 8 | import h5py 9 | import numpy as np 10 | import os 11 | import io 12 | import math 13 | import gc 14 | from PIL import Image 15 | 16 | # Use non-GUI backend to avoid display errors on headless systems / Pi 17 | import matplotlib 18 | matplotlib.use("Agg") 19 | import matplotlib.pyplot as plt 20 | from matplotlib.colors import ListedColormap, LinearSegmentedColormap, BoundaryNorm 21 | 22 | # ---------- RadarProcessor class ---------- 23 | class RadarProcessor: 24 | def __init__(self, satellite_source='simple', zoom_level=11, 25 | center_lon=8.862, center_lat=48.806, 26 | image_width_pixels=512, image_height_pixels=512, 27 | cities=None): 28 | """Initialize the radar processor with configurable parameters 29 | 30 | Requires pyproj for accurate coordinate transformations. 31 | """ 32 | 33 | # Define available background map types and tile sources 34 | self.background_types = { 35 | 'simple': 'Simple light background', 36 | 'grid': 'Light background with coordinate grid', 37 | 'topographic': 'Simulated topographic style', 38 | 'osm': 'OpenStreetMap tiles', 39 | 'esri_satellite': 'Esri satellite map', 40 | 'esri_topo': 'Esri topographic map', 41 | 'esri_street': 'Esri street map' 42 | } 43 | 44 | # Store configuration parameters 45 | self.satellite_source = satellite_source # Background map type to use 46 | self.zoom_level = max(8, min(12, zoom_level)) # Clamp zoom level to reasonable range (8-12) 47 | 48 | # Geographic center point and output image dimensions 49 | self.center_lon = float(center_lon) # Center longitude in decimal degrees 50 | self.center_lat = float(center_lat) # Center latitude in decimal degrees 51 | self.image_width_pixels = int(image_width_pixels) # Output image width 52 | self.image_height_pixels = int(image_height_pixels) # Output image height 53 | 54 | # Geographic bounds calculated from center and dimensions 55 | self.area_bounds = None # Will be set by _calculate_area_bounds() 56 | 57 | # Radar data storage (loaded from HDF5 files) 58 | self.raw_data = None # Original radar data from file (before scaling) 59 | self.scaled_data = None # Processed radar data (dBZ values) 60 | self.lons = None # Longitude coordinates for each radar pixel 61 | self.lats = None # Latitude coordinates for each radar pixel 62 | 63 | # Crop offset tracking for area-of-interest optimization 64 | self.crop_row_offset = 0 65 | self.crop_col_offset = 0 66 | self.full_rows = 0 67 | self.full_cols = 0 68 | 69 | # Coordinate system attributes for compatibility with original pyproj version 70 | self.radar_crs = None # Coordinate reference system (stored but not used) 71 | self.transformer = None # Coordinate transformer (stored but not used) 72 | 73 | # City markers configuration - supports both (lon,lat) and (lon,lat,color) formats 74 | self.cities = cities if cities is not None else {} # Dictionary of city locations 75 | 76 | # Tile caching system for faster map background loading 77 | self.tile_cache_dir = "tilecache" # Directory to store downloaded map tiles 78 | os.makedirs(self.tile_cache_dir, exist_ok=True) # Create cache directory if needed 79 | 80 | # Data freshness tracking for automatic updates 81 | self.last_modified = None # Timestamp of last radar data update 82 | 83 | # Calculate geographic area bounds from center point and image dimensions 84 | self._calculate_area_bounds() 85 | 86 | def _calculate_required_radar_bounds(self, projdef, ll_lon, ll_lat, xscale, yscale, rows, cols): 87 | """Calculate the minimum radar pixel bounds needed to cover the area of interest. 88 | 89 | This allows us to crop the radar data before coordinate transformation, 90 | dramatically reducing memory usage while preserving full quality in the AOI. 91 | 92 | Returns: 93 | tuple: (row_start, row_end, col_start, col_end) pixel bounds 94 | """ 95 | from pyproj import CRS, Transformer 96 | 97 | # Get area of interest bounds with a safety buffer 98 | lon_min, lon_max, lat_min, lat_max = self.area_bounds 99 | buffer_degrees = 0.1 # 0.1 degree buffer (~11km) for safety 100 | lon_min_buf = lon_min - buffer_degrees 101 | lon_max_buf = lon_max + buffer_degrees 102 | lat_min_buf = lat_min - buffer_degrees 103 | lat_max_buf = lat_max + buffer_degrees 104 | 105 | try: 106 | # Create coordinate transformers 107 | radar_crs = CRS.from_proj4(projdef) 108 | wgs84_crs = CRS.from_epsg(4326) 109 | reverse_transformer = Transformer.from_crs(wgs84_crs, radar_crs, always_xy=True) 110 | 111 | # Transform AOI corners to radar projection coordinates 112 | grid_origin_x, grid_origin_y = reverse_transformer.transform(ll_lon, ll_lat) 113 | 114 | # Transform AOI bounds to projection coordinates 115 | corner_coords = [ 116 | (lon_min_buf, lat_min_buf), # Bottom-left 117 | (lon_max_buf, lat_min_buf), # Bottom-right 118 | (lon_min_buf, lat_max_buf), # Top-left 119 | (lon_max_buf, lat_max_buf) # Top-right 120 | ] 121 | 122 | proj_xs, proj_ys = [], [] 123 | for lon, lat in corner_coords: 124 | proj_x, proj_y = reverse_transformer.transform(lon, lat) 125 | proj_xs.append(proj_x) 126 | proj_ys.append(proj_y) 127 | 128 | # Convert projection coordinates to pixel indices 129 | # Pixel centers: x = origin_x + (col + 0.5) * xscale 130 | # Solving for col: col = (x - origin_x) / xscale - 0.5 131 | proj_x_min, proj_x_max = min(proj_xs), max(proj_xs) 132 | proj_y_min, proj_y_max = min(proj_ys), max(proj_ys) 133 | 134 | col_min = int((proj_x_min - grid_origin_x) / xscale - 0.5) 135 | col_max = int((proj_x_max - grid_origin_x) / xscale + 0.5) 136 | 137 | # Y coordinates are flipped: y = origin_y + (rows - 1 - row + 0.5) * yscale 138 | # Solving for row: row = rows - 1 - (y - origin_y) / yscale + 0.5 139 | row_min = int(rows - 1 - (proj_y_max - grid_origin_y) / yscale + 0.5) 140 | row_max = int(rows - 1 - (proj_y_min - grid_origin_y) / yscale + 0.5) 141 | 142 | # Clamp to valid radar grid bounds 143 | col_min = max(0, col_min) 144 | col_max = min(cols - 1, col_max) 145 | row_min = max(0, row_min) 146 | row_max = min(rows - 1, row_max) 147 | 148 | # Ensure we have at least some data 149 | if col_max <= col_min or row_max <= row_min: 150 | print(f"Warning: AOI bounds too small, using safety fallback") 151 | # Use a minimum 200x200 pixel area around the center 152 | center_row, center_col = rows // 2, cols // 2 153 | row_min = max(0, center_row - 100) 154 | row_max = min(rows - 1, center_row + 100) 155 | col_min = max(0, center_col - 100) 156 | col_max = min(cols - 1, center_col + 100) 157 | 158 | crop_rows = row_max - row_min + 1 159 | crop_cols = col_max - col_min + 1 160 | original_pixels = rows * cols 161 | cropped_pixels = crop_rows * crop_cols 162 | reduction_factor = original_pixels / cropped_pixels 163 | 164 | #print(f"Radar crop: [{row_min}:{row_max+1}, {col_min}:{col_max+1}] = {crop_rows}x{crop_cols}") 165 | #print(f"Memory reduction: {reduction_factor:.1f}x ({original_pixels:,} -> {cropped_pixels:,} pixels)") 166 | 167 | return row_min, row_max + 1, col_min, col_max + 1 # Return as slice bounds 168 | 169 | except Exception as e: 170 | print(f"Error calculating radar bounds: {e}") 171 | # Fallback to full grid 172 | return 0, rows, 0, cols 173 | 174 | def _log_memory_usage(self, stage=""): 175 | """Log current memory usage for debugging purposes. 176 | 177 | This method provides memory usage information during radar data processing. 178 | Can be safely called without any external dependencies. 179 | """ 180 | try: 181 | import psutil 182 | process = psutil.Process() 183 | memory_mb = process.memory_info().rss / (1024 * 1024) 184 | print(f"Memory usage {stage}: {memory_mb:.1f} MB") 185 | except ImportError: 186 | # psutil not available, use simpler approach or skip logging 187 | pass 188 | 189 | def _gaussian_blur_numpy(self, data, sigma=1.5): 190 | """Apply Gaussian blur to smooth radar data using NumPy-only implementation. 191 | 192 | Uses separable kernel approach: blur horizontally first, then vertically. 193 | This is more efficient than 2D convolution for Gaussian kernels. 194 | """ 195 | if sigma <= 0: 196 | return data # No blurring needed 197 | 198 | # Create 1D Gaussian kernel - use float16 for memory efficiency 199 | radius = int(3 * sigma) # Kernel extends to 3 standard deviations 200 | x = np.arange(-radius, radius + 1, dtype=np.float16) # Symmetric range around zero 201 | kernel = np.exp(-(x ** 2) / (2 * sigma ** 2)) # Gaussian formula 202 | kernel = (kernel / kernel.sum()).astype(np.float16) # Normalize to sum = 1 203 | 204 | # Ensure data is float16 for consistent processing, clamping to valid range 205 | data_clamped = np.clip(data, -65500, 65500) 206 | data_f16 = data_clamped.astype(np.float16) 207 | 208 | # Apply horizontal blur (convolve each row) 209 | temp = np.apply_along_axis( 210 | lambda m: np.convolve(m, kernel, mode='same'), # Same size output 211 | axis=1, # Apply to each row (horizontal direction) 212 | arr=data_f16 213 | ).astype(np.float16) 214 | 215 | # Apply vertical blur (convolve each column) 216 | result = np.apply_along_axis( 217 | lambda m: np.convolve(m, kernel, mode='same'), # Same size output 218 | axis=0, # Apply to each column (vertical direction) 219 | arr=temp 220 | ).astype(np.float16) 221 | 222 | return result 223 | 224 | def _calculate_area_bounds(self): 225 | """Calculate geographic bounds (lon/lat) from center point and image dimensions. 226 | 227 | Uses zoom level to determine scale, then calculates the geographic area 228 | that will be covered by the requested image size in pixels. 229 | """ 230 | # Base scale reference: zoom 10 covers ~39km per 256px tile at equator 231 | base_km_per_tile = 39.0 # Kilometers per tile at zoom level 10 232 | tile_size_px = 256 # Standard tile size in pixels 233 | 234 | # Adjust for latitude (Earth is not flat - distance varies with latitude) 235 | cos_lat = np.cos(np.radians(self.center_lat)) # Correction factor for longitude 236 | base_km_per_tile_at_lat = base_km_per_tile * cos_lat # Actual km/tile at this latitude 237 | 238 | # Calculate scale factor based on zoom level difference from reference (zoom 10) 239 | scale_factor = 2 ** (10 - self.zoom_level) # Higher zoom = smaller area 240 | km_per_px = (base_km_per_tile_at_lat * scale_factor) / tile_size_px # km per pixel 241 | 242 | # Calculate total area coverage in kilometers 243 | total_width_km = self.image_width_pixels * km_per_px # Image width in km 244 | total_height_km = self.image_height_pixels * km_per_px # Image height in km 245 | 246 | # Convert from kilometers to degrees (approximate conversion) 247 | # 1 degree latitude ≈ 111 km everywhere 248 | # 1 degree longitude ≈ 111 km * cos(latitude) 249 | half_width_deg = (total_width_km / 2) / (111.0 * cos_lat) # Half-width in degrees 250 | half_height_deg = (total_height_km / 2) / 111.0 # Half-height in degrees 251 | 252 | # Calculate geographic bounds around center point 253 | lon_min = self.center_lon - half_width_deg # Western boundary 254 | lon_max = self.center_lon + half_width_deg # Eastern boundary 255 | lat_min = self.center_lat - half_height_deg # Southern boundary 256 | lat_max = self.center_lat + half_height_deg # Northern boundary 257 | 258 | # Store as tuple: (west, east, south, north) 259 | self.area_bounds = (lon_min, lon_max, lat_min, lat_max) 260 | 261 | def download_hdf5_data(self, use_local=True): 262 | """Download HDF5 radar data from DWD or use local file. 263 | 264 | This method handles dual data source capability: 265 | 1. Local file mode: Load radar data from local HDF5 file for testing/offline use 266 | 2. Online mode: Download latest radar data from DWD OpenData service 267 | 268 | Args: 269 | use_local: If True, try local file first; if False, download from server 270 | 271 | Returns: 272 | bytes: Raw HDF5 data, or None if failed to load/download 273 | """ 274 | if use_local: 275 | # Local file mode - load from disk for testing or offline operation 276 | local_filename = "composite_hx_test.hd5" # Expected local file name 277 | 278 | if os.path.exists(local_filename): 279 | try: 280 | # Read entire file into memory as binary data 281 | with open(local_filename, "rb") as f: 282 | data = f.read() 283 | 284 | # Validate that file contains actual data (not empty) 285 | if len(data) == 0: 286 | print(f"Local file {local_filename} is empty") 287 | return None 288 | 289 | print(f"Loaded local radar data from {local_filename} ({len(data)} bytes)") 290 | return data 291 | 292 | except (IOError, OSError) as e: 293 | # File system errors (permissions, disk issues, etc.) 294 | print(f"Error reading local file {local_filename}: {e}") 295 | return None 296 | else: 297 | # Local file not found - this is normal for first run 298 | print(f"Local file {local_filename} not found") 299 | return None 300 | else: 301 | # Online mode - download latest data from DWD OpenData service 302 | url = "https://opendata.dwd.de/weather/radar/composite/hx/composite_hx_LATEST-hd5" 303 | #print(f"Downloading HDF5 radar data from: {url}") # Debug output 304 | 305 | try: 306 | # Request radar data with generous timeout (files can be large ~2-4MB) 307 | r = requests.get(url, timeout=60) 308 | r.raise_for_status() # Raise exception for HTTP error codes 309 | 310 | # Validate that server returned actual data (not empty response) 311 | if len(r.content) == 0: 312 | print("Server returned empty radar data file") 313 | return None 314 | 315 | #print(f"Successfully downloaded HDF5 data ({len(r.content)} bytes)") 316 | return r.content # Return raw binary data 317 | 318 | except requests.exceptions.RequestException as e: 319 | # Network errors, timeouts, HTTP errors, etc. 320 | print(f"Error downloading radar data: {e}") 321 | return None 322 | except Exception as e: 323 | # Catch-all for unexpected errors during download 324 | print(f"Unexpected error during radar data download: {e}") 325 | return None 326 | 327 | def load_and_process_data(self, use_local=True, server_modified=None): 328 | """Load and process HDF5 radar data from file or server. 329 | 330 | This method handles the complete workflow: 331 | 1. Download/load raw HDF5 data 332 | 2. Parse radar data and metadata 333 | 3. Apply scaling to convert to dBZ values 334 | 4. Setup coordinate transformation 335 | 336 | Args: 337 | use_local: If True, try local file first before downloading 338 | server_modified: Timestamp of server data for caching 339 | 340 | Returns: 341 | bool: True if data loaded successfully, False otherwise 342 | """ 343 | # Step 1: Get raw HDF5 data (from file or download) 344 | hdf5_data = self.download_hdf5_data(use_local) 345 | if hdf5_data is None: 346 | return False # Failed to get data 347 | 348 | # Update timestamp if provided (for cache management) 349 | if server_modified is not None: 350 | self.last_modified = server_modified 351 | 352 | # Step 2: Create in-memory file object for HDF5 parsing 353 | try: 354 | memory_file = io.BytesIO(hdf5_data) # Convert bytes to file-like object 355 | except Exception as e: 356 | print(f"Error creating memory file from data: {e}") 357 | return False 358 | 359 | # Step 3: Parse HDF5 structure and extract metadata first (for area calculation) 360 | try: 361 | with h5py.File(memory_file, "r") as f: 362 | # Extract geographic reference information and grid info FIRST 363 | ll_lon = f["/where"].attrs["LL_lon"] # Lower-left longitude 364 | ll_lat = f["/where"].attrs["LL_lat"] # Lower-left latitude 365 | projdef = f["/where"].attrs["projdef"] # Projection definition string 366 | 367 | # Convert bytes to string if needed 368 | if isinstance(projdef, bytes): 369 | projdef = projdef.decode('utf-8') 370 | 371 | # Extract grid spacing (pixel size in meters) 372 | try: 373 | xscale = float(f["/where"].attrs["xscale"]) # Pixel width in meters 374 | yscale = float(f["/where"].attrs["yscale"]) # Pixel height in meters 375 | except KeyError: 376 | # Use default 250m resolution if not specified 377 | xscale = yscale = 250.0 378 | print("Using default grid scale: 250m x 250m") 379 | 380 | # Get full grid dimensions for area calculation 381 | full_shape = f["/dataset1/data1/data"].shape 382 | rows, cols = full_shape 383 | 384 | # Calculate required radar bounds for area of interest 385 | row_start, row_end, col_start, col_end = self._calculate_required_radar_bounds( 386 | projdef, ll_lon, ll_lat, xscale, yscale, rows, cols 387 | ) 388 | 389 | # Load ONLY the required subset of radar data (massive memory savings!) 390 | #print(f"Loading cropped radar data: [{row_start}:{row_end}, {col_start}:{col_end}]") 391 | self.raw_data = f["/dataset1/data1/data"][row_start:row_end, col_start:col_end] 392 | #print(f"Cropped data shape: {self.raw_data.shape} (vs {full_shape} full)") 393 | 394 | # Store crop offset for coordinate adjustment 395 | self.crop_row_offset = row_start 396 | self.crop_col_offset = col_start 397 | 398 | # Extract scaling parameters to convert raw values to dBZ 399 | gain = f["/dataset1/data1/what"].attrs["gain"] # Scaling factor 400 | offset = f["/dataset1/data1/what"].attrs["offset"] # Offset value 401 | nodata = f["/dataset1/data1/what"].attrs["nodata"] # No-data marker 402 | undetect = f["/dataset1/data1/what"].attrs["undetect"] # Below detection threshold 403 | 404 | except Exception as e: 405 | print(f"Error reading HDF5 file: {e}") 406 | print("The file might be corrupted or in an unexpected format") 407 | return False 408 | 409 | # Get cropped radar data dimensions 410 | rows, cols = self.raw_data.shape # Cropped dimensions, much smaller than full grid 411 | 412 | # Step 4: Apply scaling to convert raw values to meteorological units (dBZ) 413 | # Use float32 for better precision, then convert to float16 for storage 414 | # This avoids precision issues that can vary between platforms/NumPy versions 415 | scaled_f32 = self.raw_data.astype(np.float32) * np.float32(gain) + np.float32(offset) 416 | 417 | # Mark special values before final conversion 418 | scaled_f32[self.raw_data == undetect] = -32.0 # Below radar detection threshold 419 | scaled_f32[self.raw_data == nodata] = np.nan # No data available (NaN) 420 | 421 | # Convert to float16 only after proper scaling and special value handling 422 | # This ensures consistent behavior across different platforms/NumPy versions 423 | self.scaled_data = scaled_f32.astype(np.float16) 424 | 425 | # Step 5: Setup coordinate transformation from radar grid to lat/lon 426 | try: 427 | # Store full grid dimensions for coordinate calculations 428 | self.full_rows = full_shape[0] 429 | self.full_cols = full_shape[1] 430 | 431 | self.setup_projection(projdef, float(ll_lon), float(ll_lat), 432 | float(xscale), float(yscale), rows, cols) 433 | return True # Success 434 | except Exception as e: 435 | print(f"Error setting up coordinate projection: {e}") 436 | return False 437 | 438 | def setup_projection(self, projdef, ll_lon, ll_lat, xscale, yscale, rows, cols): 439 | """Setup coordinate transformation from radar grid to geographic coordinates. 440 | 441 | This method converts the radar's projected coordinate system (usually stereographic) 442 | to latitude/longitude coordinates for each pixel in the radar grid using pyproj. 443 | 444 | Args: 445 | projdef: PROJ.4 projection definition string 446 | ll_lon: Lower-left corner longitude 447 | ll_lat: Lower-left corner latitude 448 | xscale: Pixel width in meters 449 | yscale: Pixel height in meters 450 | rows: Number of radar grid rows 451 | cols: Number of radar grid columns 452 | """ 453 | # Store projection info for compatibility 454 | self.radar_crs = projdef 455 | self.transformer = None 456 | 457 | # Use pyproj for accurate coordinate transformation 458 | from pyproj import CRS, Transformer 459 | 460 | # Create coordinate reference systems 461 | radar_crs = CRS.from_proj4(projdef) # Radar's projection (usually stereographic) 462 | wgs84_crs = CRS.from_epsg(4326) # Standard lat/lon (WGS84) 463 | 464 | # Create bidirectional coordinate transformers 465 | transformer = Transformer.from_crs(radar_crs, wgs84_crs, always_xy=True) 466 | reverse_transformer = Transformer.from_crs(wgs84_crs, radar_crs, always_xy=True) 467 | 468 | # Calculate projected coordinates of the grid origin (lower-left corner) 469 | grid_origin_x, grid_origin_y = reverse_transformer.transform(ll_lon, ll_lat) 470 | 471 | # Create coordinate grids for CROPPED radar pixels only 472 | # Adjust for crop offset to maintain correct geographic positioning 473 | #self._log_memory_usage("before coordinate grid creation") 474 | 475 | # Create coordinate grids as float32 for good precision and memory efficiency 476 | # Add crop offsets to maintain correct geographic positioning 477 | col_indices = np.arange(cols) + self.crop_col_offset 478 | row_indices = np.arange(rows) + self.crop_row_offset 479 | 480 | x_proj_1d = (grid_origin_x + (col_indices + 0.5) * xscale).astype(np.float32) 481 | y_proj_1d = (grid_origin_y + (self.full_rows - 1 - row_indices + 0.5) * yscale).astype(np.float32) 482 | 483 | # Create meshgrids as float32 (pyproj needs reasonable precision) 484 | x_proj_grid, y_proj_grid = np.meshgrid(x_proj_1d, y_proj_1d) 485 | x_proj_grid = x_proj_grid.astype(np.float32) 486 | y_proj_grid = y_proj_grid.astype(np.float32) 487 | del x_proj_1d, y_proj_1d # Free memory immediately 488 | #self._log_memory_usage("after meshgrid creation") 489 | 490 | # Transform coordinates using pyproj 491 | lons_temp, lats_temp = transformer.transform(x_proj_grid, y_proj_grid) 492 | del x_proj_grid, y_proj_grid # Free large arrays immediately 493 | #self._log_memory_usage("after coordinate transformation") 494 | 495 | # Clamp and convert to float32 for geographic precision 496 | np.clip(lons_temp, -180.0, 180.0, out=lons_temp) 497 | np.clip(lats_temp, -90.0, 90.0, out=lats_temp) 498 | self.lons = lons_temp.astype(np.float32) 499 | self.lats = lats_temp.astype(np.float32) 500 | del lons_temp, lats_temp # Free temporary arrays 501 | 502 | gc.collect() # Force garbage collection 503 | #self._log_memory_usage("after coordinate optimization") 504 | 505 | def check_for_new_data(self): 506 | """Check if new radar data is available on DWD server using efficient HEAD request. 507 | 508 | Uses HTTP HEAD request to check file modification time without downloading 509 | the entire file. This is much more efficient than downloading to check freshness. 510 | 511 | Returns: 512 | tuple: (bool: has_new_data, datetime: server_timestamp) 513 | - has_new_data: True if server has newer data than our cache 514 | - server_timestamp: Last-Modified time from server, or None if unavailable 515 | """ 516 | url = "https://opendata.dwd.de/weather/radar/composite/hx/composite_hx_LATEST-hd5" 517 | 518 | try: 519 | # Send HEAD request - gets headers only, not file content (much faster) 520 | response = requests.head(url, timeout=30) 521 | response.raise_for_status() # Raise exception for HTTP error codes (404, 500, etc.) 522 | 523 | # Extract Last-Modified timestamp from HTTP headers 524 | last_modified_str = response.headers.get('Last-Modified') 525 | 526 | if last_modified_str: 527 | # Parse RFC 2822 date format used in HTTP headers 528 | from email.utils import parsedate_to_datetime 529 | server_modified = parsedate_to_datetime(last_modified_str) 530 | 531 | # Compare with our cached timestamp to determine if update needed 532 | if self.last_modified is None or server_modified > self.last_modified: 533 | # Either we have no data yet, or server has newer data 534 | #print(f"New radar data available (server: {server_modified})") 535 | return True, server_modified 536 | else: 537 | # Our cached data is current - no update needed 538 | #print(f"Radar data is current (last check: {self.last_modified})") 539 | return False, server_modified 540 | else: 541 | # Server doesn't provide Last-Modified header - assume data might be new 542 | print("Server doesn't provide Last-Modified header, assuming new data") 543 | return True, None 544 | 545 | except requests.exceptions.RequestException as e: 546 | # Network errors, timeouts, server errors, etc. 547 | print(f"Error checking for new radar data: {e}") 548 | return False, None # Assume no new data on network errors 549 | 550 | except Exception as e: 551 | # Catch-all for unexpected errors (parsing, etc.) 552 | print(f"Unexpected error checking for new radar data: {e}") 553 | return False, None 554 | 555 | def get_area_cities(self): 556 | """Filter cities to only those visible within the current map area bounds. 557 | 558 | Implements two-pass filtering: 559 | 1. Exact bounds check - cities within the visible map area 560 | 2. Buffer zone check - cities just outside that might still be relevant 561 | 562 | Supports backward compatibility with both city data formats: 563 | - Legacy format: {"CityName": (longitude, latitude)} 564 | - Enhanced format: {"CityName": (longitude, latitude, color)} 565 | 566 | Returns: 567 | dict: Filtered cities in format {"CityName": (lon, lat, color)} 568 | """ 569 | # Extract current map boundaries 570 | lon_min, lon_max, lat_min, lat_max = self.area_bounds 571 | 572 | area_cities = {} # Will store cities visible in current map area 573 | 574 | # First pass: Find cities exactly within map bounds 575 | for city, city_data in self.cities.items(): 576 | # Parse city data format for backward compatibility 577 | if len(city_data) == 2: 578 | # Legacy format: (longitude, latitude) 579 | lon, lat = city_data 580 | color = 'red' # Default marker color for legacy format 581 | else: 582 | # Enhanced format: (longitude, latitude, color) 583 | lon, lat, color = city_data 584 | 585 | # Check if city coordinates fall within current map view 586 | if lon_min <= lon <= lon_max and lat_min <= lat <= lat_max: 587 | # City is visible - add to results with standardized format 588 | area_cities[city] = (lon, lat, color) 589 | 590 | # Second pass: Check buffer zone for cities just outside view 591 | # This ensures cities near map edges are still shown (improves user experience) 592 | buffer = 0.5 # Degrees of latitude/longitude buffer around map edges 593 | 594 | for city, city_data in self.cities.items(): 595 | # Skip cities already found in first pass 596 | if city not in area_cities: 597 | # Parse city data format again (same logic as above) 598 | if len(city_data) == 2: 599 | lon, lat = city_data 600 | color = 'red' # Default color 601 | else: 602 | lon, lat, color = city_data 603 | 604 | # Check if city is within buffered bounds 605 | if (lon_min - buffer <= lon <= lon_max + buffer and 606 | lat_min - buffer <= lat <= lat_max + buffer): 607 | # City is in buffer zone - add to results 608 | area_cities[city] = (lon, lat, color) 609 | 610 | return area_cities # Dictionary of cities visible in current map area 611 | 612 | def _deg2num(self, lat_deg, lon_deg, zoom): 613 | """Convert geographic coordinates (latitude/longitude) to tile numbers. 614 | 615 | This implements the standard Web Mercator projection used by most map tile services 616 | (Google Maps, OpenStreetMap, etc.). The conversion follows these steps: 617 | 1. Longitude: Linear mapping from [-180,+180] to tile X coordinates 618 | 2. Latitude: Mercator projection to handle Earth's spherical geometry 619 | 620 | The Mercator projection handles the fact that lines of longitude converge at 621 | the poles, ensuring that rectangular tiles maintain consistent angular coverage. 622 | 623 | Args: 624 | lat_deg: Latitude in decimal degrees [-85.05, +85.05] 625 | lon_deg: Longitude in decimal degrees [-180, +180] 626 | zoom: Zoom level [0-20] where 0=whole world, 20=maximum detail 627 | 628 | Returns: 629 | tuple: (x_tile, y_tile) - Integer tile coordinates at specified zoom level 630 | """ 631 | # Convert latitude to radians for trigonometric calculations 632 | lat_rad = math.radians(lat_deg) 633 | 634 | # Calculate total number of tiles at this zoom level 635 | # Each zoom level doubles the resolution: 2^0=1 tile, 2^1=4 tiles, etc. 636 | n = 2.0 ** zoom 637 | 638 | # Longitude to X tile coordinate (simple linear mapping) 639 | # Longitude range [-180,+180] maps to tile range [0, n-1] 640 | x = int((lon_deg + 180.0) / 360.0 * n) 641 | 642 | # Latitude to Y tile coordinate (Mercator projection) 643 | # This complex formula handles Earth's spherical geometry: 644 | # - math.tan(lat_rad): Convert to slope at this latitude 645 | # - math.asinh(): Inverse hyperbolic sine (Mercator projection core) 646 | # - Normalization to [0,1] range, then scale to [0, n-1] 647 | y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) 648 | 649 | return (x, y) # Return tile coordinates as integers 650 | 651 | def _get_tile_cache_path(self, x, y, z, tile_source): 652 | """Generate file path for cached map tile. 653 | 654 | Args: 655 | x, y: Tile coordinates 656 | z: Zoom level 657 | tile_source: Map source (osm, esri_topo, etc.) 658 | 659 | Returns: 660 | str: Full path to cache file 661 | """ 662 | cache_filename = f"{tile_source}_{z}_{x}_{y}.png" # Unique filename per tile 663 | return os.path.join(self.tile_cache_dir, cache_filename) 664 | 665 | def _load_cached_tile(self, x, y, z, tile_source): 666 | """Load map tile from cache if it exists. 667 | 668 | Args: 669 | x, y: Tile coordinates 670 | z: Zoom level 671 | tile_source: Map source 672 | 673 | Returns: 674 | PIL.Image: Cached tile image, or None if not cached 675 | """ 676 | cache_path = self._get_tile_cache_path(x, y, z, tile_source) 677 | 678 | if os.path.exists(cache_path): 679 | try: 680 | # Load image from cache file 681 | tile_img = Image.open(cache_path).convert("RGB") 682 | #print(f"Loaded cached tile {x},{y},{z}") # Debug output (commented) 683 | return tile_img 684 | except Exception as e: 685 | print(f"Failed to load cached tile {cache_path}: {e}") 686 | # Remove corrupted cache file so it will be re-downloaded 687 | try: 688 | os.remove(cache_path) 689 | except: 690 | pass # Ignore removal errors 691 | return None # Not in cache or failed to load 692 | 693 | def _save_tile_to_cache(self, tile_img, x, y, z, tile_source): 694 | """Save downloaded map tile to local cache for future reuse. 695 | 696 | Implements persistent tile caching to dramatically improve performance: 697 | - Avoids repeated downloads of the same map tiles 698 | - Reduces network bandwidth usage 699 | - Provides offline capability for previously viewed areas 700 | - Speeds up map rendering by ~10-50x for cached tiles 701 | 702 | Args: 703 | tile_img: PIL Image object containing the downloaded tile 704 | x, y: Tile coordinates at specified zoom level 705 | z: Zoom level 706 | tile_source: Map source identifier (osm, esri_topo, etc.) 707 | """ 708 | # Generate unique cache file path for this specific tile 709 | cache_path = self._get_tile_cache_path(x, y, z, tile_source) 710 | 711 | try: 712 | # Save tile as PNG to preserve quality while maintaining reasonable file size 713 | # PNG is lossless and supports transparency (important for overlay maps) 714 | tile_img.save(cache_path, "PNG") 715 | print(f"Cached tile {x},{y},{z} to {cache_path}") # Success notification 716 | 717 | except Exception as e: 718 | # File system errors: permissions, disk full, invalid path, etc. 719 | # Non-critical error - caching failure doesn't break functionality 720 | print(f"Failed to cache tile {cache_path}: {e}") 721 | # Note: We don't re-raise the exception because caching is optional 722 | # The application should continue working even if caching fails 723 | 724 | def _download_tile(self, x, y, z, tile_source='osm'): 725 | """Download a single map tile from the specified tile service with caching. 726 | 727 | Implements intelligent caching strategy: 728 | 1. Check cache first - return immediately if tile exists locally 729 | 2. Download from appropriate tile service if not cached 730 | 3. Save to cache for future requests 731 | 4. Handle various tile service URL formats and error conditions 732 | 733 | Args: 734 | x, y: Tile coordinates at zoom level z 735 | z: Zoom level (higher = more detail) 736 | tile_source: Map service ('osm', 'esri_satellite', 'esri_topo', 'esri_street') 737 | 738 | Returns: 739 | PIL.Image: RGB tile image (256x256 pixels), or None if download failed 740 | """ 741 | # Step 1: Check if tile is already cached locally 742 | cached_tile = self._load_cached_tile(x, y, z, tile_source) 743 | if cached_tile is not None: 744 | # Cache hit - return immediately without network request 745 | return cached_tile 746 | 747 | # Step 2: Cache miss - need to download from tile service 748 | # Build appropriate URL based on tile service provider 749 | if tile_source == 'osm': 750 | # OpenStreetMap - free, community-maintained maps 751 | url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png" 752 | elif tile_source == 'esri_satellite': 753 | # Esri World Imagery - satellite/aerial photos 754 | # Note: Y coordinate comes before X in Esri services 755 | url = f"https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" 756 | elif tile_source == 'esri_topo': 757 | # Esri Topographic Map - detailed topographic features 758 | url = f"https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}" 759 | elif tile_source == 'esri_street': 760 | # Esri Street Map - detailed street and city maps 761 | url = f"https://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}" 762 | else: 763 | # Unknown tile source - cannot proceed 764 | print(f"Unknown tile source: {tile_source}") 765 | return None 766 | 767 | try: 768 | # Step 3: Download tile with proper headers to avoid blocking 769 | headers = { 770 | # Use realistic browser User-Agent to avoid bot detection 771 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)' 772 | } 773 | #print(f"Downloading tile: {url}") # Debug output (commented) 774 | 775 | # Request tile with reasonable timeout (balance speed vs reliability) 776 | response = requests.get(url, headers=headers, timeout=15) 777 | 778 | if response.status_code == 200: 779 | # Successfully downloaded - convert response to PIL Image 780 | tile_img = Image.open(io.BytesIO(response.content)).convert("RGB") 781 | #print(f"Successfully downloaded tile {x},{y},{z}") # Debug 782 | 783 | # Step 4: Save to cache for future requests 784 | self._save_tile_to_cache(tile_img, x, y, z, tile_source) 785 | 786 | return tile_img # Return the downloaded tile 787 | 788 | else: 789 | # HTTP error codes (404 = tile not found, 429 = rate limited, etc.) 790 | print(f"HTTP {response.status_code} for tile {x},{y},{z} from {tile_source}") 791 | return None 792 | 793 | except Exception as e: 794 | # Network errors, timeouts, invalid responses, etc. 795 | print(f"Failed to download tile {x},{y},{z} from {tile_source}: {e}") 796 | return None 797 | 798 | def _create_tile_background(self, ax, tile_source='osm'): 799 | """Create map background by downloading and stitching multiple tiles. 800 | 801 | This method implements the complete tile-based mapping pipeline: 802 | 1. Calculate appropriate zoom level based on area size 803 | 2. Determine which tiles are needed to cover the geographic area 804 | 3. Download all required tiles (with caching) 805 | 4. Stitch tiles into a single background image 806 | 5. Apply proper geographic coordinate transformation 807 | 808 | The process handles various error conditions gracefully, including 809 | network failures, missing tiles, and coordinate edge cases. 810 | 811 | Args: 812 | ax: Matplotlib axes object to draw the background on 813 | tile_source: Map service ('osm', 'esri_satellite', 'esri_topo', etc.) 814 | """ 815 | # Extract geographic boundaries of current map view 816 | lon_min, lon_max, lat_min, lat_max = self.area_bounds 817 | 818 | #print(f"Creating tile background with source: {tile_source}") # Debug 819 | #print(f"Area bounds: {lon_min:.4f}, {lon_max:.4f}, {lat_min:.4f}, {lat_max:.4f}") 820 | 821 | # Step 1: Calculate appropriate zoom level based on area coverage 822 | # Higher zoom = more detail but more tiles needed (balance detail vs performance) 823 | lat_range = lat_max - lat_min # Latitude span in degrees 824 | lon_range = lon_max - lon_min # Longitude span in degrees 825 | area_size = max(lat_range, lon_range) # Use larger dimension for zoom calculation 826 | 827 | # Zoom level selection based on area size (empirically optimized) 828 | if area_size > 1.0: # Large areas (>1° span): country/state level 829 | zoom = 8 830 | elif area_size > 0.5: # Medium areas (0.5-1° span): city level 831 | zoom = 9 832 | elif area_size > 0.25: # Small areas (0.25-0.5° span): neighborhood level 833 | zoom = 10 834 | elif area_size > 0.125: # Very small areas (0.125-0.25° span): district level 835 | zoom = 11 836 | else: # Tiny areas (<0.125° span): street level 837 | zoom = 12 838 | 839 | #print(f"Using zoom level: {zoom} (area size: {area_size:.4f}°)") # Debug 840 | 841 | # Step 2: Calculate tile coordinate range needed to cover the area 842 | # Convert geographic bounds to tile coordinates 843 | min_x, max_y = self._deg2num(lat_min, lon_min, zoom) # Bottom-left tile 844 | max_x, min_y = self._deg2num(lat_max, lon_max, zoom) # Top-right tile 845 | # Note: Y coordinates are inverted in tile systems (0 = north pole) 846 | 847 | #print(f"Tile range: x={min_x}-{max_x}, y={min_y}-{max_y}") # Debug 848 | 849 | # Step 3: Download all required tiles and handle failures gracefully 850 | tiles = [] # 2D array of tile images: tiles[row][col] 851 | successful_downloads = 0 # Track download success rate 852 | total_tiles = (max_x - min_x + 1) * (max_y - min_y + 1) # Total tiles needed 853 | 854 | # Download tiles row by row (top to bottom in geographic terms) 855 | for y in range(min_y, max_y + 1): # Tile Y coordinates (north to south) 856 | row_tiles = [] # Tiles for current row 857 | 858 | # Download tiles column by column (left to right) 859 | for x in range(min_x, max_x + 1): # Tile X coordinates (west to east) 860 | tile = self._download_tile(x, y, zoom, tile_source) 861 | 862 | if tile: 863 | # Successfully downloaded - add to row 864 | row_tiles.append(tile) 865 | successful_downloads += 1 866 | else: 867 | # Download failed - create fallback tile to maintain grid structure 868 | fallback_tile = Image.new('RGB', (256, 256), '#e8f4e8') # Light green 869 | row_tiles.append(fallback_tile) 870 | print(f"Using fallback tile for {x},{y} (download failed)") 871 | 872 | # Add completed row to tile grid 873 | if row_tiles: 874 | tiles.append(row_tiles) 875 | 876 | #print(f"Downloaded {successful_downloads}/{total_tiles} tiles successfully") # Debug 877 | 878 | # Step 4: Handle complete download failure 879 | if successful_downloads == 0: 880 | print("Failed to download any tiles, falling back to simple background") 881 | self._create_simple_background(ax, 'simple') # Use offline background 882 | return 883 | 884 | # Step 5: Stitch individual tiles into single background image 885 | tile_width = 256 # Standard tile size (pixels) 886 | tile_height = 256 # Standard tile size (pixels) 887 | total_width = len(tiles[0]) * tile_width # Total stitched width 888 | total_height = len(tiles) * tile_height # Total stitched height 889 | 890 | #print(f"Stitching {len(tiles[0])}x{len(tiles)} tiles into {total_width}x{total_height} image") 891 | 892 | # Create empty canvas for stitched image 893 | stitched = Image.new('RGB', (total_width, total_height)) 894 | 895 | # Paste each tile at correct position in stitched image 896 | for row_idx, row in enumerate(tiles): 897 | for col_idx, tile in enumerate(row): 898 | # Calculate pixel position for this tile 899 | x_pos = col_idx * tile_width # Left edge of tile 900 | y_pos = row_idx * tile_height # Top edge of tile 901 | 902 | # Paste tile at calculated position 903 | stitched.paste(tile, (x_pos, y_pos)) 904 | 905 | # Step 6: Calculate geographic extent of stitched image 906 | # Convert tile boundaries back to lat/lon for matplotlib 907 | def num2deg(x, y, z): 908 | """Convert tile coordinates back to lat/lon (inverse of _deg2num).""" 909 | n = 2.0 ** z 910 | # Longitude: linear conversion from tile X to degrees 911 | lon_deg = x / n * 360.0 - 180.0 912 | # Latitude: inverse Mercator projection from tile Y to degrees 913 | lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n))) 914 | lat_deg = math.degrees(lat_rad) 915 | return lat_deg, lon_deg 916 | 917 | # Calculate actual geographic extent of the stitched tile mosaic 918 | # Add 1 to get tile boundaries (not centers) 919 | tile_lat_min, tile_lon_min = num2deg(min_x, max_y + 1, zoom) # Bottom-left 920 | tile_lat_max, tile_lon_max = num2deg(max_x + 1, min_y, zoom) # Top-right 921 | 922 | # Step 7: Display stitched background in matplotlib with proper coordinates 923 | ax.imshow(np.array(stitched), 924 | extent=[tile_lon_min, tile_lon_max, tile_lat_min, tile_lat_max], # Geographic bounds 925 | aspect='auto', # Allow non-square aspect ratio 926 | origin='upper', # Image origin at top-left (standard for maps) 927 | interpolation='bilinear') # Smooth scaling if needed 928 | 929 | # Set figure background to white (clean appearance) 930 | ax.figure.patch.set_facecolor('white') 931 | 932 | def _create_simple_background(self, ax, background_type): 933 | """Create offline map backgrounds without requiring external tile downloads. 934 | 935 | This provides fallback capability when network is unavailable or tile 936 | services are not working. Generates different background styles using 937 | only matplotlib drawing primitives - no external dependencies. 938 | 939 | Available background types: 940 | - 'simple': Clean light gray background 941 | - 'grid': Coordinate grid overlay for navigation 942 | - 'topographic': Simulated terrain using mathematical functions 943 | - default: Neutral gray background 944 | 945 | Args: 946 | ax: Matplotlib axes object to draw background on 947 | background_type: Style of background to generate 948 | """ 949 | # Get current map boundaries for coordinate-aware backgrounds 950 | lon_min, lon_max, lat_min, lat_max = self.area_bounds 951 | 952 | if background_type == 'simple': 953 | # Clean, minimalist background - light gray 954 | # Good for focusing attention on radar data overlay 955 | ax.set_facecolor('#f5f5f5') # Very light gray axes background 956 | ax.figure.patch.set_facecolor('#f5f5f5') # Match figure background 957 | 958 | elif background_type == 'grid': 959 | # Coordinate grid background for navigation and reference 960 | # Helps users orient themselves geographically 961 | ax.set_facecolor('#f8f8f8') # Slightly lighter than simple 962 | ax.figure.patch.set_facecolor('#f8f8f8') 963 | 964 | # Calculate grid spacing based on map area size 965 | # Larger areas get coarser grids to avoid clutter 966 | lon_step = (lon_max - lon_min) / 10 # 10 longitude divisions 967 | lat_step = (lat_max - lat_min) / 10 # 10 latitude divisions 968 | 969 | # Draw vertical grid lines (longitude) 970 | for lon in np.arange(lon_min, lon_max + lon_step, lon_step): 971 | ax.axvline(lon, color='lightgray', linewidth=0.5, alpha=0.7) 972 | 973 | # Draw horizontal grid lines (latitude) 974 | for lat in np.arange(lat_min, lat_max + lat_step, lat_step): 975 | ax.axhline(lat, color='lightgray', linewidth=0.5, alpha=0.7) 976 | 977 | elif background_type == 'topographic': 978 | # Simulated topographic background using mathematical terrain generation 979 | # Creates visual depth without real elevation data 980 | ax.set_facecolor('#e8f4e8') # Light green base (suggests terrain) 981 | ax.figure.patch.set_facecolor('#e8f4e8') 982 | 983 | # Calculate coordinate ranges for mathematical terrain generation 984 | lon_range = lon_max - lon_min # Total longitude span 985 | lat_range = lat_max - lat_min # Total latitude span 986 | 987 | # Create coordinate grids for terrain calculation 988 | x = np.linspace(lon_min, lon_max, 20) # 20x20 grid resolution 989 | y = np.linspace(lat_min, lat_max, 20) 990 | X, Y = np.meshgrid(x, y) 991 | 992 | # Generate pseudo-elevation using sinusoidal functions 993 | # Combines multiple frequency components for realistic terrain appearance 994 | Z = (np.sin((X - lon_min) / lon_range * 4 * np.pi) * # Primary terrain waves 995 | np.cos((Y - lat_min) / lat_range * 3 * np.pi) * 0.3 + # Cross-directional waves 996 | np.sin((X - lon_min) / lon_range * 7 * np.pi) * 0.1) # Fine detail overlay 997 | 998 | # Draw terrain contours with earth-tone colors 999 | ax.contourf(X, Y, Z, levels=15, # 15 elevation levels 1000 | colors=['#d4e6d4', '#e0f0e0', '#ecf5ec'], # Green terrain colors 1001 | alpha=0.3) # Subtle transparency 1002 | 1003 | else: 1004 | # Default background for unknown types - neutral gray 1005 | ax.set_facecolor('#f0f0f0') # Standard gray background 1006 | ax.figure.patch.set_facecolor('#f0f0f0') # Match figure background 1007 | 1008 | def create_smooth_heatmap_grid(self, satellite_source=None, sigma=2.0): 1009 | """Generate complete radar visualization with background map and smooth weather overlay. 1010 | 1011 | This is the main visualization method that combines all components: 1012 | 1. Create properly sized matplotlib figure for exact pixel output 1013 | 2. Generate background map (tiles or simple graphics) 1014 | 3. Process and overlay radar data with meteorological color scheme 1015 | 4. Add city markers with customizable colors 1016 | 5. Export as PIL image with precise dimensions 1017 | 1018 | The method handles missing data gracefully and provides fallbacks for 1019 | network issues, coordinate problems, and other edge cases. 1020 | 1021 | Args: 1022 | satellite_source: Background type ('osm', 'esri_satellite', 'simple', etc.) 1023 | sigma: Gaussian blur sigma for radar smoothing (higher = smoother) 1024 | 1025 | Returns: 1026 | PIL.Image: Complete weather radar map as RGBA image 1027 | """ 1028 | # Use instance default if no specific background requested 1029 | if satellite_source is None: 1030 | satellite_source = self.satellite_source 1031 | 1032 | # Extract geographic boundaries for current view 1033 | lon_min, lon_max, lat_min, lat_max = self.area_bounds 1034 | 1035 | # Step 1: Create matplotlib figure with exact pixel dimensions 1036 | # Calculate figure size to achieve precise output dimensions 1037 | base_dpi = 100 # Use 100 DPI for predictable pixel-to-inch conversion 1038 | fig_width = self.image_width_pixels / base_dpi # Width in inches 1039 | fig_height = self.image_height_pixels / base_dpi # Height in inches 1040 | 1041 | # Create figure and axes with calculated dimensions 1042 | fig, ax = plt.subplots(figsize=(fig_width, fig_height)) 1043 | 1044 | # Set geographic coordinate system on axes 1045 | ax.set_xlim(lon_min, lon_max) # Longitude range (west to east) 1046 | ax.set_ylim(lat_min, lat_max) # Latitude range (south to north) 1047 | ax.set_aspect('equal', adjustable='box') # Maintain geographic aspect ratio 1048 | 1049 | # Step 2: Create background map layer 1050 | if satellite_source in ['osm', 'esri_satellite', 'esri_topo', 'esri_street']: 1051 | # Online tile-based backgrounds - download and stitch map tiles 1052 | self._create_tile_background(ax, satellite_source) 1053 | else: 1054 | # Offline backgrounds - generate using matplotlib primitives 1055 | self._create_simple_background(ax, satellite_source) 1056 | 1057 | # Step 3: Add radar data overlay (if available) 1058 | # Check that we have all required radar data components 1059 | if hasattr(self, 'scaled_data') and self.scaled_data is not None and \ 1060 | self.lons is not None and self.lats is not None: 1061 | 1062 | # Find radar pixels that fall within current map view 1063 | mask = ((self.lons >= lon_min) & (self.lons <= lon_max) & 1064 | (self.lats >= lat_min) & (self.lats <= lat_max)) 1065 | 1066 | if np.any(mask): # At least some radar data in view 1067 | # Extract bounding box of radar data in current view 1068 | rows_in_area, cols_in_area = np.where(mask) 1069 | row_min, row_max = rows_in_area.min(), rows_in_area.max() 1070 | col_min, col_max = cols_in_area.min(), cols_in_area.max() 1071 | 1072 | # Extract subset of radar data covering the map area 1073 | data_subset = self.scaled_data[row_min:row_max + 1, col_min:col_max + 1] 1074 | 1075 | # Extract corresponding coordinate subsets for proper geographic positioning 1076 | lons_subset = self.lons[row_min:row_max + 1, col_min:col_max + 1] 1077 | lats_subset = self.lats[row_min:row_max + 1, col_min:col_max + 1] 1078 | 1079 | # Step 3a: Clean and prepare radar data for visualization 1080 | valid_data = data_subset.copy() 1081 | valid_data[np.isnan(valid_data)] = -50 # Replace NaN with low value 1082 | valid_data[valid_data < -10] = -50 # Remove noise below detection 1083 | 1084 | # Step 3b: Apply Gaussian smoothing to reduce pixelated appearance 1085 | smoothed_data = self._gaussian_blur_numpy(valid_data, sigma=sigma) 1086 | 1087 | # Step 3c: Mask very low values to make them transparent 1088 | smoothed_data = np.ma.masked_where(smoothed_data < -30, smoothed_data) 1089 | 1090 | # Define geographic extent using actual radar data boundaries (not map view) 1091 | # This ensures radar data is positioned at its correct geographic location 1092 | radar_lon_min = lons_subset.min() 1093 | radar_lon_max = lons_subset.max() 1094 | radar_lat_min = lats_subset.min() 1095 | radar_lat_max = lats_subset.max() 1096 | extent = [radar_lon_min, radar_lon_max, radar_lat_min, radar_lat_max] 1097 | 1098 | # Step 3d: Define meteorological color scheme (dBZ reflectivity scale) 1099 | # Standard weather radar colors from light blue (weak) to magenta (extreme) 1100 | dBZ_boundaries = [0, 1, 5.5, 10, 14.5, 19, 23.5, 28, 32.5, 37, 41.5, 46, 50.5, 55, 60, 65, 75, 85] 1101 | dBZ_colors = [ 1102 | '#99ffff00', # 0-1 dBZ: Transparent (very light precipitation) 1103 | '#99ffff', # 1-5.5 dBZ: Light blue (drizzle) 1104 | '#33ffff', # 5.5-10 dBZ: Cyan (light rain) 1105 | '#00caca', # 10-14.5 dBZ: Teal (light-moderate rain) 1106 | '#009934', # 14.5-19 dBZ: Green (moderate rain) 1107 | '#4dbf1a', # 19-23.5 dBZ: Light green (moderate-heavy rain) 1108 | '#99cc00', # 23.5-28 dBZ: Yellow-green (heavy rain) 1109 | '#cce600', # 28-32.5 dBZ: Yellow (very heavy rain) 1110 | '#ffff00', # 32.5-37 dBZ: Bright yellow (intense rain) 1111 | '#ffc400', # 37-41.5 dBZ: Orange-yellow (very intense) 1112 | '#ff8900', # 41.5-46 dBZ: Orange (severe rain/small hail) 1113 | '#ff0000', # 46-50.5 dBZ: Red (severe weather) 1114 | '#b40000', # 50.5-55 dBZ: Dark red (large hail) 1115 | '#4848ff', # 55-60 dBZ: Blue (very large hail) 1116 | '#0000ca', # 60-65 dBZ: Dark blue (giant hail) 1117 | '#990099', # 65-75 dBZ: Purple (extreme hail) 1118 | '#ff33ff' # 75+ dBZ: Magenta (tornado/extreme weather) 1119 | ] 1120 | 1121 | # Create matplotlib colormap from our custom colors 1122 | dBZ_cmap = ListedColormap(dBZ_colors) 1123 | norm = BoundaryNorm(dBZ_boundaries, dBZ_cmap.N, clip=True) 1124 | 1125 | # Step 3e: Render radar data overlay with proper transparency 1126 | im = ax.imshow( 1127 | smoothed_data, 1128 | cmap=dBZ_cmap, # Meteorological color scheme 1129 | norm=norm, # Boundary-based color mapping 1130 | alpha=0.7, # Semi-transparent overlay 1131 | extent=extent, # Geographic coordinates 1132 | origin='upper', # Standard image orientation 1133 | aspect='auto', # Allow non-square pixels 1134 | interpolation='bilinear' # Smooth scaling 1135 | ) 1136 | else: 1137 | # No radar data available - background only 1138 | print("No radar data available - showing background map only") 1139 | 1140 | # Step 4: Add city markers with customizable colors 1141 | area_cities = self.get_area_cities() # Get cities in current view 1142 | 1143 | for city, (lon, lat, color) in area_cities.items(): 1144 | # Double-check that city is within view (safety check) 1145 | if lon_min <= lon <= lon_max and lat_min <= lat <= lat_max: 1146 | # Draw city marker circle with custom color and black border 1147 | ax.plot(lon, lat, 'o', markersize=10, 1148 | markerfacecolor=color, # User-configurable color 1149 | markeredgecolor='black', # Black border for visibility 1150 | markeredgewidth=1) # 1-pixel border width 1151 | 1152 | # Add city name label with readable styling 1153 | ax.text(lon, lat + 0.005, city, # Slight vertical offset 1154 | fontsize=6, # Small but readable 1155 | fontweight='bold', # Bold for better contrast 1156 | color='white', # White text 1157 | ha='center', va='bottom', # Center horizontally, bottom vertically 1158 | bbox=dict(boxstyle="round,pad=0.3", # Rounded background box 1159 | facecolor='black', # Black background 1160 | alpha=0.8)) # Semi-transparent 1161 | 1162 | # Step 5: Clean up axes appearance for map display 1163 | ax.set_xticks([]) # Remove longitude tick marks 1164 | ax.set_yticks([]) # Remove latitude tick marks 1165 | ax.axis('off') # Hide axis lines and labels 1166 | 1167 | # Ensure coordinate limits are exactly as specified 1168 | ax.set_xlim(lon_min, lon_max) 1169 | ax.set_ylim(lat_min, lat_max) 1170 | 1171 | # Remove all padding around the plot area 1172 | plt.subplots_adjust(left=0, right=1, top=1, bottom=0) 1173 | 1174 | # Step 6: Export figure as PIL image with exact dimensions 1175 | buf = io.BytesIO() # In-memory buffer for image data 1176 | 1177 | plt.savefig(buf, format='png', # PNG format for quality 1178 | dpi=base_dpi, # Match our DPI calculation 1179 | bbox_inches='tight', # Tight bounding box 1180 | pad_inches=0, # No padding 1181 | facecolor=fig.get_facecolor(), # Preserve background color 1182 | transparent=False) # Solid background 1183 | 1184 | # Convert matplotlib output to PIL Image 1185 | buf.seek(0) # Reset buffer position to beginning 1186 | pil_image = Image.open(buf).convert("RGBA") # RGBA for transparency support 1187 | 1188 | # Clean up matplotlib resources 1189 | plt.close() 1190 | 1191 | return pil_image # Return final radar map image 1192 | -------------------------------------------------------------------------------- /weatherclock_rpi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from tkinter import * 4 | from tkinter import TclError 5 | import math 6 | import time 7 | import os 8 | import threading 9 | import signal 10 | import gc 11 | import atexit 12 | from PIL import Image, ImageTk, ImageDraw, ImageFont 13 | import urllib.request 14 | import io 15 | from io import BytesIO 16 | from datetime import datetime 17 | import json 18 | import requests 19 | import paho.mqtt.client as mqtt 20 | from RadarProcessor import RadarProcessor 21 | import RPi.GPIO as GPIO 22 | 23 | script_dir = None 24 | window = None 25 | canvas = None 26 | 27 | # Global radar processor instance 28 | radar = None 29 | 30 | # Shutdown flag for clean exit 31 | shutdown_flag = False 32 | mqtt_poll_count = 0 33 | mqtt_last_poll_time = 0 34 | mqtt_connected = False 35 | mqtt_reconnect_count = 0 36 | mqtt_last_successful_time = 0 37 | mqtt_connection_stale_threshold = 30 # Consider connection stale after 30 seconds of no activity 38 | 39 | # local settings 40 | old_time = time.strftime('%H:%M:%S') 41 | longitude = "8.863" 42 | latitude = "48.808" 43 | timezone = "Europe/Berlin" 44 | zoom = 11 45 | radar_background = "esri_topo" 46 | 47 | # mqtt settings 48 | mqtt_user = "**********" 49 | mqtt_password = "****************" 50 | mqtt_broker_address = "192.168.1.10" 51 | mqtt_port = 1883 52 | mqtt_topic_pressure = "/outdoor/pressure" 53 | mqtt_topic_outtemperature = "/outdoor/temperature" 54 | mqtt_topic_outhumidity = "/outdoor/humidity" 55 | mqtt_topic_intemperature = "/483fdaaaceba/temperature" 56 | mqtt_topic_inhumidity = "/483fdaaaceba/humidity" 57 | mqtt_topic_staticiaq = "/483fdaaaceba/staticiaq" 58 | mqtt_topic_ppurchase = "/00d0935D9eb9/ppurchase" 59 | mqtt_topic_pfeed = "/00d0935D9eb9/pfeed" 60 | mqtt_topic_pconsume = "/00d0935D9eb9/pconsume" 61 | mqtt_topic_pgenerate = "/00d0935D9eb9/pgenerate" 62 | mqtt_topic_pdischarge = "/00d0935D9eb9/pdischarge" 63 | mqtt_topic_pcharge = "/00d0935D9eb9/pcharge" 64 | mqtt_topic_sbatcharge = "/00d0935D9eb9/sbatcharge" 65 | mqtt_topic_eyield = "/00d0935D9eb9/eyield" 66 | mqtt_topic_eabsorb = "/00d0935D9eb9/eabsorb" 67 | 68 | # coordinates 69 | big_day_weather_x = 512 70 | big_day_weather_y = 160 71 | day_weather_x = 2 72 | day_weather_y = 512 73 | weathermap_x = 0 74 | weathermap_y = 0 75 | clock_x = 672 76 | clock_y = 0 77 | intemperature_x = 672 78 | intemperature_y = 320 79 | inhumidity_x = 672 80 | inhumidity_y = 352 81 | outtemperature_x = 672 82 | outtemperature_y = 160 83 | outhumidity_x = 512 84 | outhumidity_y = 288 85 | pressure_x = 672 86 | pressure_y = 288 87 | staticiaq_x = 512 88 | staticiaq_y = 0 89 | pconsume_x = 512 90 | pconsume_y = 416 91 | pgenerate_x = 512 92 | pgenerate_y = 448 93 | pdischarge_x = 512 94 | pdischarge_y = 480 95 | ppurchase_x = 672 96 | ppurchase_y = 416 97 | pfeed_x = 672 98 | pfeed_y = 448 99 | pcharge_x = 672 100 | pcharge_y = 480 101 | eabsorb_x = 832 102 | eabsorb_y = 416 103 | eyield_x = 832 104 | eyield_y = 448 105 | sbatcharge_x = 832 106 | sbatcharge_y = 480 107 | 108 | # variables to hold measured values 109 | mqtt_intemperature = "--.-" 110 | dwd_intemperature = "--.-" 111 | mqtt_inhumidity = "---.-" 112 | dwd_inhumidity = "---.-" 113 | mqtt_outtemperature = "--.-" 114 | dwd_outtemperature = "--.-" 115 | mqtt_outhumidity = "---.-" 116 | dwd_outhumidity = "---.-" 117 | mqtt_pressure = "----.-" 118 | dwd_pressure = "----.-" 119 | mqtt_staticiaq = "---.-" 120 | mqtt_ppurchase = "-----" 121 | mqtt_pfeed = "-----" 122 | mqtt_pconsume = "-----" 123 | mqtt_pgenerate = "-----" 124 | mqtt_pdischarge = "-----" 125 | mqtt_pcharge = "-----" 126 | mqtt_eabsorb = "-----.-" 127 | mqtt_eyield = "-----.-" 128 | mqtt_sbatcharge = "--" 129 | 130 | # Previous values to track changes and avoid unnecessary redraws 131 | prev_intemperature = None 132 | prev_inhumidity = None 133 | prev_outtemperature = None 134 | prev_outhumidity = None 135 | prev_staticiaq = None 136 | prev_ppurchase = None 137 | prev_pfeed = None 138 | prev_pconsume = None 139 | prev_pgenerate = None 140 | prev_pdischarge = None 141 | prev_pcharge = None 142 | prev_eabsorb = None 143 | prev_eyield = None 144 | prev_sbatcharge = None 145 | 146 | # display settings 147 | display_on_time = 3000 # 5min 148 | display_onoff = "ON" 149 | 150 | class circularlist(object): 151 | def __init__(self, size, data = []): 152 | """Initialization""" 153 | self.index = 0 154 | self.size = size 155 | self._data = list(data)[-size:] 156 | 157 | def append(self, value): 158 | """Append an element""" 159 | if len(self._data) == self.size: 160 | self._data[self.index] = value 161 | else: 162 | self._data.append(value) 163 | self.index = (self.index + 1) % self.size 164 | 165 | def length(self): 166 | return(len(self._data)) 167 | 168 | def __getitem__(self, key): 169 | """Get element by index, relative to the current index""" 170 | if len(self._data) == self.size: 171 | return(self._data[(key + self.index) % self.size]) 172 | else: 173 | return(self._data[key]) 174 | 175 | def __repr__(self): 176 | """Return string representation""" 177 | return (self._data[self.index:] + self._data[:self.index]).__repr__() + ' (' + str(len(self._data))+'/{} items)'.format(self.size) 178 | 179 | def calc_pressure_tendency(l, start_index, end_index): 180 | # define tendency by linear regression 181 | avr_x = sum([x for x in range(start_index, end_index)]) / (end_index - start_index) 182 | avr_y = sum([l[x] for x in range(start_index, end_index)]) / (end_index - start_index) 183 | m = sum([(x - avr_x) * (l[x] - avr_y) for x in range(start_index, end_index)]) / sum([(x - avr_x) * (x - avr_x) for x in range(start_index, end_index)]) 184 | return (m) 185 | 186 | def get_pressure_tendency_icon(t1, t2, t3): 187 | global script_dir 188 | 189 | # zone limit 0.1 hPa 190 | th = 0.1 191 | tl = -0.1 192 | 193 | if (t1 <= tl) and (t2 <= tl) and (t3 <= tl): 194 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_7.png")) 195 | if (t1 <= tl) and (t2 <= tl) and (t3 > tl) and (t3 <= th): 196 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_6.png")) 197 | if (t1 <= tl) and (t2 <= tl) and (t3 > th): 198 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_5.png")) 199 | if (t1 <= tl) and (t2 > tl) and (t2 <= th) and (t3 <= tl): 200 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_7.png")) 201 | if (t1 <= tl) and (t2 > tl) and (t2 <= th) and (t3 > tl) and (t3 <= th): 202 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 203 | if (t1 <= tl) and (t2 > tl) and (t2 <= th) and (t3 > th): 204 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 205 | if (t1 <= tl) and (t2 > th) and (t3 <= tl): 206 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_7.png")) 207 | if (t1 <= tl) and (t2 > th) and (t3 > tl) and (t3 <= th): 208 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 209 | if (t1 <= tl) and (t2 > th) and (t3 > th): 210 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_3.png")) 211 | if (t1 > tl) and (t1 <= th) and (t2 <= tl) and (t3 <= tl): 212 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_8.png")) 213 | if (t1 > tl) and (t1 <= th) and (t2 <= tl) and (t3 > tl) and (t3 <= th): 214 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 215 | if (t1 > tl) and (t1 <= th) and (t2 <= tl) and (t3 > th): 216 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 217 | if (t1 > tl) and (t1 <= th) and (t2 > tl) and (t2 <= th) and (t3 <= tl): 218 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 219 | if (t1 > tl) and (t1 <= th) and (t2 > tl) and (t2 <= th) and (t3 > tl) and (t3 <= th): 220 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 221 | if (t1 > tl) and (t1 <= th) and (t2 > tl) and (t2 <= th) and (t3 > th): 222 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 223 | if (t1 > tl) and (t1 <= th) and (t2 > th) and (t3 <= tl): 224 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 225 | if (t1 > tl) and (t1 <= th) and (t2 > th) and (t3 > tl) and (t3 <= th): 226 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 227 | if (t1 > tl) and (t1 <= th) and (t2 > th) and (t3 > th): 228 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_3.png")) 229 | if (t1 > th) and (t2 <= tl) and (t3 <= tl): 230 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_8.png")) 231 | if (t1 > th) and (t2 <= tl) and (t3 > tl) and (t3 <= th): 232 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 233 | if (t1 > th) and (t2 <= tl) and (t3 > th): 234 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_2.png")) 235 | if (t1 > th) and (t2 > tl) and (t2 <= th) and (t3 <= tl): 236 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 237 | if (t1 > th) and (t2 > tl) and (t2 <= th) and (t3 > tl) and (t3 <= th): 238 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")) 239 | if (t1 > th) and (t2 > tl) and (t2 <= th) and (t3 > th): 240 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_2.png")) 241 | if (t1 > th) and (t2 > th) and (t3 <= tl): 242 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_0.png")) 243 | if (t1 > th) and (t2 > th) and (t3 > tl) and (t3 <= th): 244 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_1.png")) 245 | if (t1 > th) and (t2 > th) and (t3 > th): 246 | icon_pre = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_2.png")) 247 | return (icon_pre) 248 | 249 | def update_intemperature(): 250 | global script_dir 251 | global prev_intemperature 252 | 253 | # Only update if value has changed 254 | if mqtt_intemperature == prev_intemperature: 255 | return 256 | prev_intemperature = mqtt_intemperature 257 | 258 | # Create a temporary image to draw on 259 | temp_image = Image.new("RGBA", (353, 31), (0, 0, 0, 0)) 260 | draw = ImageDraw.Draw(temp_image) 261 | draw.rectangle((0, 0, 352, 30), fill="#202020") 262 | # Draw the text onto the temporary image 263 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 264 | draw.text((4, 0), "Raumtemperatur: ", font=font, fill="#ffffff") 265 | draw.text((219, 0), mqtt_intemperature + " °C", font=font, fill="#ffff00") 266 | # Convert to PhotoImage and display on canvas 267 | photo_image = safe_create_photoimage(temp_image) 268 | if photo_image: 269 | canvas.delete('intemperature') 270 | canvas.create_image(intemperature_x + 1, intemperature_y + 1, anchor = NW, image = photo_image, tags=('intemperature')) 271 | # prevent garbage collection 272 | canvas.intemperature = photo_image 273 | temp_image.close() # Close PIL image 274 | 275 | def update_inhumidity(): 276 | global script_dir 277 | global prev_inhumidity 278 | 279 | # Only update if value has changed 280 | if mqtt_inhumidity == prev_inhumidity: 281 | return 282 | prev_inhumidity = mqtt_inhumidity 283 | 284 | # Create a temporary image to draw on 285 | temp_image = Image.new("RGBA", (353, 31), (0, 0, 0, 0)) 286 | draw = ImageDraw.Draw(temp_image) 287 | draw.rectangle((0, 0, 352, 30), fill="#202020") 288 | # Draw the text onto the temporary image 289 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 290 | draw.text((4, 0), "Raumluftfeuchte: ", font=font, fill="#ffffff") 291 | draw.text((216, 0), mqtt_inhumidity + " %rF", font=font, fill="#ffff00") 292 | # Convert to PhotoImage and display on canvas 293 | photo_image = safe_create_photoimage(temp_image) 294 | if photo_image: 295 | canvas.delete('inhumidity') 296 | canvas.create_image(inhumidity_x + 1, inhumidity_y + 1, anchor = NW, image = photo_image, tags=('inhumidity')) 297 | # prevent garbage collection 298 | canvas.inhumidity = photo_image 299 | temp_image.close() # Close PIL image 300 | 301 | # Create a temporary image to draw on 302 | temp_image = Image.new("RGBA", (353, 31), (0, 0, 0, 0)) 303 | draw = ImageDraw.Draw(temp_image) 304 | draw.rectangle((0, 0, 352, 30), fill="#202020") 305 | # Convert to PhotoImage and display on canvas 306 | photo_image = safe_create_photoimage(temp_image) 307 | if photo_image: 308 | canvas.delete('inhumidity_empty') 309 | canvas.create_image(inhumidity_x + 1, inhumidity_y + 33, anchor = NW, image = photo_image, tags=('inhumidity_empty')) 310 | # prevent garbage collection 311 | canvas.inhumidity_empty = photo_image 312 | temp_image.close() # Close PIL image 313 | 314 | def update_outtemperature(): 315 | global script_dir 316 | global prev_outtemperature 317 | 318 | # Only update if value has changed 319 | if mqtt_outtemperature == prev_outtemperature: 320 | return 321 | prev_outtemperature = mqtt_outtemperature 322 | 323 | # Create a temporary image to draw on 324 | temp_image = Image.new("RGBA", (353, 127), (0, 0, 0, 0)) 325 | draw = ImageDraw.Draw(temp_image) 326 | draw.rectangle((0, 0, 352, 126), fill="#303030") 327 | # Paste the icon onto the temporary image 328 | icon = Image.open(os.path.join(script_dir, "Icons/temp.png")).convert("RGBA") 329 | temp_image.paste(icon, (5, 4), icon) 330 | # Draw the text onto the temporary image 331 | temp_font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 125) 332 | unit_font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 333 | draw.text((34, -1), "°C", font=unit_font, fill="#ffffff") 334 | if (mqtt_outtemperature == "--.-"): 335 | color = "#ffffff" 336 | else: 337 | temperature = float(mqtt_outtemperature) 338 | 339 | if (temperature < -10): 340 | color = "#0080ff" 341 | elif ((temperature >= -10) and (temperature < -5)): 342 | color = "#3380ff" 343 | elif ((temperature >= -5) and (temperature < 0)): 344 | color = "#6680ff" 345 | elif ((temperature >= 0) and (temperature < 5)): 346 | color = "#9980ff" 347 | elif ((temperature >= 5) and (temperature < 10)): 348 | color = "#cc80ff" 349 | elif ((temperature >= 10) and (temperature < 15)): 350 | color = "#ff80cc" 351 | elif ((temperature >= 15) and (temperature < 20)): 352 | color = "#ff8099" 353 | elif ((temperature >= 20) and (temperature < 25)): 354 | color = "#ff8066" 355 | elif ((temperature >= 25) and (temperature < 30)): 356 | color = "#ff8033" 357 | else: 358 | color = "#ff8000" 359 | draw.text((53, 0), mqtt_outtemperature, font=temp_font, fill=color) 360 | 361 | # Convert to PhotoImage and display on canvas 362 | photo_image = safe_create_photoimage(temp_image) 363 | if photo_image: 364 | canvas.delete('outtemperature') 365 | canvas.create_image(outtemperature_x + 1, outtemperature_y + 1, anchor = NW, image = photo_image, tags=('outtemperature')) 366 | # prevent garbage collection 367 | canvas.outtemperature = photo_image 368 | temp_image.close() # Close PIL image 369 | 370 | def update_outhumidity(): 371 | global script_dir 372 | global prev_outhumidity 373 | 374 | # Only update if value has changed 375 | if mqtt_outhumidity == prev_outhumidity: 376 | return 377 | prev_outhumidity = mqtt_outhumidity 378 | 379 | # Create a temporary image to draw on 380 | temp_image = Image.new("RGBA", (159, 127), (0, 0, 0, 0)) 381 | draw = ImageDraw.Draw(temp_image) 382 | draw.rectangle((0, 0, 158, 126), fill="#202020") 383 | # Paste the icon onto the temporary image 384 | if (mqtt_outhumidity != "---.-"): 385 | level = int(float(mqtt_outhumidity) * 1.07) 386 | icon_hum_blue = Image.open(os.path.join(script_dir, "Icons/humidity_blue.png")).convert("RGBA").crop([0, 125 - 9 - level, 125, 125]) 387 | temp_image.paste(icon_hum_blue, (16, 126 - 9 - level), icon_hum_blue) 388 | icon_hum_grey = Image.open(os.path.join(script_dir, "Icons/humidity_grey.png")).convert("RGBA").crop([0, 0, 125, 125 - 9 - level]) 389 | temp_image.paste(icon_hum_grey, (16, 1), icon_hum_grey) 390 | else: 391 | icon_hum = Image.open(os.path.join(script_dir, "Icons/humidity_grey.png")).convert("RGBA") 392 | temp_image.paste(icon_hum, (16, 1), icon_hum) 393 | # Convert to PhotoImage and display on canvas 394 | photo_image = safe_create_photoimage(temp_image) 395 | if photo_image: 396 | canvas.delete('outhumidity') 397 | canvas.create_image(outhumidity_x + 1, outhumidity_y + 1, anchor = NW, image = photo_image, tags=('outhumidity')) 398 | # prevent garbage collection 399 | canvas.outhumidity = photo_image 400 | temp_image.close() # Close PIL image 401 | 402 | def update_staticiaq(): 403 | global script_dir 404 | global prev_staticiaq 405 | 406 | # Only update if value has changed 407 | if mqtt_staticiaq == prev_staticiaq: 408 | return 409 | prev_staticiaq = mqtt_staticiaq 410 | 411 | # Create a temporary image to draw on 412 | temp_image = Image.new("RGBA", (159, 159), (0, 0, 0, 0)) 413 | draw = ImageDraw.Draw(temp_image) 414 | draw.rectangle((0, 0, 158, 158), fill="#202020") 415 | # Paste the icon onto the temporary image 416 | if (mqtt_staticiaq != "---.-"): 417 | if (float(mqtt_staticiaq) < 100.0): 418 | icon = Image.open(os.path.join(script_dir, "Icons/IAQ_good.png")).convert("RGBA") 419 | elif (float(mqtt_staticiaq) >= 100.0) and (float(mqtt_staticiaq) <= 200.0): 420 | icon = Image.open(os.path.join(script_dir, "Icons/IAQ_medium.png")).convert("RGBA") 421 | else: 422 | icon = Image.open(os.path.join(script_dir, "Icons/IAQ_bad.png")).convert("RGBA") 423 | else: 424 | icon = Image.open(os.path.join(script_dir, "Icons/IAQ_good.png")).convert("RGBA") 425 | temp_image.paste(icon, (17, 17), icon) 426 | # Convert to PhotoImage and display on canvas 427 | photo_image = safe_create_photoimage(temp_image) 428 | if photo_image: 429 | canvas.delete('staticiaq') 430 | canvas.create_image(staticiaq_x + 1, staticiaq_y + 1, anchor = NW, image = photo_image, tags=('staticiaq')) 431 | # prevent garbage collection 432 | canvas.staticiaq = photo_image 433 | temp_image.close() # Close PIL image 434 | 435 | def update_pressure(): 436 | global script_dir 437 | 438 | # Create a temporary image to draw on 439 | temp_image = Image.new("RGBA", (353, 31), (0, 0, 0, 0)) 440 | draw = ImageDraw.Draw(temp_image) 441 | draw.rectangle((0, 0, 352, 30), fill="#202020") 442 | # Paste the icon onto the temporary image 443 | if (plist.length() == 18): 444 | tend1 = calc_pressure_tendency(plist, 0, 6) 445 | tend2 = calc_pressure_tendency(plist, 6, 12) 446 | tend3 = calc_pressure_tendency(plist, 12, 18) 447 | icon = get_pressure_tendency_icon(tend1, tend2, tend3) 448 | else: 449 | icon = Image.open(os.path.join(script_dir, "Icons/pressure_tendency_4.png")).convert("RGBA") 450 | temp_image.paste(icon, (290, 1), icon) 451 | # Draw the text onto the temporary image 452 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 453 | draw.text((4, 0), "Luftdruck: ", font=font, fill="#ffffff") 454 | draw.text((130, 0), mqtt_pressure + " hPa", font=font, fill="#ffff00") 455 | # Convert to PhotoImage and display on canvas 456 | photo_image = safe_create_photoimage(temp_image) 457 | if photo_image: 458 | canvas.delete('pressure') 459 | canvas.create_image(pressure_x + 1, pressure_y + 1, anchor = NW, image = photo_image, tags=('pressure')) 460 | # prevent garbage collection 461 | canvas.pressure = photo_image 462 | temp_image.close() # Close PIL image 463 | 464 | def update_ppurchase(): 465 | global script_dir 466 | global prev_ppurchase 467 | 468 | # Only update if value has changed 469 | if mqtt_ppurchase == prev_ppurchase: 470 | return 471 | prev_ppurchase = mqtt_ppurchase 472 | 473 | # Create a temporary image to draw on 474 | temp_image = Image.new("RGBA", (159, 31), (0, 0, 0, 0)) 475 | draw = ImageDraw.Draw(temp_image) 476 | draw.rectangle((0, 0, 158, 30), fill="#303030") 477 | # Paste the icon onto the temporary image 478 | icon = Image.open(os.path.join(script_dir, "Icons/P_purchase.png")).convert("RGBA") 479 | temp_image.paste(icon, (0, 0), icon) 480 | # Draw the text onto the temporary image 481 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 482 | draw.text((36, 0), mqtt_ppurchase.split(".")[0] + " W", font=font, fill="#ff0000") 483 | # Convert to PhotoImage and display on canvas 484 | photo_image = safe_create_photoimage(temp_image) 485 | if photo_image: 486 | canvas.delete('ppurchase') 487 | canvas.create_image(ppurchase_x + 1, ppurchase_y + 1, anchor = NW, image = photo_image, tags=('ppurchase')) 488 | # prevent garbage collection 489 | canvas.ppurchase = photo_image 490 | temp_image.close() # Close PIL image 491 | 492 | def update_pfeed(): 493 | global script_dir 494 | global prev_pfeed 495 | 496 | # Only update if value has changed 497 | if mqtt_pfeed == prev_pfeed: 498 | return 499 | prev_pfeed = mqtt_pfeed 500 | 501 | # Create a temporary image to draw on 502 | temp_image = Image.new("RGBA", (159, 31), (0, 0, 0, 0)) 503 | draw = ImageDraw.Draw(temp_image) 504 | draw.rectangle((0, 0, 158, 30), fill="#303030") 505 | # Paste the icon onto the temporary image 506 | icon = Image.open(os.path.join(script_dir, "Icons/P_feed.png")).convert("RGBA") 507 | temp_image.paste(icon, (0, 0), icon) 508 | # Draw the text onto the temporary image 509 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 510 | draw.text((36, 0), mqtt_pfeed.split(".")[0] + " W", font=font, fill="#ffff00") 511 | # Convert to PhotoImage and display on canvas 512 | photo_image = safe_create_photoimage(temp_image) 513 | if photo_image: 514 | canvas.delete('pfeed') 515 | canvas.create_image(pfeed_x + 1, pfeed_y + 1, anchor = NW, image = photo_image, tags=('pfeed')) 516 | # prevent garbage collection 517 | canvas.pfeed = photo_image 518 | temp_image.close() # Close PIL image 519 | 520 | def update_pconsume(): 521 | global script_dir 522 | global prev_pconsume 523 | 524 | # Only update if value has changed 525 | if mqtt_pconsume == prev_pconsume: 526 | return 527 | prev_pconsume = mqtt_pconsume 528 | 529 | # Create a temporary image to draw on 530 | temp_image = Image.new("RGBA", (159, 31), (0, 0, 0, 0)) 531 | draw = ImageDraw.Draw(temp_image) 532 | draw.rectangle((0, 0, 158, 30), fill="#303030") 533 | # Paste the icon onto the temporary image 534 | icon = Image.open(os.path.join(script_dir, "Icons/P_consume.png")).convert("RGBA") 535 | temp_image.paste(icon, (0, 0), icon) 536 | # Draw the text onto the temporary image 537 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 538 | draw.text((36, 0), mqtt_pconsume.split(".")[0] + " W", font=font, fill="#ffffff") 539 | # Convert to PhotoImage and display on canvas 540 | photo_image = safe_create_photoimage(temp_image) 541 | if photo_image: 542 | canvas.delete('pconsume') 543 | canvas.create_image(pconsume_x + 1, pconsume_y + 1, anchor = NW, image = photo_image, tags=('pconsume')) 544 | # prevent garbage collection 545 | canvas.ppconsume = photo_image 546 | temp_image.close() # Close PIL image 547 | 548 | def update_pgenerate(): 549 | global script_dir 550 | global prev_pgenerate 551 | 552 | # Only update if value has changed 553 | if mqtt_pgenerate == prev_pgenerate: 554 | return 555 | prev_pgenerate = mqtt_pgenerate 556 | 557 | # Create a temporary image to draw on 558 | temp_image = Image.new("RGBA", (159, 31), (0, 0, 0, 0)) 559 | draw = ImageDraw.Draw(temp_image) 560 | draw.rectangle((0, 0, 158, 30), fill="#303030") 561 | # Paste the icon onto the temporary image 562 | icon = Image.open(os.path.join(script_dir, "Icons/P_generate.png")).convert("RGBA") 563 | temp_image.paste(icon, (0, 0), icon) 564 | # Draw the text onto the temporary image 565 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 566 | draw.text((36, 0), mqtt_pgenerate.split(".")[0] + " W", font=font, fill="#00ff00") 567 | # Convert to PhotoImage and display on canvas 568 | photo_image = safe_create_photoimage(temp_image) 569 | if photo_image: 570 | canvas.delete('pgenerate') 571 | canvas.create_image(pgenerate_x + 1, pgenerate_y + 1, anchor = NW, image = photo_image, tags=('pgenerate')) 572 | # prevent garbage collection 573 | canvas.ppgenerate = photo_image 574 | temp_image.close() # Close PIL image 575 | 576 | def update_pdischarge(): 577 | global script_dir 578 | global prev_pdischarge 579 | 580 | # Only update if value has changed 581 | if mqtt_pdischarge == prev_pdischarge: 582 | return 583 | prev_pdischarge = mqtt_pdischarge 584 | 585 | # Create a temporary image to draw on 586 | temp_image = Image.new("RGBA", (159, 31), (0, 0, 0, 0)) 587 | draw = ImageDraw.Draw(temp_image) 588 | draw.rectangle((0, 0, 158, 30), fill="#303030") 589 | # Paste the icon onto the temporary image 590 | icon = Image.open(os.path.join(script_dir, "Icons/P_batdischarge.png")).convert("RGBA") 591 | temp_image.paste(icon, (0, 0), icon) 592 | # Draw the text onto the temporary image 593 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 594 | draw.text((36, 0), mqtt_pdischarge.split(".")[0] + " W", font=font, fill="#8080ff") 595 | # Convert to PhotoImage and display on canvas 596 | photo_image = safe_create_photoimage(temp_image) 597 | if photo_image: 598 | canvas.delete('pdischarge') 599 | canvas.create_image(pdischarge_x + 1, pdischarge_y + 1, anchor = NW, image = photo_image, tags=('pdischarge')) 600 | # prevent garbage collection 601 | canvas.ppdischarge = photo_image 602 | temp_image.close() # Close PIL image 603 | 604 | def update_pcharge(): 605 | global script_dir 606 | global prev_pcharge 607 | 608 | # Only update if value has changed 609 | if mqtt_pcharge == prev_pcharge: 610 | return 611 | prev_pcharge = mqtt_pcharge 612 | 613 | # Create a temporary image to draw on 614 | temp_image = Image.new("RGBA", (159, 31), (0, 0, 0, 0)) 615 | draw = ImageDraw.Draw(temp_image) 616 | draw.rectangle((0, 0, 158, 30), fill="#303030") 617 | # Paste the icon onto the temporary image 618 | icon = Image.open(os.path.join(script_dir, "Icons/P_batcharge.png")).convert("RGBA") 619 | temp_image.paste(icon, (0, 0), icon) 620 | # Draw the text onto the temporary image 621 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 622 | draw.text((36, 0), mqtt_pcharge.split(".")[0] + " W", font=font, fill="#8080ff") 623 | # Convert to PhotoImage and display on canvas 624 | photo_image = safe_create_photoimage(temp_image) 625 | if photo_image: 626 | canvas.delete('pcharge') 627 | canvas.create_image(pcharge_x + 1, pcharge_y + 1, anchor = NW, image = photo_image, tags=('pcharge')) 628 | # prevent garbage collection 629 | canvas.ppcharge = photo_image 630 | temp_image.close() # Close PIL image 631 | 632 | def update_eabsorb(): 633 | global script_dir 634 | global prev_eabsorb 635 | 636 | # Only update if value has changed 637 | if mqtt_eabsorb == prev_eabsorb: 638 | return 639 | prev_eabsorb = mqtt_eabsorb 640 | 641 | # Create a temporary image to draw on 642 | temp_image = Image.new("RGBA", (191, 31), (0, 0, 0, 0)) 643 | draw = ImageDraw.Draw(temp_image) 644 | draw.rectangle((0, 0, 190, 30), fill="#303030") 645 | # Paste the icon onto the temporary image 646 | icon = Image.open(os.path.join(script_dir, "Icons/E_absorb.png")).convert("RGBA") 647 | temp_image.paste(icon, (0, 0), icon) 648 | # Draw the text onto the temporary image 649 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 650 | draw.text((36, 0), mqtt_eabsorb.split(".")[0] + " kWh", font=font, fill="#ff0000") 651 | # Convert to PhotoImage and display on canvas 652 | photo_image = safe_create_photoimage(temp_image) 653 | if photo_image: 654 | canvas.delete('eabsorb') 655 | canvas.create_image(eabsorb_x + 1, eabsorb_y + 1, anchor = NW, image = photo_image, tags=('eabsorb')) 656 | # prevent garbage collection 657 | canvas.peabsorb = photo_image 658 | temp_image.close() # Close PIL image 659 | 660 | def update_eyield(): 661 | global script_dir 662 | global prev_eyield 663 | 664 | # Only update if value has changed 665 | if mqtt_eyield == prev_eyield: 666 | return 667 | prev_eyield = mqtt_eyield 668 | 669 | # Create a temporary image to draw on 670 | temp_image = Image.new("RGBA", (191, 31), (0, 0, 0, 0)) 671 | draw = ImageDraw.Draw(temp_image) 672 | draw.rectangle((0, 0, 190, 30), fill="#303030") 673 | # Paste the icon onto the temporary image 674 | icon = Image.open(os.path.join(script_dir, "Icons/E_yield.png")).convert("RGBA") 675 | temp_image.paste(icon, (0, 0), icon) 676 | # Draw the text onto the temporary image 677 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 678 | draw.text((36, 0), mqtt_eyield.split(".")[0] + " kWh", font=font, fill="#ffff00") 679 | # Convert to PhotoImage and display on canvas 680 | photo_image = safe_create_photoimage(temp_image) 681 | if photo_image: 682 | canvas.delete('eyield') 683 | canvas.create_image(eyield_x + 1, eyield_y + 1, anchor = NW, image = photo_image, tags=('eyield')) 684 | # prevent garbage collection 685 | canvas.peyield = photo_image 686 | temp_image.close() # Close PIL image 687 | 688 | def update_sbatcharge(): 689 | global script_dir 690 | global prev_sbatcharge 691 | 692 | # Only update if value has changed 693 | if mqtt_sbatcharge == prev_sbatcharge: 694 | return 695 | prev_sbatcharge = mqtt_sbatcharge 696 | 697 | # Create a temporary image to draw on 698 | temp_image = Image.new("RGBA", (191, 31), (0, 0, 0, 0)) 699 | draw = ImageDraw.Draw(temp_image) 700 | draw.rectangle((0, 0, 190, 30), fill="#303030") 701 | # Paste the icon onto the temporary image 702 | icon = Image.open(os.path.join(script_dir, "Icons/S_batcharge.png")).convert("RGBA") 703 | temp_image.paste(icon, (0, 0), icon) 704 | # Draw the text onto the temporary image 705 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 706 | draw.text((36, 0), mqtt_sbatcharge + " %", font=font, fill="#8080ff") 707 | # Convert to PhotoImage and display on canvas 708 | photo_image = safe_create_photoimage(temp_image) 709 | if photo_image: 710 | canvas.delete('sbatcharge') 711 | canvas.create_image(sbatcharge_x + 1, sbatcharge_y + 1, anchor = NW, image = photo_image, tags=('sbatcharge')) 712 | # prevent garbage collection 713 | canvas.psbatcharge = photo_image 714 | temp_image.close() # Close PIL image 715 | 716 | def on_message(client, userdata, message): 717 | global mqtt_intemperature 718 | global mqtt_inhumidity 719 | global mqtt_outtemperature 720 | global mqtt_outhumidity 721 | global mqtt_pressure 722 | global mqtt_staticiaq 723 | global mqtt_ppurchase 724 | global mqtt_pfeed 725 | global mqtt_pconsume 726 | global mqtt_pgenerate 727 | global mqtt_pdischarge 728 | global mqtt_pcharge 729 | global mqtt_eabsorb 730 | global mqtt_eyield 731 | global mqtt_sbatcharge 732 | 733 | msg = str(message.payload.decode("utf-8")) 734 | #print(f"MQTT message received - Topic: {message.topic}, Message: {msg}") 735 | 736 | def schedule_update(update_func): 737 | """Schedule GUI update in main thread to prevent threading issues""" 738 | try: 739 | if window and hasattr(window, 'winfo_exists') and window.winfo_exists(): 740 | window.after(0, update_func) 741 | except (RuntimeError, TclError): 742 | pass 743 | 744 | if (message.topic == mqtt_topic_intemperature): 745 | mqtt_intemperature = msg 746 | schedule_update(update_intemperature) 747 | if (message.topic == mqtt_topic_inhumidity): 748 | mqtt_inhumidity = msg 749 | schedule_update(update_inhumidity) 750 | if (message.topic == mqtt_topic_outtemperature): 751 | mqtt_outtemperature = msg 752 | schedule_update(update_outtemperature) 753 | if (message.topic == mqtt_topic_outhumidity): 754 | mqtt_outhumidity = msg 755 | schedule_update(update_outhumidity) 756 | if (message.topic == mqtt_topic_pressure): 757 | mqtt_pressure = msg 758 | try: 759 | plist.append(float(mqtt_pressure)) 760 | except: 761 | print("Pressure value has wrong format.") 762 | schedule_update(update_pressure) 763 | if (message.topic == mqtt_topic_staticiaq): 764 | mqtt_staticiaq = msg 765 | schedule_update(update_staticiaq) 766 | if (message.topic == mqtt_topic_ppurchase): 767 | mqtt_ppurchase = msg 768 | schedule_update(update_ppurchase) 769 | if (message.topic == mqtt_topic_pfeed): 770 | mqtt_pfeed = msg 771 | schedule_update(update_pfeed) 772 | if (message.topic == mqtt_topic_pconsume): 773 | mqtt_pconsume = msg 774 | schedule_update(update_pconsume) 775 | if (message.topic == mqtt_topic_pgenerate): 776 | mqtt_pgenerate = msg 777 | schedule_update(update_pgenerate) 778 | if (message.topic == mqtt_topic_pdischarge): 779 | mqtt_pdischarge = msg 780 | schedule_update(update_pdischarge) 781 | if (message.topic == mqtt_topic_pcharge): 782 | mqtt_pcharge = msg 783 | schedule_update(update_pcharge) 784 | if (message.topic == mqtt_topic_eabsorb): 785 | mqtt_eabsorb = msg 786 | schedule_update(update_eabsorb) 787 | if (message.topic == mqtt_topic_eyield): 788 | mqtt_eyield = msg 789 | schedule_update(update_eyield) 790 | if (message.topic == mqtt_topic_sbatcharge): 791 | mqtt_sbatcharge = msg 792 | schedule_update(update_sbatcharge) 793 | 794 | def on_connect(client, userdata, flags, reason_code, properties): 795 | global mqtt_connected, mqtt_last_successful_time, mqtt_reconnect_count 796 | if reason_code.is_failure: 797 | print(f"MQTT connection failed with code {reason_code}") 798 | mqtt_connected = False 799 | else: 800 | print(f"MQTT connected successfully (rc={reason_code})") 801 | mqtt_connected = True 802 | mqtt_last_successful_time = time.time() 803 | mqtt_reconnect_count = 0 # Reset counter on successful connection 804 | # Subscribe to all topics 805 | topics = [ 806 | mqtt_topic_intemperature, mqtt_topic_inhumidity, 807 | mqtt_topic_outtemperature, mqtt_topic_outhumidity, 808 | mqtt_topic_pressure, mqtt_topic_staticiaq, 809 | mqtt_topic_ppurchase, mqtt_topic_pfeed, 810 | mqtt_topic_pconsume, mqtt_topic_pgenerate, 811 | mqtt_topic_pdischarge, mqtt_topic_pcharge, 812 | mqtt_topic_eabsorb, mqtt_topic_eyield, 813 | mqtt_topic_sbatcharge 814 | ] 815 | for topic in topics: 816 | client.subscribe(topic) 817 | print(f"Total: {len(topics)} MQTT topics subscription attempts completed") 818 | 819 | def on_disconnect(client, userdata, disconnect_flags, reason_code, properties): 820 | global mqtt_connected, mqtt_reconnect_count 821 | mqtt_connected = False 822 | # Reset reconnection counter on disconnect to allow fresh attempts 823 | if reason_code != 0: 824 | print(f"MQTT unexpected disconnection (rc={reason_code}) - resetting reconnection counter") 825 | if reason_code == 5: # MQTT_ERR_CONN_LOST - likely broker restart 826 | print("Disconnection appears to be due to broker restart or network loss") 827 | mqtt_reconnect_count = 0 # Reset counter for fresh attempts after unexpected disconnect 828 | else: 829 | print("MQTT disconnected normally") 830 | 831 | def open_weather_icon(icon): 832 | global script_dir 833 | 834 | if (icon == "clear-day"): 835 | weather_icon = Image.open(os.path.join(script_dir, "Icons/clear-day.png")).convert("RGBA") 836 | elif (icon == "clear-night"): 837 | weather_icon = Image.open(os.path.join(script_dir, "Icons/clear-night.png")).convert("RGBA") 838 | elif (icon == "cloudy"): 839 | weather_icon = Image.open(os.path.join(script_dir, "Icons/cloudy.png")).convert("RGBA") 840 | elif (icon == "fog"): 841 | weather_icon = Image.open(os.path.join(script_dir, "Icons/fog.png")).convert("RGBA") 842 | elif (icon == "hail"): 843 | weather_icon = Image.open(os.path.join(script_dir, "Icons/hail.png")).convert("RGBA") 844 | elif (icon == "partly-cloudy-day"): 845 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-day.png")).convert("RGBA") 846 | elif (icon == "partly-cloudy-day-rain"): 847 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-day-rain.png")).convert("RGBA") 848 | elif (icon == "partly-cloudy-day-snow"): 849 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-day-snow.png")).convert("RGBA") 850 | elif (icon == "partly-cloudy-night"): 851 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-night.png")).convert("RGBA") 852 | elif (icon == "partly-cloudy-night-rain"): 853 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-night-rain.png")).convert("RGBA") 854 | elif (icon == "partly-cloudy-night-snow"): 855 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-night-snow.png")).convert("RGBA") 856 | elif (icon == "rain"): 857 | weather_icon = Image.open(os.path.join(script_dir, "Icons/rain.png")).convert("RGBA") 858 | elif (icon == "sleet"): 859 | weather_icon = Image.open(os.path.join(script_dir, "Icons/sleet.png")).convert("RGBA") 860 | elif (icon == "snow"): 861 | weather_icon = Image.open(os.path.join(script_dir, "Icons/snow.png")).convert("RGBA") 862 | elif (icon == "thunderstorm"): 863 | weather_icon = Image.open(os.path.join(script_dir, "Icons/thunderstorm.png")).convert("RGBA") 864 | elif (icon == "wind"): 865 | weather_icon = Image.open(os.path.join(script_dir, "Icons/wind.png")).convert("RGBA") 866 | else: 867 | weather_icon = None 868 | return weather_icon 869 | 870 | def open_weather_icon_big(icon): 871 | global script_dir 872 | 873 | if (icon == "clear-day"): 874 | weather_icon = Image.open(os.path.join(script_dir, "Icons/clear-day-big.png")).convert("RGBA") 875 | elif (icon == "clear-night"): 876 | weather_icon = Image.open(os.path.join(script_dir, "Icons/clear-night-big.png")).convert("RGBA") 877 | elif (icon == "cloudy"): 878 | weather_icon = Image.open(os.path.join(script_dir, "Icons/cloudy-big.png")).convert("RGBA") 879 | elif (icon == "fog"): 880 | weather_icon = Image.open(os.path.join(script_dir, "Icons/fog-big.png")).convert("RGBA") 881 | elif (icon == "hail"): 882 | weather_icon = Image.open(os.path.join(script_dir, "Icons/hail-big.png")).convert("RGBA") 883 | elif (icon == "partly-cloudy-day"): 884 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-day-big.png")).convert("RGBA") 885 | elif (icon == "partly-cloudy-day-rain"): 886 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-day-rain-big.png")).convert("RGBA") 887 | elif (icon == "partly-cloudy-day-snow"): 888 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-day-snow-big.png")).convert("RGBA") 889 | elif (icon == "partly-cloudy-night"): 890 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-night-big.png")).convert("RGBA") 891 | elif (icon == "partly-cloudy-night-rain"): 892 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-night-rain-big.png")).convert("RGBA") 893 | elif (icon == "partly-cloudy-night-snow"): 894 | weather_icon = Image.open(os.path.join(script_dir, "Icons/partly-cloudy-night-snow-big.png")).convert("RGBA") 895 | elif (icon == "rain"): 896 | weather_icon = Image.open(os.path.join(script_dir, "Icons/rain-big.png")).convert("RGBA") 897 | elif (icon == "sleet"): 898 | weather_icon = Image.open(os.path.join(script_dir, "Icons/sleet-big.png")).convert("RGBA") 899 | elif (icon == "snow"): 900 | weather_icon = Image.open(os.path.join(script_dir, "Icons/snow-big.png")).convert("RGBA") 901 | elif (icon == "thunderstorm"): 902 | weather_icon = Image.open(os.path.join(script_dir, "Icons/thunderstorm-big.png")).convert("RGBA") 903 | elif (icon == "wind"): 904 | weather_icon = Image.open(os.path.join(script_dir, "Icons/wind-big.png")).convert("RGBA") 905 | else: 906 | weather_icon = None 907 | return weather_icon 908 | 909 | def draw_weather(now_hour, first_hour, last_hour, start_pos, url): 910 | global script_dir 911 | 912 | x = start_pos 913 | try: 914 | Response = requests.get(url) 915 | WeatherData = Response.json() 916 | 917 | if (now_hour != -1): 918 | # Create a temporary image to draw on 919 | temp_image = Image.new("RGBA", (159, 127), (0, 0, 0, 0)) 920 | draw = ImageDraw.Draw(temp_image) 921 | draw.rectangle((0, 0, 158, 126), fill="#303030") 922 | # Paste the icon onto the temporary image 923 | icon_now = str(WeatherData["weather"][now_hour]["icon"]) 924 | icon = open_weather_icon_big(icon_now) 925 | if icon: 926 | temp_image.paste(icon, (16, 0), icon) 927 | # Convert to PhotoImage and display on canvas 928 | photo_image = safe_create_photoimage(temp_image) 929 | if photo_image: 930 | canvas.delete('now_weather') 931 | canvas.create_image(big_day_weather_x + 1, big_day_weather_y + 1, anchor = NW, image = photo_image, tags=('now_weather')) 932 | # prevent garbage collection 933 | canvas.now_weather = photo_image 934 | temp_image.close() # Close PIL image 935 | 936 | for h in range(first_hour, last_hour+1, 4): 937 | icon_now = str(WeatherData["weather"][h]["icon"]) 938 | cond = str(WeatherData["weather"][h]["condition"]) 939 | temperature = str(WeatherData["weather"][h]["temperature"]) 940 | pressure = str(WeatherData["weather"][h]["pressure_msl"]) 941 | humidity = str(WeatherData["weather"][h]["relative_humidity"]) 942 | precipitation = WeatherData["weather"][h]["precipitation"] 943 | if ((icon_now == "partly-cloudy-day" or icon_now == "partly-cloudy-night") and (cond == "rain" or cond == "snow")): 944 | icon_now = icon_now + '-' + cond; 945 | if (icon_now == "cloudy" and (cond == "rain" or cond == "snow") and precipitation > 0.2): 946 | icon_now = cond; 947 | 948 | # Create a temporary image to draw on 949 | temp_image = Image.new("RGBA", (170, 87), (0, 0, 0, 0)) 950 | draw = ImageDraw.Draw(temp_image) 951 | draw.rectangle((0, 0, 169, 86), fill="#000000") 952 | # Paste the icon onto the temporary image 953 | icon = open_weather_icon(icon_now) 954 | if icon: 955 | temp_image.paste(icon, (0, 12), icon) 956 | # Draw the text onto the temporary image 957 | font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 958 | font2 = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 18) 959 | draw.text((66, 2), str(h) + ":00", font=font, fill="#ffffff") 960 | draw.text((66, 30), temperature + " °C", font=font2, fill="#ffff00") 961 | draw.text((66, 48), pressure + " hPa", font=font2, fill="#ffff00") 962 | draw.text((66, 66), humidity + " %rF", font=font2, fill="#ffff00") 963 | # Convert to PhotoImage and display on canvas 964 | photo_image = safe_create_photoimage(temp_image) 965 | if photo_image: 966 | canvas.delete('day_weather' + str(x)) 967 | canvas.create_image(day_weather_x + x * 170 + 1, day_weather_y + 1, anchor = NW, image = photo_image, tags=('day_weather' + str(x))) 968 | # prevent garbage collection 969 | if not hasattr(canvas, 'dayhour_weather'): 970 | canvas.dayhour_weather = {} 971 | canvas.dayhour_weather[x] = photo_image 972 | temp_image.close() # Close PIL image 973 | x = x + 1 974 | except: 975 | print("Couldn't load weather data.") 976 | 977 | def update_day_weather(): 978 | tc = time.time() 979 | tt = tc + 86400 980 | ty = tc - 86400 981 | today = datetime.fromtimestamp(tc).strftime('%Y-%m-%d') 982 | tomorrow = datetime.fromtimestamp(tt).strftime('%Y-%m-%d') 983 | yesterday = datetime.fromtimestamp(ty).strftime('%Y-%m-%d') 984 | hour = datetime.fromtimestamp(tc).strftime('%H') 985 | 986 | if (int(hour) < 3): 987 | # 23 from yesterday + 3, 7, 11, 15, 19 from today 988 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + yesterday + "&tz=" +timezone 989 | draw_weather(-1, 23, 23, 0, url) 990 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 991 | draw_weather(int(hour), 3, 19, 1, url) 992 | elif (int(hour) >= 3) and (int(hour) < 7): 993 | # 3, 7, 11, 15, 19, 23 from today 994 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 995 | draw_weather(int(hour), 3, 23, 0, url) 996 | elif (int(hour) >= 7) and (int(hour) < 11): 997 | # 7, 11, 15, 19, 23 from today, 3 from tomorrow 998 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 999 | draw_weather(int(hour), 7, 23, 0, url) 1000 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + tomorrow + "&tz=" +timezone 1001 | draw_weather(-1, 3, 3, 5, url) 1002 | elif (int(hour) >= 11) and (int(hour) < 15): 1003 | # 11, 15, 19, 23 from today, 3, 7 from tomorrow 1004 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 1005 | draw_weather(int(hour), 11, 23, 0, url) 1006 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + tomorrow + "&tz=" +timezone 1007 | draw_weather(-1, 3, 7, 4, url) 1008 | elif (int(hour) >= 15) and (int(hour) < 19): 1009 | # 15, 19, 23 from today, 3, 7, 11 from tomorrow 1010 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 1011 | draw_weather(int(hour), 15, 23, 0, url) 1012 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + tomorrow + "&tz=" +timezone 1013 | draw_weather(-1, 3, 11, 3, url) 1014 | elif (int(hour) >= 19) and (int(hour) < 23): 1015 | # 19, 23 from today, 3, 7, 11, 15 from tomorrow 1016 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 1017 | draw_weather(int(hour), 19, 23, 0, url) 1018 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + tomorrow + "&tz=" +timezone 1019 | draw_weather(-1, 3, 15, 2, url) 1020 | else: 1021 | # 23 from today, 3, 7, 11, 15, 19 from tomorrow 1022 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone 1023 | draw_weather(int(hour), 23, 23, 0, url) 1024 | url = "https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + tomorrow + "&tz=" +timezone 1025 | draw_weather(-1, 3, 19, 1, url) 1026 | # update every 10 min 1027 | try: 1028 | if not shutdown_flag and window and hasattr(window, 'winfo_exists'): 1029 | if window.winfo_exists(): 1030 | window.after(600000, update_day_weather) 1031 | except (RuntimeError, TclError): 1032 | pass # Main thread may no longer be in main loop 1033 | 1034 | def display_on(): 1035 | global display_onoff 1036 | if (display_onoff == "OFF"): 1037 | display_onoff = "ON" 1038 | os.system('vcgencmd display_power 1') 1039 | 1040 | def display_off(): 1041 | global display_onoff 1042 | if (display_onoff == "ON"): 1043 | display_onoff = "OFF" 1044 | os.system('vcgencmd display_power 0') 1045 | 1046 | def update_clock(): 1047 | global script_dir 1048 | global old_time 1049 | global display_on_time 1050 | 1051 | if GPIO.input(16): 1052 | display_on_time = 3000 # keep display on for 5 minutes 1053 | 1054 | if (display_on_time > 0): 1055 | display_on() 1056 | display_on_time = display_on_time - 1 1057 | else: 1058 | display_off() 1059 | 1060 | # local time 1061 | loc_time = time.strftime('%H:%M') 1062 | # MJD + week 1063 | date_txt = time.strftime('%d.%m.%Y') 1064 | week_txt = "KW %s" % (time.strftime('%W')) 1065 | if loc_time != old_time: # if time string has changed, update it 1066 | old_time = loc_time 1067 | 1068 | # Create a temporary image to draw on 1069 | temp_image = Image.new("RGBA", (351, 159), (0, 0, 0, 0)) 1070 | draw = ImageDraw.Draw(temp_image) 1071 | draw.rectangle((0, 0, 350, 158), fill="#202020") 1072 | 1073 | # Draw the text onto the temporary image 1074 | # Main clock font 1075 | clock_font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 125) 1076 | date_font = ImageFont.truetype(os.path.join(script_dir, "arial.ttf"), 27) 1077 | 1078 | # Draw time (main clock) 1079 | draw.text((10, -10), loc_time, font=clock_font, fill="#ff8000") 1080 | # Draw date and week 1081 | draw.text((10, 119), date_txt, font=date_font, fill="#ffffff") 1082 | draw.text((160, 119), week_txt, font=date_font, fill="#ffff00") 1083 | 1084 | # Convert to PhotoImage and display on canvas 1085 | photo_image = safe_create_photoimage(temp_image) 1086 | if photo_image: 1087 | canvas.delete('clock') 1088 | canvas.create_image(clock_x + 1, clock_y + 1, anchor=NW, image=photo_image, tags=('clock')) 1089 | # prevent garbage collection 1090 | canvas.clock = photo_image 1091 | temp_image.close() # Close PIL image 1092 | 1093 | # update every 100 msec 1094 | try: 1095 | if not shutdown_flag and window and hasattr(window, 'winfo_exists'): 1096 | if window.winfo_exists(): 1097 | window.after(100, update_clock) 1098 | except (RuntimeError, TclError): 1099 | pass # Main thread may no longer be in main loop 1100 | 1101 | def update_mqtt_data(): 1102 | global dwd_pressure 1103 | global dwd_outtemperature 1104 | global dwd_outhumidity 1105 | today = time.strftime('%Y-%m-%d') 1106 | try: 1107 | Response = requests.get("https://api.brightsky.dev/weather?lat=" + latitude + "&lon=" + longitude + "&date=" + today + "&tz=" +timezone) 1108 | WeatherData = Response.json() 1109 | dwd_pressure = str(WeatherData["weather"][int(time.strftime('%H'))]["pressure_msl"]) 1110 | dwd_outtemperature = str(WeatherData["weather"][int(time.strftime('%H'))]["temperature"]) 1111 | dwd_outhumidity = str(WeatherData["weather"][int(time.strftime('%H'))]["relative_humidity"]) 1112 | except: 1113 | print("Couldn't load weather data.") 1114 | update_intemperature() 1115 | update_inhumidity() 1116 | update_outtemperature() 1117 | update_outhumidity() 1118 | update_pressure() 1119 | update_staticiaq() 1120 | update_ppurchase() 1121 | update_pfeed() 1122 | update_pconsume() 1123 | update_pgenerate() 1124 | update_pdischarge() 1125 | update_pcharge() 1126 | update_eabsorb() 1127 | update_eyield() 1128 | update_sbatcharge() 1129 | 1130 | # update every 1 min 1131 | try: 1132 | if not shutdown_flag and window and hasattr(window, 'winfo_exists'): 1133 | if window.winfo_exists(): 1134 | window.after(60000, update_mqtt_data) 1135 | except (RuntimeError, TclError): 1136 | pass # Main thread may no longer be in main loop 1137 | 1138 | def update_weathermap_in_gui(): 1139 | global window 1140 | global canvas 1141 | """Update the displayed image in the GUI (thread-safe)""" 1142 | try: 1143 | # Ensure we're in the main thread 1144 | if threading.current_thread() != threading.main_thread(): 1145 | print("Warning: Weather map update attempted from background thread") 1146 | return 1147 | 1148 | new_pil_image = radar.create_smooth_heatmap_grid(sigma=1.5) 1149 | photo = safe_create_photoimage(new_pil_image) 1150 | 1151 | if photo: 1152 | canvas.delete('weather_map') 1153 | canvas.create_image(0, 0, anchor = NW, image = photo, tags=('weather_map')) 1154 | 1155 | # Store references to prevent garbage collection 1156 | window.photo = photo 1157 | window.current_pil_image = new_pil_image 1158 | else: 1159 | new_pil_image.close() # Close if PhotoImage creation failed 1160 | except Exception as e: 1161 | print(f"Error updating weathermap: {e}") 1162 | 1163 | def cleanup_and_exit(): 1164 | """Cleanup function to gracefully shutdown the application""" 1165 | global shutdown_flag, client, window, canvas, radar 1166 | 1167 | print("Cleaning up...") 1168 | shutdown_flag = True 1169 | 1170 | # Stop MQTT client properly for manual polling mode 1171 | try: 1172 | if 'client' in globals() and client: 1173 | client.disconnect() # Just disconnect, no loop_stop needed for manual polling 1174 | time.sleep(0.1) # Give it time to disconnect 1175 | client = None 1176 | except: 1177 | pass 1178 | 1179 | # Stop all window timers by destroying window immediately 1180 | try: 1181 | if window: 1182 | # Cancel all pending after() calls 1183 | window.after_cancel('all') 1184 | except: 1185 | pass 1186 | 1187 | # Clear radar processor 1188 | try: 1189 | if radar: 1190 | radar = None 1191 | except: 1192 | pass 1193 | 1194 | # Clear all canvas and image references 1195 | try: 1196 | if canvas: 1197 | # Delete all canvas items first 1198 | canvas.delete('all') 1199 | 1200 | # Clear all stored image references 1201 | for attr in dir(canvas): 1202 | if attr.startswith('p') and not attr.startswith('pack'): 1203 | try: 1204 | delattr(canvas, attr) 1205 | except: 1206 | pass 1207 | 1208 | canvas = None 1209 | except: 1210 | pass 1211 | 1212 | # Force garbage collection to clean up any remaining objects 1213 | try: 1214 | gc.collect() 1215 | except: 1216 | pass 1217 | 1218 | # Destroy window last with thread safety 1219 | try: 1220 | if window: 1221 | # Ensure we're in the main thread for window operations 1222 | if threading.current_thread() == threading.main_thread(): 1223 | window.quit() 1224 | window.destroy() 1225 | window = None 1226 | except: 1227 | pass 1228 | 1229 | # Final garbage collection 1230 | try: 1231 | gc.collect() 1232 | except: 1233 | pass 1234 | 1235 | def safe_create_photoimage(pil_image): 1236 | """Thread-safe PhotoImage creation that prevents runtime threading errors""" 1237 | try: 1238 | # Ensure we're in the main thread 1239 | if threading.current_thread() != threading.main_thread(): 1240 | print("Warning: Image creation attempted from background thread") 1241 | return None 1242 | 1243 | # Create PhotoImage and immediately close PIL image 1244 | photo = ImageTk.PhotoImage(pil_image) 1245 | return photo 1246 | except Exception as e: 1247 | print(f"Error creating PhotoImage: {e}") 1248 | return None 1249 | 1250 | def on_window_close(): 1251 | """Handle window close event""" 1252 | cleanup_and_exit() 1253 | 1254 | def main(): 1255 | global window 1256 | global canvas 1257 | global plist 1258 | global radar 1259 | global client 1260 | global script_dir 1261 | 1262 | # Register cleanup function to ensure it runs on exit 1263 | atexit.register(cleanup_and_exit) 1264 | 1265 | script_dir = os.path.dirname(os.path.realpath(__file__)) 1266 | 1267 | GPIO.setmode(GPIO.BCM) 1268 | GPIO.setwarnings(False) 1269 | GPIO.setup(16, GPIO.IN) 1270 | 1271 | # Create radar processor 1272 | radar = RadarProcessor( 1273 | satellite_source=radar_background, 1274 | zoom_level=zoom, 1275 | center_lon=float(longitude), 1276 | center_lat=float(latitude), 1277 | image_width_pixels=512, 1278 | image_height_pixels=512, 1279 | cities={ 1280 | 'Heimsheim': (8.863, 48.808, 'red'), 1281 | 'Leonberg': (9.014, 48.798, 'green'), 1282 | 'Rutesheim': (8.947, 48.808, 'green'), 1283 | 'Renningen': (8.934, 48.765, 'green'), 1284 | 'Weissach': (8.929, 48.847, 'green'), 1285 | 'Friolzheim': (8.835, 48.836, 'green'), 1286 | 'Wiernsheim': (8.851, 48.891, 'green'), 1287 | 'Liebenzell': (8.732, 48.771, 'green'), 1288 | 'Calw': (8.739, 48.715, 'green'), 1289 | 'Weil der Stadt': (8.871, 48.750, 'green'), 1290 | 'Böblingen': (9.011, 48.686, 'green'), 1291 | 'Hochdorf': (9.002, 48.886, 'green'), 1292 | 'Pforzheim': (8.704, 48.891, 'green'), 1293 | 'Sindelfingen': (9.005, 48.709, 'green'), 1294 | } 1295 | ) 1296 | 1297 | window = Tk() 1298 | canvas = Canvas(window, width = 1024, height = 600, bd = 0, highlightthickness = 0) 1299 | canvas.pack() 1300 | canvas.create_rectangle(0, 0, 1023, 599, fill='black') 1301 | 1302 | plist = circularlist(18) 1303 | 1304 | # Generate initial image 1305 | radar.load_and_process_data(use_local=False) 1306 | update_weathermap_in_gui() 1307 | 1308 | # Add periodic radar check in main thread instead of relying on background thread 1309 | def check_radar_update(): 1310 | if not shutdown_flag: 1311 | try: 1312 | has_new_data, server_modified = radar.check_for_new_data() 1313 | if has_new_data: 1314 | # Load data in background but update GUI in main thread 1315 | if radar.load_and_process_data(use_local=False, server_modified=server_modified): 1316 | update_weathermap_in_gui() 1317 | 1318 | if window and hasattr(window, 'winfo_exists') and window.winfo_exists(): 1319 | window.after(60000, check_radar_update) # Check every minute 1320 | except Exception as e: 1321 | print(f"Radar check error: {e}") 1322 | 1323 | # Start radar checking in main thread 1324 | window.after(5000, check_radar_update) # Start after 5 seconds 1325 | 1326 | update_clock() 1327 | update_day_weather() 1328 | update_mqtt_data() 1329 | 1330 | # Set up window close protocol 1331 | window.protocol("WM_DELETE_WINDOW", on_window_close) 1332 | 1333 | client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) 1334 | client.username_pw_set(mqtt_user, mqtt_password) 1335 | client.on_connect = on_connect 1336 | client.on_disconnect = on_disconnect 1337 | client.on_message = on_message 1338 | 1339 | # Set connection parameters for better stability 1340 | client.max_inflight_messages_set(20) 1341 | client.max_queued_messages_set(0) # No limit on queued messages 1342 | 1343 | try: 1344 | client.connect(mqtt_broker_address, mqtt_port, keepalive=60) # Longer keepalive 1345 | print("MQTT connection initiated...") 1346 | except Exception as e: 1347 | print(f"MQTT connection failed: {e}") 1348 | 1349 | # Use timer-based MQTT polling in main thread to avoid threading issues 1350 | def mqtt_poll(): 1351 | global mqtt_poll_count, mqtt_last_poll_time, mqtt_connected, mqtt_reconnect_count 1352 | global mqtt_last_successful_time, mqtt_connection_stale_threshold 1353 | 1354 | if shutdown_flag: 1355 | print("MQTT polling stopped due to shutdown flag") 1356 | return 1357 | 1358 | current_time = time.time() 1359 | mqtt_poll_count += 1 1360 | mqtt_last_poll_time = current_time 1361 | 1362 | # Check for stale connections (possible system resume scenario) 1363 | connection_age = current_time - mqtt_last_successful_time 1364 | is_connection_stale = mqtt_connected and connection_age > mqtt_connection_stale_threshold 1365 | 1366 | if is_connection_stale: 1367 | print(f"MQTT connection appears stale ({connection_age:.1f}s since last success) - forcing reconnect") 1368 | mqtt_connected = False 1369 | mqtt_reconnect_count = 0 # Reset for fresh attempts 1370 | try: 1371 | client.disconnect() 1372 | time.sleep(1.0) # Wait for clean disconnect 1373 | print("Initiating fresh MQTT connection after stale detection...") 1374 | client.connect(mqtt_broker_address, mqtt_port, keepalive=60) 1375 | mqtt_reconnect_count = 1 1376 | print("MQTT reconnection initiated after stale connection detection") 1377 | except Exception as e: 1378 | print(f"MQTT reconnection after stale detection failed: {e}") 1379 | mqtt_reconnect_count += 1 1380 | 1381 | # Debug output every 100 polls (about every 10 seconds) 1382 | #if mqtt_poll_count % 100 == 1: 1383 | # status = "Connected" if mqtt_connected else "Disconnected" 1384 | # print(f"MQTT poll #{mqtt_poll_count} - Status: {status}, Reconnects: {mqtt_reconnect_count}") 1385 | 1386 | poll_success = True # Assume success unless we encounter an error 1387 | schedule_next = True # Always schedule next poll unless explicitly disabled 1388 | 1389 | try: 1390 | # Poll for MQTT messages with error detection 1391 | result = client.loop(timeout=0.01) # Short timeout to prevent blocking 1392 | 1393 | if result == mqtt.MQTT_ERR_SUCCESS: 1394 | poll_success = True 1395 | if mqtt_connected: 1396 | mqtt_last_successful_time = current_time # Update successful activity time 1397 | elif result == mqtt.MQTT_ERR_NO_CONN: # Error code 7 1398 | if not shutdown_flag and mqtt_reconnect_count < 15: # Increased limit for resume scenarios 1399 | print(f"MQTT no connection (error {result}) - attempting reconnect #{mqtt_reconnect_count + 1}...") 1400 | try: 1401 | # Force a clean disconnect first 1402 | try: 1403 | client.disconnect() 1404 | except: 1405 | pass # Ignore disconnect errors 1406 | 1407 | # Progressive delay based on attempt count 1408 | delay = min(3.0, 1.0 + (mqtt_reconnect_count * 0.5)) 1409 | time.sleep(delay) 1410 | 1411 | # Fresh connection attempt 1412 | client.connect(mqtt_broker_address, mqtt_port, keepalive=60) 1413 | mqtt_reconnect_count += 1 1414 | print(f"MQTT reconnection attempt #{mqtt_reconnect_count} initiated (delay: {delay:.1f}s)") 1415 | 1416 | # After reconnection attempt, wait longer before next poll to allow connection to establish 1417 | poll_success = False 1418 | # Force longer interval for next poll after reconnection attempt 1419 | if not shutdown_flag and window and hasattr(window, 'winfo_exists'): 1420 | if window.winfo_exists(): 1421 | window.after(3000, mqtt_poll) # Wait 3 seconds before next poll 1422 | return # Exit early to prevent normal scheduling 1423 | 1424 | except Exception as reconnect_error: 1425 | print(f"MQTT reconnection failed: {reconnect_error}") 1426 | mqtt_reconnect_count += 1 1427 | poll_success = False 1428 | else: 1429 | print(f"Maximum MQTT reconnection attempts reached ({mqtt_reconnect_count}), backing off...") 1430 | poll_success = False 1431 | elif result == mqtt.MQTT_ERR_CONN_LOST: # Error code 5 - broker restart/network loss 1432 | print(f"MQTT connection lost (error {result}) - broker restart or network issue detected") 1433 | mqtt_connected = False 1434 | if not shutdown_flag and mqtt_reconnect_count < 20: # More attempts for broker restarts 1435 | print(f"Attempting to reconnect after connection lost #{mqtt_reconnect_count + 1}...") 1436 | try: 1437 | try: 1438 | client.disconnect() 1439 | except: 1440 | pass 1441 | 1442 | # Longer delay for broker restarts as they may take time to fully start 1443 | delay = min(5.0, 2.0 + (mqtt_reconnect_count * 0.5)) 1444 | time.sleep(delay) 1445 | 1446 | client.connect(mqtt_broker_address, mqtt_port, keepalive=60) 1447 | mqtt_reconnect_count += 1 1448 | print(f"MQTT reconnection attempt #{mqtt_reconnect_count} after connection lost (delay: {delay:.1f}s)") 1449 | 1450 | # Wait longer for broker restart scenarios 1451 | if not shutdown_flag and window and hasattr(window, 'winfo_exists'): 1452 | if window.winfo_exists(): 1453 | window.after(5000, mqtt_poll) # Wait 5 seconds for broker restarts 1454 | return 1455 | 1456 | except Exception as reconnect_error: 1457 | print(f"MQTT reconnection after connection lost failed: {reconnect_error}") 1458 | mqtt_reconnect_count += 1 1459 | poll_success = False 1460 | else: 1461 | print(f"MQTT loop error code: {result}") 1462 | poll_success = False 1463 | 1464 | except Exception as e: 1465 | print(f"MQTT poll exception: {e}") 1466 | poll_success = False 1467 | # Try to reconnect on exception with backoff 1468 | try: 1469 | if not shutdown_flag and mqtt_reconnect_count < 8: 1470 | print("Attempting MQTT reconnection due to exception...") 1471 | try: 1472 | client.disconnect() 1473 | except: 1474 | pass 1475 | time.sleep(1.5) # Longer wait on exception 1476 | client.connect(mqtt_broker_address, mqtt_port, keepalive=60) 1477 | mqtt_reconnect_count += 1 1478 | print(f"MQTT reconnection attempt #{mqtt_reconnect_count} initiated after exception") 1479 | except Exception as reconnect_error: 1480 | print(f"MQTT reconnection failed: {reconnect_error}") 1481 | mqtt_reconnect_count += 1 1482 | 1483 | # ALWAYS schedule next poll if not shutting down - this is critical 1484 | if not shutdown_flag and schedule_next: 1485 | try: 1486 | if window and hasattr(window, 'winfo_exists'): 1487 | if window.winfo_exists(): 1488 | # Use appropriate intervals based on connection status and reconnection count 1489 | if mqtt_connected and poll_success: 1490 | interval = 100 # 100ms when connected and working 1491 | elif mqtt_connected: 1492 | interval = 500 # 500ms when connected but having issues 1493 | elif mqtt_reconnect_count < 5: 1494 | interval = 2000 # 2s when disconnected, trying to reconnect 1495 | else: 1496 | interval = 10000 # 10s when many reconnection failures (backoff) 1497 | 1498 | window.after(interval, mqtt_poll) 1499 | else: 1500 | print("Window no longer exists, stopping MQTT polling") 1501 | schedule_next = False 1502 | else: 1503 | print("Window object invalid, stopping MQTT polling") 1504 | schedule_next = False 1505 | except Exception as schedule_error: 1506 | print(f"Failed to schedule next MQTT poll: {schedule_error}") 1507 | # Even if scheduling failed, try again in a moment 1508 | if not shutdown_flag: 1509 | try: 1510 | window.after(5000, mqtt_poll) # Retry in 5 seconds 1511 | except: 1512 | print("Could not schedule retry poll") 1513 | 1514 | return poll_success 1515 | 1516 | # Add a watchdog function to monitor MQTT polling 1517 | def mqtt_watchdog(): 1518 | global mqtt_last_poll_time, mqtt_connected, mqtt_reconnect_count 1519 | if not shutdown_flag: 1520 | current_time = time.time() 1521 | if mqtt_last_poll_time > 0: 1522 | time_since_poll = current_time - mqtt_last_poll_time 1523 | if time_since_poll > 3: # Reduced from 5 to 3 seconds for faster detection 1524 | print(f"MQTT polling appears stuck. Last poll: {time_since_poll:.1f}s ago") 1525 | print(f"Status: Connected={mqtt_connected}, Polls={mqtt_poll_count}, Reconnects={mqtt_reconnect_count}") 1526 | # Force restart polling immediately 1527 | try: 1528 | print("Force restarting MQTT polling...") 1529 | window.after(0, mqtt_poll) # Schedule immediately 1530 | except Exception as e: 1531 | print(f"Failed to restart MQTT polling: {e}") 1532 | elif mqtt_poll_count % 200 == 0: # Status report every ~20 seconds (200 polls * 100ms) 1533 | status = "Connected" if mqtt_connected else "Disconnected" 1534 | print(f"MQTT Status: {status}, Polls: {mqtt_poll_count}, Reconnects: {mqtt_reconnect_count}") 1535 | 1536 | # Schedule next watchdog check more frequently 1537 | if window and hasattr(window, 'winfo_exists'): 1538 | try: 1539 | if window.winfo_exists(): 1540 | window.after(5000, mqtt_watchdog) # Check every 5 seconds (reduced from 30) 1541 | except: 1542 | pass 1543 | 1544 | # Start MQTT polling in main thread 1545 | print("Starting MQTT polling system...") 1546 | window.after(500, mqtt_poll) # Start polling after 500ms 1547 | window.after(10000, mqtt_watchdog) # Start watchdog after 10 seconds 1548 | 1549 | window.geometry("1024x600+0+0") 1550 | window.overrideredirect(True) 1551 | window.config(cursor="none") 1552 | 1553 | # Set up signal handlers for clean shutdown 1554 | def signal_handler(sig, frame): 1555 | cleanup_and_exit() 1556 | 1557 | signal.signal(signal.SIGINT, signal_handler) 1558 | signal.signal(signal.SIGTERM, signal_handler) 1559 | 1560 | # Disable Tkinter's automatic image cleanup to prevent threading issues 1561 | try: 1562 | # This prevents the "main thread is not in main loop" errors 1563 | import tkinter as tk 1564 | 1565 | # Store original destructor 1566 | original_image_del = tk.Image.__del__ 1567 | original_var_del = tk.Variable.__del__ 1568 | 1569 | # Create safe destructors that don't fail on threading issues 1570 | def safe_image_del(self): 1571 | try: 1572 | if hasattr(self, 'tk') and self.tk and hasattr(self.tk, 'call'): 1573 | original_image_del(self) 1574 | except (RuntimeError, tk.TclError): 1575 | pass # Ignore threading errors during shutdown 1576 | 1577 | def safe_var_del(self): 1578 | try: 1579 | if hasattr(self, '_tk') and self._tk and hasattr(self._tk, 'call'): 1580 | original_var_del(self) 1581 | except (RuntimeError, tk.TclError): 1582 | pass # Ignore threading errors during shutdown 1583 | 1584 | # Apply the patches 1585 | tk.Image.__del__ = safe_image_del 1586 | tk.Variable.__del__ = safe_var_del 1587 | except Exception as e: 1588 | print(f"Warning: Could not patch Tkinter destructors: {e}") 1589 | 1590 | try: 1591 | window.mainloop() 1592 | except KeyboardInterrupt: 1593 | pass 1594 | finally: 1595 | cleanup_and_exit() 1596 | 1597 | if __name__ == '__main__': 1598 | try: 1599 | main() 1600 | except Exception as e: 1601 | print(f"Application error: {e}") 1602 | finally: 1603 | # Ensure cleanup runs even if main() fails 1604 | try: 1605 | cleanup_and_exit() 1606 | except: 1607 | pass 1608 | --------------------------------------------------------------------------------