diff --git a/plugins.json b/plugins.json index 9cfbaa77..ea541df9 100644 --- a/plugins.json +++ b/plugins.json @@ -411,10 +411,10 @@ "plugin_path": "plugins/ledmatrix-flights", "stars": 0, "downloads": 0, - "last_updated": "2026-07-17", + "last_updated": "2026-08-03", "verified": true, "screenshot": "", - "latest_version": "1.12.6" + "latest_version": "1.12.7" }, { "id": "march-madness", diff --git a/plugins/ledmatrix-flights/CHANGELOG.md b/plugins/ledmatrix-flights/CHANGELOG.md index 4de55494..1b30542c 100644 --- a/plugins/ledmatrix-flights/CHANGELOG.md +++ b/plugins/ledmatrix-flights/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.12.7] - 2026-08-03 + +### Fixed +- **Map background cache could hand a render the wrong size**: the cached + composite is already cropped to the display aspect ratio and resized to the + panel, but it was keyed on centre and zoom alone. Vegas narrows the display + manager while requesting content, so the rotation and the ticker ask for the + same view at different widths — and whichever rendered second was served the + other one's image. `_render_map_image()` copies that background, so the whole + frame came back at the wrong size, with aircraft and trails projected for the + size it didn't get. The cache now keys on the display size as well, holding + one entry per size and dropping them all when the centre or zoom moves, so + neither path re-tiles when they alternate. + ## [1.12.6] - 2026-07-29 ### Fixed diff --git a/plugins/ledmatrix-flights/manager.py b/plugins/ledmatrix-flights/manager.py index a9e1336c..2b61b3c8 100644 --- a/plugins/ledmatrix-flights/manager.py +++ b/plugins/ledmatrix-flights/manager.py @@ -149,8 +149,11 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cach self.tile_cache_dir.mkdir(parents=True, exist_ok=True) self.logger.info(f"[Flight Tracker] Using temporary map tile cache: {self.tile_cache_dir}") - # Cached map background - self.cached_map_bg = None + # Cached map backgrounds, keyed by the display size they were rendered + # for. Vegas narrows the display manager while requesting content, so + # the ticker and the rotation ask for the same map at different widths; + # a single slot would hand one of them the other's size. + self.cached_map_bgs = {} self.last_map_center = None self.last_map_zoom = None self.cached_pixels_per_mile = None # Actual scale of the cached map @@ -468,9 +471,9 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: exc_info=True) self._disable_metar() - # Invalidate the cached map background so center/radius/zoom/appearance + # Invalidate the cached map backgrounds so center/radius/zoom/appearance # changes are re-tiled on the next render. - self.cached_map_bg = None + self.cached_map_bgs = {} self.last_map_center = None self.last_map_zoom = None self.cached_pixels_per_mile = None @@ -2047,14 +2050,21 @@ def _get_map_background(self, center_lat: float, center_lon: float) -> Optional[ self.logger.debug(f"[Flight Tracker] Map zoom calculation: radius={self.map_radius_miles}mi, zoom_factor={self.zoom_factor}, effective_radius={effective_radius:.2f}mi, zoom={zoom}") - # Check if we can reuse the cached composite map + # Check if we can reuse a cached composite map. The cached image has + # already been cropped to the display aspect ratio and resized to the + # display, so the size is part of the identity — not just center and + # zoom. Rotation and Vegas render the same map at different widths. current_center = (round(center_lat, 4), round(center_lon, 4)) - if (self.cached_map_bg is not None and - self.last_map_center == current_center and - self.last_map_zoom == zoom): - # Location and zoom haven't changed, reuse cached composite - return self.cached_map_bg - + current_size = (self.display_width, self.display_height) + if self.last_map_center != current_center or self.last_map_zoom != zoom: + # Moved or re-zoomed: every size we hold is stale. + self.cached_map_bgs.clear() + else: + cached = self.cached_map_bgs.get(current_size) + if cached is not None: + # Same view at a size we've already composed, reuse it + return cached + # Calculate tile coordinates for center center_x, center_y = self._latlon_to_tile_coords(center_lat, center_lon, zoom) @@ -2209,8 +2219,8 @@ def _get_map_background(self, center_lat: float, center_lon: float) -> Optional[ cropped = enhancer.enhance(self.map_saturation) self.logger.debug(f"[Flight Tracker] Applied saturation: {self.map_saturation}") - # Cache the result - self.cached_map_bg = cropped + # Cache the result against the size it was composed for + self.cached_map_bgs[current_size] = cropped self.last_map_center = current_center self.last_map_zoom = zoom diff --git a/plugins/ledmatrix-flights/manifest.json b/plugins/ledmatrix-flights/manifest.json index a70841e0..80de6122 100644 --- a/plugins/ledmatrix-flights/manifest.json +++ b/plugins/ledmatrix-flights/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-flights", "name": "Flight Tracker", - "version": "1.12.6", + "version": "1.12.7", "description": "Real-time aircraft tracking with ADS-B/FlightRadar24/OpenSky/adsb.fi/adsb.lol data, map backgrounds, area mode, flight tracking, anchor airport, flight records, and optional airport weather (METAR/TAF/PIREP/SIGMET via the free NOAA Aviation Weather Center API)", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -37,6 +37,12 @@ "min_ledmatrix_version": "2.0.0", "max_ledmatrix_version": "3.0.0", "versions": [ + { + "released": "2026-08-03", + "version": "1.12.7", + "notes": "Fix the map background cache handing a render the wrong size. The cached composite is already cropped to the display aspect ratio and resized to the panel, but it was keyed on centre and zoom only. Vegas narrows the display manager, so rotation and the ticker request the same view at different widths and whichever rendered second was served the other's image -- a frame at the wrong size with aircraft and trails projected for the size it did not get. The cache now keys on size as well and keeps one entry per size.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.12.6", @@ -197,5 +203,5 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-17" + "last_updated": "2026-08-03" } diff --git a/plugins/ledmatrix-flights/test_config_reload.py b/plugins/ledmatrix-flights/test_config_reload.py index b20f9f67..dfbe8679 100644 --- a/plugins/ledmatrix-flights/test_config_reload.py +++ b/plugins/ledmatrix-flights/test_config_reload.py @@ -91,7 +91,7 @@ def test_on_config_change_applies_live(): renderer_before = tracker._renderer # Prime the cached map so we can confirm it is invalidated on change. - tracker.cached_map_bg = "SENTINEL" + tracker.cached_map_bgs = {(128, 64): "SENTINEL"} tracker.last_map_center = (27.95, -82.45) tracker.on_config_change( @@ -114,7 +114,7 @@ def test_on_config_change_applies_live(): check(tracker.live_priority_enabled is True, "live_priority updated live") check(tracker._fetcher is not fetcher_before, "fetcher rebuilt for new data source") check(tracker._renderer is not renderer_before, "renderer rebuilt with new config") - check(tracker.cached_map_bg is None, "cached map invalidated so it re-tiles") + check(tracker.cached_map_bgs == {}, "cached maps invalidated so they re-tile") check(tracker.last_map_center is None, "cached map center invalidated") diff --git a/plugins/ledmatrix-flights/test_vegas_map_parity.py b/plugins/ledmatrix-flights/test_vegas_map_parity.py index 0127c631..08f9f7c2 100644 --- a/plugins/ledmatrix-flights/test_vegas_map_parity.py +++ b/plugins/ledmatrix-flights/test_vegas_map_parity.py @@ -237,3 +237,104 @@ def test_renders_without_error_at_any_size(self, width, height): p = make_plugin(True, one_aircraft(), one_trail(), width=width, height=height) img = p._render_map_image() assert img.size == (width, height) + + +def enable_map_background(plugin, tmp_path): + """Turn the tile background on with a stub tiler — no network, no disk cache. + + The composite path is what caches by size, so it has to actually run; + _fetch_tile is the only part that would reach the network. + """ + plugin.map_bg_enabled = True + plugin.tile_size = 256 + plugin.tile_provider = 'osm' + plugin.custom_tile_server = None + plugin.tile_cache_dir = tmp_path / 'tiles' + plugin.cache_ttl_hours = 24 + plugin.fade_intensity = 1.0 + plugin.map_brightness = 1.0 + plugin.map_contrast = 1.0 + plugin.map_saturation = 1.0 + plugin.cached_map_bgs = {} + plugin.last_map_center = None + plugin.last_map_zoom = None + + calls = [] + + def fake_fetch(x, y, zoom): + calls.append((x, y, zoom)) + # A gradient rather than a flat fill, so a wrongly-scaled crop shows up. + tile = Image.new('RGB', (plugin.tile_size, plugin.tile_size)) + tile.putdata([ + ((i % plugin.tile_size), (i // plugin.tile_size), 128) + for i in range(plugin.tile_size ** 2) + ]) + return tile + + plugin._fetch_tile = fake_fetch + return calls + + +class TestMapBackgroundCacheIsSizeAware: + """The composite is cropped and resized to the display before it is cached. + + Vegas narrows the display manager, so rotation and the ticker ask for the + same view at different widths. A memo keyed only on centre and zoom handed + whichever path rendered second the other one's image: the wrong size, with + aircraft and trails projected for the size it didn't get. + """ + + def test_background_matches_the_requested_size_after_a_narrower_render(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) # the renderer drops debug PNGs in the cwd + p = make_plugin(False, {}, {}, width=128, height=64) + enable_map_background(p, tmp_path) + + wide = p._get_map_background(CENTER_LAT, CENTER_LON) + assert wide.size == (128, 64) + + # Same centre and zoom, narrower panel — as Vegas would ask for it. + p.display_manager.matrix.width = 64 + narrow = p._get_map_background(CENTER_LAT, CENTER_LON) + assert narrow.size == (64, 64) + + def test_each_size_is_still_cached(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + p = make_plugin(False, {}, {}, width=128, height=64) + calls = enable_map_background(p, tmp_path) + + p._get_map_background(CENTER_LAT, CENTER_LON) + p.display_manager.matrix.width = 64 + p._get_map_background(CENTER_LAT, CENTER_LON) + after_both = len(calls) + + # Re-asking for either size must not re-tile. + p._get_map_background(CENTER_LAT, CENTER_LON) + p.display_manager.matrix.width = 128 + p._get_map_background(CENTER_LAT, CENTER_LON) + assert len(calls) == after_both + + def test_moving_the_centre_drops_every_cached_size(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + p = make_plugin(False, {}, {}, width=128, height=64) + enable_map_background(p, tmp_path) + + p._get_map_background(CENTER_LAT, CENTER_LON) + p.display_manager.matrix.width = 64 + p._get_map_background(CENTER_LAT, CENTER_LON) + assert len(p.cached_map_bgs) == 2 + + p._get_map_background(CENTER_LAT + 5, CENTER_LON + 5) + assert list(p.cached_map_bgs) == [(64, 64)] + + def test_rendered_map_is_the_display_size_across_a_size_switch(self, tmp_path, monkeypatch): + # The end-to-end symptom: _render_map_image copies the background, so a + # stale-size memo produced a whole frame at the wrong size. + monkeypatch.chdir(tmp_path) + p = make_plugin(True, one_aircraft(), one_trail(), width=128, height=64) + enable_map_background(p, tmp_path) + + assert p._render_map_image().size == (128, 64) + p.display_manager.matrix.width = 256 + assert p._render_map_image().size == (256, 64) + p.display_manager.matrix.width = 128 + assert p._render_map_image().size == (128, 64)