diff --git a/.gitignore b/.gitignore index 973c7a2a..64aa9684 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ plugins/*/assets/**/*_backup/ # ledmatrix-music/emulator_config.json is committed on purpose and stays # tracked -- .gitignore does not apply to files already in the index. emulator_config.json + +# Debug output the flights map composer writes when debug logging is on. +# It lands in the process CWD, which on an install is the core checkout. +debug_composite.png +debug_cropped.png diff --git a/plugins.json b/plugins.json index fe71fed6..0873cd64 100644 --- a/plugins.json +++ b/plugins.json @@ -439,7 +439,7 @@ "last_updated": "2026-08-03", "verified": true, "screenshot": "", - "latest_version": "1.12.13" + "latest_version": "1.12.17" }, { "id": "march-madness", diff --git a/plugins/ledmatrix-flights/debug_composite.png b/plugins/ledmatrix-flights/debug_composite.png deleted file mode 100644 index c899e0e3..00000000 Binary files a/plugins/ledmatrix-flights/debug_composite.png and /dev/null differ diff --git a/plugins/ledmatrix-flights/debug_cropped.png b/plugins/ledmatrix-flights/debug_cropped.png deleted file mode 100644 index 2c2cc736..00000000 Binary files a/plugins/ledmatrix-flights/debug_cropped.png and /dev/null differ diff --git a/plugins/ledmatrix-flights/manager.py b/plugins/ledmatrix-flights/manager.py index 06385747..237b0267 100644 --- a/plugins/ledmatrix-flights/manager.py +++ b/plugins/ledmatrix-flights/manager.py @@ -591,6 +591,29 @@ def _resolve_metar_airports(self, codes) -> list: @property def display_width(self) -> int: + """Width to render at: the panel, or the slice Vegas asked for. + + Vegas requests a narrower render so a layout built for the full panel + does not read as sparse in the ticker. It normally delivers that by + narrowing the shared canvas for the duration of the call, which this + property picks up for free through matrix.width -- but it cannot narrow + the canvas in offscreen mode, where it only sets the hint. Reading the + hint covers both paths. + + Without this the map was composed at the full panel width and then + cropped by the adapter: on one rig, of twelve narrow requests only + three were honoured and nine rendered full width. The composite cache + below is already keyed by size and expects both widths, so honouring + the request adds an entry rather than thrashing. + """ + hint = getattr(self, "get_vegas_render_width", None) + if callable(hint): + try: + requested = int(hint()) + if requested > 0: + return requested + except (TypeError, ValueError): + pass return self._display_manager_ref.matrix.width @property @@ -1703,7 +1726,11 @@ def _process_aircraft_data(self, data: Dict) -> None: return total_aircraft = len(data['aircraft']) - self.logger.info(f"[Flight Tracker] Processing {total_aircraft} aircraft from SkyAware") + # Trace, not news: the Summary line below reports the same total, and + # this pair ran every few seconds. Lazy %-args so a disabled level + # costs nothing. + self.logger.debug("[Flight Tracker] Processing %d aircraft from SkyAware", + total_aircraft) current_time = time.time() active_icao = set() @@ -1820,7 +1847,30 @@ def _process_aircraft_data(self, data: Dict) -> None: for icao in stale_all: del self.all_aircraft_data[icao] - self.logger.info(f"[Flight Tracker] Summary - Total: {total_aircraft}, With position: {aircraft_with_position}, In range ({self.map_radius_miles}mi): {aircraft_in_range}, Tracking: {len(self.aircraft_data)}, Removed stale: {len(stale_icao)}") + # This ran on every poll -- roughly every five seconds, so ~690 lines + # per half hour, most of the device's log volume and a steady trickle + # of SD writes for a line that usually repeats itself. + # + # Keyed on what the plugin actually shows: aircraft in range and + # tracked. Total and With-position jitter every poll as distant + # traffic drifts in and out of the receiver, so keying on them + # collapsed almost nothing (343 lines -> 210 on measured data); + # keying on these two gives 343 -> 67. The jittery counts still ride + # along in the message, where they cost nothing. + summary = (aircraft_in_range, len(self.aircraft_data)) + last_logged = getattr(self, '_last_summary_log', 0.0) + message = ("[Flight Tracker] Summary - Total: %d, With position: %d, " + "In range (%smi): %d, Tracking: %d, Removed stale: %d") + args = (total_aircraft, aircraft_with_position, self.map_radius_miles, + aircraft_in_range, len(self.aircraft_data), len(stale_icao)) + # The heartbeat keeps a quiet sky from looking like a stalled tracker. + if summary != getattr(self, '_last_summary', None) or \ + current_time - last_logged >= 300: + self.logger.info(message, *args) + self._last_summary_log = current_time + else: + self.logger.debug(message, *args) + self._last_summary = summary self._update_flight_records() def _altitude_to_color(self, altitude: float) -> Tuple[int, int, int]: @@ -2406,17 +2456,29 @@ def _get_map_background(self, center_lat: float, center_lon: float, self.logger.debug(f"[Flight Tracker] Map displays {desired_miles_wide:.1f} miles wide x {desired_miles_high:.1f} miles high (no stretching)") self.logger.debug(f"[Flight Tracker] Native tile scale: {pixels_per_mile_at_zoom:.3f} pixels/mile, cropped {crop_width_needed}x{crop_height_needed} pixels, scaled to {self.display_width}x{self.display_height}") - # Debug: Save composite image to see what's happening - try: - debug_composite = Path("debug_composite.png") - composite.save(debug_composite) - self.logger.debug(f"[Flight Tracker] Saved composite to: {debug_composite}") - - debug_cropped = Path("debug_cropped.png") - cropped.save(debug_cropped) - self.logger.debug(f"[Flight Tracker] Saved cropped to: {debug_cropped}") - except Exception as e: - self.logger.debug(f"[Flight Tracker] Could not save debug images: {e}") + # Debug aid, off unless debug logging is on. This used to run + # unconditionally, writing both PNGs on every composite: measured on a + # live rig at 5.36 MB for the composite and 0.04 MB for the crop, five + # composites in six hours -- about 108 MB a day written to an SD card + # for files nothing reads. They also landed in the process's working + # directory, which is the install root, where a pre-commit hook has + # previously swept them into a commit. + if self.logger.isEnabledFor(logging.DEBUG): + debug_dir = getattr(self, "tile_cache_dir", None) + if debug_dir is None: + self.logger.debug( + "[Flight Tracker] No cache directory; skipping debug images") + else: + try: + composite_path = Path(debug_dir) / "debug_composite.png" + composite.save(composite_path) + cropped_path = Path(debug_dir) / "debug_cropped.png" + cropped.save(cropped_path) + self.logger.debug( + "[Flight Tracker] Saved debug images to: %s", debug_dir) + except OSError as e: + self.logger.debug( + f"[Flight Tracker] Could not save debug images: {e}") return cropped diff --git a/plugins/ledmatrix-flights/manifest.json b/plugins/ledmatrix-flights/manifest.json index a293588b..cc586c16 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.13", + "version": "1.12.17", "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", diff --git a/plugins/ledmatrix-flights/test_no_debug_image_writes.py b/plugins/ledmatrix-flights/test_no_debug_image_writes.py new file mode 100644 index 00000000..dff0249d --- /dev/null +++ b/plugins/ledmatrix-flights/test_no_debug_image_writes.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Composing the map must not write debug PNGs unless debug logging is on. + +_get_map_background() saved two images on every composite, ungated -- no debug +flag, no config check, and the comment above it read "Debug: Save composite +image to see what's happening". Left-over debugging shipped as production +behaviour. + +Measured on a live rig: debug_composite.png is 5.36 MB and debug_cropped.png +0.04 MB, with five composites in six hours. That is roughly 108 MB a day +written to an SD card for files nothing reads, on a device whose cards have +already failed twice. Honouring the ticker's narrower render request makes the +plugin compose at two widths rather than one, so the count roughly doubles. + +They also landed in the process's working directory -- the install root -- +where a pre-commit hook has previously swept them into a commit. + +Run: /bin/python plugins/ledmatrix-flights/test_no_debug_image_writes.py +""" + +import ast +import sys +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parent +MANAGER = PLUGIN / "manager.py" +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _save_calls(tree): + """Every `.save(...)` in the module, with its line number.""" + return [n for n in ast.walk(tree) + if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "save"] + + +def _guarding_debug_check(tree, lineno): + """True when the statement at `lineno` sits under an isEnabledFor guard.""" + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + test = ast.dump(node.test) + if "isEnabledFor" not in test: + continue + for child in ast.walk(node): + if getattr(child, "lineno", None) == lineno: + return True + return False + + +def main(): + tree = ast.parse(MANAGER.read_text(encoding="utf-8")) + + print("debug images are written only when debug logging is enabled") + saves = _save_calls(tree) + check(f"{len(saves)} save() call(s) found in manager.py", bool(saves)) + for call in saves: + guarded = _guarding_debug_check(tree, call.lineno) + check(f"the save at line {call.lineno} is behind a debug-level guard", + guarded) + + print("\nand not into the process working directory") + source = MANAGER.read_text(encoding="utf-8") + check('no bare Path("debug_composite.png")', + 'Path("debug_composite.png")' not in source) + check('no bare Path("debug_cropped.png")', + 'Path("debug_cropped.png")' not in source) + check("debug images go under the tile cache directory", + "tile_cache_dir" in source.split("debug_composite.png")[0][-600:]) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ledmatrix-flights/test_vegas_render_width.py b/plugins/ledmatrix-flights/test_vegas_render_width.py new file mode 100644 index 00000000..78f22c93 --- /dev/null +++ b/plugins/ledmatrix-flights/test_vegas_render_width.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""The map must be composed at the width the ticker asked for. + +Vegas asks a plugin for a narrower render so a layout built for the full panel +does not read as sparse in the ticker. It normally delivers that by narrowing +the shared canvas for the duration of the call, which a plugin sizing itself +from ``matrix.width`` picks up for free. But it cannot narrow the canvas in +offscreen mode -- ``_render_at`` swaps the shared canvas, which is unsafe +there -- so it only sets ``_vegas_render_width`` and notes that a plugin +reading ``get_vegas_render_width()`` still gets the narrow size while one that +only reads ``matrix.width`` "renders full width and is trimmed instead". + +This plugin relied on the canvas being narrowed. Its own docstring said the +projection scales "without any extra plumbing". On a live rig that assumption +held three times out of twelve: + + [ledmatrix-flights] Native: requesting 256px instead of 512px + [ledmatrix-flights] Native: SUCCESS - 1 images, 512px total width + [ledmatrix-flights] Native: SUCCESS - 1 images, 256px total width + +so the same map came back at two different widths depending on which path the +adapter took, and the full-width renders were cropped afterwards. + +No plugin in the repo read get_vegas_render_width() before this. + +Run: /bin/python plugins/ledmatrix-flights/test_vegas_render_width.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Matrix: + width, height = 512, 64 + + +class _DisplayManager: + matrix = _Matrix() + + +def _plugin(): + """A FlightTracker carrying what the width property reads. + + Both names are set on purpose. This plugin keeps its own + ``_display_manager_ref``, while BasePlugin.__init__ stores the same object + as ``display_manager`` -- and get_vegas_render_width()'s fallback reads the + latter. A stub with only the private name makes the fallback miss the + matrix and return its hard-coded 128, which looks exactly like the property + being broken. + """ + from manager import FlightTrackerPlugin as _P + obj = object.__new__(_P) + dm = _DisplayManager() + obj._display_manager_ref = dm + obj.display_manager = dm + return obj + + +def main(): + print("the render width follows the ticker's request") + from src.plugin_system.base_plugin import BasePlugin + + p = _plugin() + check("the plugin inherits get_vegas_render_width from BasePlugin", + isinstance(p, BasePlugin) or hasattr(p, "get_vegas_render_width")) + + check("outside a Vegas request it is the panel width (%d)" % p.display_width, + p.display_width == 512) + + # Exactly what PluginAdapter does before calling get_vegas_content(), and + # the only thing it can do in offscreen mode. + p._vegas_render_width = 256 + check("during a narrow request it is the requested width (%d)" % p.display_width, + p.display_width == 256) + + # And exactly what the adapter's finally clause does afterwards. + p._vegas_render_width = None + check("afterwards it is the panel width again (%d)" % p.display_width, + p.display_width == 512) + + print("\nnonsense hints fall back rather than propagating") + for bad in (0, -1, "wide", None): + p._vegas_render_width = bad + ok = p.display_width == 512 + check(f"a hint of {bad!r} falls back to the panel width", ok) + + print("\nthe composite cache keys on the size, so both widths coexist") + source = (plugin_dir / "manager.py").read_text(encoding="utf-8") + check("cached_map_bgs is keyed by (width, height)", + "current_size = (self.display_width, self.display_height)" in source) + check("and it is only cleared when the view itself moves", + "if self.last_map_center != current_center or self.last_map_zoom != zoom:" + in source) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())