From 154c8589d4b7544d62de43ddab5757a5add049c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:31:25 -0400 Subject: [PATCH 1/2] fix(ledmatrix-flights): give the Vegas map the same features as the rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_vegas_content() reimplemented a cut-down version of the map view rather than sharing it. Its copy drew only the map background and the aircraft dots, so three things were silently missing from the ticker: - aircraft trails, even with show_trails enabled - the white centre-position marker, which is the map's only reference point - the aircraft count and airplane icon The trail data was always there — aircraft_trails is populated in update() regardless of which display path runs — so this was purely a rendering gap. It is duplication drift rather than a deliberate simplification: _display_map gained features over time and the Vegas copy did not track them. Extracted _render_map_image() as the single source of truth, called by both _display_map and get_vegas_content. Fixing the copy in place would have left the same trap for the next feature added to the map view. Sizing needs no extra plumbing: display_width/display_height are properties over matrix, and Vegas narrows the display manager while requesting content, so _latlon_to_pixel already scales its projection to whatever width the ticker asked for. Verified at 64x32 through 512x64, including a 64px-wide render. test_vegas_map_parity.py asserts the Vegas image is byte-identical to what the rotation pushes to the display, so the two cannot diverge again, plus direct coverage of the trails, the centre marker and the untracked-trail skip. Also drops two now-unused locals from get_vegas_content. Safety harness: all sizes PASS. Module collisions: OK. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 4 +- plugins/ledmatrix-flights/CHANGELOG.md | 13 + plugins/ledmatrix-flights/manager.py | 78 +++--- plugins/ledmatrix-flights/manifest.json | 8 +- .../test_vegas_map_parity.py | 225 ++++++++++++++++++ 5 files changed, 292 insertions(+), 36 deletions(-) create mode 100644 plugins/ledmatrix-flights/CHANGELOG.md create mode 100644 plugins/ledmatrix-flights/test_vegas_map_parity.py diff --git a/plugins.json b/plugins.json index 41ef668a..ba0a2c42 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-28", + "last_updated": "2026-07-29", "plugins": [ { "id": "cricket-scoreboard", @@ -414,7 +414,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.12.5" + "latest_version": "1.12.6" }, { "id": "march-madness", diff --git a/plugins/ledmatrix-flights/CHANGELOG.md b/plugins/ledmatrix-flights/CHANGELOG.md new file mode 100644 index 00000000..4de55494 --- /dev/null +++ b/plugins/ledmatrix-flights/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [1.12.6] - 2026-07-29 + +### Fixed +- **Vegas scroll map was missing trails, the centre marker and the aircraft + count**: `get_vegas_content()` reimplemented a cut-down version of the map + view that drew only the background and the aircraft dots, so aircraft trails + never appeared in the ticker even with `show_trails` enabled — and neither did + the white centre-position dot or the aircraft count. Both paths now share a + single `_render_map_image()`, so the ticker renders the same map as the normal + rotation and cannot drift from it again. + diff --git a/plugins/ledmatrix-flights/manager.py b/plugins/ledmatrix-flights/manager.py index d35884c5..a9e1336c 100644 --- a/plugins/ledmatrix-flights/manager.py +++ b/plugins/ledmatrix-flights/manager.py @@ -2558,9 +2558,6 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: Flight tracking returns one card per tracked flight. """ try: - w = self.display_width - h = self.display_height - mode = self.display_mode if mode == 'auto': has_airborne_tracked = any( @@ -2581,15 +2578,11 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: mode = 'stats' if mode == 'map': - map_bg = self._get_map_background(self.center_lat, self.center_lon) - img = map_bg.copy() if map_bg else Image.new('RGB', (w, h), (0, 0, 0)) - draw = ImageDraw.Draw(img) - for aircraft in self.aircraft_data.values(): - pixel = self._latlon_to_pixel(aircraft['lat'], aircraft['lon']) - if pixel: - color = tuple(min(255, int(c * 1.3)) for c in aircraft['color']) - draw.point(pixel, fill=color) - return [img] + # Shared with _display_map so the ticker gets the same map the + # rotation does — trails, centre marker and aircraft count + # included. This used to be a cut-down copy that omitted all + # three. + return [self._render_map_image()] if mode == 'overhead': # Render the proximity aircraft from the full pool (independent of @@ -2889,44 +2882,56 @@ def _display_flight_tracking(self, force_clear: bool = False) -> None: # Original display modes (kept in manager.py for backward compatibility) # ------------------------------------------------------------------------- - def _display_map(self, force_clear: bool = False) -> None: - """Display the flight map with aircraft and geographical background.""" - if force_clear: - self.display_manager.clear() - + def _render_map_image(self) -> Image.Image: + """Render the flight map: background, centre marker, trails, aircraft, count. + + The single source of truth for the map view, shared by ``_display_map`` + and ``get_vegas_content``. Vegas used to reimplement a cut-down version + of this, which drifted: it drew only the background and the aircraft + dots, so trails, the centre position marker and the aircraft count were + all silently missing from the ticker even with ``show_trails`` enabled. + + Sizing comes from ``display_width``/``display_height``, which are + properties over ``matrix``. Vegas narrows the display manager while it + requests content, so the projection in ``_latlon_to_pixel`` scales to + whatever width the ticker asked for without any extra plumbing. + + Returns: + The composed map as a new RGB image at the current display size + """ # Get map background if enabled map_bg = self._get_map_background(self.center_lat, self.center_lon) - + # Create image with background if map_bg: img = map_bg.copy() else: self.logger.debug("[Flight Tracker] Map background unavailable; using solid background") img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - + draw = ImageDraw.Draw(img) - + # Draw center position marker (white dot at our lat/lon) center_pixel = self._latlon_to_pixel(self.center_lat, self.center_lon) if center_pixel: x, y = center_pixel # Draw white center dot draw.point((x, y), fill=(255, 255, 255)) - + # Draw aircraft trails if enabled if self.show_trails: for icao, trail in self.aircraft_trails.items(): if icao not in self.aircraft_data: continue - + aircraft = self.aircraft_data[icao] trail_pixels = [] - + for lat, lon, timestamp in trail: pixel = self._latlon_to_pixel(lat, lon) if pixel: trail_pixels.append(pixel) - + # Draw trail with fading effect if len(trail_pixels) >= 2: for i in range(len(trail_pixels) - 1): @@ -2934,37 +2939,44 @@ def _display_map(self, force_clear: bool = False) -> None: alpha = int(255 * (i + 1) / len(trail_pixels)) color = tuple(int(c * alpha / 255) for c in aircraft['color']) draw.line([trail_pixels[i], trail_pixels[i + 1]], fill=color, width=1) - + # Draw aircraft for aircraft in self.aircraft_data.values(): pixel = self._latlon_to_pixel(aircraft['lat'], aircraft['lon']) if not pixel: continue - + x, y = pixel # Brighten the plane colors by boosting RGB values base_color = aircraft['color'] color = tuple(min(255, int(c * 1.3)) for c in base_color) - + # Draw single pixel for each aircraft draw.point((x, y), fill=color) - + # Draw info text with pixel-perfect rendering for better readability if len(self.aircraft_data) > 0: # Draw aircraft count info_text = f"{len(self.aircraft_data)}" - self._draw_text_smart(draw, info_text, (2, 2), self.fonts['small'], + self._draw_text_smart(draw, info_text, (2, 2), self.fonts['small'], fill=(200, 200, 200), use_outline=False) - + # Get text width to position the airplane icon bbox = draw.textbbox((0, 0), info_text, font=self.fonts['small']) text_width = bbox[2] - bbox[0] - + # Draw airplane icon after the count (with 2px spacing) self._draw_airplane_icon(draw, 2 + text_width + 2, 2, color=(200, 200, 200)) - + + return img + + def _display_map(self, force_clear: bool = False) -> None: + """Display the flight map with aircraft and geographical background.""" + if force_clear: + self.display_manager.clear() + # Display the image - self.display_manager.image = img.copy() + self.display_manager.image = self._render_map_image() self.display_manager.update_display() def _display_overhead(self, force_clear: bool = False) -> None: diff --git a/plugins/ledmatrix-flights/manifest.json b/plugins/ledmatrix-flights/manifest.json index 5f3568fb..a70841e0 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.5", + "version": "1.12.6", "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-07-29", + "version": "1.12.6", + "notes": "Fix the Vegas scroll map missing aircraft trails, the centre position marker and the aircraft count. Vegas rendered its own cut-down copy of the map that drew only the background and aircraft dots; both paths now share one renderer so the ticker shows the same map as the normal rotation.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.12.5", diff --git a/plugins/ledmatrix-flights/test_vegas_map_parity.py b/plugins/ledmatrix-flights/test_vegas_map_parity.py new file mode 100644 index 00000000..de20805a --- /dev/null +++ b/plugins/ledmatrix-flights/test_vegas_map_parity.py @@ -0,0 +1,225 @@ +""" +Tests that Vegas map content matches the rotation's map view. + +get_vegas_content() used to reimplement a cut-down map: background plus +aircraft dots only, so trails, the centre position marker and the aircraft +count were silently missing from the ticker even with show_trails enabled. +Both paths now share _render_map_image(); these tests pin that so the +duplication cannot creep back. +""" + +import sys +import types + +import pytest +from PIL import Image, ImageDraw, ImageFont + +# The plugin imports BasePlugin from the core, which is not on the path here. +if 'src' not in sys.modules: + src = types.ModuleType('src') + plugin_system = types.ModuleType('src.plugin_system') + base_plugin = types.ModuleType('src.plugin_system.base_plugin') + + class _BasePlugin: + def __init__(self, *args, **kwargs): + pass + + base_plugin.BasePlugin = _BasePlugin + base_plugin.VegasDisplayMode = None + plugin_system.base_plugin = base_plugin + src.plugin_system = plugin_system + sys.modules['src'] = src + sys.modules['src.plugin_system'] = plugin_system + sys.modules['src.plugin_system.base_plugin'] = base_plugin + +W, H = 128, 64 +CENTER_LAT, CENTER_LON = 28.0, -82.0 + + +class FakeLogger: + def debug(self, *a, **k): + pass + + def info(self, *a, **k): + pass + + def warning(self, *a, **k): + pass + + +class FakeDisplayManager: + def __init__(self, width=W, height=H): + self.matrix = types.SimpleNamespace(width=width, height=height) + self.image = Image.new('RGB', (width, height)) + self.updated = False + + def update_display(self): + self.updated = True + + def clear(self): + self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) + + +def make_plugin(show_trails=True, aircraft=None, trails=None, width=W, height=H): + """Build a plugin shell with just the state the map renderer touches.""" + from manager import FlightTrackerPlugin + + p = FlightTrackerPlugin.__new__(FlightTrackerPlugin) + dm = FakeDisplayManager(width, height) + p._display_manager_ref = dm + p.display_manager = dm + p.logger = FakeLogger() + + p.center_lat = CENTER_LAT + p.center_lon = CENTER_LON + p.map_radius_miles = 10 + p.zoom_factor = 1.0 + p.show_trails = show_trails + p.trail_length = 10 + p.bounds_warning_cache = {} + p.bounds_warning_interval = 60 + + # Disable the map background so the renderer takes the deterministic + # solid-black path — no network, no tile cache. + p.map_bg_enabled = False + p.disable_on_cache_error = False + p.cache_error_count = 0 + p.max_cache_errors = 5 + + p.aircraft_data = aircraft if aircraft is not None else {} + p.aircraft_trails = trails if trails is not None else {} + + # Real font so the count overlay can measure and draw. + p.fonts = {'small': ImageFont.load_default()} + return p + + +def near(lat_offset=0.0, lon_offset=0.0): + return CENTER_LAT + lat_offset, CENTER_LON + lon_offset + + +def one_aircraft(): + lat, lon = near(0.02, 0.02) + return {'ABC123': {'lat': lat, 'lon': lon, 'color': (0, 200, 0)}} + + +def one_trail(): + return { + 'ABC123': [ + (CENTER_LAT + 0.005 * i, CENTER_LON + 0.005 * i, 1000.0 + i) + for i in range(6) + ] + } + + +def lit_pixels(img): + """Count pixels with any channel above the noise floor. + + Uses tobytes() rather than getdata(), which Pillow deprecates. + """ + raw = img.convert('RGB').tobytes() + return sum( + 1 for i in range(0, len(raw), 3) + if raw[i] > 10 or raw[i + 1] > 10 or raw[i + 2] > 10 + ) + + +class TestRenderMapImage: + def test_returns_an_image_at_display_size(self): + img = make_plugin()._render_map_image() + assert img.size == (W, H) + + def test_centre_marker_is_drawn(self): + # The white dot at our own lat/lon; Vegas was missing this entirely. + img = make_plugin(show_trails=False)._render_map_image() + assert img.getpixel((W // 2, H // 2)) == (255, 255, 255) + + def test_trails_add_pixels_when_enabled(self): + on = make_plugin(True, one_aircraft(), one_trail())._render_map_image() + off = make_plugin(False, one_aircraft(), one_trail())._render_map_image() + assert lit_pixels(on) > lit_pixels(off) + + def test_trails_absent_when_disabled(self): + off = make_plugin(False, one_aircraft(), one_trail())._render_map_image() + bare = make_plugin(False, one_aircraft(), {})._render_map_image() + assert lit_pixels(off) == lit_pixels(bare) + + def test_trail_for_untracked_aircraft_is_skipped(self): + # A trail whose ICAO is no longer in aircraft_data must not be drawn. + p = make_plugin(True, {}, one_trail()) + img = p._render_map_image() + # Only the centre marker should be lit. + assert lit_pixels(img) == 1 + + def test_aircraft_dot_is_drawn(self): + with_ac = make_plugin(False, one_aircraft(), {})._render_map_image() + without = make_plugin(False, {}, {})._render_map_image() + assert lit_pixels(with_ac) > lit_pixels(without) + + +class TestVegasMatchesRotation: + def _vegas_map(self, plugin): + plugin.display_mode = 'map' + plugin.tracked_flight_data = {} + plugin.proximity_enabled = False + plugin.anchor_airport = None + return plugin.get_vegas_content() + + def test_vegas_map_is_pixel_identical_to_the_rotation_view(self): + # The whole point of the refactor: one renderer, so the ticker cannot + # drift from the rotation again. + rotation = make_plugin(True, one_aircraft(), one_trail()) + rotation._display_map() + expected = rotation.display_manager.image + + vegas = make_plugin(True, one_aircraft(), one_trail()) + images = self._vegas_map(vegas) + + assert images is not None and len(images) == 1 + assert images[0].convert('RGB').tobytes() == expected.convert('RGB').tobytes() + + def test_vegas_map_includes_trails(self): + with_trails = make_plugin(True, one_aircraft(), one_trail()) + without = make_plugin(False, one_aircraft(), one_trail()) + assert lit_pixels(self._vegas_map(with_trails)[0]) > \ + lit_pixels(self._vegas_map(without)[0]) + + def test_vegas_map_includes_the_centre_marker(self): + vegas = make_plugin(False, one_aircraft(), {}) + img = self._vegas_map(vegas)[0] + assert img.getpixel((W // 2, H // 2)) == (255, 255, 255) + + def test_display_map_still_pushes_to_the_display(self): + p = make_plugin(True, one_aircraft(), one_trail()) + p._display_map() + assert p.display_manager.updated + assert p.display_manager.image.size == (W, H) + + def test_force_clear_is_honoured(self): + p = make_plugin(True, one_aircraft(), one_trail()) + p._display_map(force_clear=True) + assert p.display_manager.updated + + +class TestNarrowerRender: + """Vegas narrows the display manager; the projection must follow.""" + + def test_map_renders_at_the_narrowed_width(self): + p = make_plugin(True, one_aircraft(), one_trail(), width=64, height=64) + p.display_mode = 'map' + p.tracked_flight_data = {} + p.proximity_enabled = False + p.anchor_airport = None + images = p.get_vegas_content() + assert images[0].size == (64, 64) + + def test_centre_marker_tracks_the_narrowed_width(self): + p = make_plugin(False, {}, {}, width=64, height=64) + img = p._render_map_image() + assert img.getpixel((32, 32)) == (255, 255, 255) + + @pytest.mark.parametrize("width,height", [(64, 32), (128, 32), (256, 64), (512, 64)]) + 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) From eea66e6ebf0315b912e7f1027fcf100a6fe2959e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:38:58 -0400 Subject: [PATCH 2/2] Address review lint on the flights Vegas map tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops an unused ImageDraw import, and replaces Cls.__new__(Cls) with a no-op-__init__ subclass for building the test shell. The __new__ form is valid Python but Codacy's Pylint reports it as a missing-cls call; the subclass reads better anyway and states the intent — bypass the real __init__, which builds fetchers, threads and caches the map renderer does not need. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- .../test_vegas_map_parity.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/plugins/ledmatrix-flights/test_vegas_map_parity.py b/plugins/ledmatrix-flights/test_vegas_map_parity.py index de20805a..0127c631 100644 --- a/plugins/ledmatrix-flights/test_vegas_map_parity.py +++ b/plugins/ledmatrix-flights/test_vegas_map_parity.py @@ -12,7 +12,7 @@ import types import pytest -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageFont # The plugin imports BasePlugin from the core, which is not on the path here. if 'src' not in sys.modules: @@ -60,11 +60,25 @@ def clear(self): self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) -def make_plugin(show_trails=True, aircraft=None, trails=None, width=W, height=H): - """Build a plugin shell with just the state the map renderer touches.""" +def _plugin_shell(): + """A FlightTrackerPlugin whose __init__ is a no-op. + + The real __init__ builds fetchers, threads and caches; the map renderer + needs none of that. Subclassing with an empty __init__ is clearer than + __new__ gymnastics and keeps static analysis happy. + """ from manager import FlightTrackerPlugin - p = FlightTrackerPlugin.__new__(FlightTrackerPlugin) + class _Shell(FlightTrackerPlugin): + def __init__(self): + pass + + return _Shell() + + +def make_plugin(show_trails=True, aircraft=None, trails=None, width=W, height=H): + """Build a plugin shell with just the state the map renderer touches.""" + p = _plugin_shell() dm = FakeDisplayManager(width, height) p._display_manager_ref = dm p.display_manager = dm