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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file removed plugins/ledmatrix-flights/debug_composite.png
Binary file not shown.
Binary file removed plugins/ledmatrix-flights/debug_cropped.png
Binary file not shown.
88 changes: 75 additions & 13 deletions plugins/ledmatrix-flights/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion 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.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",
Expand Down
83 changes: 83 additions & 0 deletions plugins/ledmatrix-flights/test_no_debug_image_writes.py
Original file line number Diff line number Diff line change
@@ -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: <core-venv>/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 `<something>.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())
117 changes: 117 additions & 0 deletions plugins/ledmatrix-flights/test_vegas_render_width.py
Original file line number Diff line number Diff line change
@@ -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: <core-venv>/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())
Loading