Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions plugins/ledmatrix-flights/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
36 changes: 23 additions & 13 deletions plugins/ledmatrix-flights/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions plugins/ledmatrix-flights/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -197,5 +203,5 @@
"ledmatrix_min_version": "2.0.0"
}
],
"last_updated": "2026-07-17"
"last_updated": "2026-08-03"
}
4 changes: 2 additions & 2 deletions plugins/ledmatrix-flights/test_config_reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")


Expand Down
101 changes: 101 additions & 0 deletions plugins/ledmatrix-flights/test_vegas_map_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading