├── 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 |
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 |
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 |
--------------------------------------------------------------------------------