From d6ca71e460419a5fff1091360ba9a2c2e2898ce4 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:18:41 -0500 Subject: [PATCH 01/36] FIX: render via the active matrix backend The renderer hardcoded `from RGBMatrixEmulator import graphics`, so on real hardware it drew text with the emulator's graphics engine onto an rgbmatrix canvas. The two libraries' internals differ, which garbled all output on the panel while the emulator looked perfect (there both halves came from the emulator). Add a single backend selector (backend.py) that imports RGBMatrix, RGBMatrixOptions, and graphics from rgbmatrix on hardware and from the emulator otherwise, so the matrix and the drawing engine can never drift apart. A regression test pins renderer.graphics to the active backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/backend.py | 21 +++++++++++++++++++++ src/jetset/renderer.py | 2 +- tests/test_backend.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/jetset/backend.py create mode 100644 tests/test_backend.py diff --git a/src/jetset/backend.py b/src/jetset/backend.py new file mode 100644 index 0000000..948d7f6 --- /dev/null +++ b/src/jetset/backend.py @@ -0,0 +1,21 @@ +"""Selects the LED matrix backend once, for the whole app to share. + +Prefers the real rpi-rgb-led-matrix library (on the Pi) and falls back to the +emulator (on a dev machine). The emulator mirrors the rpi-rgb-led-matrix API, +so the rest of the app is backend-agnostic — but every component MUST use the +SAME backend. Drawing with the emulator's `graphics` onto a hardware canvas +produces garbled output, because the two libraries' canvas internals differ. +Importing `graphics`, `RGBMatrix`, and `RGBMatrixOptions` from here guarantees +the matrix and the drawing engine never drift apart. +""" + +try: + from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + + IS_HARDWARE = True +except ImportError: + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions, graphics + + IS_HARDWARE = False + +__all__ = ["RGBMatrix", "RGBMatrixOptions", "graphics", "IS_HARDWARE"] diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index af6fb9e..169c104 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -1,6 +1,6 @@ -from RGBMatrixEmulator import graphics from RGBMatrixEmulator.emulation.canvas import Canvas +from jetset.backend import graphics from jetset.display import aircraft_label, flight_label, loading_label, metrics_label, route_label from jetset.models import Flight diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..05e400e --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,31 @@ +"""Tests for the LED matrix backend selector.""" + + +class TestBackendContract: + def test_exposes_matrix_classes(self) -> None: + from jetset import backend + + assert hasattr(backend, "RGBMatrix") + assert hasattr(backend, "RGBMatrixOptions") + + def test_exposes_graphics_drawing_api(self) -> None: + from jetset import backend + + assert hasattr(backend.graphics, "Font") + assert hasattr(backend.graphics, "Color") + assert hasattr(backend.graphics, "DrawText") + + def test_reports_hardware_flag_as_bool(self) -> None: + from jetset import backend + + assert isinstance(backend.IS_HARDWARE, bool) + + +class TestRendererBackendConsistency: + def test_renderer_draws_with_the_active_backend_graphics(self) -> None: + # The garble bug was the renderer using the emulator's graphics engine + # to draw onto a hardware canvas. Renderer and matrix must share one + # backend, or glyphs and buffering won't match the canvas. + from jetset import backend, renderer + + assert renderer.graphics is backend.graphics From 135cf6f3e8380c0a940317d90016ee86aebbedd3 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:18:54 -0500 Subject: [PATCH 02/36] FIX: reuse the canvas returned by SwapOnVSync SwapOnVSync is double-buffered on hardware: it displays the given canvas and returns the now-offscreen buffer to draw the next frame into. The loop discarded that return value and kept drawing into the same object, so on the panel it ping-ponged between two physical buffers while only ever updating one - producing flashing and old frames showing through. The emulator has one effective buffer, so it was invisible there. Thread the returned canvas back through _render_frame/_render_loading and reassign it each loop iteration, matching the library's canonical usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/app.py | 23 +++++++++++++---------- tests/test_main.py | 21 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/jetset/app.py b/src/jetset/app.py index 2bb8db3..ee43b14 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -54,24 +54,27 @@ def _current_frame(self) -> Frame | None: return self.Frame(self.buffer.flights[idx], metric_page) - def _after_render(self, matrix: RGBMatrix, canvas: Canvas): - # This needs to be it's own method so that tests don't need to run the infinite loop method - matrix.SwapOnVSync(canvas) + def _after_render(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: + # This needs to be it's own method so that tests don't need to run the infinite loop method. + # SwapOnVSync returns the now-offscreen buffer to draw the *next* frame into; on real + # hardware this double-buffering is mandatory, or frames flash and tear over stale content. + next_canvas = matrix.SwapOnVSync(canvas) self.frame += 1 time.sleep(self.config.pause) + return next_canvas - def _render_frame(self, matrix: RGBMatrix, canvas: Canvas, frame: Frame): + def _render_frame(self, matrix: RGBMatrix, canvas: Canvas, frame: Frame) -> Canvas: flight, metric_page = frame render_flight_card(canvas, flight, metric_page) - self._after_render(matrix, canvas) + return self._after_render(matrix, canvas) - def _render_loading(self, matrix: RGBMatrix, canvas: Canvas): + def _render_loading(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: render_loading(canvas, self.frame) - self._after_render(matrix, canvas) + return self._after_render(matrix, canvas) def loop(self, matrix: RGBMatrix, canvas: Canvas): # Show LOADING immediately while the first fetch runs - self._render_loading(matrix, canvas) + canvas = self._render_loading(matrix, canvas) try: while True: @@ -79,9 +82,9 @@ def loop(self, matrix: RGBMatrix, canvas: Canvas): self._fetch() if frame := self._current_frame(): - self._render_frame(matrix, canvas, frame) + canvas = self._render_frame(matrix, canvas, frame) else: - self._render_loading(matrix, canvas) + canvas = self._render_loading(matrix, canvas) except KeyboardInterrupt: canvas.Clear() matrix.SwapOnVSync(canvas) diff --git a/tests/test_main.py b/tests/test_main.py index 3563e36..2a6439c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -135,6 +135,24 @@ def test_renders_and_advances_frame(self) -> None: mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) assert app.frame == 4 + def test_returns_swapped_canvas_for_next_frame(self) -> None: + from jetset.app import App + + app = App(AppConfig()) + mock_matrix = MagicMock() + mock_canvas = MagicMock() + frame = app.Frame(Flight(callsign="UAL2337"), 1) + + with ( + patch("jetset.app.render_flight_card"), + patch("jetset.app.time.sleep"), + ): + next_canvas = app._render_frame(mock_matrix, mock_canvas, frame) + + # SwapOnVSync hands back the now-offscreen buffer; the next frame must + # be drawn into it, not into the canvas we just displayed. + assert next_canvas is mock_matrix.SwapOnVSync.return_value + class TestAppLoading: def test_renders_loading_when_buffer_empty(self) -> None: @@ -151,8 +169,9 @@ def test_renders_loading_when_buffer_empty(self) -> None: patch("jetset.app.render_loading") as mock_render, patch("jetset.app.time.sleep"), ): - app._render_loading(mock_matrix, mock_canvas) + next_canvas = app._render_loading(mock_matrix, mock_canvas) mock_render.assert_called_once_with(mock_canvas, 0) mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) assert app.frame == 1 + assert next_canvas is mock_matrix.SwapOnVSync.return_value From 8b47af7fa56f1b340046ca66a1c5f6188b1e04ea Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:19:19 -0500 Subject: [PATCH 03/36] FEATURE: make LED panel tuning configurable Expose panel_type, gpio_slowdown, and rgb_sequence via the hardware section of the config YAML (and JETSET_CONFIG), instead of hardcoding them. main() now loads config first and applies hardware-only options behind IS_HARDWARE so the emulator path is unaffected. Defaults reflect hardware bring-up findings: - gpio_slowdown = 5: the Pi 3 A+ / Adafruit HAT needs 5 for a stable signal; 4 produced intermittent corruption that looked like scrambled pixels. - rgb_sequence = "RGB": the standard order; override per-panel if colors come out swapped. - panel_type = "": no chip-specific init by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 13 +++++++++++++ src/jetset/__main__.py | 22 +++++++++++++++------- src/jetset/config.py | 10 ++++++++++ tests/test_config.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f392e1c..5fd2d4e 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,16 @@ uv sync | `make run` | Run the display emulator at http://localhost:8888/ | | `make test` | Run tests (pytest) | | `make lint` | Lint check (ruff) | + +## Configuration + +Settings load from a YAML file pointed to by the `JETSET_CONFIG` environment +variable (defaults built in when unset). The `hardware` section tunes the +physical LED panel and is ignored by the emulator: + +```yaml +hardware: + panel_type: "" # empty = no chip-specific init; set "FM6126A" only if the panel comes up garbled + gpio_slowdown: 5 # raise if the image is unstable/corrupts; lower if the display lags + rgb_sequence: RGB # physical subpixel order; override (e.g. "RBG") if colors come out swapped +``` diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index 417ea93..7854af6 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -25,17 +25,26 @@ def _configure_logging() -> None: # duplicate INFO lines in the root logger. logging.getLogger("RGBME").propagate = False def main() -> None: - try: - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions - except ImportError: - print("RGBMatrixEmulator not installed. Run: uv add RGBMatrixEmulator") - return - _configure_logging() + config = AppConfig.load(os.environ.get("JETSET_CONFIG")) + + from jetset.backend import IS_HARDWARE, RGBMatrix, RGBMatrixOptions + options = RGBMatrixOptions() options.cols = DISPLAY_WIDTH options.rows = DISPLAY_HEIGHT + if IS_HARDWARE: + # Physical-panel tuning. These are hardware-only knobs; the emulator + # neither needs nor accepts them, so they stay behind IS_HARDWARE. + options.hardware_mapping = "adafruit-hat" + options.gpio_slowdown = config.hardware_gpio_slowdown + options.multiplexing = 0 + options.row_address_type = 1 + options.panel_type = config.hardware_panel_type + options.pwm_bits = 6 # lower PWM reduces flicker on Pi 3 A+ + options.led_rgb_sequence = config.hardware_rgb_sequence + options.disable_hardware_pulsing = True matrix = RGBMatrix(options=options) canvas = matrix.CreateFrameCanvas() @@ -44,7 +53,6 @@ def main() -> None: import time time.sleep(1) - config = AppConfig() app = App(config) app.loop(matrix, canvas) except KeyboardInterrupt: diff --git a/src/jetset/config.py b/src/jetset/config.py index 1648534..7d75a02 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -12,6 +12,16 @@ class AppConfig: pause: int = 2 refresh: int = 60 api_source: str = "adsblol" + # Physical LED panel tuning (ignored by the emulator). panel_type is empty + # by default — the P2.5 64x32 panel needs no chip-specific init sequence. + # Set it (e.g. "FM6126A") only if a panel comes up garbled without one. + hardware_panel_type: str = "" + # Pi 3 A+ / Adafruit HAT needs slowdown 5 for a stable signal; 4 produced + # intermittent corruption that looked like scrambled pixels. + hardware_gpio_slowdown: int = 5 + # Physical subpixel order. "RGB" is the standard default; override per-panel + # (e.g. "RBG") if red/green/blue come out swapped. + hardware_rgb_sequence: str = "RGB" @classmethod def load(cls, path=None): diff --git a/tests/test_config.py b/tests/test_config.py index bdb20e6..d48cfbb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,6 +34,23 @@ def test_default_refresh(self) -> None: config = AppConfig.load() assert config.refresh > 0 + def test_default_panel_type_is_empty(self) -> None: + # Empty = the library's default (no chip-specific init sequence). The + # P2.5 64x32 panel renders correctly without one; see panel-diag sweep. + config = AppConfig.load() + assert config.hardware_panel_type == "" + + def test_default_gpio_slowdown(self) -> None: + # Pi 3 A+ / Adafruit HAT needs 5 for a stable signal; 4 corrupted output. + config = AppConfig.load() + assert config.hardware_gpio_slowdown == 5 + + def test_default_rgb_sequence(self) -> None: + # "RGB" is the standard order; panels with a different physical subpixel + # order override it via config. + config = AppConfig.load() + assert config.hardware_rgb_sequence == "RGB" + class TestConfigFromYaml: def test_loads_custom_yaml(self, tmp_path: Path) -> None: @@ -54,6 +71,19 @@ def test_loads_custom_yaml(self, tmp_path: Path) -> None: assert config.pause == 4 assert config.refresh == 15 + def test_loads_custom_hardware_section(self, tmp_path: Path) -> None: + yaml_content = { + "hardware": {"panel_type": "FM6127", "gpio_slowdown": 2, "rgb_sequence": "RGB"} + } + config_path = tmp_path / "config.yaml" + with open(config_path, "w") as f: + yaml.dump(yaml_content, f) + + config = AppConfig.load(str(config_path)) + assert config.hardware_panel_type == "FM6127" + assert config.hardware_gpio_slowdown == 2 + assert config.hardware_rgb_sequence == "RGB" + def test_api_source_defaults_to_adsblol(self) -> None: config = AppConfig.load() assert config.api_source == "adsblol" From a68605be111da0206ae0d09da151f93aebc11f7c Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:19:42 -0500 Subject: [PATCH 04/36] CHORE: add LED panel diagnostic scripts Standalone scripts used to bring up and characterize a HUB75 panel directly via rgbmatrix, isolating hardware behavior from the app: backend/geometry probes, multiplexing and row-address sweeps, color-channel and rgb-sequence tests, and GPIO-slowdown stability checks. Useful for validating the next panel quickly. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/blue-current.py | 50 +++++++++++++++++++++++ scripts/blue-hold.py | 38 ++++++++++++++++++ scripts/content-sweep.sh | 36 +++++++++++++++++ scripts/hold-sweep.sh | 30 ++++++++++++++ scripts/mux-sweep.sh | 22 ++++++++++ scripts/panel-colors.py | 36 +++++++++++++++++ scripts/panel-confirm.py | 45 +++++++++++++++++++++ scripts/panel-content.py | 56 ++++++++++++++++++++++++++ scripts/panel-diag.sh | 51 +++++++++++++++++++++++ scripts/panel-hbands.py | 60 +++++++++++++++++++++++++++ scripts/panel-mux.py | 47 ++++++++++++++++++++++ scripts/panel-rgbseq.py | 36 +++++++++++++++++ scripts/probe-color.py | 87 ++++++++++++++++++++++++++++++++++++++++ scripts/probe-geo.py | 47 ++++++++++++++++++++++ scripts/probe-hold.py | 52 ++++++++++++++++++++++++ scripts/probe-scan.py | 54 +++++++++++++++++++++++++ scripts/probe-sparse.py | 75 ++++++++++++++++++++++++++++++++++ scripts/probe-text.py | 65 ++++++++++++++++++++++++++++++ scripts/py-panel-test.py | 72 +++++++++++++++++++++++++++++++++ scripts/rgbseq-sweep.sh | 20 +++++++++ 20 files changed, 979 insertions(+) create mode 100755 scripts/blue-current.py create mode 100755 scripts/blue-hold.py create mode 100755 scripts/content-sweep.sh create mode 100755 scripts/hold-sweep.sh create mode 100755 scripts/mux-sweep.sh create mode 100755 scripts/panel-colors.py create mode 100755 scripts/panel-confirm.py create mode 100755 scripts/panel-content.py create mode 100755 scripts/panel-diag.sh create mode 100755 scripts/panel-hbands.py create mode 100755 scripts/panel-mux.py create mode 100755 scripts/panel-rgbseq.py create mode 100755 scripts/probe-color.py create mode 100755 scripts/probe-geo.py create mode 100755 scripts/probe-hold.py create mode 100755 scripts/probe-scan.py create mode 100755 scripts/probe-sparse.py create mode 100755 scripts/probe-text.py create mode 100755 scripts/py-panel-test.py create mode 100755 scripts/rgbseq-sweep.sh diff --git a/scripts/blue-current.py b/scripts/blue-current.py new file mode 100755 index 0000000..f5a7ab4 --- /dev/null +++ b/scripts/blue-current.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Distinguish a power/current problem from a data problem on the blue channel. + +Blue and white fail while red/green work — the signature of a sagging 5V rail +(blue LEDs have the highest forward voltage, so they drop out first under +brownout; white is peak current). This draws a SMALL blue patch (tiny current) +then a FULL-panel blue fill (large current): + + - small blue lights, full blue blank/dim -> POWER: the 5V rail sags under + load. Check the panel's power feed (supply rating, barrel jack, screw + terminal, wire gauge). Ideally measure 5V at the panel input during the + full-blue phase — if it sags below ~4.5V, that's the cause. + - both blank -> not current; deeper blue fault + - both solid blue -> blue is fine at this drive + +Uses led_rgb_sequence=RBG (this panel's order). +""" + +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True +options.led_rgb_sequence = "RBG" + +matrix = RGBMatrix(options=options) + +# Phase 1: small 8x8 blue patch in the center — minimal current. +canvas = matrix.CreateFrameCanvas() +for x in range(28, 36): + for y in range(12, 20): + canvas.SetPixel(x, y, 0, 0, 255) +matrix.SwapOnVSync(canvas) +print(">>> SMALL blue patch (low current) — does this 8x8 center square light blue? (12s)") +time.sleep(12) + +# Phase 2: full-panel blue — large current. +canvas = matrix.CreateFrameCanvas() +canvas.Fill(0, 0, 255) +matrix.SwapOnVSync(canvas) +print(">>> FULL-PANEL blue (high current) — measure 5V at the panel now if you can. (12s)") +time.sleep(12) + +matrix.Clear() +print("done") diff --git a/scripts/blue-hold.py b/scripts/blue-hold.py new file mode 100755 index 0000000..a493089 --- /dev/null +++ b/scripts/blue-hold.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Hold a sustained, full-brightness BLUE fill to test the blue connection. + +Blue flashes briefly at matrix init but won't hold during a steady fill — the +signature of an intermittent connection. While this runs, GENTLY press/wiggle +the IDC ribbon cable at BOTH ends (HAT and panel): + + - blue flickers on/off in response to pressure -> bad connection (reseat / + replace the ribbon cable, or try the panel's other HUB75 header) + - blue stays solid for the full 30s -> connection is fine; the + earlier blanks were the dim 150-level drive, not a fault + - blue stays totally dark regardless -> deeper fault on that line + +Uses led_rgb_sequence=RBG (this panel's order) so logical blue drives the +physical blue subpixels. +""" + +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True +options.led_rgb_sequence = "RBG" + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() +canvas.Fill(0, 0, 255) # full-brightness blue +matrix.SwapOnVSync(canvas) + +print("Solid BLUE for 30s — gently wiggle the ribbon cable at both ends and watch.") +time.sleep(30) +matrix.Clear() +print("done") diff --git a/scripts/content-sweep.sh b/scripts/content-sweep.sh new file mode 100755 index 0000000..8dc716c --- /dev/null +++ b/scripts/content-sweep.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# content-sweep.sh: Run on the Pi to find the row_address_type x multiplexing +# combo that renders text cleanly on this 1/16-scan panel. +# +# Per hzeller issue #1640, 1/16-scan panels often need row_address_type=3 (and +# sometimes a multiplexing mapper). Draws the app's 4-row card in blue-free +# colors. Watch for the combo where all four rows are readable, in order, with +# no scrambling inside each row. +# +# Ctrl-C once to skip to the next combo. +set -u + +HOLD="${HOLD:-12}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# "row_addr_type multiplexing" — most-likely combos first (row_addr_type=3 alone +# fixed a similar panel in issue #1640), then pair it with the column mappers. +COMBOS=( + "3 0" + "2 0" + "4 0" + "3 1" + "3 17" + "2 17" + "4 17" +) + +for combo in "${COMBOS[@]}"; do + read -r addr mux <<<"$combo" + echo + echo ">>> row_address_type=$addr multiplexing=$mux (${HOLD}s)" + sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/panel-content.py" "$addr" "$mux" "$HOLD" +done + +echo +echo "=== Done. Which (row_address_type, multiplexing) showed 4 clean readable rows? ===" diff --git a/scripts/hold-sweep.sh b/scripts/hold-sweep.sh new file mode 100755 index 0000000..358247d --- /dev/null +++ b/scripts/hold-sweep.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# hold-sweep.sh: Run on the Pi to see if any GPIO-timing setting lets a STATIC +# image hold steady instead of degrading. +# +# The box+X renders correctly then corrupts under sustained refresh. This sweeps +# gpio_slowdown (and a hardware-pulsing variant) holding the static box+X each +# time. Watch for a setting where the image stays STABLE for the full hold. +set -u + +HOLD="${HOLD:-12}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# "gpio_slowdown disable_pulsing" +COMBOS=( + "2 1" + "3 1" + "5 1" + "6 1" + "4 0" +) + +for combo in "${COMBOS[@]}"; do + read -r slowdown pulsing <<<"$combo" + echo + echo ">>> gpio_slowdown=$slowdown disable_hardware_pulsing=$pulsing (${HOLD}s)" + sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/probe-hold.py" "$slowdown" "$pulsing" "$HOLD" +done + +echo +echo "=== Done. Did any setting hold the box+X stable for the full duration? ===" diff --git a/scripts/mux-sweep.sh b/scripts/mux-sweep.sh new file mode 100755 index 0000000..27e656d --- /dev/null +++ b/scripts/mux-sweep.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# mux-sweep.sh: Run on the Pi to find the panel's multiplexing type. +# +# Solid fills render fine but sparse content (text/lines) garbles — a +# pixel-mapping mismatch. This sweeps every multiplexing type (0-17), drawing a +# border + text each time. Watch for the value that yields a CLEAN rectangle +# border and READABLE "AbCd12", then put it in config.yaml as hardware.multiplexing. +# +# Ctrl-C once to skip to the next value; Ctrl-C twice quickly to abort. +set -u + +HOLD="${HOLD:-6}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +for mux in $(seq 0 17); do + echo + echo ">>> Testing multiplexing=$mux (${HOLD}s) — clean border + readable text?" + sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/panel-mux.py" "$mux" "$HOLD" +done + +echo +echo "=== Done. Tell me which multiplexing value rendered a clean border + readable text. ===" diff --git a/scripts/panel-colors.py b/scripts/panel-colors.py new file mode 100755 index 0000000..a89fe86 --- /dev/null +++ b/scripts/panel-colors.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Test each color channel with a solid full-panel fill. + +A solid fill is immune to pixel-mapping/multiplexing scrambles, so this +isolates the color channels themselves. If solid GREEN or BLUE does NOT light +the whole panel in that color, that channel isn't reaching the panel — a +hardware/wiring fault (ribbon cable, HAT seating, solder), not a config issue. +""" + +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) + +for name, (r, g, b) in [ + ("RED", (150, 0, 0)), + ("GREEN", (0, 150, 0)), + ("BLUE", (0, 0, 150)), + ("WHITE", (150, 150, 150)), +]: + canvas = matrix.CreateFrameCanvas() + canvas.Fill(r, g, b) + matrix.SwapOnVSync(canvas) + print(f">>> solid {name} — the ENTIRE panel should be {name}") + time.sleep(4) + +matrix.Clear() +print("done") diff --git a/scripts/panel-confirm.py b/scripts/panel-confirm.py new file mode 100755 index 0000000..f483b6f --- /dev/null +++ b/scripts/panel-confirm.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Confirm a (multiplexing, rows, cols) combo renders the app's 4-row card. + +Mirrors renderer.render_flight_card exactly (same y-positions, colors, font) so +we verify the actual use case before baking the values into config. For a +multiplexed 1/16-scan panel the PHYSICAL rows/cols differ from the visible +64x32 — the mux mapper folds e.g. 16x128 physical into 32x64 visible. + +Usage: panel-confirm.py [multiplexing] [rows] [cols] (default: 17 16 128) +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + +MULTIPLEXING = int(sys.argv[1]) if len(sys.argv) > 1 else 17 +ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 16 +COLS = int(sys.argv[3]) if len(sys.argv) > 3 else 128 +FONT_HEIGHT = 7 + +options = RGBMatrixOptions() +options.cols = COLS +options.rows = ROWS +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True +options.multiplexing = MULTIPLEXING + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +font = graphics.Font() +font.LoadFont("fonts/5x7.bdf") + +# Same layout as renderer.render_flight_card. +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 1 + 0, graphics.Color(255, 140, 0), "UAL123") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 2 + 1, graphics.Color(0, 255, 255), "IAH->LAX") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 3 + 2, graphics.Color(0, 255, 0), "B738") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 4 + 3, graphics.Color(0, 120, 255), "35K ft") + +canvas = matrix.SwapOnVSync(canvas) +print(f"mux={MULTIPLEXING} rows={ROWS} cols={COLS}: 4 rows, distinct colors (orange/cyan/green/blue)?") +time.sleep(15) +matrix.Clear() diff --git a/scripts/panel-content.py b/scripts/panel-content.py new file mode 100755 index 0000000..634a0e2 --- /dev/null +++ b/scripts/panel-content.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Verify the app's content renders correctly, ignoring the dead blue channel. + +Draws renderer.render_flight_card's exact 4-row layout in BLUE-FREE colors +(red / green / yellow / orange), so we judge readability and positioning +without the blue fault interfering. Sweeps row_address_type x multiplexing — +per hzeller issue #1640, 1/16-scan panels often need row_address_type=3 (and +sometimes a multiplexing mapper) to stop the jumble. + +Usage: panel-content.py [row_addr_type] [multiplexing] [hold-seconds] + defaults: 0 0 15 +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + +ROW_ADDR_TYPE = int(sys.argv[1]) if len(sys.argv) > 1 else 0 +MULTIPLEXING = int(sys.argv[2]) if len(sys.argv) > 2 else 0 +HOLD_SECONDS = int(sys.argv[3]) if len(sys.argv) > 3 else 15 +FONT_HEIGHT = 7 + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.multiplexing = MULTIPLEXING +options.row_address_type = ROW_ADDR_TYPE +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +font = graphics.Font() +font.LoadFont("fonts/5x7.bdf") + +# Same positions as render_flight_card, blue-free colors (B=0 everywhere). +RED = graphics.Color(255, 0, 0) +GREEN = graphics.Color(0, 255, 0) +YELLOW = graphics.Color(255, 255, 0) +ORANGE = graphics.Color(255, 90, 0) + +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 1 + 0, RED, "UAL123") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 2 + 1, GREEN, "IAH-LAX") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 3 + 2, YELLOW, "B738") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 4 + 3, ORANGE, "35K ft") + +matrix.SwapOnVSync(canvas) +print(f"row_addr_type={ROW_ADDR_TYPE} multiplexing={MULTIPLEXING}: " + "UAL123 / IAH-LAX / B738 / 35K ft") +print("All four readable, in order, no scrambling within each row?") +time.sleep(HOLD_SECONDS) +matrix.Clear() diff --git a/scripts/panel-diag.sh b/scripts/panel-diag.sh new file mode 100755 index 0000000..1d3ca42 --- /dev/null +++ b/scripts/panel-diag.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# panel-diag.sh: Run on the Pi to find working LED panel flags. +# +# Drives the rpi-rgb-led-matrix `demo` binary (a moving square across the WHOLE +# panel) through a sweep of hardware options. Watch the panel during each step +# and note which label — if any — renders a clean, full-panel square. +# +# This isolates the panel/wiring from the jetset app: if NO combo renders +# cleanly, the problem is hardware (cable, power, mapping); if one does, copy +# its flags into config.yaml's `hardware` section. +set -u + +DEMO="${DEMO:-$HOME/rpi-rgb-led-matrix/examples-api-use/demo}" +if [ ! -x "$DEMO" ]; then + echo "ERROR: demo binary not found at $DEMO" + echo "Build it first: (cd ~/rpi-rgb-led-matrix && make -j4)" + exit 1 +fi + +# Shared flags: single 64x32 panel on the Adafruit HAT (no PWM solder mod), +# moving-square demo (-D0). Each run is capped at 8s via `timeout` because this +# demo build has no -t flag. +RUN_SECONDS="${RUN_SECONDS:-8}" +COMMON=(-D0 --led-rows=32 --led-cols=64 --led-chain=1 + --led-gpio-mapping=adafruit-hat --led-no-hardware-pulse + --led-slowdown-gpio=4) + +# label : extra flags to test on top of COMMON +VARIANTS=( + "baseline (no panel-type) :" + "panel-type FM6126A :--led-panel-type=FM6126A" + "panel-type FM6127 :--led-panel-type=FM6127" + "row-addr-type 1 :--led-row-addr-type=1" + "multiplexing 1 (Stripe) :--led-multiplexing=1" + "slower GPIO (slowdown=2) :--led-slowdown-gpio=2" + "slower GPIO (slowdown=6) :--led-slowdown-gpio=6" +) + +echo "=== Panel diagnostic sweep — watch the panel, note the clean one ===" +for entry in "${VARIANTS[@]}"; do + label="${entry%%:*}" + extra="${entry#*:}" + echo + echo ">>> Testing: $label (${RUN_SECONDS}s)" + # `timeout` sends SIGTERM after RUN_SECONDS; the demo exits on it. + # shellcheck disable=SC2086 + sudo timeout "$RUN_SECONDS" "$DEMO" "${COMMON[@]}" $extra +done + +echo +echo "=== Done. Tell me which label showed a clean full-panel moving square. ===" diff --git a/scripts/panel-hbands.py b/scripts/panel-hbands.py new file mode 100755 index 0000000..264e2b9 --- /dev/null +++ b/scripts/panel-hbands.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Characterize the horizontal column mapping. + +At mux=0/rows=32/cols=64 the vertical layout is correct but columns are +scrambled. Draw known patterns so we can read off the exact column permutation +and choose the right pixel mapper / fix. + +Usage: panel-hbands.py [multiplexing] [rows] [cols] (default 0 32 64) +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +MUX = int(sys.argv[1]) if len(sys.argv) > 1 else 0 +ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 32 +COLS = int(sys.argv[3]) if len(sys.argv) > 3 else 64 + +options = RGBMatrixOptions() +options.cols = COLS +options.rows = ROWS +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True +options.multiplexing = MUX + +matrix = RGBMatrix(options=options) + + +def show(label, paint, seconds=10): + canvas = matrix.CreateFrameCanvas() + w, h = canvas.width, canvas.height + for x in range(w): + for y in range(h): + r, g, b = paint(x, w) + canvas.SetPixel(x, y, r, g, b) + matrix.SwapOnVSync(canvas) + print(f">>> {label} ({seconds}s)") + time.sleep(seconds) + + +# Pattern A: four equal quarters, left->right RED GREEN BLUE WHITE. +QUARTERS = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] +show( + "A: quarters L->R should be RED GREEN BLUE WHITE", + lambda x, w: QUARTERS[min(x // (w // 4), 3)], +) + +# Pattern B: even columns RED, odd columns BLUE — exposes odd/even interleave. +# If columns are interleaved you'll see a RED block and a BLUE block (or larger +# stripes) instead of a fine red/blue comb. +show( + "B: even cols RED, odd cols BLUE (fine comb if mapping is linear)", + lambda x, w: (255, 0, 0) if x % 2 == 0 else (0, 0, 255), + 8, +) + +matrix.Clear() +print("done") diff --git a/scripts/panel-mux.py b/scripts/panel-mux.py new file mode 100755 index 0000000..8c8cb4b --- /dev/null +++ b/scripts/panel-mux.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Draw a border + text under a given multiplexing value. + +Solid fills render fine on this panel but sparse content (text, lines) garbles +— the signature of a pixel-mapping/multiplexing mismatch. This draws SPARSE +content so the right mapping is visually obvious. Sweep values with mux-sweep.sh. + +Usage: panel-mux.py [hold-seconds] +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + +MULTIPLEXING = int(sys.argv[1]) if len(sys.argv) > 1 else 0 +HOLD_SECONDS = int(sys.argv[2]) if len(sys.argv) > 2 else 6 +WIDTH, HEIGHT = 64, 32 + +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True +options.multiplexing = MULTIPLEXING + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +# 1px green border around the full panel edge. +for x in range(WIDTH): + canvas.SetPixel(x, 0, 0, 80, 0) + canvas.SetPixel(x, HEIGHT - 1, 0, 80, 0) +for y in range(HEIGHT): + canvas.SetPixel(0, y, 0, 80, 0) + canvas.SetPixel(WIDTH - 1, y, 0, 80, 0) + +# Readable text spanning most of the width. +font = graphics.Font() +font.LoadFont("fonts/5x7.bdf") +graphics.DrawText(canvas, font, 2, 14, graphics.Color(255, 255, 255), "AbCd12") + +canvas = matrix.SwapOnVSync(canvas) +print(f"multiplexing={MULTIPLEXING}: clean rectangle border + readable 'AbCd12'?") +time.sleep(HOLD_SECONDS) +matrix.Clear() diff --git a/scripts/panel-rgbseq.py b/scripts/panel-rgbseq.py new file mode 100755 index 0000000..25a7c1d --- /dev/null +++ b/scripts/panel-rgbseq.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Show solid RED/GREEN/BLUE under a given led_rgb_sequence. + +Solid-color fills tested earlier came out misordered (blue-input lit green) and +partly blank. led_rgb_sequence permutes the panel's subpixel order. The correct +sequence makes red->red, green->green, blue->blue. If BLUE never appears under +ANY of the six sequences, the blue channel is a hardware fault, not config. + +Usage: panel-rgbseq.py one of RGB RBG GRB GBR BRG BGR +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +SEQUENCE = sys.argv[1] if len(sys.argv) > 1 else "RGB" + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True +options.led_rgb_sequence = SEQUENCE + +matrix = RGBMatrix(options=options) + +for name, (r, g, b) in [("RED", (150, 0, 0)), ("GREEN", (0, 150, 0)), ("BLUE", (0, 0, 150))]: + canvas = matrix.CreateFrameCanvas() + canvas.Fill(r, g, b) + matrix.SwapOnVSync(canvas) + print(f" seq={SEQUENCE}: solid {name} — panel should be {name}") + time.sleep(2.5) + +matrix.Clear() diff --git a/scripts/probe-color.py b/scripts/probe-color.py new file mode 100755 index 0000000..caec0e5 --- /dev/null +++ b/scripts/probe-color.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Compare animated vs static rendering for a single color, at a stable slowdown. + +Green worked as a sweeping line but vanished as a static X — but those tests +differed in slowdown (4 vs 5) AND animated-vs-static AND had a red box next to +the green. This isolates it: pick ONE color, run the SAME three things at the +SAME slowdown: + + Phase 1: horizontal line sweep (animated) + Phase 2: vertical line sweep (animated) + Phase 3: static box + X, held + +Run it per color (e.g. 'green 5' then 'red 5') and compare: does the color show +in the animated sweeps but disappear in the static shape? That would pin a +static-content problem to that specific channel. + +Usage: probe-color.py [color] [gpio_slowdown] + color: red green blue yellow cyan white magenta (default green 5) +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +COLORS = { + "red": (255, 0, 0), + "green": (0, 255, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "cyan": (0, 255, 255), + "white": (255, 255, 255), + "magenta": (255, 0, 255), +} + +NAME = sys.argv[1] if len(sys.argv) > 1 else "green" +SLOWDOWN = int(sys.argv[2]) if len(sys.argv) > 2 else 5 +R, G, B = COLORS.get(NAME, (0, 255, 0)) +WIDTH, HEIGHT = 64, 32 + +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = SLOWDOWN +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +print(f">>> color={NAME} slowdown={SLOWDOWN}") + +print(" PHASE 1: horizontal line sweep (animated)") +for y in range(HEIGHT): + canvas.Clear() + for x in range(WIDTH): + canvas.SetPixel(x, y, R, G, B) + canvas = matrix.SwapOnVSync(canvas) + time.sleep(0.3) + +print(" PHASE 2: vertical line sweep (animated)") +for x in range(WIDTH): + canvas.Clear() + for y in range(HEIGHT): + canvas.SetPixel(x, y, R, G, B) + canvas = matrix.SwapOnVSync(canvas) + time.sleep(0.2) + +print(" PHASE 3: STATIC box + X (held 12s) — does it show as cleanly as the sweeps?") +canvas.Clear() +inset = 2 +for x in range(inset, WIDTH - inset): + canvas.SetPixel(x, inset, R, G, B) + canvas.SetPixel(x, HEIGHT - 1 - inset, R, G, B) +for y in range(inset, HEIGHT - inset): + canvas.SetPixel(inset, y, R, G, B) + canvas.SetPixel(WIDTH - 1 - inset, y, R, G, B) +for i in range(HEIGHT): + x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) + canvas.SetPixel(x_left, i, R, G, B) + canvas.SetPixel(WIDTH - 1 - x_left, i, R, G, B) +canvas = matrix.SwapOnVSync(canvas) +time.sleep(12) + +matrix.Clear() +print("done") diff --git a/scripts/probe-geo.py b/scripts/probe-geo.py new file mode 100755 index 0000000..a4dfd77 --- /dev/null +++ b/scripts/probe-geo.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Confirm geometry is correct (no mapper needed) using only the solid RED +channel at the stable gpio_slowdown. Draws a box outline + an X, all red, and +holds. If this is a clean, stable box-with-an-X, the panel's pixel mapping is +standard and the earlier 'jumble' was purely refresh instability + the panel's +dead/flaky green & blue channels. + +Usage: probe-geo.py [gpio_slowdown] [hold-seconds] (default 5 15) +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +WIDTH, HEIGHT = 64, 32 +SLOWDOWN = int(sys.argv[1]) if len(sys.argv) > 1 else 5 +HOLD = int(sys.argv[2]) if len(sys.argv) > 2 else 15 + +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = SLOWDOWN +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +RED = (255, 0, 0) +inset = 2 +for x in range(inset, WIDTH - inset): + canvas.SetPixel(x, inset, *RED) + canvas.SetPixel(x, HEIGHT - 1 - inset, *RED) +for y in range(inset, HEIGHT - inset): + canvas.SetPixel(inset, y, *RED) + canvas.SetPixel(WIDTH - 1 - inset, y, *RED) +for i in range(HEIGHT): + x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) + canvas.SetPixel(x_left, i, *RED) + canvas.SetPixel(WIDTH - 1 - x_left, i, *RED) + +matrix.SwapOnVSync(canvas) +print(f">>> slowdown={SLOWDOWN}: a RED box with a RED X. Clean, stable, correct geometry?") +time.sleep(HOLD) +matrix.Clear() diff --git a/scripts/probe-hold.py b/scripts/probe-hold.py new file mode 100755 index 0000000..99316fd --- /dev/null +++ b/scripts/probe-hold.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Hold a STATIC box+X and see if it stays stable or degrades. + +The image renders correctly then corrupts under sustained refresh — a +refresh-stability / GPIO-timing symptom. This redraws nothing after the first +frame, so any degradation is the hardware refresh, not the code. Sweep +gpio_slowdown (and optionally hardware-pulsing) to see if any setting holds the +image steady for the full duration. + +Usage: probe-hold.py [gpio_slowdown] [disable_hw_pulsing 0|1] [hold-seconds] + defaults: 4 1 12 +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +WIDTH, HEIGHT = 64, 32 + +SLOWDOWN = int(sys.argv[1]) if len(sys.argv) > 1 else 4 +DISABLE_PULSING = (int(sys.argv[2]) if len(sys.argv) > 2 else 1) == 1 +HOLD = int(sys.argv[3]) if len(sys.argv) > 3 else 12 + +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = SLOWDOWN +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = DISABLE_PULSING + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +inset = 2 +for x in range(inset, WIDTH - inset): + canvas.SetPixel(x, inset, 255, 0, 0) + canvas.SetPixel(x, HEIGHT - 1 - inset, 255, 0, 0) +for y in range(inset, HEIGHT - inset): + canvas.SetPixel(inset, y, 255, 0, 0) + canvas.SetPixel(WIDTH - 1 - inset, y, 255, 0, 0) +for i in range(HEIGHT): + x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) + canvas.SetPixel(x_left, i, 0, 255, 0) + canvas.SetPixel(WIDTH - 1 - x_left, i, 0, 255, 0) + +matrix.SwapOnVSync(canvas) +print(f">>> slowdown={SLOWDOWN} disable_pulsing={DISABLE_PULSING}: " + f"does the box+X stay STABLE for {HOLD}s, or degrade?") +time.sleep(HOLD) +matrix.Clear() diff --git a/scripts/probe-scan.py b/scripts/probe-scan.py new file mode 100755 index 0000000..1bd1fb4 --- /dev/null +++ b/scripts/probe-scan.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Reverse-engineer probe: animate ONE logical line scanning the panel to reveal +how logical coordinates map to physical pixels on this non-standard panel. + +Phase 1: a full-width RED horizontal line steps through logical rows 0..31. +Phase 2: a full-height GREEN vertical line steps through logical cols 0..63. + +Watch how the PHYSICAL line moves and describe the motion: + - smooth top->bottom / left->right -> that axis maps linearly + - jumps between halves, or two lines at -> reveals the scan/multiplex split + once moving together, or scans in blocks +Blue-free colors so the dead blue channel doesn't interfere. Runs each phase +twice so you can confirm the pattern. +""" + +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions + +WIDTH, HEIGHT = 64, 32 + +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +for _ in range(2): + print("PHASE 1: RED horizontal line, logical row 0 -> 31 (top to bottom)") + for y in range(HEIGHT): + canvas.Clear() + for x in range(WIDTH): + canvas.SetPixel(x, y, 255, 0, 0) + canvas = matrix.SwapOnVSync(canvas) + time.sleep(0.4) + +time.sleep(1.0) + +for _ in range(2): + print("PHASE 2: GREEN vertical line, logical col 0 -> 63 (left to right)") + for x in range(WIDTH): + canvas.Clear() + for y in range(HEIGHT): + canvas.SetPixel(x, y, 0, 255, 0) + canvas = matrix.SwapOnVSync(canvas) + time.sleep(0.25) + +matrix.Clear() +print("done") diff --git a/scripts/probe-sparse.py b/scripts/probe-sparse.py new file mode 100755 index 0000000..2f51e80 --- /dev/null +++ b/scripts/probe-sparse.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Full lines scan cleanly but text is jumbled — so the coordinate mapping is +likely fine and the cause is elsewhere. This isolates it: + +Phase A: a sparse 2D shape drawn pixel-by-pixel (box outline + an X). No font. + Clean box with a clean X -> individual-pixel mapping is correct, the + panel does NOT need a custom mapper. The problem is the font/text. + Scrambled -> there IS a 2D mapping quirk full lines couldn't show. + +Phase B: text drawn with the LIBRARY's bundled font (not the project's 5x7.bdf). + Readable here -> the project's fonts/5x7.bdf is the culprit; swap it. + Jumbled here too -> DrawText/graphics issue. + +Settings match the app (mux=0, row_addr=0, RBG). Blue-free colors. +""" + +import os +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + +WIDTH, HEIGHT = 64, 32 + +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) + +# --- Phase A: sparse 2D shape, pixel-by-pixel (no font) --------------------- +canvas = matrix.CreateFrameCanvas() +inset = 2 +# box outline +for x in range(inset, WIDTH - inset): + canvas.SetPixel(x, inset, 255, 0, 0) + canvas.SetPixel(x, HEIGHT - 1 - inset, 255, 0, 0) +for y in range(inset, HEIGHT - inset): + canvas.SetPixel(inset, y, 255, 0, 0) + canvas.SetPixel(WIDTH - 1 - inset, y, 255, 0, 0) +# an X across the box (diagonals) +for i in range(HEIGHT): + x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) + canvas.SetPixel(x_left, i, 0, 255, 0) + canvas.SetPixel(WIDTH - 1 - x_left, i, 0, 255, 0) +canvas = matrix.SwapOnVSync(canvas) +print(">>> PHASE A: a red BOX outline with a green X through it. Clean? (12s)") +time.sleep(12) + +# --- Phase B: text with the library's bundled font -------------------------- +candidates = [ + "/home/hao/rpi-rgb-led-matrix/fonts/6x10.bdf", + os.path.expanduser("~/rpi-rgb-led-matrix/fonts/6x10.bdf"), + "/home/hao/rpi-rgb-led-matrix/fonts/5x7.bdf", +] +font_path = next((p for p in candidates if os.path.exists(p)), None) +if font_path is None: + print("!! library font not found; checked:", candidates, file=sys.stderr) +else: + font = graphics.Font() + font.LoadFont(font_path) + canvas = matrix.CreateFrameCanvas() + graphics.DrawText(canvas, font, 1, 10, graphics.Color(255, 0, 0), "ABCD12") + graphics.DrawText(canvas, font, 1, 26, graphics.Color(0, 255, 0), "wxyz89") + canvas = matrix.SwapOnVSync(canvas) + print(f">>> PHASE B: text via library font {font_path}") + print(" 'ABCD12' / 'wxyz89' — readable? (12s)") + time.sleep(12) + +matrix.Clear() +print("done") diff --git a/scripts/probe-text.py b/scripts/probe-text.py new file mode 100755 index 0000000..3f3d829 --- /dev/null +++ b/scripts/probe-text.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Final check: render the app's 4-row text card, held static, at a stable +slowdown, in any color (including combination colors that exercise multiple +channels at once). + +Uses the project's own fonts/5x7.bdf and the app's exact row positions. Run it +per color to confirm text renders reliably: + - red / green : single working channels + - yellow / cyan / white / orange : combination colors (exercise the + channel-collapse we saw with white->red) + +Usage: probe-text.py [color] [gpio_slowdown] [hold-seconds] + color: red green blue yellow cyan magenta white orange (default red 5 15) +""" + +import sys +import time + +from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + +COLORS = { + "red": (255, 0, 0), + "green": (0, 255, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "cyan": (0, 255, 255), + "magenta": (255, 0, 255), + "white": (255, 255, 255), + "orange": (255, 140, 0), + "dimwhite": (64, 64, 64), + "dimyellow": (64, 64, 0), +} + +NAME = sys.argv[1] if len(sys.argv) > 1 else "red" +SLOWDOWN = int(sys.argv[2]) if len(sys.argv) > 2 else 5 +HOLD = int(sys.argv[3]) if len(sys.argv) > 3 else 15 +R, G, B = COLORS.get(NAME, (255, 0, 0)) +FONT_HEIGHT = 7 + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = SLOWDOWN +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +font = graphics.Font() +font.LoadFont("fonts/5x7.bdf") # the project's font, the app's actual path +color = graphics.Color(R, G, B) + +# App layout: same y-positions as renderer.render_flight_card. +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 1 + 0, color, "UAL123") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 2 + 1, color, "IAH-LAX") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 3 + 2, color, "B738") +graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 4 + 3, color, "35K ft") + +matrix.SwapOnVSync(canvas) +print(f">>> color={NAME} slowdown={SLOWDOWN}: four rows held {HOLD}s") +print(" UAL123 / IAH-LAX / B738 / 35K ft — all four readable and stable?") +time.sleep(HOLD) +matrix.Clear() diff --git a/scripts/py-panel-test.py b/scripts/py-panel-test.py new file mode 100755 index 0000000..5717d25 --- /dev/null +++ b/scripts/py-panel-test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""On-Pi isolation test: drives the panel directly via the rgbmatrix Python +bindings to find where the app's render path diverges from the (working) C demo. + +The C `demo` binary renders cleanly, so the hardware + flags are good. This +walks the Python draw path one stage at a time — solid fill, geometry border, +then BDF text — so we can see exactly which stage garbles. + +Run on the Pi (root needed for GPIO): + cd ~/jetset && sudo -E env PATH=$PATH uv run python scripts/py-panel-test.py +""" + +import time + +# --- Stage 0: backend probe ------------------------------------------------- +try: + from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics +except ImportError as exc: # pragma: no cover - hardware-only path + print(f"FAIL: cannot import rgbmatrix (incl. graphics): {exc}") + raise SystemExit(1) from exc +print("OK: imported rgbmatrix incl. graphics") + +WIDTH, HEIGHT = 64, 32 + +# Match the WORKING C-demo baseline as closely as possible: only the flags the +# demo used, nothing extra (no pwm_bits / multiplexing / panel_type / rgb_seq). +options = RGBMatrixOptions() +options.cols = WIDTH +options.rows = HEIGHT +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 4 +options.disable_hardware_pulsing = True + +matrix = RGBMatrix(options=options) + + +def hold(label: str, seconds: int = 4) -> None: + print(f">>> {label} ({seconds}s)") + time.sleep(seconds) + + +# --- Stage A: solid fill across the whole panel ----------------------------- +canvas = matrix.CreateFrameCanvas() +canvas.Fill(40, 0, 0) # dim red +canvas = matrix.SwapOnVSync(canvas) +hold("A: whole panel dim RED — should fill ALL 64x32 evenly") + +# --- Stage B: 1px border to verify dimensions/mapping ----------------------- +canvas.Clear() +for x in range(WIDTH): + canvas.SetPixel(x, 0, 0, 60, 0) + canvas.SetPixel(x, HEIGHT - 1, 0, 60, 0) +for y in range(HEIGHT): + canvas.SetPixel(0, y, 0, 60, 0) + canvas.SetPixel(WIDTH - 1, y, 0, 60, 0) +canvas = matrix.SwapOnVSync(canvas) +hold("B: GREEN border hugging all four edges — clean rectangle?") + +# --- Stage C: BDF text on four rows (the app's actual draw path) ------------ +canvas.Clear() +font = graphics.Font() +font.LoadFont("fonts/5x7.bdf") +white = graphics.Color(255, 255, 255) +graphics.DrawText(canvas, font, 1, 7, white, "ABCD") +graphics.DrawText(canvas, font, 1, 15, white, "1234") +graphics.DrawText(canvas, font, 1, 23, white, "wxyz") +graphics.DrawText(canvas, font, 1, 31, white, "EDGE") +canvas = matrix.SwapOnVSync(canvas) +hold("C: four rows of text top-to-bottom — all four readable?", 8) + +matrix.Clear() +print("done") diff --git a/scripts/rgbseq-sweep.sh b/scripts/rgbseq-sweep.sh new file mode 100755 index 0000000..fa8e361 --- /dev/null +++ b/scripts/rgbseq-sweep.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# rgbseq-sweep.sh: Run on the Pi to find the panel's subpixel order. +# +# Solid color fills came out misordered/blank. This sweeps all six +# led_rgb_sequence permutations, showing solid RED/GREEN/BLUE for each. Watch +# for the sequence where red->red, green->green, blue->blue. If BLUE never +# lights under any sequence, the blue channel is a hardware fault (cable/panel). +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +for seq in RGB RBG GRB GBR BRG BGR; do + echo + echo ">>> led_rgb_sequence=$seq" + sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/panel-rgbseq.py" "$seq" +done + +echo +echo "=== Done. Which sequence (if any) showed red=red, green=green, blue=blue? ===" +echo "=== If blue NEVER appeared, the blue channel is a hardware fault. ===" From 41cbf6fb4c023107d08a9950e3a2d488a9c7dccc Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:22:46 -0500 Subject: [PATCH 05/36] DOCS: record panel bring-up findings in HARDWARE.md Capture the hardware lessons from debugging so they are not relearned: gpio_slowdown=5 is required on the Pi 3 A+ / Adafruit HAT (4 corrupts static images), use indoor standard 1/16-scan panels and avoid outdoor multiplexed ones, rgb_sequence defaults to RGB, the original test panel was defective, and the scripts/probe-* tooling characterizes a new panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- HARDWARE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/HARDWARE.md b/HARDWARE.md index b6c7558..f3a331c 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -68,3 +68,27 @@ 4. Plug 5V 4A supply into HAT's barrel jack (powers panel) 5. Plug micro USB into Pi from phone charger (powers Pi) 6. SSH in, install software, run + +## Panel notes & troubleshooting + +Hard-won lessons from bring-up: + +- **`gpio_slowdown = 5` is required** on the Pi 3 A+ / Adafruit HAT. At `4` the + signal is unstable: a static image renders correctly for a moment, then + corrupts into scrambled pixels (it masquerades as a "bad pixel mapping"). + `5` holds steady. Configurable via `hardware.gpio_slowdown`. +- **Use INDOOR, standard 1/16-scan panels.** They map 1:1 and work with the + defaults (`multiplexing=0`, `row_address_type=0`). **Avoid OUTDOOR / + multiplexed panels** — they use scrambled internal wiring that needs a custom + multiplex-mapper compiled into rpi-rgb-led-matrix (see hzeller issue #1640). +- **`rgb_sequence`** defaults to `"RGB"` (standard). Override per-panel (e.g. + `"RBG"`) only if red/green/blue come out swapped. +- **The original test panel was defective** (a generic P2.5 64×32 "outdoor" + 1/16-scan unit): dead blue channel, underpowered green, and red-channel + crosstalk that garbled any combination color at full brightness. Single red + and green rendered fine; it was replaced with a standard indoor panel. +- **Bring-up tooling:** `scripts/probe-*.py` and `scripts/*-sweep.sh` drive a + panel directly via rgbmatrix to characterize geometry, scan/multiplexing, + color channels, and signal stability — independent of the app. Start with + `panel-colors.py` to confirm the three channels, then `probe-text.py` for the + real 4-row layout. From 87cd2c42f15027946ea398e195126a25bf80e49e Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:23:14 -0500 Subject: [PATCH 06/36] FIX: verify HTTPS against the system CA bundle On the Pi the bundled certifi store did not match the OS trust store, breaking TLS verification for API calls. Detect a CA bundle from REQUESTS_CA_BUNDLE / SSL_CERT_FILE or common system locations and use it for the session's verify setting, falling back to the default when none is found. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/http.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/jetset/http.py b/src/jetset/http.py index 8d39f33..6456c06 100644 --- a/src/jetset/http.py +++ b/src/jetset/http.py @@ -1,15 +1,36 @@ """HTTP utilities for API communication.""" +import os +from pathlib import Path from typing import ClassVar import requests +# Common locations for the system CA bundle on Linux +_SYSTEM_CA_BUNDLES = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", +] + + +def _find_ca_bundle() -> str | None: + """Return a path to a valid CA bundle, or None.""" + # Honour an explicit env var first + env = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE") + if env and Path(env).is_file(): + return env + for path in _SYSTEM_CA_BUNDLES: + if Path(path).is_file(): + return path + return None + class RequestsAPI(requests.Session): """A requests.Session subclass that prepends a base URL to every request. Useful for API clients that talk to a single host — provides - convenience like `api.get(\"/endpoint\")` without repeating + convenience like `api.get("/endpoint")` without repeating the scheme and hostname. """ @@ -19,6 +40,10 @@ def __init__( self, base_url: str | None = None, headers: dict[str, str] | None = None, *args, **kwargs ): super().__init__(*args, **kwargs) + # Use a detected system CA bundle if available, otherwise certifi + ca_bundle = _find_ca_bundle() + if ca_bundle: + self.verify = ca_bundle self.base_url = base_url if headers: From 15801a2df27c8857821535228d67b17c5ffc29b2 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:23:31 -0500 Subject: [PATCH 07/36] FEATURE: add Raspberry Pi deployment tooling Provide the path from dev machine to running on the Pi: - Makefile targets: deploy (rsync), setup-pi (remote deps + build rpi-rgb-led-matrix), start / debug-start (run over SSH with GPIO root). - scripts/setup-pi.sh: installs system deps, adds swap, builds the rgbmatrix wheel, and adds it as a project dependency. - scripts/jetset.service: systemd unit to run the display on boot. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 22 +++++++++++++- scripts/jetset.service | 17 +++++++++++ scripts/setup-pi.sh | 68 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 scripts/jetset.service create mode 100755 scripts/setup-pi.sh diff --git a/Makefile b/Makefile index c586897..70144b7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ -.PHONY: run debug test lint fixtures +.PHONY: run debug test lint fixtures deploy setup-pi start debug-start +# Local dev run: ## Run the emulator smoke test uv run python -m jetset @@ -14,3 +15,22 @@ lint: ## Lint and format check fixtures: ## Save live AeroAPI response as test fixture uv run python scripts/save_fixtures.py + +# Pi Deployment +JETSET_USER ?= hao +JETSET_HOST ?= jetset.local +JETSET_PATH ?= /home/$(JETSET_USER)/jetset + +deploy: ## rsync code to Pi + rsync -avz --exclude '.venv' --exclude '__pycache__' --exclude '.git' \ + --exclude '*.pyc' --exclude 'wheels' \ + . $(JETSET_USER)@$(JETSET_HOST):$(JETSET_PATH)/ + +setup-pi: ## Setup Pi deps via SSH (clone, build, uv add) + cat scripts/setup-pi.sh | ssh $(JETSET_USER)@$(JETSET_HOST) "bash -s" + +start: ## Run on Pi via SSH (needs root for GPIO) + ssh -t $(JETSET_USER)@$(JETSET_HOST) "cd $(JETSET_PATH) && sudo -E env PATH=\\$PATH uv run python -m jetset" + +debug-start: ## Run on Pi via SSH with debug logging + ssh -t $(JETSET_USER)@$(JETSET_HOST) "cd $(JETSET_PATH) && sudo -E env PATH=\\$PATH JETSET_DEBUG=1 uv run python -m jetset" diff --git a/scripts/jetset.service b/scripts/jetset.service new file mode 100644 index 0000000..a77d4dd --- /dev/null +++ b/scripts/jetset.service @@ -0,0 +1,17 @@ +[Unit] +Description=Jetset FlightWall +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/home/hao/jetset +Environment=PATH=/home/hao/.local/bin:/usr/bin:/bin +Environment=JETSET_DEBUG=1 +ExecStart=/home/hao/.local/bin/uv run python -m jetset +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh new file mode 100755 index 0000000..3e962aa --- /dev/null +++ b/scripts/setup-pi.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# setup-pi.sh: Run locally on the Pi after `make deploy` +# Installs system deps, builds rpi-rgb-led-matrix, and adds it to the project. +set -e + +# 1. Add swap if missing +SWAPON=$(command -v swapon 2>/dev/null || echo "/usr/sbin/swapon") +if [ -x "$SWAPON" ]; then + if ! $SWAPON --show 2>/dev/null | grep -q /swapfile; then + echo "=== Creating 1GB swapfile ===" + sudo fallocate -l 1G /swapfile 2>/dev/null || sudo dd if=/dev/zero of=/swapfile bs=1M count=1024 + sudo chmod 600 /swapfile + sudo mkswap /swapfile 2>/dev/null || true + sudo swapon /swapfile 2>/dev/null || true + fi +else + echo "=== Skipping swap (swapon not available) ===" +fi + +# 2. Install system deps +sudo apt update && sudo apt install -y python3-dev python3-pil cython3 build-essential cmake + +# 3. Install uv if missing +if ! command -v uv &> /dev/null; then + echo "=== Installing uv ===" + curl -LsSf https://astral.sh/uv/install.sh | sh + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc + export PATH="$HOME/.local/bin:$PATH" +else + export PATH="$HOME/.local/bin:$PATH" +fi + +# 4. Sync project deps and add scikit-build-core +cd ~/jetset +# Fix permissions if previous builds ran as root +sudo chown -R $(whoami):$(whoami) .venv 2>/dev/null || true +uv sync +uv pip install scikit-build-core + +# 5. Clone, build, and add rgbmatrix to the project +WHEEL_FILE=$(ls ~/jetset/wheels/rgbmatrix-*.whl 2>/dev/null | head -1) +if [ ! -f "$WHEEL_FILE" ]; then + echo "=== Building rpi-rgb-led-matrix ===" + [ -d "$HOME/rpi-rgb-led-matrix" ] || git clone https://github.com/hzeller/rpi-rgb-led-matrix.git + cd ~/rpi-rgb-led-matrix + make -j4 + cd ~/jetset + + # Ensure Pillow headers are available for the pillow shim + if [ ! -d "/tmp/Pillow" ]; then + git clone --depth 1 https://github.com/python-pillow/Pillow.git /tmp/Pillow + fi + + # Build a local wheel so it becomes a proper project dependency + mkdir -p wheels + CFLAGS="-I/tmp/Pillow/src/libImaging" uv build --no-build-isolation --wheel \ + "$HOME/rpi-rgb-led-matrix" --out-dir "$PWD/wheels" + + # Add wheel as a path dependency in pyproject.toml (persists across uv sync) + uv add ./wheels/rgbmatrix-*.whl + + # Free up space + rm -rf /tmp/Pillow +else + echo "=== rgbmatrix wheel already built ===" +fi + +echo "=== Setup complete! Run \`make start\` ===" From 45ca1d7cb74b2f2c75da806500fd4daee64e9af4 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:44:16 -0500 Subject: [PATCH 08/36] FIX: default row_address_type to 0 and expose panel scan knobs A leftover experimental row_address_type=1 in main() collapsed the panel to two horizontal lines on hardware. Restore the standard 1/16-scan value 0, and make row_address_type and multiplexing configurable (defaults 0/0) alongside the other hardware knobs so they no longer need code edits. Also set the rgb_sequence default back to "RBG" for the panel currently deployed; a standard panel uses "RGB" (verify with scripts/panel-colors.py). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +++- src/jetset/__main__.py | 4 ++-- src/jetset/config.py | 10 +++++++--- tests/test_config.py | 14 +++++++++++--- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5fd2d4e..8f683ca 100644 --- a/README.md +++ b/README.md @@ -36,5 +36,7 @@ physical LED panel and is ignored by the emulator: hardware: panel_type: "" # empty = no chip-specific init; set "FM6126A" only if the panel comes up garbled gpio_slowdown: 5 # raise if the image is unstable/corrupts; lower if the display lags - rgb_sequence: RGB # physical subpixel order; override (e.g. "RBG") if colors come out swapped + multiplexing: 0 # 0 for standard 1/16-scan panels; nonzero only for outdoor/multiplexed + row_address_type: 0 # 0 for standard panels; other values for some 1/16-scan panels (e.g. 3) + rgb_sequence: RBG # physical subpixel order; "RGB" for a standard panel, "RBG" for the current one ``` diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index 7854af6..dbbe5bb 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -39,8 +39,8 @@ def main() -> None: # neither needs nor accepts them, so they stay behind IS_HARDWARE. options.hardware_mapping = "adafruit-hat" options.gpio_slowdown = config.hardware_gpio_slowdown - options.multiplexing = 0 - options.row_address_type = 1 + options.multiplexing = config.hardware_multiplexing + options.row_address_type = config.hardware_row_address_type options.panel_type = config.hardware_panel_type options.pwm_bits = 6 # lower PWM reduces flicker on Pi 3 A+ options.led_rgb_sequence = config.hardware_rgb_sequence diff --git a/src/jetset/config.py b/src/jetset/config.py index 7d75a02..ca69a52 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -19,9 +19,13 @@ class AppConfig: # Pi 3 A+ / Adafruit HAT needs slowdown 5 for a stable signal; 4 produced # intermittent corruption that looked like scrambled pixels. hardware_gpio_slowdown: int = 5 - # Physical subpixel order. "RGB" is the standard default; override per-panel - # (e.g. "RBG") if red/green/blue come out swapped. - hardware_rgb_sequence: str = "RGB" + # Standard 1/16-scan panels use multiplexing 0 / row_address_type 0. + # Override only for "outdoor"/multiplexed panels. + hardware_multiplexing: int = 0 + hardware_row_address_type: int = 0 + # Physical subpixel order. The panel currently deployed needs "RBG"; a + # standard panel uses "RGB" (verify with scripts/panel-colors.py). + hardware_rgb_sequence: str = "RBG" @classmethod def load(cls, path=None): diff --git a/tests/test_config.py b/tests/test_config.py index d48cfbb..5d50723 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -46,10 +46,18 @@ def test_default_gpio_slowdown(self) -> None: assert config.hardware_gpio_slowdown == 5 def test_default_rgb_sequence(self) -> None: - # "RGB" is the standard order; panels with a different physical subpixel - # order override it via config. + # The panel currently deployed needs "RBG"; standard panels use "RGB". config = AppConfig.load() - assert config.hardware_rgb_sequence == "RGB" + assert config.hardware_rgb_sequence == "RBG" + + def test_default_multiplexing(self) -> None: + config = AppConfig.load() + assert config.hardware_multiplexing == 0 + + def test_default_row_address_type(self) -> None: + # Standard 1/16-scan addressing; 1 collapsed the image to two lines. + config = AppConfig.load() + assert config.hardware_row_address_type == 0 class TestConfigFromYaml: From 8f30b78cfe54594e770e7125eb5b3d20604b1bdc Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:44:28 -0500 Subject: [PATCH 09/36] FIX: render every row red to work around the faulty panel The deployed panel suppresses other color channels whenever red is present in a frame (and its blue channel is dead), so multi-color cards render with missing rows. Draw all four rows red so the live display is fully readable on this panel. Temporary workaround: when a standard panel is installed, switch the rows back to ORANGE/CYAN/GREEN/BLUE (the constants are still defined) and set rgb_sequence to "RGB". See HARDWARE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/renderer.py | 12 +++++++----- tests/test_renderer.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index 169c104..277798b 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -27,11 +27,13 @@ def draw_text(canvas: Canvas, x: int, y: int, text: str, color: tuple[int, int, def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): canvas.Clear() - # y-values are based off of the font height - draw_text(canvas, 1, FONT_HEIGHT * 1 + 0, flight_label(flight), ORANGE) - draw_text(canvas, 1, FONT_HEIGHT * 2 + 1, route_label(flight), CYAN) - draw_text(canvas, 1, FONT_HEIGHT * 3 + 2, aircraft_label(flight), GREEN) - draw_text(canvas, 1, FONT_HEIGHT * 4 + 3, metrics_label(flight, metric_page), BLUE) + # y-values are based off of the font height. The deployed panel suppresses + # other channels when red is present in a frame, so every row is red until + # a standard panel restores the full palette. + draw_text(canvas, 1, FONT_HEIGHT * 1 + 0, flight_label(flight), RED) + draw_text(canvas, 1, FONT_HEIGHT * 2 + 1, route_label(flight), RED) + draw_text(canvas, 1, FONT_HEIGHT * 3 + 2, aircraft_label(flight), RED) + draw_text(canvas, 1, FONT_HEIGHT * 4 + 3, metrics_label(flight, metric_page), RED) def render_loading(canvas: Canvas, page=0): diff --git a/tests/test_renderer.py b/tests/test_renderer.py index b87b2af..e0abd8c 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -32,6 +32,23 @@ def test_clears_canvas_before_drawing(self) -> None: assert canvas.Clear.called + def test_all_rows_are_red(self) -> None: + # The deployed panel suppresses other channels when red is present, so + # every row is red (single working channel) until a standard panel. + from unittest.mock import MagicMock, patch + + from jetset import renderer + from jetset.models import Flight + + canvas = MagicMock() + flight = Flight(callsign="UAL2337", altitude=35000) + + with patch("jetset.renderer.draw_text") as mock_draw: + renderer.render_flight_card(canvas, flight) + + row_colors = [call.args[4] for call in mock_draw.call_args_list] + assert row_colors == [renderer.RED] * 4 + def test_renders_all_metric_pages_without_error(self) -> None: from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions From 7d7a3a41a64ff3f3f82ed2885f93cce517d567d6 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:44:55 -0500 Subject: [PATCH 10/36] DOCS: add new-panel verification checklist and fault finding Record that the original panel is a faulty unit (not an incompatibility) - it addresses correctly and drives red perfectly; only its color channels are defective. Add a step-by-step checklist for verifying a replacement panel (channels, geometry, restore palette, live app), including the test that tells HAT/cable faults apart from panel faults. Co-Authored-By: Claude Opus 4.8 (1M context) --- HARDWARE.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/HARDWARE.md b/HARDWARE.md index f3a331c..dada3db 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -81,14 +81,34 @@ Hard-won lessons from bring-up: defaults (`multiplexing=0`, `row_address_type=0`). **Avoid OUTDOOR / multiplexed panels** — they use scrambled internal wiring that needs a custom multiplex-mapper compiled into rpi-rgb-led-matrix (see hzeller issue #1640). -- **`rgb_sequence`** defaults to `"RGB"` (standard). Override per-panel (e.g. - `"RBG"`) only if red/green/blue come out swapped. -- **The original test panel was defective** (a generic P2.5 64×32 "outdoor" +- **`rgb_sequence`** currently defaults to `"RBG"` for the deployed (faulty) + panel; a standard panel uses `"RGB"`. Override per-panel if red/green/blue + come out swapped. +- **The original test panel is defective** (a generic P2.5 64×32 "outdoor" 1/16-scan unit): dead blue channel, underpowered green, and red-channel crosstalk that garbled any combination color at full brightness. Single red - and green rendered fine; it was replaced with a standard indoor panel. + and green rendered fine — which proves it's a *faulty unit*, not an + incompatibility (it addresses correctly and drives red perfectly). The app + currently renders every row red as a workaround; replace with a standard + indoor panel and restore the palette. - **Bring-up tooling:** `scripts/probe-*.py` and `scripts/*-sweep.sh` drive a panel directly via rgbmatrix to characterize geometry, scan/multiplexing, color channels, and signal stability — independent of the app. Start with `panel-colors.py` to confirm the three channels, then `probe-text.py` for the real 4-row layout. + +### Verifying a new panel + +When swapping in a replacement panel (reuse the same HAT + ribbon cable): + +1. `make deploy` +2. **Channels:** `sudo -E env PATH=$PATH uv run python scripts/panel-colors.py` + - red→red, green→green, **blue→blue**. If a channel is swapped, set + `hardware.rgb_sequence`. If **blue is still dead** on a known-good panel + with this same HAT + cable, the fault is the **HAT or cable**, not panels. +3. **Geometry + combination colors:** `... scripts/probe-text.py white 5` + - four stable rows, and white renders white (no collapse-to-red / garbling). +4. **Restore the palette:** in `renderer.render_flight_card`, switch the four + rows back to `ORANGE` / `CYAN` / `GREEN` / `BLUE`, and set + `hardware_rgb_sequence` to `"RGB"` (or whatever step 2 found). +5. **Live app:** `make start` — confirm flight cards render in full color. From 19151063b424e02d3c951ac4c53002ae32b4c2f8 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:46:20 -0500 Subject: [PATCH 11/36] FIX: handle empty aircraft list in refresh_flight The /callsign endpoint can return valid JSON with an empty "ac" list when the aircraft is no longer airborne. Indexing [0] on it raised an uncaught IndexError that crashed the render loop. Check the list is non-empty before indexing and otherwise return the original flight, matching the documented "show stale data rather than nothing" behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/fetcher.py | 6 +++--- tests/test_fetcher.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/jetset/fetcher.py b/src/jetset/fetcher.py index f0945db..f1a76f2 100644 --- a/src/jetset/fetcher.py +++ b/src/jetset/fetcher.py @@ -190,9 +190,9 @@ def refresh_flight(self, flight: Flight) -> Flight: with self._flight_api as api: logger.debug(f"Fetching flight with callsign {flight.callsign}") if data := api.get(f"/callsign/{flight.callsign}").json(): - aircraft = data.get("ac", [])[0] - if aircraft: - updated = self.json_to_flight(aircraft) + aircraft_list = data.get("ac", []) + if aircraft_list: + updated = self.json_to_flight(aircraft_list[0]) return replace(updated, route=flight.route) return flight diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index b407a05..edc9050 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -423,6 +423,24 @@ def test_returns_fresh_metrics_with_preserved_route(self) -> None: # Route should be preserved from the original assert refreshed.route is route + def test_returns_original_when_no_aircraft_in_response(self) -> None: + # The callsign endpoint can return valid JSON with an empty "ac" list + # when the aircraft is no longer airborne; keep the stale flight. + from unittest.mock import MagicMock, patch + + from jetset.fetcher import AdsbLolAdapter + from jetset.models import Flight + + adapter = AdsbLolAdapter() + mock_resp = MagicMock() + mock_resp.json.return_value = {"ac": [], "total": 0} + existing = Flight(callsign="UCA4237", altitude=35000, speed=450) + + with patch.object(adapter._flight_api, "get", return_value=mock_resp): + refreshed = adapter.refresh_flight(existing) + + assert refreshed is existing + class TestAdsbLolFixture: def test_parses_all_fixture_flights(self) -> None: From 6c50548fe7dbdec863d74a473b07cd44646c3b5b Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 13:52:41 -0500 Subject: [PATCH 12/36] FIX: keep the render loop alive when a fetch cycle fails An unexpected error during fetch (anything not already handled inside nearby_flights/refresh_flight) propagated out and crashed the whole display. Wrap the fetch in _safe_fetch: log the error and keep rendering existing flights. last_fetch is advanced on failure so a persistent outage backs off to the normal refresh interval instead of retrying every frame. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/app.py | 20 +++++++++++++++++++- tests/test_main.py | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/jetset/app.py b/src/jetset/app.py index ee43b14..e9f6f8c 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -1,3 +1,4 @@ +import logging import time from typing import NamedTuple @@ -9,6 +10,8 @@ from jetset.models import Flight, FlightBuffer from jetset.renderer import render_flight_card, render_loading +logger = logging.getLogger(__name__) + class App: class Frame(NamedTuple): @@ -45,6 +48,21 @@ def _fetch(self): self.last_fetch = time.time() + def _safe_fetch(self) -> None: + """Run a fetch cycle, swallowing unexpected errors. + + nearby_flights/refresh_flight already handle expected network and parse + errors; this is the last line of defense so an unforeseen failure logs + and the display keeps showing existing flights instead of crashing the + loop. last_fetch is advanced so a persistent failure backs off to the + normal refresh interval rather than retrying every frame. + """ + try: + self._fetch() + except Exception: + logger.exception("Fetch cycle failed; keeping existing flights") + self.last_fetch = time.time() + def _current_frame(self) -> Frame | None: if not self.buffer.flights: return None @@ -79,7 +97,7 @@ def loop(self, matrix: RGBMatrix, canvas: Canvas): try: while True: if self._should_fetch(): - self._fetch() + self._safe_fetch() if frame := self._current_frame(): canvas = self._render_frame(matrix, canvas, frame) diff --git a/tests/test_main.py b/tests/test_main.py index 2a6439c..3974312 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -49,6 +49,21 @@ def test_fetches_and_pushes_to_buffer(self) -> None: assert app.last_fetch == 500 +class TestAppSafeFetch: + def test_swallows_fetch_errors_so_the_loop_survives(self) -> None: + from unittest.mock import patch + + from jetset.app import App + + app = App(AppConfig()) + # An unexpected error in a fetch cycle must not propagate out of + # _safe_fetch — the display keeps running on existing flights. + with patch.object(app, "_fetch", side_effect=RuntimeError("boom")): + app._safe_fetch() + + assert len(app.buffer) == 0 + + class TestAppCurrentFrame: def test_selects_flight_and_metric_page(self) -> None: from jetset.app import App From a065420a34e5e3fa77759678f067ed78812e88c5 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 14:06:39 -0500 Subject: [PATCH 13/36] FIX: fetch on a background thread so the display never freezes Fetching (nearby flights + sequential route lookups + stale refreshes) ran synchronously on the render loop and could take ~30s, freezing the display on one frame the whole time. Move fetching to a daemon thread that refreshes the shared buffer on the refresh interval, while the render loop only renders and keeps cycling smoothly. The buffer is guarded by a lock held solely for fast mutations - network calls happen outside it, so the render thread never blocks on I/O. KeyboardInterrupt signals the fetch thread to stop before clearing the panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/app.py | 58 ++++++++++++++++++++++++++++++++++++---------- tests/test_main.py | 20 ++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/jetset/app.py b/src/jetset/app.py index e9f6f8c..2f5bfb2 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -1,4 +1,5 @@ import logging +import threading import time from typing import NamedTuple @@ -14,6 +15,9 @@ class App: + # How often the background fetch thread wakes to check if a refresh is due. + _FETCH_POLL_SECONDS = 1.0 + class Frame(NamedTuple): flight: Flight metric_page: int @@ -24,6 +28,11 @@ def __init__(self, config: AppConfig) -> None: self.frame = 0 self.last_fetch: float | None = None self.adapter: FlightAPI = _create_adapter(config) + # Fetching runs on a background thread so the render loop never blocks + # on the (multi-second) network round-trips. _lock guards the shared + # buffer; _stop signals the fetch thread to exit. + self._lock = threading.Lock() + self._stop = threading.Event() def _should_fetch(self) -> bool: now = time.time() @@ -33,18 +42,24 @@ def _should_fetch(self) -> bool: return False def _fetch(self): + # Network calls happen OUTSIDE the lock; the lock is held only for the + # fast buffer mutations, so the render thread is never blocked on I/O. flights = self.adapter.nearby_flights( self.config.home_lat, self.config.home_lon, self.config.range ) live_callsigns = {f.callsign for f in flights} - for f in flights: - self.buffer.push(f) + with self._lock: + for f in flights: + self.buffer.push(f) + stale = [f for f in self.buffer.flights if f.callsign not in live_callsigns] + + refreshed = [(f.callsign, self.adapter.refresh_flight(f)) for f in stale] - for existing in self.buffer.flights: - if existing.callsign not in live_callsigns: - self.buffer.replace(existing.callsign, self.adapter.refresh_flight(existing)) + with self._lock: + for callsign, updated in refreshed: + self.buffer.replace(callsign, updated) self.last_fetch = time.time() @@ -63,14 +78,29 @@ def _safe_fetch(self) -> None: logger.exception("Fetch cycle failed; keeping existing flights") self.last_fetch = time.time() + def _fetch_loop(self) -> None: + """Background loop: refresh flights when due until stopped. + + Runs on its own thread so the multi-second fetch never blocks the + render loop. Polls frequently but only fetches once the refresh + interval has elapsed. + """ + while not self._stop.is_set(): + if self._should_fetch(): + self._safe_fetch() + self._stop.wait(self._FETCH_POLL_SECONDS) + def _current_frame(self) -> Frame | None: - if not self.buffer.flights: + with self._lock: + flights = self.buffer.flights + + if not flights: return None - idx = self.frame // 4 % len(self.buffer.flights) + idx = self.frame // 4 % len(flights) metric_page = self.frame % 4 - return self.Frame(self.buffer.flights[idx], metric_page) + return self.Frame(flights[idx], metric_page) def _after_render(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: # This needs to be it's own method so that tests don't need to run the infinite loop method. @@ -91,19 +121,23 @@ def _render_loading(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: return self._after_render(matrix, canvas) def loop(self, matrix: RGBMatrix, canvas: Canvas): - # Show LOADING immediately while the first fetch runs + # Fetching runs on a background thread; this loop only renders, so the + # display keeps cycling smoothly while a fetch is in flight. canvas = self._render_loading(matrix, canvas) + fetch_thread = threading.Thread( + target=self._fetch_loop, name="jetset-fetch", daemon=True + ) + fetch_thread.start() + try: while True: - if self._should_fetch(): - self._safe_fetch() - if frame := self._current_frame(): canvas = self._render_frame(matrix, canvas, frame) else: canvas = self._render_loading(matrix, canvas) except KeyboardInterrupt: + self._stop.set() canvas.Clear() matrix.SwapOnVSync(canvas) diff --git a/tests/test_main.py b/tests/test_main.py index 3974312..71a6de8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -64,6 +64,26 @@ def test_swallows_fetch_errors_so_the_loop_survives(self) -> None: assert len(app.buffer) == 0 +class TestAppFetchLoop: + def test_runs_a_fetch_cycle_then_honors_stop(self) -> None: + from unittest.mock import patch + + from jetset.app import App + + app = App(AppConfig()) + + # One cycle: fetch is due so _safe_fetch runs, then the stop event is + # set during the inter-cycle wait so the loop exits (no infinite spin). + with ( + patch.object(app, "_should_fetch", return_value=True), + patch.object(app, "_safe_fetch") as mock_fetch, + patch.object(app._stop, "wait", side_effect=lambda *_a: app._stop.set()), + ): + app._fetch_loop() + + mock_fetch.assert_called_once() + + class TestAppCurrentFrame: def test_selects_flight_and_metric_page(self) -> None: from jetset.app import App From 23f2d89e9cb23b5c9e92b896631bf95fb2083727 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 14:14:00 -0500 Subject: [PATCH 14/36] CHORE: rename Pi run targets and run them locally on the Pi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename start / debug-start to run-pi / debug-pi (parallel to the local run / debug targets), and run them — plus setup-pi — directly instead of over SSH, since work now happens on the Pi itself. deploy still rsyncs from a dev machine. Update the make-target references in setup-pi.sh and HARDWARE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- HARDWARE.md | 2 +- Makefile | 14 +++++++------- scripts/setup-pi.sh | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/HARDWARE.md b/HARDWARE.md index dada3db..9510c84 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -111,4 +111,4 @@ When swapping in a replacement panel (reuse the same HAT + ribbon cable): 4. **Restore the palette:** in `renderer.render_flight_card`, switch the four rows back to `ORANGE` / `CYAN` / `GREEN` / `BLUE`, and set `hardware_rgb_sequence` to `"RGB"` (or whatever step 2 found). -5. **Live app:** `make start` — confirm flight cards render in full color. +5. **Live app:** `make run-pi` — confirm flight cards render in full color. diff --git a/Makefile b/Makefile index 70144b7..8ac029a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run debug test lint fixtures deploy setup-pi start debug-start +.PHONY: run debug test lint fixtures deploy setup-pi run-pi debug-pi # Local dev run: ## Run the emulator smoke test @@ -26,11 +26,11 @@ deploy: ## rsync code to Pi --exclude '*.pyc' --exclude 'wheels' \ . $(JETSET_USER)@$(JETSET_HOST):$(JETSET_PATH)/ -setup-pi: ## Setup Pi deps via SSH (clone, build, uv add) - cat scripts/setup-pi.sh | ssh $(JETSET_USER)@$(JETSET_HOST) "bash -s" +setup-pi: ## Install deps and build rpi-rgb-led-matrix (run on the Pi) + bash scripts/setup-pi.sh -start: ## Run on Pi via SSH (needs root for GPIO) - ssh -t $(JETSET_USER)@$(JETSET_HOST) "cd $(JETSET_PATH) && sudo -E env PATH=\\$PATH uv run python -m jetset" +run-pi: ## Run the app on the Pi (needs root for GPIO) + sudo -E env PATH="$$PATH" uv run python -m jetset -debug-start: ## Run on Pi via SSH with debug logging - ssh -t $(JETSET_USER)@$(JETSET_HOST) "cd $(JETSET_PATH) && sudo -E env PATH=\\$PATH JETSET_DEBUG=1 uv run python -m jetset" +debug-pi: ## Run the app on the Pi with debug logging + sudo -E env PATH="$$PATH" JETSET_DEBUG=1 uv run python -m jetset diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh index 3e962aa..6aa05fc 100755 --- a/scripts/setup-pi.sh +++ b/scripts/setup-pi.sh @@ -65,4 +65,4 @@ else echo "=== rgbmatrix wheel already built ===" fi -echo "=== Setup complete! Run \`make start\` ===" +echo "=== Setup complete! Run \`make run-pi\` ===" From 2d468fea740ad9a49563a10100df0630169e14a9 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 15:39:00 -0500 Subject: [PATCH 15/36] CHORE: add route-accuracy comparison tool scripts/route-compare.py pulls nearby flights through the normal pipeline and compares each callsign's route across adsbdb (ours), AirLabs, and hexdb.io, scored against AeroAPI as ground truth. AeroAPI is opt-in via --truth (it costs ~$0.05/call); the default run uses only free sources. Adds python-dotenv (dev) so keys load from a .env file. Used to measure route accuracy: adsbdb proved unreliable while AirLabs matched AeroAPI, motivating the route-source switch. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 6 +- scripts/route-compare.py | 203 +++++++++++++++++++++++++++++++++++++++ uv.lock | 11 +++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100755 scripts/route-compare.py diff --git a/pyproject.toml b/pyproject.toml index 5884009..32f6d30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,4 +35,8 @@ filterwarnings = [ ] [dependency-groups] -dev = ["pytest>=9.0.3", "ruff>=0.15.16"] +dev = [ + "pytest>=9.0.3", + "python-dotenv>=1.2.2", + "ruff>=0.15.16", +] diff --git a/scripts/route-compare.py b/scripts/route-compare.py new file mode 100755 index 0000000..40250c4 --- /dev/null +++ b/scripts/route-compare.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Compare route sources against AeroAPI (ground truth). + +Pulls nearby flights through the normal app pipeline (so the "ours" column is +exactly what the app would display), then for up to 5 callsigns queries: + - adsbdb (ours, free) - what the app shows today + - AirLabs (free) - candidate free source (1000 calls/month) + - AeroAPI (paid, truth) - authoritative, the column to trust + - hexdb.io (free) - reference cross-check + +AeroAPI costs money (~$0.05/call), so it is OPT-IN: by default this runs free +sources only. Pass --truth to add the AeroAPI ground-truth column and the two +✓/✗ scores (adsbdb and AirLabs vs AeroAPI). Hard-capped at 5 callsigns. + +Provide keys via the environment or a .env file: + AIRLABS_API_KEY (optional, free signup) AEROAPI_KEY (only for --truth) +Run: + uv run python scripts/route-compare.py # free sources only + uv run python scripts/route-compare.py --truth # adds paid AeroAPI + +Codes: adsbdb / AirLabs / AeroAPI report IATA (IAH); hexdb.io reports ICAO. +""" + +import os +import sys + +import requests + +try: + from dotenv import load_dotenv +except ImportError: # dotenv is a convenience; env vars work without it + load_dotenv = None + +from jetset.config import AppConfig +from jetset.fetcher import AdsbLolAdapter +from jetset.http import RequestsAPI + +MAX_CALLSIGNS = 5 # AeroAPI is ~$0.05/call — cap the cost per run +AEROAPI_COST = 0.05 +TIMEOUT = 10 +AEROAPI_BASE = "https://aeroapi.flightaware.com/aeroapi" +AIRLABS_BASE = "https://airlabs.co/api/v9" +HEXDB_BASE = "https://hexdb.io/api/v1" +MISSING = "—" +NO_KEY = "no key" + + +def our_route(flight) -> str: + if flight.route: + return f"{flight.route.origin.iata_code}→{flight.route.destination.iata_code}" + return MISSING + + +def aeroapi_route(api: RequestsAPI, callsign: str) -> str: + """Authoritative origin→dest (IATA) for the in-progress flight, if any.""" + try: + resp = api.get(f"/flights/{callsign}", timeout=TIMEOUT) + resp.raise_for_status() + flights = resp.json().get("flights", []) + if not flights: + return MISSING + + # Prefer the flight currently in the air; fall back to most recent. + airborne = [ + f for f in flights + if f.get("progress_percent") not in (None, 0, 100) and not f.get("cancelled") + ] + flight = (airborne or flights)[0] + + def code(end: str) -> str: + obj = flight.get(end) or {} + return obj.get("code_iata") or obj.get("code_icao") or obj.get("code") or "?" + + return f"{code('origin')}→{code('destination')}" + except (requests.RequestException, ValueError) as e: + return f"error: {e}" + + +def airlabs_route(api: RequestsAPI, callsign: str, key: str) -> str: + """Origin→dest (IATA) from AirLabs' real-time flights data, if available.""" + try: + resp = api.get( + "/flights", params={"flight_icao": callsign, "api_key": key}, timeout=TIMEOUT + ) + resp.raise_for_status() + body = resp.json() + if isinstance(body, dict) and body.get("error"): + return f"error: {body['error'].get('code', 'api error')}" + flights = body.get("response") or [] + flight = flights[0] if isinstance(flights, list) and flights else None + if not isinstance(flight, dict): + return MISSING + dep = flight.get("dep_iata") or flight.get("dep_icao") + arr = flight.get("arr_iata") or flight.get("arr_icao") + if not dep and not arr: + return MISSING + return f"{dep or '?'}→{arr or '?'}" + except (requests.RequestException, ValueError) as e: + return f"error: {e}" + + +def hexdb_route(api: RequestsAPI, callsign: str) -> str: + """Origin→dest (ICAO) from hexdb.io's crowd-sourced route data.""" + try: + resp = api.get(f"/route/icao/{callsign}", timeout=TIMEOUT) + if resp.status_code == 404: + return MISSING + resp.raise_for_status() + route = resp.json().get("route", "") + legs = route.split("-") if route else [] + if len(legs) < 2: + return MISSING + return f"{legs[0]}→{legs[-1]}" + except (requests.RequestException, ValueError) as e: + return f"error: {e}" + + +def agree(candidate: str, truth: str) -> str: + """Score a candidate route against the ground-truth route.""" + if truth in (MISSING, NO_KEY) or truth.startswith("error"): + return "?" # no ground truth to compare against + if candidate in (MISSING, NO_KEY) or candidate.startswith("error"): + return "·" # candidate offered no route + return "✓" if candidate == truth else "✗" + + +def render_table(rows: list[tuple[str, ...]], headers: tuple[str, ...]) -> None: + widths = [max(len(str(r[i])) for r in [*rows, headers]) for i in range(len(headers))] + + def line(row: tuple[str, ...]) -> str: + return " ".join(str(c).ljust(widths[i]) for i, c in enumerate(row)) + + print(line(headers)) + print(" ".join("-" * w for w in widths)) + for row in rows: + print(line(row)) + + +def main() -> None: + if load_dotenv is not None: + load_dotenv() # load API keys from a .env file if present + + # AeroAPI costs money, so it is opt-in only. Default run uses free sources. + use_truth = "--truth" in sys.argv + truth_key = os.environ.get("AEROAPI_KEY") + if use_truth and not truth_key: + print("--truth needs AEROAPI_KEY (AeroAPI is ~$0.05/call).", file=sys.stderr) + sys.exit(1) + + airlabs_key = os.environ.get("AIRLABS_API_KEY") + if not airlabs_key: + print("(AIRLABS_API_KEY not set — AirLabs column will be blank)", file=sys.stderr) + + config = AppConfig.load(os.environ.get("JETSET_CONFIG")) + adapter = AdsbLolAdapter() + flights = adapter.nearby_flights(config.home_lat, config.home_lon, config.range) + flights = [f for f in flights if f.callsign][:MAX_CALLSIGNS] + + if not flights: + print("No nearby flights with routes right now — try again later.") + return + + if use_truth: + cost = len(flights) * AEROAPI_COST + print(f"Comparing {len(flights)} callsign(s) — AeroAPI cost ~${cost:.2f}\n") + else: + print( + f"{len(flights)} callsign(s), free sources only — " + "pass --truth to add AeroAPI (~$0.05/callsign)\n" + ) + + airlabs = RequestsAPI(base_url=AIRLABS_BASE) + hexdb = RequestsAPI(base_url=HEXDB_BASE) + aero = None + if use_truth: + aero = RequestsAPI(base_url=AEROAPI_BASE, headers={"x-apikey": truth_key}) + + rows = [] + for flight in flights: + ours = our_route(flight) + air = airlabs_route(airlabs, flight.callsign, airlabs_key) if airlabs_key else NO_KEY + hex_route = hexdb_route(hexdb, flight.callsign) + if use_truth: + truth = aeroapi_route(aero, flight.callsign) + rows.append( + (flight.callsign, ours, air, truth, hex_route, + agree(ours, truth), agree(air, truth)) + ) + else: + rows.append((flight.callsign, ours, air, hex_route)) + + if use_truth: + render_table( + rows, ("Callsign", "adsbdb", "AirLabs", "AeroAPI*", "hexdb.io", "ours✓", "AirLabs✓") + ) + print("\n* AeroAPI = ground truth. ✓ correct ✗ wrong · no route ? no truth") + else: + render_table(rows, ("Callsign", "adsbdb", "AirLabs", "hexdb.io")) + print("Codes: adsbdb/AirLabs/AeroAPI = IATA; hexdb.io = ICAO.") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 5100bbc..d81ca4d 100644 --- a/uv.lock +++ b/uv.lock @@ -101,6 +101,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "python-dotenv" }, { name = "ruff" }, ] @@ -114,6 +115,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.3" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "ruff", specifier = ">=0.15.16" }, ] @@ -222,6 +224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From c1c4c89c47e34d943335ca0d27727a4b1070620a Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 17:40:50 -0500 Subject: [PATCH 16/36] FEATURE: switch flight data to a single AirLabs source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the adsb.lol + adsbdb + hexdb + plausibility-filter stack with one AirLabsAdapter: a single /flights?bbox= call returns every nearby flight with positions, metrics, AND route, so no route lookup, cross-track plausibility check, or second source is needed (adsbdb measured ~20% correct; its great-circle filter had a bug — see README's data-source history). - App refreshes every 45 min (one bbox call ≈ 960/month, within the free tier) and replaces the buffer wholesale; the display slides a window of 5 across all captured flights so fresh aircraft trickle in between refreshes. - FlightBuffer is now a plain list (capture-all, no bounded deque/push/dedup). - Retire the old pipeline: delete geo.py, AdsbLolAdapter, RouteCache, and the FlightRoute geo helpers; repoint route-compare and save_fixtures to AirLabs. - Display: show the full altitude (e.g. "18238 ft") and a cardinal heading (e.g. "270° W") to distinguish track from temperature. - AIRLABS_API_KEY loads from the environment or a .env file. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 24 + scripts/route-compare.py | 73 +- scripts/save_fixtures.py | 4 +- src/jetset/__main__.py | 8 + src/jetset/app.py | 45 +- src/jetset/config.py | 4 +- src/jetset/display.py | 12 +- src/jetset/fetcher.py | 223 +--- src/jetset/geo.py | 91 -- src/jetset/models.py | 83 +- tests/fixtures/adsblol_response.json | 1539 -------------------------- tests/fixtures/airlabs_response.json | 55 + tests/test_cache.py | 105 -- tests/test_config.py | 4 +- tests/test_display.py | 15 +- tests/test_fetcher.py | 517 ++------- tests/test_geo.py | 56 - tests/test_main.py | 74 +- tests/test_models.py | 45 +- tests/test_save_fixtures.py | 8 +- 20 files changed, 341 insertions(+), 2644 deletions(-) delete mode 100644 src/jetset/geo.py delete mode 100644 tests/fixtures/adsblol_response.json create mode 100644 tests/fixtures/airlabs_response.json delete mode 100644 tests/test_cache.py delete mode 100644 tests/test_geo.py diff --git a/README.md b/README.md index 8f683ca..80e4310 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,27 @@ hardware: row_address_type: 0 # 0 for standard panels; other values for some 1/16-scan panels (e.g. 3) rgb_sequence: RBG # physical subpixel order; "RGB" for a standard panel, "RBG" for the current one ``` + +Flights come from a single source, **AirLabs** (`api_source: airlabs`): one +`/flights?bbox=` call returns every nearby flight with positions, metrics, and +route. `AIRLABS_API_KEY` is read from the environment or a `.env` file. The +free tier allows ~1000 requests/month, so the app refreshes every 45 minutes +(`refresh: 2700`) — one call per refresh — and the display slides a window +across all captured flights so fresh aircraft trickle in between refreshes. + +## Data source history + +How we got to a single AirLabs source (kept here so the decisions aren't relearned): + +- **adsb.lol** (free) gave positions/metrics; **adsbdb** (free) gave routes by + callsign; a **plausibility filter** (great-circle bearing + cross-track + distance, in `geo.py`) tried to reject wrong routes; **hexdb.io** was a free + cross-check. +- Measuring against **FlightAware AeroAPI** (authoritative, `scripts/route-compare.py`) + showed adsbdb routes were only ~20% correct, and the plausibility filter had a + great-circle bug (a route's *extended* great circle can pass near the receiver, + so far-away routes slipped through). hexdb was sparse and years-stale. +- **AirLabs** matched AeroAPI in testing and returns positions, metrics, and + route in one bbox call — so it replaced adsb.lol + adsbdb + hexdb + the + plausibility filter. `AdsbLolAdapter` and `geo.py` remain only for the + `route-compare` diagnostic and are slated for removal. diff --git a/scripts/route-compare.py b/scripts/route-compare.py index 40250c4..d128535 100755 --- a/scripts/route-compare.py +++ b/scripts/route-compare.py @@ -1,24 +1,22 @@ #!/usr/bin/env python3 -"""Compare route sources against AeroAPI (ground truth). +"""Validate the app's routes (AirLabs) against AeroAPI ground truth. -Pulls nearby flights through the normal app pipeline (so the "ours" column is -exactly what the app would display), then for up to 5 callsigns queries: - - adsbdb (ours, free) - what the app shows today - - AirLabs (free) - candidate free source (1000 calls/month) +Pulls nearby flights through the app's real pipeline (AirLabsAdapter), then for +up to 5 callsigns compares each route to: - AeroAPI (paid, truth) - authoritative, the column to trust - hexdb.io (free) - reference cross-check AeroAPI costs money (~$0.05/call), so it is OPT-IN: by default this runs free -sources only. Pass --truth to add the AeroAPI ground-truth column and the two -✓/✗ scores (adsbdb and AirLabs vs AeroAPI). Hard-capped at 5 callsigns. +sources only. Pass --truth to add the AeroAPI ground-truth column and the ✓/✗ +score. Hard-capped at 5 callsigns. Provide keys via the environment or a .env file: - AIRLABS_API_KEY (optional, free signup) AEROAPI_KEY (only for --truth) + AIRLABS_API_KEY (required, free) AEROAPI_KEY (only for --truth) Run: uv run python scripts/route-compare.py # free sources only uv run python scripts/route-compare.py --truth # adds paid AeroAPI -Codes: adsbdb / AirLabs / AeroAPI report IATA (IAH); hexdb.io reports ICAO. +Codes: AirLabs / AeroAPI report IATA (IAH); hexdb.io reports ICAO. """ import os @@ -32,17 +30,15 @@ load_dotenv = None from jetset.config import AppConfig -from jetset.fetcher import AdsbLolAdapter +from jetset.fetcher import AirLabsAdapter from jetset.http import RequestsAPI MAX_CALLSIGNS = 5 # AeroAPI is ~$0.05/call — cap the cost per run AEROAPI_COST = 0.05 TIMEOUT = 10 AEROAPI_BASE = "https://aeroapi.flightaware.com/aeroapi" -AIRLABS_BASE = "https://airlabs.co/api/v9" HEXDB_BASE = "https://hexdb.io/api/v1" MISSING = "—" -NO_KEY = "no key" def our_route(flight) -> str: @@ -76,29 +72,6 @@ def code(end: str) -> str: return f"error: {e}" -def airlabs_route(api: RequestsAPI, callsign: str, key: str) -> str: - """Origin→dest (IATA) from AirLabs' real-time flights data, if available.""" - try: - resp = api.get( - "/flights", params={"flight_icao": callsign, "api_key": key}, timeout=TIMEOUT - ) - resp.raise_for_status() - body = resp.json() - if isinstance(body, dict) and body.get("error"): - return f"error: {body['error'].get('code', 'api error')}" - flights = body.get("response") or [] - flight = flights[0] if isinstance(flights, list) and flights else None - if not isinstance(flight, dict): - return MISSING - dep = flight.get("dep_iata") or flight.get("dep_icao") - arr = flight.get("arr_iata") or flight.get("arr_icao") - if not dep and not arr: - return MISSING - return f"{dep or '?'}→{arr or '?'}" - except (requests.RequestException, ValueError) as e: - return f"error: {e}" - - def hexdb_route(api: RequestsAPI, callsign: str) -> str: """Origin→dest (ICAO) from hexdb.io's crowd-sourced route data.""" try: @@ -117,9 +90,9 @@ def hexdb_route(api: RequestsAPI, callsign: str) -> str: def agree(candidate: str, truth: str) -> str: """Score a candidate route against the ground-truth route.""" - if truth in (MISSING, NO_KEY) or truth.startswith("error"): + if truth == MISSING or truth.startswith("error"): return "?" # no ground truth to compare against - if candidate in (MISSING, NO_KEY) or candidate.startswith("error"): + if candidate == MISSING or candidate.startswith("error"): return "·" # candidate offered no route return "✓" if candidate == truth else "✗" @@ -147,17 +120,12 @@ def main() -> None: print("--truth needs AEROAPI_KEY (AeroAPI is ~$0.05/call).", file=sys.stderr) sys.exit(1) - airlabs_key = os.environ.get("AIRLABS_API_KEY") - if not airlabs_key: - print("(AIRLABS_API_KEY not set — AirLabs column will be blank)", file=sys.stderr) - config = AppConfig.load(os.environ.get("JETSET_CONFIG")) - adapter = AdsbLolAdapter() - flights = adapter.nearby_flights(config.home_lat, config.home_lon, config.range) + flights = AirLabsAdapter().nearby_flights(config.home_lat, config.home_lon, config.range) flights = [f for f in flights if f.callsign][:MAX_CALLSIGNS] if not flights: - print("No nearby flights with routes right now — try again later.") + print("No nearby flights (check AIRLABS_API_KEY) — try again later.") return if use_truth: @@ -169,7 +137,6 @@ def main() -> None: "pass --truth to add AeroAPI (~$0.05/callsign)\n" ) - airlabs = RequestsAPI(base_url=AIRLABS_BASE) hexdb = RequestsAPI(base_url=HEXDB_BASE) aero = None if use_truth: @@ -178,25 +145,19 @@ def main() -> None: rows = [] for flight in flights: ours = our_route(flight) - air = airlabs_route(airlabs, flight.callsign, airlabs_key) if airlabs_key else NO_KEY hex_route = hexdb_route(hexdb, flight.callsign) if use_truth: truth = aeroapi_route(aero, flight.callsign) - rows.append( - (flight.callsign, ours, air, truth, hex_route, - agree(ours, truth), agree(air, truth)) - ) + rows.append((flight.callsign, ours, truth, hex_route, agree(ours, truth))) else: - rows.append((flight.callsign, ours, air, hex_route)) + rows.append((flight.callsign, ours, hex_route)) if use_truth: - render_table( - rows, ("Callsign", "adsbdb", "AirLabs", "AeroAPI*", "hexdb.io", "ours✓", "AirLabs✓") - ) + render_table(rows, ("Callsign", "ours (AirLabs)", "AeroAPI*", "hexdb.io", "ours✓")) print("\n* AeroAPI = ground truth. ✓ correct ✗ wrong · no route ? no truth") else: - render_table(rows, ("Callsign", "adsbdb", "AirLabs", "hexdb.io")) - print("Codes: adsbdb/AirLabs/AeroAPI = IATA; hexdb.io = ICAO.") + render_table(rows, ("Callsign", "ours (AirLabs)", "hexdb.io")) + print("Codes: AirLabs/AeroAPI = IATA; hexdb.io = ICAO.") if __name__ == "__main__": diff --git a/scripts/save_fixtures.py b/scripts/save_fixtures.py index 20ecbb7..26a1f5a 100644 --- a/scripts/save_fixtures.py +++ b/scripts/save_fixtures.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from jetset.config import AppConfig -from jetset.fetcher import AdsbLolAdapter +from jetset.fetcher import AirLabsAdapter FIXTURES_DIR = "tests/fixtures" @@ -62,7 +62,7 @@ def save(self, config: AppConfig) -> None: FIXTURES: list[type] = [ - AdsbLolAdapter, + AirLabsAdapter, ] diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index dbbe5bb..1f7fb0a 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -27,6 +27,14 @@ def _configure_logging() -> None: def main() -> None: _configure_logging() + # Load AIRLABS_API_KEY (and friends) from a .env file if present. + try: + from dotenv import load_dotenv + + load_dotenv() + except ImportError: + pass + config = AppConfig.load(os.environ.get("JETSET_CONFIG")) from jetset.backend import IS_HARDWARE, RGBMatrix, RGBMatrixOptions diff --git a/src/jetset/app.py b/src/jetset/app.py index 2f5bfb2..d68b894 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -7,7 +7,7 @@ from RGBMatrixEmulator.emulation.matrix import RGBMatrix from jetset.config import AppConfig -from jetset.fetcher import AdsbLolAdapter, FlightAPI +from jetset.fetcher import AirLabsAdapter, FlightAPI from jetset.models import Flight, FlightBuffer from jetset.renderer import render_flight_card, render_loading @@ -17,6 +17,9 @@ class App: # How often the background fetch thread wakes to check if a refresh is due. _FETCH_POLL_SECONDS = 1.0 + # Display rotates a sliding window of this many flights; 4 metric pages each. + WINDOW_SIZE = 5 + PAGES_PER_FLIGHT = 4 class Frame(NamedTuple): flight: Flight @@ -42,24 +45,15 @@ def _should_fetch(self) -> bool: return False def _fetch(self): - # Network calls happen OUTSIDE the lock; the lock is held only for the - # fast buffer mutations, so the render thread is never blocked on I/O. + # One AirLabs fetch captures all nearby flights (with routes + metrics); + # replace the buffer wholesale. The display then slides a window across + # them until the next refresh. Network happens outside the lock. flights = self.adapter.nearby_flights( self.config.home_lat, self.config.home_lon, self.config.range ) - live_callsigns = {f.callsign for f in flights} - with self._lock: - for f in flights: - self.buffer.push(f) - stale = [f for f in self.buffer.flights if f.callsign not in live_callsigns] - - refreshed = [(f.callsign, self.adapter.refresh_flight(f)) for f in stale] - - with self._lock: - for callsign, updated in refreshed: - self.buffer.replace(callsign, updated) + self.buffer.set_all(flights) self.last_fetch = time.time() @@ -94,13 +88,24 @@ def _current_frame(self) -> Frame | None: with self._lock: flights = self.buffer.flights - if not flights: + n = len(flights) + if n == 0: return None - idx = self.frame // 4 % len(flights) - metric_page = self.frame % 4 + window = min(self.WINDOW_SIZE, n) + # Slide the window across all captured flights once per refresh interval + # so fresh flights trickle into the rotation between (infrequent) fetches. + # The slide cadence scales with the catch size (refresh / N). + slide_interval = self.config.refresh / n + since = self.last_fetch if self.last_fetch is not None else time.time() + elapsed = time.time() - since + window_start = int(elapsed / slide_interval) % n if slide_interval else 0 + + # Within the window, rotate one flight at a time through its metric pages. + flight_in_window = (self.frame // self.PAGES_PER_FLIGHT) % window + metric_page = self.frame % self.PAGES_PER_FLIGHT - return self.Frame(flights[idx], metric_page) + return self.Frame(flights[(window_start + flight_in_window) % n], metric_page) def _after_render(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: # This needs to be it's own method so that tests don't need to run the infinite loop method. @@ -143,6 +148,6 @@ def loop(self, matrix: RGBMatrix, canvas: Canvas): def _create_adapter(config: AppConfig): - if config.api_source == "adsblol": - return AdsbLolAdapter() + if config.api_source == "airlabs": + return AirLabsAdapter() raise ValueError(f"Unknown api_source: {config.api_source}") diff --git a/src/jetset/config.py b/src/jetset/config.py index ca69a52..ae1d412 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -10,8 +10,8 @@ class AppConfig: home_lon: float = -95.3416 range: int = 200 pause: int = 2 - refresh: int = 60 - api_source: str = "adsblol" + refresh: int = 2700 # 45 min — one AirLabs bbox call per refresh ≈ 960/month + api_source: str = "airlabs" # Physical LED panel tuning (ignored by the emulator). panel_type is empty # by default — the P2.5 64x32 panel needs no chip-specific init sequence. # Set it (e.g. "FM6126A") only if a panel comes up garbled without one. diff --git a/src/jetset/display.py b/src/jetset/display.py index f75b229..cb62982 100644 --- a/src/jetset/display.py +++ b/src/jetset/display.py @@ -2,6 +2,13 @@ from jetset.models import Flight +_COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + + +def _cardinal(track: float) -> str: + """Nearest 8-point compass direction for a heading in degrees.""" + return _COMPASS[int(track / 45 + 0.5) % 8] + def loading_label(page: int = 0) -> str: if page == 1: @@ -46,8 +53,7 @@ def metrics_label(flight: Flight, page: int = 0) -> str: """ data = "" if page == 0 and flight.altitude: - formatted_altitude = f"{flight.altitude // 1000}K" - data += f"{formatted_altitude} ft" + data += f"{flight.altitude} ft" elif page == 1 and flight.speed: data += f"{flight.speed} kt" elif page == 2 and flight.vertical_rate is not None: @@ -61,6 +67,6 @@ def metrics_label(flight: Flight, page: int = 0) -> str: formatted_rate += "\u25bc" data += f"{formatted_rate} ft/m" elif page == 3 and flight.track is not None: - data += f"{int(flight.track)}\u00b0" + data += f"{int(flight.track)}\u00b0 {_cardinal(flight.track)}" return data diff --git a/src/jetset/fetcher.py b/src/jetset/fetcher.py index f1a76f2..57e2699 100644 --- a/src/jetset/fetcher.py +++ b/src/jetset/fetcher.py @@ -1,202 +1,97 @@ import logging -import time +import math +import os from collections.abc import Sequence -from dataclasses import dataclass, replace -from typing import Any, Protocol +from typing import Protocol import requests from jetset.http import RequestsAPI -from jetset.models import Airport, Flight, FlightRoute, Position +from jetset.models import Airport, Flight, FlightRoute -logger = logging.getLogger(__name__) - - -class RouteCache: - @dataclass(frozen=True) - class CacheEntry: - timestamp: float - route: FlightRoute - - def __init__(self, ttl: int = 900) -> None: - self.cache: dict[str, self.CacheEntry] = {} - self.ttl = ttl - - def get(self, callsign: str) -> FlightRoute | None: - entry = self.cache.get(callsign) - - if entry: - now = time.time() - if now - entry.timestamp <= self.ttl: - return entry.route +ROUTE_TIMEOUT = 10 # seconds; AirLabs lookups must not hang the fetch +NM_PER_DEGREE = 60.0 - def put(self, callsign: str, route: FlightRoute) -> None: - self.cache[callsign] = self.CacheEntry(time.time(), route) - - def clear(self) -> None: - self.cache = {} +logger = logging.getLogger(__name__) class FlightAPI(Protocol): def nearby_flights( self, lat: float, lon: float, range: int, raw: bool = False ) -> Sequence[Flight]: ... - def refresh_flight(self, flight: Flight) -> Flight: ... -class AdsbLolAdapter(FlightAPI): - def __init__(self) -> None: - self._flight_api = RequestsAPI("https://api.adsb.lol/v2") - self._route_api = RequestsAPI("https://api.adsbdb.com/v0") - self._route_cache = RouteCache() +def _meters_to_feet(meters: float | None) -> int | None: + return round(meters * 3.28084) if meters is not None else None - @staticmethod - def _parse_airport(data: dict) -> Airport | None: - code = data.get("iata_code") - lat = data.get("latitude") - lon = data.get("longitude") - if code and lat is not None and lon is not None: - return Airport(code, Position(lat, lon)) +def _kmh_to_knots(kmh: float | None) -> int | None: + return round(kmh / 1.852) if kmh else None - def _fetch_route(self, callsign: str) -> FlightRoute | None: - try: - with self._route_api as api: - logger.debug("Fetching route for callsign %s", callsign) - data = api.get(f"/callsign/{callsign}").json() - if not data: - return None - route_resp = data.get("response", {}) - if not isinstance(route_resp, dict): - return None +def _ms_to_ft_per_min(ms: float | None) -> int | None: + return round(ms * 196.850394) if ms is not None else None - flight_route = route_resp.get("flightroute") - if not flight_route: - return None - route = route_resp.get("flightroute", {}) - origin = self._parse_airport(route.get("origin", {})) - destination = self._parse_airport(route.get("destination", {})) +class AirLabsAdapter(FlightAPI): + """Single data source for the display. - if origin and destination: - return FlightRoute(origin, destination) + One AirLabs ``/flights?bbox=`` call returns every nearby flight with its + metrics AND route, so it replaces the former adsb.lol + adsbdb + hexdb + + plausibility-filter stack (see README's "Data source history"). The + 1000-requests/month free tier is honoured by the app's long refresh + interval — one bbox call per refresh. + """ - except (requests.exceptions.RequestException, ValueError) as e: - logger.warning("Error fetching route for callsign %s: %s", callsign, e) - - def _enrich_routes(self, flight_data: list[Any], max_flights: int = 5, max_xtd: float = 100): - enriched = [] - - for d in flight_data: - if len(enriched) >= max_flights: - break - callsign = d.get("flight", "").rstrip() - if not callsign: - continue - - route = self._route_cache.get(callsign) - if route is None: - aircraft_track = d.get("track", 0) - aircraft_position = Position(d.get("lat", 0), d.get("lon", 0)) - route = self._fetch_route(callsign) - if route: - xtd = route.cross_track_distance(aircraft_position) - diff = abs(aircraft_track - route.bearing) - bearing_diff = min(diff, 360 - diff) - plausible = bearing_diff <= 60 and xtd <= max_xtd - logger.debug( - "Route %s→%s: track=%s bearing=%s diff=%s° xtd=%s NM " - "max_xtd=%s → %s", - route.origin.iata_code, route.destination.iata_code, - aircraft_track, route.bearing, - round(bearing_diff, 1), round(xtd, 1), max_xtd, - "ACCEPTED" if plausible else "REJECTED", - ) - if plausible: - self._route_cache.put(callsign, route) - else: - route = None - - if route is not None: - enriched.append({**d, "route": route}) - - return enriched + def __init__(self) -> None: + self._api = RequestsAPI("https://airlabs.co/api/v9") + self._api_key = os.environ.get("AIRLABS_API_KEY") @staticmethod - def _is_airborne(aircraft: dict) -> bool: - alt = aircraft.get("alt_baro") - if alt is None or alt == "ground": - return False - return int(alt) > 0 + def _bbox(lat: float, lon: float, range_nm: float) -> str: + """AirLabs bbox 'min_lat,min_lon,max_lat,max_lon' covering the range.""" + half_lat = range_nm / NM_PER_DEGREE + half_lon = range_nm / (NM_PER_DEGREE * max(math.cos(math.radians(lat)), 0.1)) + return f"{lat - half_lat},{lon - half_lon},{lat + half_lat},{lon + half_lon}" @staticmethod - def json_to_flight(data: dict) -> Flight: - route = data.get("route") - callsign = data.get("flight", "").rstrip() - aircraft = data.get("t") - altitude = data.get("alt_baro") - speed = int(data.get("gs", 0)) if data.get("gs") else None - track = data.get("track") - vrate = data.get("baro_rate") - + def to_flight(data: dict) -> Flight: + """Map an AirLabs flight (metric units) to a Flight (feet/knots/ft-min).""" + dep, arr = data.get("dep_iata"), data.get("arr_iata") + route = FlightRoute(Airport(dep), Airport(arr)) if dep and arr else None return Flight( - callsign=callsign, + callsign=(data.get("flight_icao") or "").strip(), + aircraft=data.get("aircraft_icao"), route=route, - aircraft=aircraft, - altitude=altitude, - speed=speed, - track=track, - vertical_rate=vrate, + altitude=_meters_to_feet(data.get("alt")), + speed=_kmh_to_knots(data.get("speed")), + track=data.get("dir"), + vertical_rate=_ms_to_ft_per_min(data.get("v_speed")), ) def nearby_flights( self, lat: float, lon: float, range: int, raw: bool = False ) -> Sequence[Flight]: - flights = [] + if not self._api_key: + logger.warning("AIRLABS_API_KEY not set; cannot fetch flights") + return [] range_nm = range / 1.852 # km -> nautical miles try: - with self._flight_api as api: - logger.debug( - "Fetching nearby flights at (%.4f, %.4f) within %d NM", lat, lon, range_nm - ) - if data := api.get(f"/point/{lat}/{lon}/{range_nm}").json(): - airborne = [a for a in data["ac"] if self._is_airborne(a)] - enriched = self._enrich_routes(airborne, max_flights=5, max_xtd=range_nm) - - if raw: - return enriched - elif enriched: - return [self.json_to_flight(f) for f in enriched] - - except (requests.exceptions.RequestException, ValueError) as e: - logger.warning("Error fetching nearby flights: %s", e) - - return flights - - def refresh_flight(self, flight: Flight) -> Flight: - """Update a flight's metrics from adsb.lol, preserving its route. - - The route is kept from the original Flight because it's static for a - given callsign — re-fetching it would waste API calls and risk the - plausibility check failing (the aircraft may have flown out of range - or be circling / landing). - - If the API call fails or the aircraft is no longer airborne, the - original flight is returned unchanged so the display can show stale - data rather than nothing. - """ - try: - with self._flight_api as api: - logger.debug(f"Fetching flight with callsign {flight.callsign}") - if data := api.get(f"/callsign/{flight.callsign}").json(): - aircraft_list = data.get("ac", []) - if aircraft_list: - updated = self.json_to_flight(aircraft_list[0]) - return replace(updated, route=flight.route) - - return flight - + with self._api as api: + logger.debug("Fetching AirLabs flights near (%.4f, %.4f)", lat, lon) + body = api.get( + "/flights", + params={"bbox": self._bbox(lat, lon, range_nm), "api_key": self._api_key}, + timeout=ROUTE_TIMEOUT, + ).json() + if not isinstance(body, dict) or body.get("error"): + detail = body.get("error") if isinstance(body, dict) else body + logger.warning("AirLabs returned no usable data: %s", detail) + return [] + raw_flights = [f for f in (body.get("response") or []) if f.get("flight_icao")] + if raw: + return raw_flights + return [self.to_flight(f) for f in raw_flights] except (requests.exceptions.RequestException, ValueError) as e: - logger.warning("Error fetching flight by callsign: %s", e) - return flight + logger.warning("Error fetching flights from AirLabs: %s", e) + return [] diff --git a/src/jetset/geo.py b/src/jetset/geo.py deleted file mode 100644 index 7fb56f8..0000000 --- a/src/jetset/geo.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Geospatial calculations for aviation (great-circle bearing, distance, -cross-track distance). - -All functions operate on Position dataclass instances and return results -in nautical miles or degrees. -""" - -import math -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Position: - latitude: float - longitude: float - - -EARTH_RADIUS = 3440.065 # nautical miles - - -def bearing(origin: Position, destination: Position) -> float: - """Calculate initial great-circle bearing from origin to destination. - - Aviation formula: - θ = atan2(sin(Δlon)·cos(lat2), cos(lat1)·sin(lat2) − sin(lat1)·cos(lat2)·cos(Δlon)) - - Returns bearing in degrees (0–360), where 0 = north, 90 = east, etc. - """ - lat1_rad = math.radians(origin.latitude) - lat2_rad = math.radians(destination.latitude) - d_lon_rad = math.radians(destination.longitude - origin.longitude) - - x = math.sin(d_lon_rad) * math.cos(lat2_rad) - y = math.cos(lat1_rad) * math.sin(lat2_rad) - math.sin(lat1_rad) * math.cos( - lat2_rad - ) * math.cos(d_lon_rad) - - angle_rad = math.atan2(x, y) - result = math.degrees(angle_rad) - return (result + 360) % 360 - - -def distance(origin: Position, destination: Position) -> float: - """Great-circle distance between two positions in nautical miles. - - Uses the haversine formula: - a = sin²(Δφ/2) + cos(φ1)·cos(φ2)·sin²(Δλ/2) - c = 2·atan2(√a, √(1−a)) - d = c · R - - where R = 3440 NM (Earth's mean radius). - """ - lat1_rad = math.radians(origin.latitude) - lat2_rad = math.radians(destination.latitude) - d_lat_rad = math.radians(destination.latitude - origin.latitude) - d_lon_rad = math.radians(destination.longitude - origin.longitude) - - haversine = ( - math.sin(d_lat_rad / 2) ** 2 - + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(d_lon_rad / 2) ** 2 - ) - central_angle_rad = 2 * math.atan2(math.sqrt(haversine), math.sqrt(1 - haversine)) - return central_angle_rad * EARTH_RADIUS - - -def cross_track_distance( - route_origin: Position, route_destination: Position, aircraft_position: Position -) -> float: - """Perpendicular distance from an aircraft to a great-circle route. - - Given a route (origin → destination) and an aircraft position, returns - how far off the great-circle path the aircraft is, in nautical miles. - - Uses the cross-track formula: - xtd = asin(sin(d_ac / R) · sin(θ_ac − θ_route)) · R - - where d_ac is distance from route origin to aircraft and θ_ac is the - bearing from route origin to aircraft. - - Returns a non-negative distance in NM. - """ - d_ac = distance(route_origin, aircraft_position) - # Bearing from route origin to aircraft - bearing_ac = bearing(route_origin, aircraft_position) - # Bearing of the route itself - bearing_route = bearing(route_origin, route_destination) - - xtd = math.asin( - math.sin(d_ac / EARTH_RADIUS) * math.sin(math.radians(bearing_ac - bearing_route)) - ) - return abs(xtd * EARTH_RADIUS) diff --git a/src/jetset/models.py b/src/jetset/models.py index e5e825e..8b10644 100644 --- a/src/jetset/models.py +++ b/src/jetset/models.py @@ -1,10 +1,6 @@ import logging -from collections import deque from dataclasses import dataclass -from jetset import geo -from jetset.geo import Position - logger = logging.getLogger(__name__) @@ -43,8 +39,15 @@ def flight_number(self) -> str: class FlightBuffer: - def __init__(self, maxlen=5): - self._flights: deque[Flight] = deque(maxlen=maxlen) + """Holds the flights captured by the most recent fetch. + + A fetch captures all nearby flights at once and replaces the buffer + wholesale, so a plain list is all that's needed — no bounded deque, + incremental push, or dedup. The display slides a window across the flights. + """ + + def __init__(self) -> None: + self._flights: list[Flight] = [] def __len__(self) -> int: return len(self._flights) @@ -53,24 +56,14 @@ def __len__(self) -> int: def flights(self) -> list[Flight]: return list(self._flights) - def push(self, flight: Flight) -> None: - for existing in self._flights: - if existing.callsign == flight.callsign: - return - - self._flights.append(flight) - - def replace(self, callsign: str, flight: Flight) -> None: - for i, existing in enumerate(self._flights): - if existing.callsign == callsign: - self._flights[i] = flight - return + def set_all(self, flights) -> None: + """Replace the buffer with a fresh batch of flights.""" + self._flights = list(flights) @dataclass(frozen=True) class Airport: iata_code: str - position: Position @dataclass(frozen=True) @@ -84,55 +77,3 @@ def __post_init__(self) -> None: self.origin.iata_code, self.destination.iata_code, ) - - @property - def bearing(self) -> float: - """Great-circle bearing from origin to destination (0–360°).""" - return geo.bearing(self.origin.position, self.destination.position) - - @property - def distance(self) -> float: - """Great-circle distance from origin to destination in NM.""" - return geo.distance(self.origin.position, self.destination.position) - - def cross_track_distance(self, aircraft_position: Position) -> float: - """Perpendicular distance from aircraft to this route's great-circle path in NM.""" - return geo.cross_track_distance( - self.origin.position, self.destination.position, aircraft_position - ) - - def plausible(self, aircraft_track: float, aircraft_position: Position, max_xtd: float) -> bool: - """Check if an aircraft is plausibly flying this route. - - Two checks are applied: - - 1. **Bearing alignment** — the aircraft's track must be within 60° - of the great-circle bearing from origin to destination. This - rejects stale schedule data where the route doesn't match the - aircraft's heading. - - 2. **Cross-track distance** — the aircraft must be within - `max_xtd` nautical miles of the great-circle path. This - rejects routes that pass the bearing check but are - geographically far from the aircraft. - - For example, a flight near Houston tracking 140° is likely - IAH→BOG (bearing ~138°), not LAX→ITO (bearing ~256°). - - Args: - aircraft_track: The aircraft's current heading in degrees (0–360). - aircraft_position: The aircraft's current position. - max_xtd: Maximum allowable cross-track distance in nautical miles. - Typically the user's configured range. - - Returns: - True if both checks pass, False otherwise. - """ - diff = abs(aircraft_track - self.bearing) - if min(diff, 360 - diff) > 60: - return False - - if self.cross_track_distance(aircraft_position) > max_xtd: - return False - - return True diff --git a/tests/fixtures/adsblol_response.json b/tests/fixtures/adsblol_response.json deleted file mode 100644 index c97a65f..0000000 --- a/tests/fixtures/adsblol_response.json +++ /dev/null @@ -1,1539 +0,0 @@ -[ - { - "hex": "a364ea", - "type": "adsb_icao", - "flight": "DAL1170 ", - "r": "N318DX", - "t": "A321", - "alt_baro": 24900, - "alt_geom": 26650, - "gs": 455.1, - "track": 280.12, - "baro_rate": -4224, - "squawk": "3120", - "emergency": "none", - "category": "A3", - "nav_altitude_mcp": 4000, - "nav_heading": 0.0, - "lat": 29.787334, - "lon": -96.243035, - "nic": 8, - "rc": 186, - "seen_pos": 0.227, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 63833, - "seen": 0.0, - "rssi": -21.1, - "dst": 48.424, - "dir": 255.5, - "origin": "SLC", - "destination": "AUS" - }, - { - "hex": "a449a4", - "type": "adsb_icao", - "flight": "UAL1360 ", - "r": "N37563", - "t": "B39M", - "alt_baro": 21325, - "alt_geom": 22650, - "gs": 388.8, - "track": 283.54, - "baro_rate": 1664, - "squawk": "2745", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 36000, - "nav_heading": 277.73, - "lat": 30.024471, - "lon": -96.229358, - "nic": 8, - "rc": 186, - "seen_pos": 0.069, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 7112, - "seen": 0.0, - "rssi": -20.2, - "dst": 46.092, - "dir": 272.6, - "origin": "IAD", - "destination": "RSW" - }, - { - "hex": "a2519f", - "type": "adsb_icao", - "flight": "MXY6837 ", - "r": "N249BZ", - "t": "BCS3", - "alt_baro": 22000, - "alt_geom": 23400, - "gs": 420.3, - "track": 107.87, - "geom_rate": 1088, - "squawk": "7314", - "emergency": "none", - "category": "A3", - "nav_qnh": 1012.8, - "nav_altitude_mcp": 35008, - "nav_heading": 106.17, - "lat": 30.19693, - "lon": -96.187565, - "nic": 8, - "rc": 186, - "seen_pos": 0.339, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 43328, - "seen": 0.0, - "rssi": -12.9, - "dst": 45.526, - "dir": 285.8, - "origin": null, - "destination": null - }, - { - "hex": "a00067", - "type": "adsb_icao", - "flight": "N1EA ", - "r": "N1EA", - "t": "EA50", - "alt_baro": 27200, - "alt_geom": 29025, - "gs": 271.4, - "track": 93.17, - "geom_rate": 640, - "squawk": "4630", - "emergency": "none", - "category": "A1", - "lat": 30.309814, - "lon": -96.053951, - "nic": 9, - "rc": 75, - "seen_pos": 0.115, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 14288, - "seen": 0.1, - "rssi": -18.8, - "dst": 41.522, - "dir": 297.4, - "origin": null, - "destination": null - }, - { - "hex": "a448f5", - "type": "adsb_icao", - "flight": "UAL2461 ", - "r": "N37513", - "t": "B39M", - "alt_baro": 15100, - "alt_geom": 15950, - "gs": 353.5, - "track": 288.64, - "baro_rate": 1728, - "squawk": "2437", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 23008, - "nav_heading": 284.77, - "lat": 30.043883, - "lon": -95.971893, - "nic": 8, - "rc": 186, - "seen_pos": 0.115, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 5256, - "seen": 0.1, - "rssi": -16.8, - "dst": 32.836, - "dir": 275.5, - "origin": "ORF", - "destination": "DEN" - }, - { - "hex": "a04070", - "type": "adsb_icao", - "flight": "CXK618 ", - "r": "N115TA", - "t": "C172", - "dbFlags": 8, - "alt_baro": 5025, - "alt_geom": 5175, - "gs": 123.1, - "track": 61.35, - "baro_rate": 128, - "squawk": "4556", - "emergency": "none", - "category": "A1", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 4992, - "lat": 30.256485, - "lon": -95.945327, - "nic": 9, - "rc": 75, - "seen_pos": 0.116, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 52805, - "seen": 0.1, - "rssi": -13.9, - "dst": 35.06, - "dir": 296.9, - "origin": null, - "destination": null - }, - { - "hex": "aa4e57", - "type": "adsb_icao", - "flight": "N763TA ", - "r": "N763TA", - "t": "SR20", - "alt_baro": 1025, - "alt_geom": 1000, - "gs": 103.8, - "track": 28.79, - "baro_rate": -384, - "squawk": "1200", - "category": "A1", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 1216, - "nav_heading": 175.08, - "nav_modes": [], - "lat": 29.843429, - "lon": -95.922223, - "nic": 9, - "rc": 75, - "seen_pos": 45.739, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 5656, - "seen": 45.6, - "rssi": -25.6, - "dst": 31.461, - "dir": 253.6, - "origin": null, - "destination": null - }, - { - "hex": "abff87", - "type": "adsb_icao", - "flight": "UAL283 ", - "r": "N872UA", - "t": "A319", - "alt_baro": 5950, - "alt_geom": 6225, - "gs": 219.5, - "track": 163.05, - "baro_rate": 64, - "squawk": "2603", - "emergency": "none", - "category": "A3", - "nav_qnh": 1014.4, - "nav_altitude_mcp": 6016, - "nav_heading": 160.31, - "lat": 30.200455, - "lon": -95.814083, - "nic": 8, - "rc": 186, - "seen_pos": 0.183, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 123669, - "seen": 0.0, - "rssi": -9.4, - "dst": 27.478, - "dir": 297.0, - "origin": "DFW", - "destination": "EWR" - }, - { - "hex": "a3d2c0", - "type": "adsb_icao", - "flight": "UAL1954 ", - "r": "N34535", - "t": "A21N", - "alt_baro": 5975, - "alt_geom": 6250, - "gs": 219.8, - "track": 161.98, - "baro_rate": 0, - "squawk": "3237", - "emergency": "none", - "category": "A3", - "nav_qnh": 1014.4, - "nav_altitude_mcp": 6016, - "nav_heading": 160.31, - "lat": 30.078472, - "lon": -95.745355, - "nic": 8, - "rc": 186, - "seen_pos": 0.373, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 105760, - "seen": 0.1, - "rssi": -11.4, - "dst": 21.559, - "dir": 283.8, - "origin": "RDM", - "destination": "SFO" - }, - { - "hex": "aaa68a", - "type": "adsb_icao", - "flight": "SWA2065 ", - "r": "N7858A", - "t": "B737", - "alt_baro": 12900, - "alt_geom": 13550, - "gs": 360.5, - "track": 296.0, - "baro_rate": 3072, - "squawk": "7230", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 16000, - "nav_heading": 289.69, - "lat": 29.660934, - "lon": -95.646973, - "nic": 8, - "rc": 186, - "seen_pos": 0.351, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 3592, - "seen": 0.2, - "rssi": -11.4, - "dst": 25.505, - "dir": 218.7, - "origin": "SAN", - "destination": "OAK" - }, - { - "hex": "a9e4ce", - "type": "adsb_icao", - "flight": "N7368J ", - "r": "N7368J", - "t": "C172", - "alt_baro": 1700, - "alt_geom": 1600, - "gs": 71.2, - "track": 175.97, - "geom_rate": -256, - "squawk": "0444", - "emergency": "none", - "category": "A1", - "lat": 30.148021, - "lon": -95.565454, - "nic": 9, - "rc": 75, - "seen_pos": 6.942, - "version": 2, - "nic_baro": 0, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 25558, - "seen": 6.3, - "rssi": -28.2, - "dst": 14.879, - "dir": 308.7, - "origin": null, - "destination": null - }, - { - "hex": "a73cd5", - "type": "adsb_icao", - "flight": "N5657L ", - "r": "N5657L", - "t": "AA1", - "alt_baro": 1150, - "alt_geom": 1100, - "gs": 88.6, - "track": 208.3, - "geom_rate": 704, - "squawk": "1200", - "emergency": "none", - "category": "A1", - "lat": 30.038204, - "lon": -95.563696, - "nic": 9, - "rc": 75, - "seen_pos": 0.115, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 113, - "seen": 0.1, - "rssi": -13.1, - "dst": 11.835, - "dir": 283.3, - "origin": null, - "destination": null - }, - { - "hex": "a24508", - "type": "adsb_icao", - "flight": "UAL1585 ", - "r": "N24505", - "t": "A21N", - "alt_baro": 2750, - "alt_geom": 2850, - "gs": 202.0, - "track": 89.72, - "baro_rate": -1152, - "squawk": "0647", - "emergency": "none", - "category": "A3", - "nav_qnh": 1014.4, - "nav_altitude_mcp": 3008, - "nav_heading": 0.0, - "lat": 29.993134, - "lon": -95.516483, - "nic": 8, - "rc": 186, - "seen_pos": 0.402, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 74891, - "seen": 0.1, - "rssi": -4.2, - "dst": 9.074, - "dir": 270.1, - "origin": "SFO", - "destination": "PVR" - }, - { - "hex": "a671f2", - "type": "adsb_icao", - "flight": "SKW5005 ", - "r": "N514SY", - "t": "E75L", - "alt_baro": 5975, - "alt_geom": 6275, - "gs": 291.2, - "track": 291.98, - "baro_rate": 2816, - "squawk": "2571", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 16000, - "nav_modes": [ - "autopilot", - "vnav", - "tcas" - ], - "lat": 29.877925, - "lon": -95.504581, - "nic": 8, - "rc": 186, - "seen_pos": 0.117, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 2034, - "seen": 0.1, - "rssi": -5.8, - "dst": 10.932, - "dir": 230.8, - "origin": "PHX", - "destination": "STS" - }, - { - "hex": "a40434", - "type": "adsb_icao", - "flight": "TRF558 ", - "r": "N358TF", - "t": "P28A", - "alt_baro": 1650, - "alt_geom": 1600, - "gs": 73.1, - "track": 139.99, - "baro_rate": -640, - "squawk": "5374", - "emergency": "none", - "category": "A1", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 2112, - "nav_heading": 170.16, - "nav_modes": [ - "approach" - ], - "lat": 30.424681, - "lon": -95.469159, - "nic": 9, - "rc": 75, - "seen_pos": 0.001, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 4586, - "seen": 0.0, - "rssi": -7.3, - "dst": 26.769, - "dir": 345.7, - "origin": null, - "destination": null - }, - { - "hex": "a030e9", - "type": "adsb_icao", - "flight": "N111LE ", - "r": "N111LE", - "t": "R44", - "alt_baro": 900, - "alt_geom": 825, - "gs": 104.4, - "track": 99.37, - "geom_rate": 384, - "squawk": "1200", - "emergency": "none", - "category": "A7", - "lat": 29.767502, - "lon": -95.429781, - "nic": 9, - "rc": 75, - "seen_pos": 0.402, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 1867, - "seen": 0.1, - "rssi": -6.4, - "dst": 14.313, - "dir": 198.7, - "origin": null, - "destination": null - }, - { - "hex": "c06724", - "type": "adsb_icao", - "flight": "ACA1359 ", - "r": "C-GNBN", - "t": "BCS3", - "alt_baro": 36000, - "alt_geom": 38400, - "gs": 445.5, - "track": 225.0, - "geom_rate": 64, - "squawk": "2263", - "emergency": "none", - "category": "A3", - "nav_qnh": 1012.8, - "nav_altitude_mcp": 36000, - "nav_heading": 224.3, - "lat": 29.129784, - "lon": -95.399942, - "nic": 8, - "rc": 186, - "seen_pos": 0.183, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 131086, - "seen": 0.1, - "rssi": -17.7, - "dst": 51.981, - "dir": 183.4, - "origin": "YYZ", - "destination": "MTY" - }, - { - "hex": "aafa1d", - "type": "adsb_icao", - "flight": "N806WB ", - "r": "N806WB", - "t": "BE20", - "dbFlags": 8, - "alt_baro": 1300, - "alt_geom": 1325, - "gs": 137.4, - "track": 111.34, - "baro_rate": 1856, - "emergency": "none", - "category": "A1", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 1984, - "nav_heading": 59.77, - "nav_modes": [], - "lat": 30.33949, - "lon": -95.398462, - "nic": 9, - "rc": 75, - "seen_pos": 0.115, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 54, - "seen": 0.1, - "rssi": -27.7, - "dst": 21.028, - "dir": 351.9, - "origin": null, - "destination": null - }, - { - "hex": "aa56b4", - "type": "adsb_icao", - "flight": "UAL1392 ", - "r": "N76515", - "t": "B738", - "alt_baro": 6900, - "alt_geom": 7225, - "gs": 267.0, - "track": 269.79, - "baro_rate": -1152, - "squawk": "3545", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 6016, - "nav_heading": 265.08, - "lat": 30.092606, - "lon": -95.36289, - "nic": 8, - "rc": 186, - "seen_pos": 0.119, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 154563, - "seen": 0.1, - "rssi": -20.2, - "dst": 6.082, - "dir": 349.5, - "origin": "EWR", - "destination": "CLT" - }, - { - "hex": "a330a0", - "type": "adsb_icao", - "flight": "UAL2304 ", - "r": "N30401", - "t": "B739", - "alt_baro": 800, - "alt_geom": 775, - "gs": 158.8, - "track": 143.71, - "baro_rate": 2176, - "squawk": "7223", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 4000, - "nav_heading": 340.31, - "lat": 29.96077, - "lon": -95.340756, - "nic": 8, - "rc": 186, - "seen_pos": 0.298, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 76, - "seen": 0.3, - "rssi": -23.1, - "dst": 1.944, - "dir": 178.7, - "origin": "SFO", - "destination": "PSP" - }, - { - "hex": "ac136d", - "type": "adsb_icao", - "flight": "SWA322 ", - "r": "N8775Q", - "t": "B38M", - "alt_baro": 325, - "alt_geom": 275, - "gs": 140.7, - "track": 134.14, - "baro_rate": -832, - "squawk": "1071", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 2208, - "nav_heading": 120.94, - "lat": 29.659408, - "lon": -95.295518, - "nic": 8, - "rc": 186, - "seen_pos": 0.114, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 48749, - "seen": 0.1, - "rssi": -25.4, - "dst": 20.2, - "dir": 173.2, - "origin": "OMA", - "destination": "DEN" - }, - { - "hex": "acb608", - "type": "adsb_icao", - "flight": "SKW5094 ", - "r": "N918SW", - "t": "CRJ2", - "alt_baro": 2725, - "alt_geom": 2800, - "gs": 246.7, - "track": 19.9, - "geom_rate": 960, - "squawk": "2744", - "emergency": "none", - "category": "A2", - "nav_qnh": 1014.4, - "nav_altitude_mcp": 4000, - "lat": 30.001556, - "lon": -95.292017, - "nic": 8, - "rc": 186, - "seen_pos": 0.402, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 801, - "seen": 0.4, - "rssi": -11.4, - "dst": 2.622, - "dir": 78.9, - "origin": "ORD", - "destination": "FOD" - }, - { - "hex": "a8856d", - "type": "adsb_icao", - "flight": "UAL1780 ", - "r": "N64809", - "t": "B739", - "alt_baro": 6050, - "alt_geom": 6300, - "gs": 293.5, - "track": 3.32, - "baro_rate": 3520, - "squawk": "2561", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 16000, - "nav_heading": 0.0, - "lat": 30.047049, - "lon": -95.276239, - "nic": 8, - "rc": 186, - "seen_pos": 0.119, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 1490, - "seen": 0.1, - "rssi": -13.3, - "dst": 4.691, - "dir": 46.4, - "origin": "PDX", - "destination": "SFO" - }, - { - "hex": "a6a688", - "type": "adsb_icao", - "r": "N5279F", - "t": "H500", - "alt_baro": 700, - "alt_geom": 675, - "gs": 92.1, - "track": 252.94, - "geom_rate": 192, - "squawk": "0133", - "emergency": "none", - "category": "A7", - "lat": 29.783005, - "lon": -95.27263, - "nic": 9, - "rc": 75, - "seen_pos": 2.084, - "version": 2, - "nic_baro": 0, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 15850, - "seen": 2.1, - "rssi": -22.8, - "dst": 13.127, - "dir": 164.1, - "origin": null, - "destination": null - }, - { - "hex": "a7b048", - "type": "adsb_icao", - "flight": "N595DC ", - "r": "N595DC", - "t": "C650", - "dbFlags": 8, - "alt_baro": 40000, - "alt_geom": 42575, - "gs": 429.3, - "track": 243.43, - "baro_rate": 64, - "squawk": "2411", - "emergency": "none", - "category": "A2", - "lat": 30.252205, - "lon": -95.120343, - "nic": 8, - "rc": 186, - "seen_pos": 0.115, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 75313, - "seen": 0.1, - "rssi": -24.8, - "dst": 19.339, - "dir": 36.4, - "origin": null, - "destination": null - }, - { - "hex": "a2b17a", - "type": "adsb_icao", - "flight": "UAL1055 ", - "r": "N27287", - "t": "B38M", - "alt_baro": 9200, - "alt_geom": 9625, - "gs": 283.9, - "track": 157.86, - "baro_rate": 2112, - "squawk": "2554", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 16000, - "nav_heading": 156.8, - "lat": 29.789475, - "lon": -95.120221, - "nic": 8, - "rc": 186, - "seen_pos": 0.162, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 2684, - "seen": 0.2, - "rssi": -8.1, - "dst": 16.793, - "dir": 136.6, - "origin": "EWR", - "destination": "ATL" - }, - { - "hex": "a98d7e", - "type": "adsb_icao", - "flight": "UAL1591 ", - "r": "N71411", - "t": "B739", - "alt_baro": 18225, - "alt_geom": 19350, - "gs": 383.6, - "track": 40.67, - "baro_rate": 1792, - "squawk": "7466", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 27008, - "nav_heading": 35.16, - "lat": 30.44458, - "lon": -95.000072, - "nic": 8, - "rc": 186, - "seen_pos": 0.519, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 5079, - "seen": 0.0, - "rssi": -24.3, - "dst": 32.388, - "dir": 33.1, - "origin": "DEN", - "destination": "EWR" - }, - { - "hex": "a36636", - "type": "adsb_icao", - "flight": "AAL496 ", - "r": "N318TD", - "t": "B38M", - "alt_baro": 39000, - "alt_geom": 41525, - "gs": 481.1, - "track": 102.85, - "baro_rate": 0, - "squawk": "7332", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 39008, - "nav_heading": 99.14, - "lat": 30.240287, - "lon": -94.98241, - "nic": 8, - "rc": 186, - "seen_pos": 0.491, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 66661, - "seen": 0.2, - "rssi": -15.4, - "dst": 23.816, - "dir": 51.4, - "origin": "GUA", - "destination": "DFW" - }, - { - "hex": "aca9b6", - "type": "adsb_icao", - "flight": "SKW323G ", - "r": "N915EV", - "t": "CRJ2", - "alt_baro": 19600, - "alt_geom": 20825, - "gs": 346.4, - "track": 28.64, - "geom_rate": 1600, - "squawk": "2473", - "emergency": "none", - "category": "A2", - "nav_qnh": 1012.8, - "nav_altitude_mcp": 27008, - "lat": 30.78411, - "lon": -94.949561, - "nic": 8, - "rc": 186, - "seen_pos": 0.338, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 7946, - "seen": 0.2, - "rssi": -20.7, - "dst": 51.682, - "dir": 23.0, - "origin": "IAH", - "destination": "LIT" - }, - { - "hex": "a35781", - "type": "adsb_icao", - "flight": "AAL3066 ", - "r": "N314UT", - "t": "B38M", - "alt_baro": 36000, - "alt_geom": 38400, - "gs": 450.1, - "track": 282.18, - "baro_rate": 64, - "squawk": "2304", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 36000, - "nav_heading": 281.95, - "lat": 29.702454, - "lon": -94.934499, - "nic": 8, - "rc": 186, - "seen_pos": 0.234, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 64683, - "seen": 0.0, - "rssi": -19.4, - "dst": 27.434, - "dir": 129.4, - "origin": "DCA", - "destination": "DFW" - }, - { - "hex": "a57eb8", - "type": "adsb_icao", - "flight": "N453R ", - "r": "N453R", - "t": "B407", - "alt_baro": 1400, - "alt_geom": 1350, - "gs": 133.8, - "track": 42.27, - "baro_rate": -64, - "squawk": "1200", - "emergency": "none", - "category": "A7", - "lat": 30.020467, - "lon": -94.914349, - "nic": 10, - "rc": 25, - "seen_pos": 7.054, - "version": 2, - "nic_baro": 0, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 4631, - "seen": 7.1, - "rssi": -27.9, - "dst": 22.225, - "dir": 85.7, - "origin": null, - "destination": null - }, - { - "hex": "a69f88", - "type": "adsb_icao", - "flight": "DAL872 ", - "r": "N526DE", - "t": "A21N", - "alt_baro": 34000, - "alt_geom": 36300, - "gs": 460.8, - "track": 282.41, - "baro_rate": 0, - "squawk": "3024", - "emergency": "none", - "category": "A3", - "nav_qnh": 1012.8, - "nav_altitude_mcp": 34016, - "lat": 30.152583, - "lon": -94.819318, - "nic": 8, - "rc": 186, - "seen_pos": 0.115, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 3, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 57843, - "seen": 0.0, - "rssi": -18.6, - "dst": 28.723, - "dir": 70.4, - "origin": "SEA", - "destination": "MSP" - }, - { - "hex": "aa56d5", - "type": "adsb_icao", - "flight": "UAL2759 ", - "r": "N76523", - "t": "B738", - "alt_baro": 15975, - "alt_geom": 16925, - "gs": 381.7, - "track": 229.68, - "baro_rate": -2048, - "squawk": "0571", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 10016, - "nav_heading": 229.22, - "lat": 30.392166, - "lon": -94.796017, - "nic": 8, - "rc": 186, - "seen_pos": 0.162, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 126941, - "seen": 0.0, - "rssi": -21.5, - "dst": 37.06, - "dir": 49.6, - "origin": "IAD", - "destination": "EWR" - }, - { - "hex": "abec6c", - "type": "adsb_icao", - "flight": "SWA2460 ", - "r": "N8679A", - "t": "B738", - "alt_baro": 16850, - "alt_geom": 17875, - "gs": 362.5, - "track": 252.66, - "baro_rate": -1920, - "squawk": "2455", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 6016, - "nav_heading": 250.31, - "lat": 30.119293, - "lon": -94.75051, - "nic": 8, - "rc": 186, - "seen_pos": 0.299, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 121260, - "seen": 0.0, - "rssi": -18.9, - "dst": 31.573, - "dir": 76.0, - "origin": "DEN", - "destination": "OKC" - }, - { - "hex": "abb507", - "type": "adsb_icao", - "flight": "SWA2282 ", - "r": "N8531Q", - "t": "B738", - "alt_baro": 18700, - "alt_geom": 19825, - "gs": 398.8, - "track": 77.25, - "baro_rate": 2560, - "squawk": "7252", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 36992, - "nav_heading": 75.94, - "lat": 29.656801, - "lon": -94.668112, - "nic": 8, - "rc": 186, - "seen_pos": 0.328, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 2, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 4446, - "seen": 0.0, - "rssi": -9.2, - "dst": 40.42, - "dir": 119.8, - "origin": "STL", - "destination": "PHL" - }, - { - "hex": "abddbf", - "type": "adsb_icao", - "flight": "N864AM ", - "r": "N864AM", - "t": "H25B", - "alt_baro": 19975, - "alt_geom": 21100, - "gs": 378.9, - "track": 252.81, - "geom_rate": -2048, - "squawk": "7275", - "emergency": "none", - "category": "A2", - "nav_qnh": 1014.4, - "nav_altitude_mcp": 6016, - "nav_heading": 247.5, - "lat": 30.166316, - "lon": -94.575146, - "nic": 8, - "rc": 186, - "seen_pos": 0.533, - "version": 2, - "nic_baro": 1, - "nac_p": 10, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 36226, - "seen": 0.0, - "rssi": -18.2, - "dst": 41.073, - "dir": 75.2, - "origin": null, - "destination": null - }, - { - "hex": "a0e6c6", - "type": "adsb_icao", - "flight": "UAL2293 ", - "r": "N15712", - "t": "B737", - "alt_baro": 23525, - "alt_geom": 25050, - "gs": 426.9, - "track": 96.73, - "baro_rate": -2112, - "squawk": "2767", - "emergency": "none", - "category": "A3", - "nav_qnh": 1013.6, - "nav_altitude_mcp": 35008, - "nav_heading": 92.81, - "lat": 29.870499, - "lon": -94.401926, - "nic": 8, - "rc": 186, - "seen_pos": 0.162, - "version": 2, - "nic_baro": 1, - "nac_p": 9, - "nac_v": 1, - "sil": 3, - "sil_type": "perhour", - "gva": 2, - "sda": 2, - "alert": 0, - "spi": 0, - "mlat": [], - "tisb": [], - "messages": 6010, - "seen": 0.0, - "rssi": -13.0, - "dst": 49.337, - "dir": 98.3, - "origin": "JAX", - "destination": "EWR" - } -] \ No newline at end of file diff --git a/tests/fixtures/airlabs_response.json b/tests/fixtures/airlabs_response.json new file mode 100644 index 0000000..2a8c12d --- /dev/null +++ b/tests/fixtures/airlabs_response.json @@ -0,0 +1,55 @@ +[ + { + "hex": "0D0ACA", + "reg_number": "XA-VAX", + "lat": 29.566162, + "lng": -95.341946, + "alt": 5559, + "dir": 228.9, + "speed": 621, + "v_speed": 0, + "flight_number": "613", + "flight_icao": "VIV613", + "flight_iata": "VB613", + "dep_icao": "KIAH", + "dep_iata": "IAH", + "arr_icao": "MMMY", + "arr_iata": "MTY", + "airline_icao": "VIV", + "airline_iata": "VB", + "aircraft_icao": "A320", + "status": "en-route" + }, + { + "hex": "A12B3C", + "reg_number": "N771UA", + "lat": 30.12, + "lng": -95.48, + "alt": 10668, + "dir": 140.0, + "speed": 740, + "v_speed": 12.7, + "flight_icao": "UAL1170", + "flight_iata": "UA1170", + "dep_icao": "KSFO", + "dep_iata": "SFO", + "arr_icao": "KIAH", + "arr_iata": "IAH", + "airline_icao": "UAL", + "aircraft_icao": "B772", + "status": "en-route" + }, + { + "hex": "ABC123", + "reg_number": "N45XX", + "lat": 29.81, + "lng": -95.21, + "alt": 2438, + "dir": 10.0, + "speed": 380, + "v_speed": -5.0, + "flight_icao": "SWA45", + "aircraft_icao": "B737", + "status": "en-route" + } +] diff --git a/tests/test_cache.py b/tests/test_cache.py deleted file mode 100644 index 034ca1d..0000000 --- a/tests/test_cache.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for the RouteCache.""" - -from unittest.mock import patch - -from jetset.fetcher import AdsbLolAdapter, RouteCache -from jetset.geo import Position -from jetset.models import Airport, FlightRoute - -ADSB_LOL_AIRCRAFT = { - "flight": "UAL1170 ", - "t": "B772", - "alt_baro": 17700, - "gs": 400.3, - "track": 140.0, - "baro_rate": 1600, - "lat": 33.568214, - "lon": -119.232388, - "r": "N771UA", - "category": "A5", -} - -ADSBDB_RESPONSE = { - "response": { - "flightroute": { - "origin": {"iata_code": "SFO", "latitude": 37.62, "longitude": -122.38}, - "destination": {"iata_code": "LAX", "latitude": 33.94, "longitude": -118.41}, - } - }, -} - - -class TestRouteCache: - def test_returns_route_after_put(self) -> None: - cache = RouteCache() - route = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) - - cache.put("UAL1170", route) - result = cache.get("UAL1170") - - assert result is route - - def test_returns_none_for_missing(self) -> None: - cache = RouteCache() - assert cache.get("NONEXIST") is None - - def test_expired_entry_returns_none(self) -> None: - cache = RouteCache(ttl=60) - route = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) - - with patch("jetset.fetcher.time.time") as mock_time: - mock_time.return_value = 1000.0 - cache.put("UAL1170", route) - - mock_time.return_value = 1061.0 # 61 seconds later - result = cache.get("UAL1170") - - assert result is None - - def test_clear_removes_all_entries(self) -> None: - cache = RouteCache() - route = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) - - cache.put("UAL1170", route) - cache.clear() - - assert cache.get("UAL1170") is None - - -class TestAdapterCacheIntegration: - def test_uses_cached_route_without_api_call(self) -> None: - adapter = AdsbLolAdapter() - route = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) - adapter._route_cache.put("UAL1170", route) - - aircraft = dict(ADSB_LOL_AIRCRAFT) - - with patch.object(adapter, "_fetch_route") as mock_fetch: - result = adapter._enrich_routes([aircraft]) - - assert len(result) == 1 - assert result[0]["route"] is route - mock_fetch.assert_not_called() - - def test_fetch_failure_does_not_enrich_nor_cache(self) -> None: - adapter = AdsbLolAdapter() - aircraft = dict(ADSB_LOL_AIRCRAFT) - - with patch.object(adapter, "_fetch_route", return_value=None): - result = adapter._enrich_routes([aircraft]) - - assert len(result) == 0 - # Nothing should have been cached for this callsign - assert adapter._route_cache.get("UAL1170") is None diff --git a/tests/test_config.py b/tests/test_config.py index 5d50723..862b604 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -92,6 +92,6 @@ def test_loads_custom_hardware_section(self, tmp_path: Path) -> None: assert config.hardware_gpio_slowdown == 2 assert config.hardware_rgb_sequence == "RGB" - def test_api_source_defaults_to_adsblol(self) -> None: + def test_api_source_defaults_to_airlabs(self) -> None: config = AppConfig.load() - assert config.api_source == "adsblol" + assert config.api_source == "airlabs" diff --git a/tests/test_display.py b/tests/test_display.py index c7d830f..8e7a724 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,6 +1,6 @@ """Tests for display formatting functions.""" -from jetset.models import Airport, Flight, FlightRoute, Position +from jetset.models import Airport, Flight, FlightRoute class TestLoadingLabel: @@ -38,10 +38,7 @@ def test_route_label_when_both_set(self) -> None: flight = Flight( callsign="UAL2337", - route=FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ), + route=FlightRoute(Airport("SFO"), Airport("LAX")), ) assert route_label(flight) == "SFO→LAX" @@ -57,7 +54,7 @@ def test_metrics_label_defaults_to_page_0(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", altitude=35000, speed=450) - assert metrics_label(flight) == "35K ft" + assert metrics_label(flight) == "35000 ft" def test_metrics_label_empty_when_no_data(self) -> None: from jetset.display import metrics_label @@ -70,7 +67,7 @@ def test_page_0_shows_altitude(self) -> None: flight = Flight(callsign="UAL2337", altitude=35000, speed=450) label = metrics_label(flight, page=0) - assert label == "35K ft" + assert label == "35000 ft" def test_page_1_shows_speed(self) -> None: from jetset.display import metrics_label @@ -105,14 +102,14 @@ def test_page_3_shows_track(self) -> None: flight = Flight(callsign="UAL2337", track=270) label = metrics_label(flight, page=3) - assert label == "270°" + assert label == "270° W" def test_track_format_as_int(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", track=280.12) label = metrics_label(flight, page=3) - assert label == "280°" + assert label == "280° W" def test_four_pages_rotates(self) -> None: from jetset.display import metrics_label diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index edc9050..3f2acc1 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -1,503 +1,132 @@ -"""Tests for the flight API interface and adapters.""" +"""Tests for the AirLabs flight adapter.""" import json from collections.abc import Sequence from pathlib import Path from typing import Protocol, runtime_checkable +from unittest.mock import MagicMock, patch from jetset.models import Flight -ADSB_LOL_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "adsblol_response.json" +AIRLABS_FIXTURE = Path(__file__).parent / "fixtures" / "airlabs_response.json" @runtime_checkable class FlightAPI(Protocol): - """Protocol for flight data sources.""" - - def nearby_flights( # noqa: E501 + def nearby_flights( self, lat: float, lon: float, range: int, raw: bool = False - ) -> Sequence[Flight]: - ... - - def refresh_flight(self, flight: Flight) -> Flight | None: ... + ) -> Sequence[Flight]: ... class TestFlightAPIProtocol: - def test_protocol_accepts_valid_adapter(self) -> None: - from jetset.fetcher import AdsbLolAdapter - - assert isinstance(AdsbLolAdapter(), FlightAPI) - - - + def test_adapter_satisfies_protocol(self) -> None: + from jetset.fetcher import AirLabsAdapter + assert isinstance(AirLabsAdapter(), FlightAPI) -ADSB_LOL_AIRCRAFT = { - "flight": "UAL1170 ", - "t": "B772", - "alt_baro": 17700, - "gs": 400.3, - "track": 140.0, - "baro_rate": 1600, - "lat": 33.568214, - "lon": -119.232388, - "r": "N771UA", - "category": "A5", -} -ADSBDB_RESPONSE = { - "response": { - "flightroute": { - "origin": {"iata_code": "SFO", "latitude": 37.62, "longitude": -122.38}, - "destination": {"iata_code": "LAX", "latitude": 33.94, "longitude": -118.41}, - } - }, +AIRLABS_FLIGHT = { + "flight_icao": "UAL1170", + "aircraft_icao": "B772", + "alt": 5559, # meters -> ~18238 ft + "speed": 621, # km/h -> ~335 kt + "dir": 140.0, # degrees + "v_speed": 0, # m/s -> 0 ft/min + "dep_iata": "SFO", + "arr_iata": "LAX", } -class TestAdsbLolFlightToFlight: - def test_converts_enriched_aircraft(self) -> None: - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Airport, FlightRoute, Position - - aircraft = dict(ADSB_LOL_AIRCRAFT) - aircraft["route"] = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) +class TestAirLabsAdapter: + def test_to_flight_converts_units_and_route(self) -> None: + from jetset.fetcher import AirLabsAdapter - flight = AdsbLolAdapter.json_to_flight(aircraft) + flight = AirLabsAdapter.to_flight(dict(AIRLABS_FLIGHT)) assert flight.callsign == "UAL1170" assert flight.aircraft == "B772" - assert flight.altitude == 17700 - assert flight.speed == 400 + assert flight.altitude == 18238 # meters -> feet + assert flight.speed == 335 # km/h -> knots assert flight.track == 140.0 - assert flight.vertical_rate == 1600 + assert flight.vertical_rate == 0 assert flight.route.origin.iata_code == "SFO" assert flight.route.destination.iata_code == "LAX" - def test_enrich_routes_adds_origin_destination(self) -> None: - from unittest.mock import MagicMock, patch - - from jetset.fetcher import AdsbLolAdapter - - adapter = AdsbLolAdapter() - aircraft_list = [dict(ADSB_LOL_AIRCRAFT)] - - mock_route = MagicMock() - mock_route.json.return_value = ADSBDB_RESPONSE - - with patch.object(adapter._route_api, "get", return_value=mock_route): - result = adapter._enrich_routes(aircraft_list) - - assert len(result) == 1 - assert result[0]["route"].origin.iata_code == "SFO" - assert result[0]["route"].destination.iata_code == "LAX" - - def test_enrich_routes_stops_after_max_flights(self) -> None: - from unittest.mock import MagicMock, patch - - from jetset.fetcher import AdsbLolAdapter - - adapter = AdsbLolAdapter() - # Two aircraft with different callsigns - ac1 = dict(ADSB_LOL_AIRCRAFT) # UAL1170 - ac2 = dict(ADSB_LOL_AIRCRAFT) - ac2["flight"] = "SWA4186" - - mock_route = MagicMock() - mock_route.json.return_value = ADSBDB_RESPONSE - - with patch.object(adapter._route_api, "get", return_value=mock_route) as mock_get: - # max_flights=1 so it should stop after the first successful enrich - result = adapter._enrich_routes([ac1, ac2], max_flights=1) - - # Only one flight should be enriched - assert len(result) == 1 - assert result[0]["route"].origin.iata_code == "SFO" - # API should only have been called once - assert mock_get.call_count == 1 - - - - def test_airborne_filter(self) -> None: - from jetset.fetcher import AdsbLolAdapter - - airborne = {"flight": "UAL2337", "alt_baro": 35000} - on_ground = {"flight": "SWA45", "alt_baro": 0} - grounded_str = {"flight": "AAL100", "alt_baro": "ground"} - no_alt = {"flight": "DAL200", "t": "B738"} - - assert AdsbLolAdapter._is_airborne(airborne) - assert not AdsbLolAdapter._is_airborne(on_ground) - assert not AdsbLolAdapter._is_airborne(grounded_str) - assert not AdsbLolAdapter._is_airborne(no_alt) - - -def _make_adsblol_response() -> dict: - return { - "ac": [dict(ADSB_LOL_AIRCRAFT)], - "total": 1, - "now": 1234567890, - "msg": "No error", - "ctime": 0, - "ptime": 0, - } - - -class TestDistance: - def test_distance_iah_to_bog(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - route = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("BOG", Position(4.70, -74.15)), - ) - # IAH -> BOG ≈ 1936 NM - assert round(route.distance) == 1936 - - def test_distance_zero(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - route = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("IAH", Position(29.99, -95.34)), - ) - assert route.distance == 0.0 - - -class TestBearing: - def test_bearing_north_to_south(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - origin = Airport("NPL", Position(0.0, 0.0)) - destination = Airport("SPL", Position(-10.0, 0.0)) - route = FlightRoute(origin, destination) - - assert route.bearing == 180.0 - - -class TestCrossTrackDistance: - def test_aircraft_at_origin_zero_cross_track(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - route = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("BOG", Position(4.70, -74.15)), - ) - xtd = route.cross_track_distance(Position(29.99, -95.34)) - assert round(xtd) == 0 - - def test_aircraft_near_iah_is_off_phx_sfo_route(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - # PHX (33.43, -112.02) -> SFO (37.62, -122.38) - route = FlightRoute( - Airport("PHX", Position(33.43, -112.02)), - Airport("SFO", Position(37.62, -122.38)), - ) - # Aircraft near IAH (29.99, -95.34) — ~300 NM off this route - xtd = route.cross_track_distance(Position(29.99, -95.34)) - assert 250 < xtd < 350 - - -class TestRoutePlausible: - def test_track_aligned_with_route_bearing(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - iah_bog = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("BOG", Position(4.70, -74.15)), - ) - # Aircraft at origin — cross-track = 0, always passes - assert iah_bog.plausible(140.0, Position(29.99, -95.34), 100) - - def test_track_opposite_route_rejected(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - iah_bog = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("BOG", Position(4.70, -74.15)), - ) - - assert not iah_bog.plausible(10.0, Position(29.99, -95.34), 100) - - def test_cross_track_rejects_off_route_aircraft(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - # PHX (33.43, -112.02) -> SFO (37.62, -122.38), bearing ~299° - phx_sfo = FlightRoute( - Airport("PHX", Position(33.43, -112.02)), - Airport("SFO", Position(37.62, -122.38)), - ) - # Aircraft near IAH heading 299° — passes bearing check but ~300 NM off - assert not phx_sfo.plausible(299.0, Position(29.99, -95.34), 100) - - def test_cross_track_accepts_on_route_aircraft(self) -> None: - from jetset.models import Airport, FlightRoute, Position - - iah_bog = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("BOG", Position(4.70, -74.15)), - ) - # Aircraft near origin heading toward destination — well within 100 NM - assert iah_bog.plausible(140.0, Position(29.99, -95.34), 100) - - def test_enrich_routes_rejects_implausible_route(self) -> None: - from unittest.mock import patch - - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Airport, FlightRoute, Position - - adapter = AdsbLolAdapter() - - # Aircraft heading east (track 90°) over Texas - aircraft = dict(ADSB_LOL_AIRCRAFT) - aircraft["track"] = 90.0 - # Route LAX→ITO has bearing ~256° — 166° off track, should be rejected - lax_ito = FlightRoute( - Airport("LAX", Position(33.94, -118.41)), - Airport("ITO", Position(19.72, -155.05)), - ) + def test_to_flight_no_route_when_endpoints_missing(self) -> None: + from jetset.fetcher import AirLabsAdapter - with patch.object(adapter, "_fetch_route", return_value=lax_ito): - result = adapter._enrich_routes([aircraft]) + data = {k: v for k, v in AIRLABS_FLIGHT.items() if k not in ("dep_iata", "arr_iata")} + assert AirLabsAdapter.to_flight(data).route is None - assert len(result) == 0 - assert "route" not in aircraft + def test_nearby_flights_parses_bbox_response(self) -> None: + from jetset.fetcher import AirLabsAdapter - def test_enrich_routes_accepts_plausible_route(self) -> None: - from unittest.mock import patch - - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Airport, FlightRoute, Position - - adapter = AdsbLolAdapter() - - # Aircraft heading ~140° (southeast) at IAH - aircraft = dict(ADSB_LOL_AIRCRAFT) - aircraft["track"] = 140.0 - aircraft["lat"] = 29.99 - aircraft["lon"] = -95.34 - # Route IAH→BOG has bearing ~138° — only 2° off, should be accepted - iah_bog = FlightRoute( - Airport("IAH", Position(29.99, -95.34)), - Airport("BOG", Position(4.70, -74.15)), - ) - - with patch.object(adapter, "_fetch_route", return_value=iah_bog): - result = adapter._enrich_routes([aircraft]) - - assert len(result) == 1 - assert result[0]["route"].origin.iata_code == "IAH" - - def test_enrich_routes_respects_custom_range(self) -> None: - from unittest.mock import patch - - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Airport, FlightRoute, Position - - adapter = AdsbLolAdapter() - - # Aircraft near LAX (33.57, -119.23) heading 140° - # SFO→LAX has bearing ~138°, so bearing check passes. - # Cross-track from aircraft to SFO→LAX is ~20 NM. - aircraft = dict(ADSB_LOL_AIRCRAFT) - aircraft["track"] = 140.0 - sfo_lax = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) - - with patch.object(adapter, "_fetch_route", return_value=sfo_lax): - # max_xtd=5 NM — too tight, cross-track ~20 NM exceeds it - tight = adapter._enrich_routes([dict(aircraft)], max_xtd=5) - assert len(tight) == 0 - - # max_xtd=50 NM — loose enough, aircraft ~20 NM off is accepted - loose = adapter._enrich_routes([dict(aircraft)], max_xtd=50) - assert len(loose) == 1 - - -class TestAdsbLolFetchNearby: - def test_passes_range_to_enrich_routes(self) -> None: - from unittest.mock import MagicMock, patch - - from jetset.fetcher import AdsbLolAdapter - - adapter = AdsbLolAdapter() - - mock_flight = MagicMock() - mock_flight.json.return_value = _make_adsblol_response() - - with patch.object(adapter._flight_api, "get", return_value=mock_flight): - with patch.object(adapter, "_enrich_routes", return_value=[]) as mock_enrich: - adapter.nearby_flights(37.6, -122.4, 185) - mock_enrich.assert_called_once() - _, kwargs = mock_enrich.call_args - assert round(kwargs.get("max_xtd")) == 100 - - def test_returns_flights_from_mocked_response(self) -> None: - from unittest.mock import MagicMock, patch - - from jetset.fetcher import AdsbLolAdapter - - adapter = AdsbLolAdapter() - - mock_flight = MagicMock() - mock_flight.json.return_value = _make_adsblol_response() - - mock_route = MagicMock() - mock_route.json.return_value = ADSBDB_RESPONSE + adapter = AirLabsAdapter() + adapter._api_key = "test" + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": [dict(AIRLABS_FLIGHT)]} - with patch.object(adapter._flight_api, "get", return_value=mock_flight), \ - patch.object(adapter._route_api, "get", return_value=mock_route): - flights = adapter.nearby_flights(37.6, -122.4, 185) + with patch.object(adapter._api, "get", return_value=mock_resp): + flights = adapter.nearby_flights(29.99, -95.34, 200) assert len(flights) == 1 - flight = flights[0] - assert flight.callsign == "UAL1170" - assert flight.route.origin.iata_code == "SFO" - assert flight.route.destination.iata_code == "LAX" - assert flight.aircraft == "B772" - - def test_raw_returns_enriched_dicts(self) -> None: - from unittest.mock import MagicMock, patch - - from jetset.fetcher import AdsbLolAdapter - - adapter = AdsbLolAdapter() + assert flights[0].callsign == "UAL1170" + assert flights[0].route.destination.iata_code == "LAX" - mock_flight = MagicMock() - mock_flight.json.return_value = _make_adsblol_response() + def test_nearby_flights_raw_returns_dicts(self) -> None: + from jetset.fetcher import AirLabsAdapter - mock_route = MagicMock() - mock_route.json.return_value = ADSBDB_RESPONSE - - with patch.object(adapter._flight_api, "get", return_value=mock_flight), \ - patch.object(adapter._route_api, "get", return_value=mock_route): - data = adapter.nearby_flights(37.6, -122.4, 185, raw=True) - - assert isinstance(data, list) - assert len(data) == 1 - assert data[0]["flight"] == "UAL1170 " - # raw mode returns filtered + enriched dicts - assert data[0]["route"].origin.iata_code == "SFO" - assert data[0]["route"].destination.iata_code == "LAX" + adapter = AirLabsAdapter() + adapter._api_key = "test" + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": [dict(AIRLABS_FLIGHT)]} + with patch.object(adapter._api, "get", return_value=mock_resp): + data = adapter.nearby_flights(29.99, -95.34, 200, raw=True) -ADSB_LOL_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "adsblol_response.json" + assert data == [dict(AIRLABS_FLIGHT)] + def test_nearby_flights_empty_without_key(self) -> None: + from jetset.fetcher import AirLabsAdapter -class TestRefreshFlight: - def test_returns_fresh_metrics_with_preserved_route(self) -> None: - from unittest.mock import MagicMock, patch + adapter = AirLabsAdapter() + adapter._api_key = None + assert adapter.nearby_flights(29.99, -95.34, 200) == [] - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Airport, Flight, FlightRoute, Position + def test_nearby_flights_handles_api_error(self) -> None: + from jetset.fetcher import AirLabsAdapter - adapter = AdsbLolAdapter() + adapter = AirLabsAdapter() + adapter._api_key = "test" mock_resp = MagicMock() - mock_resp.json.return_value = { - "ac": [dict(ADSB_LOL_AIRCRAFT)], - "total": 1, - } - - route = FlightRoute( - Airport("SFO", Position(37.62, -122.38)), - Airport("LAX", Position(33.94, -118.41)), - ) - existing = Flight( - callsign="UAL1170", route=route, altitude=35000, - speed=450, track=140.0, vertical_rate=1600, - ) - - with patch.object(adapter._flight_api, "get", return_value=mock_resp): - refreshed = adapter.refresh_flight(existing) - - assert refreshed is not None - assert refreshed.callsign == "UAL1170" - assert refreshed.altitude == 17700 # fresh from mock - assert refreshed.speed == 400 - # Route should be preserved from the original - assert refreshed.route is route - - def test_returns_original_when_no_aircraft_in_response(self) -> None: - # The callsign endpoint can return valid JSON with an empty "ac" list - # when the aircraft is no longer airborne; keep the stale flight. - from unittest.mock import MagicMock, patch - - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Flight - - adapter = AdsbLolAdapter() - mock_resp = MagicMock() - mock_resp.json.return_value = {"ac": [], "total": 0} - existing = Flight(callsign="UCA4237", altitude=35000, speed=450) + mock_resp.json.return_value = {"error": {"code": "unknown_api_key"}} - with patch.object(adapter._flight_api, "get", return_value=mock_resp): - refreshed = adapter.refresh_flight(existing) + with patch.object(adapter._api, "get", return_value=mock_resp): + assert adapter.nearby_flights(29.99, -95.34, 200) == [] - assert refreshed is existing + def test_bbox_covers_the_range(self) -> None: + from jetset.fetcher import AirLabsAdapter + bbox = AirLabsAdapter._bbox(29.99, -95.34, 108.0) + min_lat, min_lon, max_lat, max_lon = (float(x) for x in bbox.split(",")) + assert min_lat < 29.99 < max_lat + assert min_lon < -95.34 < max_lon -class TestAdsbLolFixture: - def test_parses_all_fixture_flights(self) -> None: - from jetset.fetcher import AdsbLolAdapter - fixture = json.loads(ADSB_LOL_FIXTURE_PATH.read_text()) - assert len(fixture) >= 1 +class TestAirLabsFixture: + def test_maps_all_fixture_flights(self) -> None: + from jetset.fetcher import AirLabsAdapter - flights = [AdsbLolAdapter.json_to_flight(f) for f in fixture] + fixture = json.loads(AIRLABS_FIXTURE.read_text()) + flights = [AirLabsAdapter.to_flight(f) for f in fixture] assert len(flights) == len(fixture) for flight in flights: - # callsign can be empty if aircraft had no flight field assert isinstance(flight.callsign, str) assert isinstance(flight.altitude, (int, type(None))) assert isinstance(flight.speed, (int, type(None))) assert isinstance(flight.track, (float, type(None))) assert isinstance(flight.vertical_rate, (int, type(None))) - assert isinstance(flight.route, (type(None),)) - assert isinstance(flight.aircraft, (str, type(None))) - - # Fixture data has origin/destination string keys but not FlightRoute objects - assert all(f.route is None for f in flights) - - -class TestParseAirport: - def test_parses_valid_dict(self) -> None: - from jetset.fetcher import AdsbLolAdapter - from jetset.models import Airport - - data = {"iata_code": "SFO", "latitude": 37.62, "longitude": -122.38} - result = AdsbLolAdapter._parse_airport(data) - - assert isinstance(result, Airport) - assert result.iata_code == "SFO" - assert result.position.latitude == 37.62 - assert result.position.longitude == -122.38 - - def test_returns_none_when_missing_iata(self) -> None: - from jetset.fetcher import AdsbLolAdapter - - data = {"latitude": 37.62, "longitude": -122.38} - assert AdsbLolAdapter._parse_airport(data) is None - - def test_returns_none_when_missing_latitude(self) -> None: - from jetset.fetcher import AdsbLolAdapter - - data = {"iata_code": "SFO", "longitude": -122.38} - assert AdsbLolAdapter._parse_airport(data) is None - - def test_returns_none_when_missing_longitude(self) -> None: - from jetset.fetcher import AdsbLolAdapter - - data = {"iata_code": "SFO", "latitude": 37.62} - assert AdsbLolAdapter._parse_airport(data) is None - - def test_returns_none_for_empty_dict(self) -> None: - from jetset.fetcher import AdsbLolAdapter - - assert AdsbLolAdapter._parse_airport({}) is None + # First fixture flight has a route; the last (no dep/arr) does not. + assert flights[0].route.origin.iata_code == "IAH" + assert flights[-1].route is None diff --git a/tests/test_geo.py b/tests/test_geo.py deleted file mode 100644 index ee60127..0000000 --- a/tests/test_geo.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Tests for geospatial utility functions.""" - -from jetset.models import Position - - -class TestBearing: - def test_north_to_south(self) -> None: - from jetset.geo import bearing - - result = bearing(Position(0.0, 0.0), Position(-10.0, 0.0)) - assert result == 180.0 - - def test_east_to_west(self) -> None: - from jetset.geo import bearing - - result = bearing(Position(0.0, 0.0), Position(0.0, -10.0)) - assert result == 270.0 - - def test_iah_to_bog(self) -> None: - from jetset.geo import bearing - - result = bearing(Position(29.99, -95.34), Position(4.70, -74.15)) - assert round(result) == 138 - - -class TestDistance: - def test_iah_to_bog(self) -> None: - from jetset.geo import distance - - result = distance(Position(29.99, -95.34), Position(4.70, -74.15)) - assert round(result) == 1936 - - def test_zero_distance(self) -> None: - from jetset.geo import distance - - result = distance(Position(29.99, -95.34), Position(29.99, -95.34)) - assert result == 0.0 - - -class TestCrossTrackDistance: - def test_aircraft_at_origin_zero(self) -> None: - from jetset.geo import cross_track_distance - - origin = Position(29.99, -95.34) - dest = Position(4.70, -74.15) - xtd = cross_track_distance(origin, dest, origin) - assert round(xtd) == 0 - - def test_aircraft_near_iah_is_off_phx_sfo(self) -> None: - from jetset.geo import cross_track_distance - - phx = Position(33.43, -112.02) - sfo = Position(37.62, -122.38) - iah = Position(29.99, -95.34) - xtd = cross_track_distance(phx, sfo, iah) - assert 250 < xtd < 350 diff --git a/tests/test_main.py b/tests/test_main.py index 71a6de8..5a0b03f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -85,67 +85,47 @@ def test_runs_a_fetch_cycle_then_honors_stop(self) -> None: class TestAppCurrentFrame: - def test_selects_flight_and_metric_page(self) -> None: + def test_cycles_window_flights_through_metric_pages(self) -> None: from jetset.app import App - config = AppConfig() - app = App(config) - app.buffer.push(Flight(callsign="UAL2337")) - app.buffer.push(Flight(callsign="SWA45")) + app = App(AppConfig()) + app.buffer.set_all([Flight(callsign="UAL2337"), Flight(callsign="SWA45")]) + app.last_fetch = 1000.0 frames: list[tuple[str, int]] = [] - for frame_idx in range(8): - app.frame = frame_idx - result = app._current_frame() - assert result is not None - frames.append((result.flight.callsign, result.metric_page)) + # elapsed == 0 → window_start == 0, so the window is [UAL2337, SWA45]. + with patch("jetset.app.time.time", return_value=1000.0): + for frame_idx in range(8): + app.frame = frame_idx + result = app._current_frame() + frames.append((result.flight.callsign, result.metric_page)) assert frames == [ - ("UAL2337", 0), - ("UAL2337", 1), - ("UAL2337", 2), - ("UAL2337", 3), - ("SWA45", 0), - ("SWA45", 1), - ("SWA45", 2), - ("SWA45", 3), + ("UAL2337", 0), ("UAL2337", 1), ("UAL2337", 2), ("UAL2337", 3), + ("SWA45", 0), ("SWA45", 1), ("SWA45", 2), ("SWA45", 3), ] - def test_empty_buffer_returns_none(self) -> None: + def test_window_slides_over_the_refresh_interval(self) -> None: from jetset.app import App - config = AppConfig() - app = App(config) - - result = app._current_frame() - assert result is None + app = App(AppConfig()) # refresh = 2700s + app.buffer.set_all([Flight(callsign=str(i)) for i in range(10)]) + app.last_fetch = 0.0 + app.frame = 0 + # slide_interval = 2700 / 10 = 270s; one slide advances window_start by 1. + with patch("jetset.app.time.time", return_value=0.0): + assert app._current_frame().flight.callsign == "0" + with patch("jetset.app.time.time", return_value=270.0): + assert app._current_frame().flight.callsign == "1" + with patch("jetset.app.time.time", return_value=540.0): + assert app._current_frame().flight.callsign == "2" -class TestAppHistory: - def test_refreshes_stale_flights_after_fetch(self) -> None: - from unittest.mock import patch - + def test_empty_buffer_returns_none(self) -> None: from jetset.app import App - config = AppConfig() - app = App(config) - # Seed buffer with a flight not in the new live set - stale = Flight(callsign="UAL2337") - app.buffer.push(stale) - # Live set returns only one different flight - live = [Flight(callsign="SWA45")] - - with ( - patch.object(app.adapter, "nearby_flights", return_value=live), - patch.object(app.adapter, "refresh_flight", return_value=stale) as mock_refresh, - patch("jetset.app.time.time", return_value=500), - ): - app._fetch() - - # SWA45 was pushed to the buffer - assert len(app.buffer) == 2 - # UAL2337 was refreshed via refresh_flight - mock_refresh.assert_called_once_with(stale) + app = App(AppConfig()) + assert app._current_frame() is None class TestAppRenderFrame: diff --git a/tests/test_models.py b/tests/test_models.py index a570a03..d387e02 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -13,42 +13,29 @@ def test_airline_and_flight_number_from_callsign(self) -> None: assert flight_label(flight) == "UAL2337" class TestFlightBuffer: - def test_push_and_retrieve(self) -> None: + def test_set_all_replaces_contents(self) -> None: from jetset.models import FlightBuffer - buf = FlightBuffer(maxlen=3) - f1 = Flight(callsign="UAL2337") - buf.push(f1) - assert len(buf) == 1 - assert buf.flights == [f1] + buf = FlightBuffer() + buf.set_all([Flight(callsign="UAL2337"), Flight(callsign="SWA45")]) - def test_replace_updates_existing_flight_by_callsign(self) -> None: - from jetset.models import FlightBuffer + assert len(buf) == 2 + assert [f.callsign for f in buf.flights] == ["UAL2337", "SWA45"] - buf = FlightBuffer(maxlen=3) - buf.push(Flight(callsign="UAL2337", altitude=35000)) - replacement = Flight(callsign="UAL2337", altitude=37000) + def test_set_all_overwrites_previous_batch(self) -> None: + from jetset.models import FlightBuffer - buf.replace("UAL2337", replacement) + buf = FlightBuffer() + buf.set_all([Flight(callsign="UAL2337")]) + buf.set_all([Flight(callsign="AAL100"), Flight(callsign="SWA45")]) - assert len(buf) == 1 - assert buf.flights[0].altitude == 37000 + assert [f.callsign for f in buf.flights] == ["AAL100", "SWA45"] - def test_deduplicates_by_callsign(self) -> None: + def test_flights_returns_a_copy(self) -> None: from jetset.models import FlightBuffer - buf = FlightBuffer(maxlen=3) - buf.push(Flight(callsign="UAL2337")) - buf.push(Flight(callsign="UAL2337")) - assert len(buf) == 1 - - def test_respects_maxlen(self) -> None: - from jetset.models import FlightBuffer + buf = FlightBuffer() + buf.set_all([Flight(callsign="UAL2337")]) + buf.flights.append(Flight(callsign="SWA45")) - buf = FlightBuffer(maxlen=2) - buf.push(Flight(callsign="UAL2337")) - buf.push(Flight(callsign="AAL100")) - buf.push(Flight(callsign="SWA450")) - assert len(buf) == 2 - assert buf.flights[0].callsign == "AAL100" - assert buf.flights[1].callsign == "SWA450" + assert len(buf) == 1 diff --git a/tests/test_save_fixtures.py b/tests/test_save_fixtures.py index 5a84eb4..ac69e5d 100644 --- a/tests/test_save_fixtures.py +++ b/tests/test_save_fixtures.py @@ -8,15 +8,15 @@ class TestFixtureProviderSave: def test_saves_keyless_adapter_without_api_key(self, tmp_path) -> None: - from jetset.fetcher import AdsbLolAdapter + from jetset.fetcher import AirLabsAdapter mock_data = [{"flight": "UAL1170 "}] with patch("scripts.save_fixtures.os.environ.get", return_value=None), \ - patch.object(AdsbLolAdapter, "nearby_flights", return_value=mock_data): + patch.object(AirLabsAdapter, "nearby_flights", return_value=mock_data): with patch("scripts.save_fixtures.FIXTURES_DIR", str(tmp_path)): - provider = FixtureProvider(AdsbLolAdapter) + provider = FixtureProvider(AirLabsAdapter) provider.save(AppConfig()) - expected_file = tmp_path / "adsblol_response.json" + expected_file = tmp_path / "airlabs_response.json" assert expected_file.exists() From 65e425e1212b6337bd69f5e95f7972f184e85614 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 17:42:49 -0500 Subject: [PATCH 17/36] CHORE: exclude .pytest_cache and .ruff_cache from deploy These local tooling caches don't belong on the Pi; add them to the deploy rsync excludes alongside .venv/__pycache__/wheels. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 8ac029a..8839087 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ JETSET_PATH ?= /home/$(JETSET_USER)/jetset deploy: ## rsync code to Pi rsync -avz --exclude '.venv' --exclude '__pycache__' --exclude '.git' \ --exclude '*.pyc' --exclude 'wheels' \ + --exclude '.pytest_cache' --exclude '.ruff_cache' \ . $(JETSET_USER)@$(JETSET_HOST):$(JETSET_PATH)/ setup-pi: ## Install deps and build rpi-rgb-led-matrix (run on the Pi) From fbe72dd2d6e36557ffbbae9823931808f8a85c72 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 17:51:58 -0500 Subject: [PATCH 18/36] CHORE: remove one-off panel-diagnostic scripts The probe/sweep scripts written to characterize the original faulty panel (color channels, multiplexing, scan, signal stability) are no longer needed now that the panel is diagnosed. Keep only panel-colors.py and probe-text.py for bringing up a replacement panel, route-compare.py for data accuracy, and the deploy/fixture tooling. Git retains the removed scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- HARDWARE.md | 11 ++--- scripts/blue-current.py | 50 ----------------------- scripts/blue-hold.py | 38 ------------------ scripts/content-sweep.sh | 36 ----------------- scripts/hold-sweep.sh | 30 -------------- scripts/mux-sweep.sh | 22 ---------- scripts/panel-confirm.py | 45 --------------------- scripts/panel-content.py | 56 -------------------------- scripts/panel-diag.sh | 51 ----------------------- scripts/panel-hbands.py | 60 --------------------------- scripts/panel-mux.py | 47 ---------------------- scripts/panel-rgbseq.py | 36 ----------------- scripts/probe-color.py | 87 ---------------------------------------- scripts/probe-geo.py | 47 ---------------------- scripts/probe-hold.py | 52 ------------------------ scripts/probe-scan.py | 54 ------------------------- scripts/probe-sparse.py | 75 ---------------------------------- scripts/py-panel-test.py | 72 --------------------------------- scripts/rgbseq-sweep.sh | 20 --------- 19 files changed, 6 insertions(+), 883 deletions(-) delete mode 100755 scripts/blue-current.py delete mode 100755 scripts/blue-hold.py delete mode 100755 scripts/content-sweep.sh delete mode 100755 scripts/hold-sweep.sh delete mode 100755 scripts/mux-sweep.sh delete mode 100755 scripts/panel-confirm.py delete mode 100755 scripts/panel-content.py delete mode 100755 scripts/panel-diag.sh delete mode 100755 scripts/panel-hbands.py delete mode 100755 scripts/panel-mux.py delete mode 100755 scripts/panel-rgbseq.py delete mode 100755 scripts/probe-color.py delete mode 100755 scripts/probe-geo.py delete mode 100755 scripts/probe-hold.py delete mode 100755 scripts/probe-scan.py delete mode 100755 scripts/probe-sparse.py delete mode 100755 scripts/py-panel-test.py delete mode 100755 scripts/rgbseq-sweep.sh diff --git a/HARDWARE.md b/HARDWARE.md index 9510c84..e9fab74 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -91,11 +91,12 @@ Hard-won lessons from bring-up: incompatibility (it addresses correctly and drives red perfectly). The app currently renders every row red as a workaround; replace with a standard indoor panel and restore the palette. -- **Bring-up tooling:** `scripts/probe-*.py` and `scripts/*-sweep.sh` drive a - panel directly via rgbmatrix to characterize geometry, scan/multiplexing, - color channels, and signal stability — independent of the app. Start with - `panel-colors.py` to confirm the three channels, then `probe-text.py` for the - real 4-row layout. +- **Bring-up tooling:** `scripts/panel-colors.py` (solid red/green/blue/white + fills to confirm the three channels) and `scripts/probe-text.py` (the real + 4-row text layout) drive a panel directly via rgbmatrix, independent of the + app. Run colors first, then text. (The many one-off probes/sweeps used to + characterize the original faulty panel were removed once diagnosed — see git + history if you need them again.) ### Verifying a new panel diff --git a/scripts/blue-current.py b/scripts/blue-current.py deleted file mode 100755 index f5a7ab4..0000000 --- a/scripts/blue-current.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Distinguish a power/current problem from a data problem on the blue channel. - -Blue and white fail while red/green work — the signature of a sagging 5V rail -(blue LEDs have the highest forward voltage, so they drop out first under -brownout; white is peak current). This draws a SMALL blue patch (tiny current) -then a FULL-panel blue fill (large current): - - - small blue lights, full blue blank/dim -> POWER: the 5V rail sags under - load. Check the panel's power feed (supply rating, barrel jack, screw - terminal, wire gauge). Ideally measure 5V at the panel input during the - full-blue phase — if it sags below ~4.5V, that's the cause. - - both blank -> not current; deeper blue fault - - both solid blue -> blue is fine at this drive - -Uses led_rgb_sequence=RBG (this panel's order). -""" - -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True -options.led_rgb_sequence = "RBG" - -matrix = RGBMatrix(options=options) - -# Phase 1: small 8x8 blue patch in the center — minimal current. -canvas = matrix.CreateFrameCanvas() -for x in range(28, 36): - for y in range(12, 20): - canvas.SetPixel(x, y, 0, 0, 255) -matrix.SwapOnVSync(canvas) -print(">>> SMALL blue patch (low current) — does this 8x8 center square light blue? (12s)") -time.sleep(12) - -# Phase 2: full-panel blue — large current. -canvas = matrix.CreateFrameCanvas() -canvas.Fill(0, 0, 255) -matrix.SwapOnVSync(canvas) -print(">>> FULL-PANEL blue (high current) — measure 5V at the panel now if you can. (12s)") -time.sleep(12) - -matrix.Clear() -print("done") diff --git a/scripts/blue-hold.py b/scripts/blue-hold.py deleted file mode 100755 index a493089..0000000 --- a/scripts/blue-hold.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -"""Hold a sustained, full-brightness BLUE fill to test the blue connection. - -Blue flashes briefly at matrix init but won't hold during a steady fill — the -signature of an intermittent connection. While this runs, GENTLY press/wiggle -the IDC ribbon cable at BOTH ends (HAT and panel): - - - blue flickers on/off in response to pressure -> bad connection (reseat / - replace the ribbon cable, or try the panel's other HUB75 header) - - blue stays solid for the full 30s -> connection is fine; the - earlier blanks were the dim 150-level drive, not a fault - - blue stays totally dark regardless -> deeper fault on that line - -Uses led_rgb_sequence=RBG (this panel's order) so logical blue drives the -physical blue subpixels. -""" - -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True -options.led_rgb_sequence = "RBG" - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() -canvas.Fill(0, 0, 255) # full-brightness blue -matrix.SwapOnVSync(canvas) - -print("Solid BLUE for 30s — gently wiggle the ribbon cable at both ends and watch.") -time.sleep(30) -matrix.Clear() -print("done") diff --git a/scripts/content-sweep.sh b/scripts/content-sweep.sh deleted file mode 100755 index 8dc716c..0000000 --- a/scripts/content-sweep.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# content-sweep.sh: Run on the Pi to find the row_address_type x multiplexing -# combo that renders text cleanly on this 1/16-scan panel. -# -# Per hzeller issue #1640, 1/16-scan panels often need row_address_type=3 (and -# sometimes a multiplexing mapper). Draws the app's 4-row card in blue-free -# colors. Watch for the combo where all four rows are readable, in order, with -# no scrambling inside each row. -# -# Ctrl-C once to skip to the next combo. -set -u - -HOLD="${HOLD:-12}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# "row_addr_type multiplexing" — most-likely combos first (row_addr_type=3 alone -# fixed a similar panel in issue #1640), then pair it with the column mappers. -COMBOS=( - "3 0" - "2 0" - "4 0" - "3 1" - "3 17" - "2 17" - "4 17" -) - -for combo in "${COMBOS[@]}"; do - read -r addr mux <<<"$combo" - echo - echo ">>> row_address_type=$addr multiplexing=$mux (${HOLD}s)" - sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/panel-content.py" "$addr" "$mux" "$HOLD" -done - -echo -echo "=== Done. Which (row_address_type, multiplexing) showed 4 clean readable rows? ===" diff --git a/scripts/hold-sweep.sh b/scripts/hold-sweep.sh deleted file mode 100755 index 358247d..0000000 --- a/scripts/hold-sweep.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -# hold-sweep.sh: Run on the Pi to see if any GPIO-timing setting lets a STATIC -# image hold steady instead of degrading. -# -# The box+X renders correctly then corrupts under sustained refresh. This sweeps -# gpio_slowdown (and a hardware-pulsing variant) holding the static box+X each -# time. Watch for a setting where the image stays STABLE for the full hold. -set -u - -HOLD="${HOLD:-12}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# "gpio_slowdown disable_pulsing" -COMBOS=( - "2 1" - "3 1" - "5 1" - "6 1" - "4 0" -) - -for combo in "${COMBOS[@]}"; do - read -r slowdown pulsing <<<"$combo" - echo - echo ">>> gpio_slowdown=$slowdown disable_hardware_pulsing=$pulsing (${HOLD}s)" - sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/probe-hold.py" "$slowdown" "$pulsing" "$HOLD" -done - -echo -echo "=== Done. Did any setting hold the box+X stable for the full duration? ===" diff --git a/scripts/mux-sweep.sh b/scripts/mux-sweep.sh deleted file mode 100755 index 27e656d..0000000 --- a/scripts/mux-sweep.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# mux-sweep.sh: Run on the Pi to find the panel's multiplexing type. -# -# Solid fills render fine but sparse content (text/lines) garbles — a -# pixel-mapping mismatch. This sweeps every multiplexing type (0-17), drawing a -# border + text each time. Watch for the value that yields a CLEAN rectangle -# border and READABLE "AbCd12", then put it in config.yaml as hardware.multiplexing. -# -# Ctrl-C once to skip to the next value; Ctrl-C twice quickly to abort. -set -u - -HOLD="${HOLD:-6}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -for mux in $(seq 0 17); do - echo - echo ">>> Testing multiplexing=$mux (${HOLD}s) — clean border + readable text?" - sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/panel-mux.py" "$mux" "$HOLD" -done - -echo -echo "=== Done. Tell me which multiplexing value rendered a clean border + readable text. ===" diff --git a/scripts/panel-confirm.py b/scripts/panel-confirm.py deleted file mode 100755 index f483b6f..0000000 --- a/scripts/panel-confirm.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""Confirm a (multiplexing, rows, cols) combo renders the app's 4-row card. - -Mirrors renderer.render_flight_card exactly (same y-positions, colors, font) so -we verify the actual use case before baking the values into config. For a -multiplexed 1/16-scan panel the PHYSICAL rows/cols differ from the visible -64x32 — the mux mapper folds e.g. 16x128 physical into 32x64 visible. - -Usage: panel-confirm.py [multiplexing] [rows] [cols] (default: 17 16 128) -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics - -MULTIPLEXING = int(sys.argv[1]) if len(sys.argv) > 1 else 17 -ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 16 -COLS = int(sys.argv[3]) if len(sys.argv) > 3 else 128 -FONT_HEIGHT = 7 - -options = RGBMatrixOptions() -options.cols = COLS -options.rows = ROWS -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True -options.multiplexing = MULTIPLEXING - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -font = graphics.Font() -font.LoadFont("fonts/5x7.bdf") - -# Same layout as renderer.render_flight_card. -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 1 + 0, graphics.Color(255, 140, 0), "UAL123") -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 2 + 1, graphics.Color(0, 255, 255), "IAH->LAX") -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 3 + 2, graphics.Color(0, 255, 0), "B738") -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 4 + 3, graphics.Color(0, 120, 255), "35K ft") - -canvas = matrix.SwapOnVSync(canvas) -print(f"mux={MULTIPLEXING} rows={ROWS} cols={COLS}: 4 rows, distinct colors (orange/cyan/green/blue)?") -time.sleep(15) -matrix.Clear() diff --git a/scripts/panel-content.py b/scripts/panel-content.py deleted file mode 100755 index 634a0e2..0000000 --- a/scripts/panel-content.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Verify the app's content renders correctly, ignoring the dead blue channel. - -Draws renderer.render_flight_card's exact 4-row layout in BLUE-FREE colors -(red / green / yellow / orange), so we judge readability and positioning -without the blue fault interfering. Sweeps row_address_type x multiplexing — -per hzeller issue #1640, 1/16-scan panels often need row_address_type=3 (and -sometimes a multiplexing mapper) to stop the jumble. - -Usage: panel-content.py [row_addr_type] [multiplexing] [hold-seconds] - defaults: 0 0 15 -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics - -ROW_ADDR_TYPE = int(sys.argv[1]) if len(sys.argv) > 1 else 0 -MULTIPLEXING = int(sys.argv[2]) if len(sys.argv) > 2 else 0 -HOLD_SECONDS = int(sys.argv[3]) if len(sys.argv) > 3 else 15 -FONT_HEIGHT = 7 - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.multiplexing = MULTIPLEXING -options.row_address_type = ROW_ADDR_TYPE -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -font = graphics.Font() -font.LoadFont("fonts/5x7.bdf") - -# Same positions as render_flight_card, blue-free colors (B=0 everywhere). -RED = graphics.Color(255, 0, 0) -GREEN = graphics.Color(0, 255, 0) -YELLOW = graphics.Color(255, 255, 0) -ORANGE = graphics.Color(255, 90, 0) - -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 1 + 0, RED, "UAL123") -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 2 + 1, GREEN, "IAH-LAX") -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 3 + 2, YELLOW, "B738") -graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 4 + 3, ORANGE, "35K ft") - -matrix.SwapOnVSync(canvas) -print(f"row_addr_type={ROW_ADDR_TYPE} multiplexing={MULTIPLEXING}: " - "UAL123 / IAH-LAX / B738 / 35K ft") -print("All four readable, in order, no scrambling within each row?") -time.sleep(HOLD_SECONDS) -matrix.Clear() diff --git a/scripts/panel-diag.sh b/scripts/panel-diag.sh deleted file mode 100755 index 1d3ca42..0000000 --- a/scripts/panel-diag.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash -# panel-diag.sh: Run on the Pi to find working LED panel flags. -# -# Drives the rpi-rgb-led-matrix `demo` binary (a moving square across the WHOLE -# panel) through a sweep of hardware options. Watch the panel during each step -# and note which label — if any — renders a clean, full-panel square. -# -# This isolates the panel/wiring from the jetset app: if NO combo renders -# cleanly, the problem is hardware (cable, power, mapping); if one does, copy -# its flags into config.yaml's `hardware` section. -set -u - -DEMO="${DEMO:-$HOME/rpi-rgb-led-matrix/examples-api-use/demo}" -if [ ! -x "$DEMO" ]; then - echo "ERROR: demo binary not found at $DEMO" - echo "Build it first: (cd ~/rpi-rgb-led-matrix && make -j4)" - exit 1 -fi - -# Shared flags: single 64x32 panel on the Adafruit HAT (no PWM solder mod), -# moving-square demo (-D0). Each run is capped at 8s via `timeout` because this -# demo build has no -t flag. -RUN_SECONDS="${RUN_SECONDS:-8}" -COMMON=(-D0 --led-rows=32 --led-cols=64 --led-chain=1 - --led-gpio-mapping=adafruit-hat --led-no-hardware-pulse - --led-slowdown-gpio=4) - -# label : extra flags to test on top of COMMON -VARIANTS=( - "baseline (no panel-type) :" - "panel-type FM6126A :--led-panel-type=FM6126A" - "panel-type FM6127 :--led-panel-type=FM6127" - "row-addr-type 1 :--led-row-addr-type=1" - "multiplexing 1 (Stripe) :--led-multiplexing=1" - "slower GPIO (slowdown=2) :--led-slowdown-gpio=2" - "slower GPIO (slowdown=6) :--led-slowdown-gpio=6" -) - -echo "=== Panel diagnostic sweep — watch the panel, note the clean one ===" -for entry in "${VARIANTS[@]}"; do - label="${entry%%:*}" - extra="${entry#*:}" - echo - echo ">>> Testing: $label (${RUN_SECONDS}s)" - # `timeout` sends SIGTERM after RUN_SECONDS; the demo exits on it. - # shellcheck disable=SC2086 - sudo timeout "$RUN_SECONDS" "$DEMO" "${COMMON[@]}" $extra -done - -echo -echo "=== Done. Tell me which label showed a clean full-panel moving square. ===" diff --git a/scripts/panel-hbands.py b/scripts/panel-hbands.py deleted file mode 100755 index 264e2b9..0000000 --- a/scripts/panel-hbands.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -"""Characterize the horizontal column mapping. - -At mux=0/rows=32/cols=64 the vertical layout is correct but columns are -scrambled. Draw known patterns so we can read off the exact column permutation -and choose the right pixel mapper / fix. - -Usage: panel-hbands.py [multiplexing] [rows] [cols] (default 0 32 64) -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -MUX = int(sys.argv[1]) if len(sys.argv) > 1 else 0 -ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 32 -COLS = int(sys.argv[3]) if len(sys.argv) > 3 else 64 - -options = RGBMatrixOptions() -options.cols = COLS -options.rows = ROWS -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True -options.multiplexing = MUX - -matrix = RGBMatrix(options=options) - - -def show(label, paint, seconds=10): - canvas = matrix.CreateFrameCanvas() - w, h = canvas.width, canvas.height - for x in range(w): - for y in range(h): - r, g, b = paint(x, w) - canvas.SetPixel(x, y, r, g, b) - matrix.SwapOnVSync(canvas) - print(f">>> {label} ({seconds}s)") - time.sleep(seconds) - - -# Pattern A: four equal quarters, left->right RED GREEN BLUE WHITE. -QUARTERS = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)] -show( - "A: quarters L->R should be RED GREEN BLUE WHITE", - lambda x, w: QUARTERS[min(x // (w // 4), 3)], -) - -# Pattern B: even columns RED, odd columns BLUE — exposes odd/even interleave. -# If columns are interleaved you'll see a RED block and a BLUE block (or larger -# stripes) instead of a fine red/blue comb. -show( - "B: even cols RED, odd cols BLUE (fine comb if mapping is linear)", - lambda x, w: (255, 0, 0) if x % 2 == 0 else (0, 0, 255), - 8, -) - -matrix.Clear() -print("done") diff --git a/scripts/panel-mux.py b/scripts/panel-mux.py deleted file mode 100755 index 8c8cb4b..0000000 --- a/scripts/panel-mux.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -"""Draw a border + text under a given multiplexing value. - -Solid fills render fine on this panel but sparse content (text, lines) garbles -— the signature of a pixel-mapping/multiplexing mismatch. This draws SPARSE -content so the right mapping is visually obvious. Sweep values with mux-sweep.sh. - -Usage: panel-mux.py [hold-seconds] -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics - -MULTIPLEXING = int(sys.argv[1]) if len(sys.argv) > 1 else 0 -HOLD_SECONDS = int(sys.argv[2]) if len(sys.argv) > 2 else 6 -WIDTH, HEIGHT = 64, 32 - -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True -options.multiplexing = MULTIPLEXING - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -# 1px green border around the full panel edge. -for x in range(WIDTH): - canvas.SetPixel(x, 0, 0, 80, 0) - canvas.SetPixel(x, HEIGHT - 1, 0, 80, 0) -for y in range(HEIGHT): - canvas.SetPixel(0, y, 0, 80, 0) - canvas.SetPixel(WIDTH - 1, y, 0, 80, 0) - -# Readable text spanning most of the width. -font = graphics.Font() -font.LoadFont("fonts/5x7.bdf") -graphics.DrawText(canvas, font, 2, 14, graphics.Color(255, 255, 255), "AbCd12") - -canvas = matrix.SwapOnVSync(canvas) -print(f"multiplexing={MULTIPLEXING}: clean rectangle border + readable 'AbCd12'?") -time.sleep(HOLD_SECONDS) -matrix.Clear() diff --git a/scripts/panel-rgbseq.py b/scripts/panel-rgbseq.py deleted file mode 100755 index 25a7c1d..0000000 --- a/scripts/panel-rgbseq.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""Show solid RED/GREEN/BLUE under a given led_rgb_sequence. - -Solid-color fills tested earlier came out misordered (blue-input lit green) and -partly blank. led_rgb_sequence permutes the panel's subpixel order. The correct -sequence makes red->red, green->green, blue->blue. If BLUE never appears under -ANY of the six sequences, the blue channel is a hardware fault, not config. - -Usage: panel-rgbseq.py one of RGB RBG GRB GBR BRG BGR -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -SEQUENCE = sys.argv[1] if len(sys.argv) > 1 else "RGB" - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True -options.led_rgb_sequence = SEQUENCE - -matrix = RGBMatrix(options=options) - -for name, (r, g, b) in [("RED", (150, 0, 0)), ("GREEN", (0, 150, 0)), ("BLUE", (0, 0, 150))]: - canvas = matrix.CreateFrameCanvas() - canvas.Fill(r, g, b) - matrix.SwapOnVSync(canvas) - print(f" seq={SEQUENCE}: solid {name} — panel should be {name}") - time.sleep(2.5) - -matrix.Clear() diff --git a/scripts/probe-color.py b/scripts/probe-color.py deleted file mode 100755 index caec0e5..0000000 --- a/scripts/probe-color.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Compare animated vs static rendering for a single color, at a stable slowdown. - -Green worked as a sweeping line but vanished as a static X — but those tests -differed in slowdown (4 vs 5) AND animated-vs-static AND had a red box next to -the green. This isolates it: pick ONE color, run the SAME three things at the -SAME slowdown: - - Phase 1: horizontal line sweep (animated) - Phase 2: vertical line sweep (animated) - Phase 3: static box + X, held - -Run it per color (e.g. 'green 5' then 'red 5') and compare: does the color show -in the animated sweeps but disappear in the static shape? That would pin a -static-content problem to that specific channel. - -Usage: probe-color.py [color] [gpio_slowdown] - color: red green blue yellow cyan white magenta (default green 5) -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -COLORS = { - "red": (255, 0, 0), - "green": (0, 255, 0), - "blue": (0, 0, 255), - "yellow": (255, 255, 0), - "cyan": (0, 255, 255), - "white": (255, 255, 255), - "magenta": (255, 0, 255), -} - -NAME = sys.argv[1] if len(sys.argv) > 1 else "green" -SLOWDOWN = int(sys.argv[2]) if len(sys.argv) > 2 else 5 -R, G, B = COLORS.get(NAME, (0, 255, 0)) -WIDTH, HEIGHT = 64, 32 - -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = SLOWDOWN -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -print(f">>> color={NAME} slowdown={SLOWDOWN}") - -print(" PHASE 1: horizontal line sweep (animated)") -for y in range(HEIGHT): - canvas.Clear() - for x in range(WIDTH): - canvas.SetPixel(x, y, R, G, B) - canvas = matrix.SwapOnVSync(canvas) - time.sleep(0.3) - -print(" PHASE 2: vertical line sweep (animated)") -for x in range(WIDTH): - canvas.Clear() - for y in range(HEIGHT): - canvas.SetPixel(x, y, R, G, B) - canvas = matrix.SwapOnVSync(canvas) - time.sleep(0.2) - -print(" PHASE 3: STATIC box + X (held 12s) — does it show as cleanly as the sweeps?") -canvas.Clear() -inset = 2 -for x in range(inset, WIDTH - inset): - canvas.SetPixel(x, inset, R, G, B) - canvas.SetPixel(x, HEIGHT - 1 - inset, R, G, B) -for y in range(inset, HEIGHT - inset): - canvas.SetPixel(inset, y, R, G, B) - canvas.SetPixel(WIDTH - 1 - inset, y, R, G, B) -for i in range(HEIGHT): - x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) - canvas.SetPixel(x_left, i, R, G, B) - canvas.SetPixel(WIDTH - 1 - x_left, i, R, G, B) -canvas = matrix.SwapOnVSync(canvas) -time.sleep(12) - -matrix.Clear() -print("done") diff --git a/scripts/probe-geo.py b/scripts/probe-geo.py deleted file mode 100755 index a4dfd77..0000000 --- a/scripts/probe-geo.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -"""Confirm geometry is correct (no mapper needed) using only the solid RED -channel at the stable gpio_slowdown. Draws a box outline + an X, all red, and -holds. If this is a clean, stable box-with-an-X, the panel's pixel mapping is -standard and the earlier 'jumble' was purely refresh instability + the panel's -dead/flaky green & blue channels. - -Usage: probe-geo.py [gpio_slowdown] [hold-seconds] (default 5 15) -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -WIDTH, HEIGHT = 64, 32 -SLOWDOWN = int(sys.argv[1]) if len(sys.argv) > 1 else 5 -HOLD = int(sys.argv[2]) if len(sys.argv) > 2 else 15 - -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = SLOWDOWN -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -RED = (255, 0, 0) -inset = 2 -for x in range(inset, WIDTH - inset): - canvas.SetPixel(x, inset, *RED) - canvas.SetPixel(x, HEIGHT - 1 - inset, *RED) -for y in range(inset, HEIGHT - inset): - canvas.SetPixel(inset, y, *RED) - canvas.SetPixel(WIDTH - 1 - inset, y, *RED) -for i in range(HEIGHT): - x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) - canvas.SetPixel(x_left, i, *RED) - canvas.SetPixel(WIDTH - 1 - x_left, i, *RED) - -matrix.SwapOnVSync(canvas) -print(f">>> slowdown={SLOWDOWN}: a RED box with a RED X. Clean, stable, correct geometry?") -time.sleep(HOLD) -matrix.Clear() diff --git a/scripts/probe-hold.py b/scripts/probe-hold.py deleted file mode 100755 index 99316fd..0000000 --- a/scripts/probe-hold.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -"""Hold a STATIC box+X and see if it stays stable or degrades. - -The image renders correctly then corrupts under sustained refresh — a -refresh-stability / GPIO-timing symptom. This redraws nothing after the first -frame, so any degradation is the hardware refresh, not the code. Sweep -gpio_slowdown (and optionally hardware-pulsing) to see if any setting holds the -image steady for the full duration. - -Usage: probe-hold.py [gpio_slowdown] [disable_hw_pulsing 0|1] [hold-seconds] - defaults: 4 1 12 -""" - -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -WIDTH, HEIGHT = 64, 32 - -SLOWDOWN = int(sys.argv[1]) if len(sys.argv) > 1 else 4 -DISABLE_PULSING = (int(sys.argv[2]) if len(sys.argv) > 2 else 1) == 1 -HOLD = int(sys.argv[3]) if len(sys.argv) > 3 else 12 - -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = SLOWDOWN -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = DISABLE_PULSING - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -inset = 2 -for x in range(inset, WIDTH - inset): - canvas.SetPixel(x, inset, 255, 0, 0) - canvas.SetPixel(x, HEIGHT - 1 - inset, 255, 0, 0) -for y in range(inset, HEIGHT - inset): - canvas.SetPixel(inset, y, 255, 0, 0) - canvas.SetPixel(WIDTH - 1 - inset, y, 255, 0, 0) -for i in range(HEIGHT): - x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) - canvas.SetPixel(x_left, i, 0, 255, 0) - canvas.SetPixel(WIDTH - 1 - x_left, i, 0, 255, 0) - -matrix.SwapOnVSync(canvas) -print(f">>> slowdown={SLOWDOWN} disable_pulsing={DISABLE_PULSING}: " - f"does the box+X stay STABLE for {HOLD}s, or degrade?") -time.sleep(HOLD) -matrix.Clear() diff --git a/scripts/probe-scan.py b/scripts/probe-scan.py deleted file mode 100755 index 1bd1fb4..0000000 --- a/scripts/probe-scan.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -"""Reverse-engineer probe: animate ONE logical line scanning the panel to reveal -how logical coordinates map to physical pixels on this non-standard panel. - -Phase 1: a full-width RED horizontal line steps through logical rows 0..31. -Phase 2: a full-height GREEN vertical line steps through logical cols 0..63. - -Watch how the PHYSICAL line moves and describe the motion: - - smooth top->bottom / left->right -> that axis maps linearly - - jumps between halves, or two lines at -> reveals the scan/multiplex split - once moving together, or scans in blocks -Blue-free colors so the dead blue channel doesn't interfere. Runs each phase -twice so you can confirm the pattern. -""" - -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -WIDTH, HEIGHT = 64, 32 - -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -for _ in range(2): - print("PHASE 1: RED horizontal line, logical row 0 -> 31 (top to bottom)") - for y in range(HEIGHT): - canvas.Clear() - for x in range(WIDTH): - canvas.SetPixel(x, y, 255, 0, 0) - canvas = matrix.SwapOnVSync(canvas) - time.sleep(0.4) - -time.sleep(1.0) - -for _ in range(2): - print("PHASE 2: GREEN vertical line, logical col 0 -> 63 (left to right)") - for x in range(WIDTH): - canvas.Clear() - for y in range(HEIGHT): - canvas.SetPixel(x, y, 0, 255, 0) - canvas = matrix.SwapOnVSync(canvas) - time.sleep(0.25) - -matrix.Clear() -print("done") diff --git a/scripts/probe-sparse.py b/scripts/probe-sparse.py deleted file mode 100755 index 2f51e80..0000000 --- a/scripts/probe-sparse.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -"""Full lines scan cleanly but text is jumbled — so the coordinate mapping is -likely fine and the cause is elsewhere. This isolates it: - -Phase A: a sparse 2D shape drawn pixel-by-pixel (box outline + an X). No font. - Clean box with a clean X -> individual-pixel mapping is correct, the - panel does NOT need a custom mapper. The problem is the font/text. - Scrambled -> there IS a 2D mapping quirk full lines couldn't show. - -Phase B: text drawn with the LIBRARY's bundled font (not the project's 5x7.bdf). - Readable here -> the project's fonts/5x7.bdf is the culprit; swap it. - Jumbled here too -> DrawText/graphics issue. - -Settings match the app (mux=0, row_addr=0, RBG). Blue-free colors. -""" - -import os -import sys -import time - -from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics - -WIDTH, HEIGHT = 64, 32 - -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) - -# --- Phase A: sparse 2D shape, pixel-by-pixel (no font) --------------------- -canvas = matrix.CreateFrameCanvas() -inset = 2 -# box outline -for x in range(inset, WIDTH - inset): - canvas.SetPixel(x, inset, 255, 0, 0) - canvas.SetPixel(x, HEIGHT - 1 - inset, 255, 0, 0) -for y in range(inset, HEIGHT - inset): - canvas.SetPixel(inset, y, 255, 0, 0) - canvas.SetPixel(WIDTH - 1 - inset, y, 255, 0, 0) -# an X across the box (diagonals) -for i in range(HEIGHT): - x_left = int(i * (WIDTH - 1) / (HEIGHT - 1)) - canvas.SetPixel(x_left, i, 0, 255, 0) - canvas.SetPixel(WIDTH - 1 - x_left, i, 0, 255, 0) -canvas = matrix.SwapOnVSync(canvas) -print(">>> PHASE A: a red BOX outline with a green X through it. Clean? (12s)") -time.sleep(12) - -# --- Phase B: text with the library's bundled font -------------------------- -candidates = [ - "/home/hao/rpi-rgb-led-matrix/fonts/6x10.bdf", - os.path.expanduser("~/rpi-rgb-led-matrix/fonts/6x10.bdf"), - "/home/hao/rpi-rgb-led-matrix/fonts/5x7.bdf", -] -font_path = next((p for p in candidates if os.path.exists(p)), None) -if font_path is None: - print("!! library font not found; checked:", candidates, file=sys.stderr) -else: - font = graphics.Font() - font.LoadFont(font_path) - canvas = matrix.CreateFrameCanvas() - graphics.DrawText(canvas, font, 1, 10, graphics.Color(255, 0, 0), "ABCD12") - graphics.DrawText(canvas, font, 1, 26, graphics.Color(0, 255, 0), "wxyz89") - canvas = matrix.SwapOnVSync(canvas) - print(f">>> PHASE B: text via library font {font_path}") - print(" 'ABCD12' / 'wxyz89' — readable? (12s)") - time.sleep(12) - -matrix.Clear() -print("done") diff --git a/scripts/py-panel-test.py b/scripts/py-panel-test.py deleted file mode 100755 index 5717d25..0000000 --- a/scripts/py-panel-test.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""On-Pi isolation test: drives the panel directly via the rgbmatrix Python -bindings to find where the app's render path diverges from the (working) C demo. - -The C `demo` binary renders cleanly, so the hardware + flags are good. This -walks the Python draw path one stage at a time — solid fill, geometry border, -then BDF text — so we can see exactly which stage garbles. - -Run on the Pi (root needed for GPIO): - cd ~/jetset && sudo -E env PATH=$PATH uv run python scripts/py-panel-test.py -""" - -import time - -# --- Stage 0: backend probe ------------------------------------------------- -try: - from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics -except ImportError as exc: # pragma: no cover - hardware-only path - print(f"FAIL: cannot import rgbmatrix (incl. graphics): {exc}") - raise SystemExit(1) from exc -print("OK: imported rgbmatrix incl. graphics") - -WIDTH, HEIGHT = 64, 32 - -# Match the WORKING C-demo baseline as closely as possible: only the flags the -# demo used, nothing extra (no pwm_bits / multiplexing / panel_type / rgb_seq). -options = RGBMatrixOptions() -options.cols = WIDTH -options.rows = HEIGHT -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) - - -def hold(label: str, seconds: int = 4) -> None: - print(f">>> {label} ({seconds}s)") - time.sleep(seconds) - - -# --- Stage A: solid fill across the whole panel ----------------------------- -canvas = matrix.CreateFrameCanvas() -canvas.Fill(40, 0, 0) # dim red -canvas = matrix.SwapOnVSync(canvas) -hold("A: whole panel dim RED — should fill ALL 64x32 evenly") - -# --- Stage B: 1px border to verify dimensions/mapping ----------------------- -canvas.Clear() -for x in range(WIDTH): - canvas.SetPixel(x, 0, 0, 60, 0) - canvas.SetPixel(x, HEIGHT - 1, 0, 60, 0) -for y in range(HEIGHT): - canvas.SetPixel(0, y, 0, 60, 0) - canvas.SetPixel(WIDTH - 1, y, 0, 60, 0) -canvas = matrix.SwapOnVSync(canvas) -hold("B: GREEN border hugging all four edges — clean rectangle?") - -# --- Stage C: BDF text on four rows (the app's actual draw path) ------------ -canvas.Clear() -font = graphics.Font() -font.LoadFont("fonts/5x7.bdf") -white = graphics.Color(255, 255, 255) -graphics.DrawText(canvas, font, 1, 7, white, "ABCD") -graphics.DrawText(canvas, font, 1, 15, white, "1234") -graphics.DrawText(canvas, font, 1, 23, white, "wxyz") -graphics.DrawText(canvas, font, 1, 31, white, "EDGE") -canvas = matrix.SwapOnVSync(canvas) -hold("C: four rows of text top-to-bottom — all four readable?", 8) - -matrix.Clear() -print("done") diff --git a/scripts/rgbseq-sweep.sh b/scripts/rgbseq-sweep.sh deleted file mode 100755 index fa8e361..0000000 --- a/scripts/rgbseq-sweep.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -# rgbseq-sweep.sh: Run on the Pi to find the panel's subpixel order. -# -# Solid color fills came out misordered/blank. This sweeps all six -# led_rgb_sequence permutations, showing solid RED/GREEN/BLUE for each. Watch -# for the sequence where red->red, green->green, blue->blue. If BLUE never -# lights under any sequence, the blue channel is a hardware fault (cable/panel). -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -for seq in RGB RBG GRB GBR BRG BGR; do - echo - echo ">>> led_rgb_sequence=$seq" - sudo -E env PATH="$PATH" uv run python "$SCRIPT_DIR/panel-rgbseq.py" "$seq" -done - -echo -echo "=== Done. Which sequence (if any) showed red=red, green=green, blue=blue? ===" -echo "=== If blue NEVER appeared, the blue channel is a hardware fault. ===" From e27f1c4361a04e9cf12861b9392bc2b0529e3f03 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 17:59:05 -0500 Subject: [PATCH 19/36] CHORE: post-migration tidy (dotenv dep, docstring, README targets) - Move python-dotenv from dev to main dependencies: __main__ calls load_dotenv() at runtime to read AIRLABS_API_KEY, so it is a runtime dep (a --no-dev sync would otherwise drop .env support). - Update the stale __main__ module docstring (no longer an emulator smoke test). - Refresh the README Targets table with the current Make targets (deploy, setup-pi, run-pi, debug-pi, fixtures, debug). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 +++++++- pyproject.toml | 2 +- src/jetset/__main__.py | 3 ++- uv.lock | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 80e4310..0af40a8 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,15 @@ uv sync | Command | What it does | |---|---| -| `make run` | Run the display emulator at http://localhost:8888/ | +| `make run` | Run the display in the emulator at http://localhost:8888/ | +| `make debug` | Run the emulator with debug logging | | `make test` | Run tests (pytest) | | `make lint` | Lint check (ruff) | +| `make fixtures` | Save a live AirLabs response as a test fixture | +| `make deploy` | rsync the code to the Pi | +| `make setup-pi` | Install deps + build rpi-rgb-led-matrix (run on the Pi) | +| `make run-pi` | Run the app on the Pi (needs root for GPIO) | +| `make debug-pi` | Run on the Pi with debug logging | ## Configuration diff --git a/pyproject.toml b/pyproject.toml index 32f6d30..ae5a1ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "rgbmatrixemulator>=0.18.1", "pyyaml>=6", "requests>=2.34.2", + "python-dotenv>=1.2.2", ] [project.scripts] @@ -37,6 +38,5 @@ filterwarnings = [ [dependency-groups] dev = [ "pytest>=9.0.3", - "python-dotenv>=1.2.2", "ruff>=0.15.16", ] diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index 1f7fb0a..eaf43e6 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -1,4 +1,5 @@ -"""Minimal emulator smoke test — proves the LED matrix emulator works.""" +"""Entry point: fetch nearby flights from AirLabs and render the flight board +on the LED matrix (real rpi-rgb-led-matrix on the Pi, emulator elsewhere).""" import logging import os diff --git a/uv.lock b/uv.lock index d81ca4d..5a74418 100644 --- a/uv.lock +++ b/uv.lock @@ -93,6 +93,7 @@ name = "jetset" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, { name = "rgbmatrixemulator" }, @@ -101,12 +102,12 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, - { name = "python-dotenv" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "pyyaml", specifier = ">=6" }, { name = "requests", specifier = ">=2.34.2" }, { name = "rgbmatrixemulator", specifier = ">=0.18.1" }, @@ -115,7 +116,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.3" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "ruff", specifier = ">=0.15.16" }, ] From 0156170b7503cc104c3d8020aa4257f589b94514 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 18:26:07 -0500 Subject: [PATCH 20/36] FIX: cycle the LOADING animation instead of freezing on "LOADING" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _render_loading passed the raw frame counter to loading_label, which only returns dotted variants for pages 1-3 and "LOADING" otherwise — so once the frame passed 3 the animation froze. The threaded render loop free-runs while the buffer is empty (startup, or no flights returned), making it obvious. Pass frame % 4 so the dots keep cycling, matching the flight-card paging. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/jetset/app.py | 4 +++- tests/test_main.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/jetset/app.py b/src/jetset/app.py index d68b894..4fa1d66 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -122,7 +122,9 @@ def _render_frame(self, matrix: RGBMatrix, canvas: Canvas, frame: Frame) -> Canv return self._after_render(matrix, canvas) def _render_loading(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: - render_loading(canvas, self.frame) + # % 4 so the "LOADING." dots keep cycling instead of freezing on + # "LOADING" once the frame counter passes 3. + render_loading(canvas, self.frame % self.PAGES_PER_FLIGHT) return self._after_render(matrix, canvas) def loop(self, matrix: RGBMatrix, canvas: Canvas): diff --git a/tests/test_main.py b/tests/test_main.py index 5a0b03f..dab6a2c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -190,3 +190,18 @@ def test_renders_loading_when_buffer_empty(self) -> None: mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) assert app.frame == 1 assert next_canvas is mock_matrix.SwapOnVSync.return_value + + def test_loading_animation_cycles_with_frame(self) -> None: + from jetset.app import App + + app = App(AppConfig()) + app.frame = 5 # past the first cycle — must wrap, not freeze on "LOADING" + + with ( + patch("jetset.app.render_loading") as mock_render, + patch("jetset.app.time.sleep"), + ): + app._render_loading(MagicMock(), MagicMock()) + + # 5 % 4 == 1 → "LOADING." so the dots keep animating + assert mock_render.call_args.args[1] == 1 From 78a246f540224863882dcf287fdc7eadce9fb903 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 20:48:27 -0500 Subject: [PATCH 21/36] FEATURE: run jetset as a boot-enabled systemd service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render jetset.service.template → unit at install (@HOME@ → $HOME); systemd can't expand $HOME and %h is /root for a root service. - setup-pi.sh installs + enables the service (boot); make deploy auto-runs setup-pi.sh when the unit's missing, then restarts. - Build fixes: clone into $HOME, Cython into the venv, drop unused system cython3/python3-pil. --- Makefile | 10 +++++++--- scripts/jetset.service | 17 ----------------- scripts/jetset.service.template | 19 +++++++++++++++++++ scripts/setup-pi.sh | 27 ++++++++++++++++++++------- 4 files changed, 46 insertions(+), 27 deletions(-) delete mode 100644 scripts/jetset.service create mode 100644 scripts/jetset.service.template diff --git a/Makefile b/Makefile index 8839087..447d05e 100644 --- a/Makefile +++ b/Makefile @@ -13,19 +13,23 @@ test: ## Run all tests lint: ## Lint and format check uv run ruff check src/ -fixtures: ## Save live AeroAPI response as test fixture +fixtures: ## Save a live AirLabs response as a test fixture uv run python scripts/save_fixtures.py # Pi Deployment -JETSET_USER ?= hao +JETSET_USER ?= pi JETSET_HOST ?= jetset.local JETSET_PATH ?= /home/$(JETSET_USER)/jetset -deploy: ## rsync code to Pi +deploy: ## rsync to the Pi, run first-time setup if needed, restart the service rsync -avz --exclude '.venv' --exclude '__pycache__' --exclude '.git' \ --exclude '*.pyc' --exclude 'wheels' \ --exclude '.pytest_cache' --exclude '.ruff_cache' \ . $(JETSET_USER)@$(JETSET_HOST):$(JETSET_PATH)/ + ssh -t $(JETSET_USER)@$(JETSET_HOST) \ + "cd $(JETSET_PATH) && \ + { [ -f /etc/systemd/system/jetset.service ] || bash scripts/setup-pi.sh; } && \ + sudo systemctl restart jetset" setup-pi: ## Install deps and build rpi-rgb-led-matrix (run on the Pi) bash scripts/setup-pi.sh diff --git a/scripts/jetset.service b/scripts/jetset.service deleted file mode 100644 index a77d4dd..0000000 --- a/scripts/jetset.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Jetset FlightWall -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=root -WorkingDirectory=/home/hao/jetset -Environment=PATH=/home/hao/.local/bin:/usr/bin:/bin -Environment=JETSET_DEBUG=1 -ExecStart=/home/hao/.local/bin/uv run python -m jetset -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/scripts/jetset.service.template b/scripts/jetset.service.template new file mode 100644 index 0000000..d4c6b15 --- /dev/null +++ b/scripts/jetset.service.template @@ -0,0 +1,19 @@ +# Template — rendered to /etc/systemd/system/jetset.service by setup-pi.sh, +# replacing @HOME@ with the deploying user's home dir. Runs as root for GPIO +# (so %h can't be used — it would resolve to /root), hence the templating. +[Unit] +Description=Jetset FlightWall +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=@HOME@/jetset +Environment=PATH=@HOME@/.local/bin:/usr/bin:/bin +ExecStart=@HOME@/.local/bin/uv run python -m jetset +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh index 6aa05fc..753d11c 100755 --- a/scripts/setup-pi.sh +++ b/scripts/setup-pi.sh @@ -17,8 +17,8 @@ else echo "=== Skipping swap (swapon not available) ===" fi -# 2. Install system deps -sudo apt update && sudo apt install -y python3-dev python3-pil cython3 build-essential cmake +# 2. Install system deps (Cython and Pillow live in the venv, not here) +sudo apt update && sudo apt install -y python3-dev build-essential cmake # 3. Install uv if missing if ! command -v uv &> /dev/null; then @@ -30,19 +30,22 @@ else export PATH="$HOME/.local/bin:$PATH" fi -# 4. Sync project deps and add scikit-build-core +# 4. Sync project deps and add the rgbmatrix build backend (into the venv — +# the wheel builds with --no-build-isolation, so Cython/scikit-build-core must +# live in the venv, not just the system Python from apt). cd ~/jetset # Fix permissions if previous builds ran as root sudo chown -R $(whoami):$(whoami) .venv 2>/dev/null || true uv sync -uv pip install scikit-build-core +uv pip install scikit-build-core cython # 5. Clone, build, and add rgbmatrix to the project WHEEL_FILE=$(ls ~/jetset/wheels/rgbmatrix-*.whl 2>/dev/null | head -1) if [ ! -f "$WHEEL_FILE" ]; then echo "=== Building rpi-rgb-led-matrix ===" - [ -d "$HOME/rpi-rgb-led-matrix" ] || git clone https://github.com/hzeller/rpi-rgb-led-matrix.git - cd ~/rpi-rgb-led-matrix + [ -d "$HOME/rpi-rgb-led-matrix" ] || \ + git clone https://github.com/hzeller/rpi-rgb-led-matrix.git "$HOME/rpi-rgb-led-matrix" + cd "$HOME/rpi-rgb-led-matrix" make -j4 cd ~/jetset @@ -65,4 +68,14 @@ else echo "=== rgbmatrix wheel already built ===" fi -echo "=== Setup complete! Run \`make run-pi\` ===" +# 6. Install + enable the systemd service (runs on boot) +echo "=== Installing jetset systemd service ===" +sed "s|@HOME@|$HOME|g" scripts/jetset.service.template \ + | sudo tee /etc/systemd/system/jetset.service > /dev/null +sudo systemctl daemon-reload +sudo systemctl enable jetset +sudo systemctl restart jetset + +echo "=== Setup complete! The jetset service is enabled and running. ===" +echo " Logs: journalctl -u jetset -f" +echo " (foreground debug run: \`make debug-pi\` — stop the service first)" From c0f023960031f56c680a8822f29e26c53a5c7e8d Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 22:50:59 -0500 Subject: [PATCH 22/36] FEATURE: airline logos on flight cards Render an airline's logo (cached under JETSET_LOGO_DIR) on each flight card; scaled once and memoized, drawn as red silhouettes while MONOCHROME_RED is on for the faulty panel. The systemd unit gains JETSET_LOGO_DIR and pillow becomes a runtime dependency. --- pyproject.toml | 1 + scripts/download_logos.py | 57 ++++++++++++++++++++++ scripts/jetset.service.template | 1 + src/jetset/display.py | 24 ++++++++++ src/jetset/renderer.py | 83 +++++++++++++++++++++++++++++---- tests/test_display.py | 7 +++ tests/test_renderer.py | 46 ++++++++++++++++++ uv.lock | 2 + 8 files changed, 213 insertions(+), 8 deletions(-) create mode 100755 scripts/download_logos.py diff --git a/pyproject.toml b/pyproject.toml index ae5a1ad..d3293db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pyyaml>=6", "requests>=2.34.2", "python-dotenv>=1.2.2", + "pillow>=11", ] [project.scripts] diff --git a/scripts/download_logos.py b/scripts/download_logos.py new file mode 100755 index 0000000..0ece78f --- /dev/null +++ b/scripts/download_logos.py @@ -0,0 +1,57 @@ +"""Download airline logos from the sexym0nk3y/airline-logos repository. + +Logos are cached locally in ~/.cache/jetset/logos/ as {ICAO}.png files. +""" + +import json +import urllib.error +import urllib.request + +from jetset.display import LOGO_DIR + +REPO_API = "https://api.github.com/repos/sexym0nk3y/airline-logos/contents/logos" +RAW_BASE = "https://raw.githubusercontent.com/sexym0nk3y/airline-logos/main/logos" + + +def _fetch_logo_list() -> list[str]: + """Fetch the list of logo filenames from the GitHub API.""" + with urllib.request.urlopen(REPO_API) as resp: + entries = json.loads(resp.read().decode()) + return [e["name"] for e in entries if e["name"].endswith(".png")] + + +def download_logos(icao_codes: list[str] | None = None) -> int: + """Download airline logos to LOGO_DIR. + + Args: + icao_codes: Optional list of ICAO codes to download. If None, all + 993 logos from the repo are downloaded. + + Returns: + Number of logos downloaded. + """ + LOGO_DIR.mkdir(parents=True, exist_ok=True) + + if icao_codes is not None: + filenames = [f"{code}.png" for code in icao_codes] + else: + filenames = _fetch_logo_list() + + count = 0 + for name in filenames: + dest = LOGO_DIR / name + if dest.exists(): + continue + url = f"{RAW_BASE}/{name}" + try: + urllib.request.urlretrieve(url, dest) + count += 1 + except (urllib.error.URLError, OSError): + pass + + return count + + +if __name__ == "__main__": + count = download_logos() + print(f"Downloaded {count} airline logos to {LOGO_DIR}") diff --git a/scripts/jetset.service.template b/scripts/jetset.service.template index d4c6b15..020ba6a 100644 --- a/scripts/jetset.service.template +++ b/scripts/jetset.service.template @@ -11,6 +11,7 @@ Type=simple User=root WorkingDirectory=@HOME@/jetset Environment=PATH=@HOME@/.local/bin:/usr/bin:/bin +Environment=JETSET_LOGO_DIR=@HOME@/.cache/jetset/logos ExecStart=@HOME@/.local/bin/uv run python -m jetset Restart=on-failure RestartSec=5 diff --git a/src/jetset/display.py b/src/jetset/display.py index cb62982..443e37a 100644 --- a/src/jetset/display.py +++ b/src/jetset/display.py @@ -1,7 +1,17 @@ """Display formatting functions for flight cards on the LED panel.""" +import os +from pathlib import Path + +from PIL import Image + from jetset.models import Flight +# Where airline logos live. Overridable via JETSET_LOGO_DIR because the systemd +# service runs as root (so Path.home() would be /root), while download_logos.py +# runs as the deploy user — the unit points both at the same dir. +LOGO_DIR = Path(os.environ.get("JETSET_LOGO_DIR") or Path.home() / ".cache" / "jetset" / "logos") + _COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -10,6 +20,20 @@ def _cardinal(track: float) -> str: return _COMPASS[int(track / 45 + 0.5) % 8] +def load_logo(airline_code: str) -> Image.Image | None: + """Load the airline logo for a given ICAO airline code. + + Looks for {airline_code}.png in LOGO_DIR. Returns the image + unchanged (caller is responsible for scaling and blitting). + Returns None if the file doesn't exist or can't be opened. + """ + path = LOGO_DIR / f"{airline_code}.png" + try: + return Image.open(path) + except (FileNotFoundError, PermissionError): + return None + + def loading_label(page: int = 0) -> str: if page == 1: return "LOADING." diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index 277798b..3f6513e 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -1,7 +1,17 @@ +from functools import lru_cache + +from PIL import Image from RGBMatrixEmulator.emulation.canvas import Canvas from jetset.backend import graphics -from jetset.display import aircraft_label, flight_label, loading_label, metrics_label, route_label +from jetset.display import ( + aircraft_label, + flight_label, + load_logo, + loading_label, + metrics_label, + route_label, +) from jetset.models import Flight # Colour palette @@ -14,26 +24,83 @@ DIM_WHITE = (80, 80, 80) BLACK = (0, 0, 0) +# Faulty-panel workaround: render everything in red (the deployed panel +# suppresses other channels when red is present). Set False once a standard +# panel is installed to restore the full palette and full-colour logos. +MONOCHROME_RED = True + FONT_HEIGHT = 7 font = graphics.Font() font.LoadFont("fonts/5x7.bdf") +CANVAS_WIDTH = 64 +LOGO_WIDTH = 20 +LOGO_HEIGHT = 16 + def draw_text(canvas: Canvas, x: int, y: int, text: str, color: tuple[int, int, int]) -> None: c = graphics.Color(*color) graphics.DrawText(canvas, font, x, y, c, text) +@lru_cache(maxsize=256) +def _scaled_logo(airline_code: str) -> Image.Image | None: + """Load + proportionally scale an airline logo to fit the logo box, cached. + + Decoding and resizing happen once per airline, not every frame. + """ + img = load_logo(airline_code) + if img is None: + return None + img = img.convert("RGBA") + src_w, src_h = img.size + scale = min(LOGO_WIDTH / src_w, LOGO_HEIGHT / src_h) + new_w = max(1, int(src_w * scale)) + new_h = max(1, int(src_h * scale)) + return img.resize((new_w, new_h), Image.LANCZOS) + + +def render_logo(canvas: Canvas, flight: Flight) -> None: + """Draw the airline logo, centred in the top-right corner. + + Honours MONOCHROME_RED (renders the logo as a red-luminance silhouette + while the faulty panel can't do colour). Silently skips if no logo exists. + """ + scaled = _scaled_logo(flight.airline) + if scaled is None: + return + + new_w, new_h = scaled.size + x_offset = CANVAS_WIDTH - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 + y_offset = (LOGO_HEIGHT - new_h) // 2 + + for y in range(new_h): + for x in range(new_w): + r, g, b, a = scaled.getpixel((x, y)) + if a == 0 or (r, g, b) == (0, 0, 0): + continue + if MONOCHROME_RED: + lum = round(0.299 * r + 0.587 * g + 0.114 * b) + canvas.SetPixel(x_offset + x, y_offset + y, lum, 0, 0) + else: + canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) + + def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): canvas.Clear() - # y-values are based off of the font height. The deployed panel suppresses - # other channels when red is present in a frame, so every row is red until - # a standard panel restores the full palette. - draw_text(canvas, 1, FONT_HEIGHT * 1 + 0, flight_label(flight), RED) - draw_text(canvas, 1, FONT_HEIGHT * 2 + 1, route_label(flight), RED) - draw_text(canvas, 1, FONT_HEIGHT * 3 + 2, aircraft_label(flight), RED) - draw_text(canvas, 1, FONT_HEIGHT * 4 + 3, metrics_label(flight, metric_page), RED) + # y-values are based off of the font height. Colours come from the palette, + # but collapse to red while MONOCHROME_RED is set (faulty-panel workaround). + rows = ( + (flight_label(flight), ORANGE), + (route_label(flight), CYAN), + (aircraft_label(flight), GREEN), + (metrics_label(flight, metric_page), BLUE), + ) + for i, (text, color) in enumerate(rows): + draw_text(canvas, 1, FONT_HEIGHT * (i + 1) + i, text, RED if MONOCHROME_RED else color) + + render_logo(canvas, flight) def render_loading(canvas: Canvas, page=0): diff --git a/tests/test_display.py b/tests/test_display.py index 8e7a724..bc30dbd 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -10,6 +10,13 @@ def test_returns_loading_text(self) -> None: assert loading_label() == "LOADING" +class TestLogo: + def test_returns_none_for_missing_airline(self) -> None: + from jetset.display import load_logo + + assert load_logo("NONEXIST") is None + + class TestFlightLabel: def test_returns_callsign(self) -> None: from jetset.display import flight_label diff --git a/tests/test_renderer.py b/tests/test_renderer.py index e0abd8c..192d80f 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -70,3 +70,49 @@ def test_renders_all_metric_pages_without_error(self) -> None: for page in range(4): render_flight_card(canvas, flight, metric_page=page) canvas = matrix.SwapOnVSync(canvas) + + +class TestRenderLogo: + def test_skips_missing_airline(self) -> None: + from unittest.mock import patch + + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions + + from jetset.models import Flight + from jetset.renderer import _scaled_logo, render_logo + + options = RGBMatrixOptions() + options.cols = 64 + options.rows = 32 + matrix = RGBMatrix(options=options) + canvas = matrix.CreateFrameCanvas() + + flight = Flight(callsign="UAL2337") + with patch("jetset.renderer.load_logo", return_value=None): + _scaled_logo.cache_clear() # so the patched load_logo is used + render_logo(canvas, flight) # no crash + + def test_draws_pixels_for_known_airline(self) -> None: + from unittest.mock import patch + + from PIL import Image + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions + + from jetset.models import Flight + from jetset.renderer import LOGO_WIDTH, _scaled_logo, render_logo + + test_logo = Image.new("RGB", (LOGO_WIDTH, 20), (255, 0, 0)) + + options = RGBMatrixOptions() + options.cols = 64 + options.rows = 32 + matrix = RGBMatrix(options=options) + canvas = matrix.CreateFrameCanvas() + + flight = Flight(callsign="UAL2337", aircraft="B738") + with patch("jetset.renderer.load_logo", return_value=test_logo): + _scaled_logo.cache_clear() # so the patched load_logo is used + with patch.object(canvas, "SetPixel") as mock_setpixel: + render_logo(canvas, flight) + + mock_setpixel.assert_called() diff --git a/uv.lock b/uv.lock index 5a74418..7285625 100644 --- a/uv.lock +++ b/uv.lock @@ -93,6 +93,7 @@ name = "jetset" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pillow" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, @@ -107,6 +108,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "pillow", specifier = ">=11" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "pyyaml", specifier = ">=6" }, { name = "requests", specifier = ">=2.34.2" }, From 8baacf6c57a4de39e6208839226054b57a95ef12 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 22:52:01 -0500 Subject: [PATCH 23/36] FIX: harden Pi deploy and the jetset systemd service deploy re-adds the rgbmatrix wheel via an absolute uv path (uv isn't on a non-interactive ssh PATH) so a synced dev pyproject can't drop the hardware backend and silently fall back to the emulator; backend warns when rgbmatrix is present but fails to import; extract install-service.sh and run it every deploy so unit/env changes propagate without re-running setup-pi. --- Makefile | 14 ++++++++++---- README.md | 5 +++-- scripts/install-service.sh | 10 ++++++++++ scripts/setup-pi.sh | 11 ++++++----- src/jetset/backend.py | 14 +++++++++++++- 5 files changed, 42 insertions(+), 12 deletions(-) create mode 100755 scripts/install-service.sh diff --git a/Makefile b/Makefile index 447d05e..eebfcbb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run debug test lint fixtures deploy setup-pi run-pi debug-pi +.PHONY: run debug test lint fixtures deploy setup-pi run-pi logs-pi debug-pi # Local dev run: ## Run the emulator smoke test @@ -29,13 +29,19 @@ deploy: ## rsync to the Pi, run first-time setup if needed, restart the service ssh -t $(JETSET_USER)@$(JETSET_HOST) \ "cd $(JETSET_PATH) && \ { [ -f /etc/systemd/system/jetset.service ] || bash scripts/setup-pi.sh; } && \ + /home/$(JETSET_USER)/.local/bin/uv add ./wheels/rgbmatrix-*.whl && \ + bash scripts/install-service.sh && \ sudo systemctl restart jetset" setup-pi: ## Install deps and build rpi-rgb-led-matrix (run on the Pi) bash scripts/setup-pi.sh -run-pi: ## Run the app on the Pi (needs root for GPIO) - sudo -E env PATH="$$PATH" uv run python -m jetset +run-pi: ## (Re)start the jetset service on the Pi + sudo systemctl restart jetset -debug-pi: ## Run the app on the Pi with debug logging +logs-pi: ## Tail the jetset service logs on the Pi + journalctl -u jetset -f + +debug-pi: ## Stop the service and run in the foreground with debug logging + sudo systemctl stop jetset sudo -E env PATH="$$PATH" JETSET_DEBUG=1 uv run python -m jetset diff --git a/README.md b/README.md index 0af40a8..3f90ded 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ uv sync | `make fixtures` | Save a live AirLabs response as a test fixture | | `make deploy` | rsync the code to the Pi | | `make setup-pi` | Install deps + build rpi-rgb-led-matrix (run on the Pi) | -| `make run-pi` | Run the app on the Pi (needs root for GPIO) | -| `make debug-pi` | Run on the Pi with debug logging | +| `make run-pi` | (Re)start the jetset service on the Pi | +| `make logs-pi` | Tail the jetset service logs (`journalctl -u jetset -f`) | +| `make debug-pi` | Stop the service and run in the foreground with debug logging | ## Configuration diff --git a/scripts/install-service.sh b/scripts/install-service.sh new file mode 100755 index 0000000..ccc65d9 --- /dev/null +++ b/scripts/install-service.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Render scripts/jetset.service.template (@HOME@ -> $HOME) and install + enable +# the systemd unit. Idempotent; run on the Pi. Callers restart the service. +set -e +cd "$(dirname "$0")/.." + +sed "s|@HOME@|$HOME|g" scripts/jetset.service.template \ + | sudo tee /etc/systemd/system/jetset.service > /dev/null +sudo systemctl daemon-reload +sudo systemctl enable jetset diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh index 753d11c..c5c7ea5 100755 --- a/scripts/setup-pi.sh +++ b/scripts/setup-pi.sh @@ -68,12 +68,13 @@ else echo "=== rgbmatrix wheel already built ===" fi -# 6. Install + enable the systemd service (runs on boot) +# 6. Download airline logos (cached in ~/.cache/jetset/logos; skips existing) +echo "=== Downloading airline logos ===" +uv run python scripts/download_logos.py || echo "(logo download incomplete — re-run later)" + +# 7. Install + enable the systemd service (runs on boot) echo "=== Installing jetset systemd service ===" -sed "s|@HOME@|$HOME|g" scripts/jetset.service.template \ - | sudo tee /etc/systemd/system/jetset.service > /dev/null -sudo systemctl daemon-reload -sudo systemctl enable jetset +bash scripts/install-service.sh sudo systemctl restart jetset echo "=== Setup complete! The jetset service is enabled and running. ===" diff --git a/src/jetset/backend.py b/src/jetset/backend.py index 948d7f6..6106bce 100644 --- a/src/jetset/backend.py +++ b/src/jetset/backend.py @@ -9,11 +9,23 @@ the matrix and the drawing engine never drift apart. """ +import logging + +logger = logging.getLogger(__name__) + try: from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics IS_HARDWARE = True -except ImportError: +except ImportError as exc: + # rgbmatrix simply being absent is the normal dev-machine case — fall back + # quietly. But if rgbmatrix is *present* and failed for another reason (a + # missing dependency, a broken/removed build), warn loudly so the Pi never + # silently runs the emulator. + rgbmatrix_absent = isinstance(exc, ModuleNotFoundError) and exc.name == "rgbmatrix" + if not rgbmatrix_absent: + logger.warning("rgbmatrix present but failed to import (%r) — using emulator", exc) + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions, graphics IS_HARDWARE = False From 3e6e832f4eea9c34e2db8bb55b2e60552131c468 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 23:54:49 -0500 Subject: [PATCH 24/36] FIX: render airline logos on the Pi panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logos were loading as None on the running display: rpi-rgb-led-matrix drops privileges to the 'daemon' user after GPIO init, and daemon can't traverse /home/pi (mode 700), so load_logo (called lazily in the render loop) failed. Fonts escaped this because they load at import, before the matrix drops privileges. Keep the service as root (drop_privileges=False) — it already runs as User=root and needs root for GPIO. Also tune the logo for the panel: size it 23x23 in the top-right with a 1px margin off the top and right edges, and floor luminance at 120 so dark airline colors clear the panel's low-PWM (pwm_bits=6) lighting threshold. --- src/jetset/__main__.py | 6 ++++++ src/jetset/renderer.py | 42 +++++++++++++++++++++++++++++++++++++----- tests/test_renderer.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index eaf43e6..fcd3b1d 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -54,6 +54,12 @@ def main() -> None: options.pwm_bits = 6 # lower PWM reduces flicker on Pi 3 A+ options.led_rgb_sequence = config.hardware_rgb_sequence options.disable_hardware_pulsing = True + # Keep running as root after GPIO init. By default the library drops to + # the 'daemon' user, which can't traverse /home/pi (mode 700) — so the + # logos, loaded lazily in the render loop, would fail to read and the + # cards would show text but never a logo. (Fonts escape this because + # they load at import, before the matrix drops privileges.) + options.drop_privileges = False matrix = RGBMatrix(options=options) canvas = matrix.CreateFrameCanvas() diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index 3f6513e..7e2cad8 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -34,8 +34,22 @@ font.LoadFont("fonts/5x7.bdf") CANVAS_WIDTH = 64 -LOGO_WIDTH = 20 -LOGO_HEIGHT = 16 +# Logo box, top-right corner, over the top three text rows (row 4/metrics can +# run full-width, so the box stops above it). A square logo scales to +# LOGO_WIDTH x LOGO_HEIGHT; the margins keep it one pixel off the top and right +# edges. With these values the logo spans cols 40-62, rows 1-23. +LOGO_WIDTH = 23 +LOGO_HEIGHT = 23 +LOGO_RIGHT_MARGIN = 1 # empty columns kept to the right of the logo +LOGO_TOP_MARGIN = 1 # empty rows kept above the logo +# Faulty-panel visibility: map logo luminance onto [LOGO_RED_FLOOR, 255] so dark +# airline colors don't fall below the panel's low-PWM threshold (pwm_bits=6), +# while brighter pixels stay brighter (keeps the shape's detail). Set 0 to +# render true luminance (or once MONOCHROME_RED is off, on a standard panel). +LOGO_RED_FLOOR = 120 +# Debug aid: outline the logo box on the panel so its bounds can be eyeballed +# against the card. Set False for normal use. +LOGO_DEBUG_BORDER = False def draw_text(canvas: Canvas, x: int, y: int, text: str, color: tuple[int, int, int]) -> None: @@ -60,19 +74,36 @@ def _scaled_logo(airline_code: str) -> Image.Image | None: return img.resize((new_w, new_h), Image.LANCZOS) +def _draw_logo_border(canvas: Canvas) -> None: + """Outline the logo box (debug aid for checking its bounds on the panel).""" + left = CANVAS_WIDTH - LOGO_RIGHT_MARGIN - LOGO_WIDTH + right = left + LOGO_WIDTH - 1 + top = LOGO_TOP_MARGIN + bottom = top + LOGO_HEIGHT - 1 + for x in range(left, right + 1): + canvas.SetPixel(x, top, 255, 0, 0) + canvas.SetPixel(x, bottom, 255, 0, 0) + for y in range(top, bottom + 1): + canvas.SetPixel(left, y, 255, 0, 0) + canvas.SetPixel(right, y, 255, 0, 0) + + def render_logo(canvas: Canvas, flight: Flight) -> None: """Draw the airline logo, centred in the top-right corner. Honours MONOCHROME_RED (renders the logo as a red-luminance silhouette while the faulty panel can't do colour). Silently skips if no logo exists. """ + if LOGO_DEBUG_BORDER: + _draw_logo_border(canvas) + scaled = _scaled_logo(flight.airline) if scaled is None: return new_w, new_h = scaled.size - x_offset = CANVAS_WIDTH - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 - y_offset = (LOGO_HEIGHT - new_h) // 2 + x_offset = CANVAS_WIDTH - LOGO_RIGHT_MARGIN - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 + y_offset = LOGO_TOP_MARGIN + (LOGO_HEIGHT - new_h) // 2 for y in range(new_h): for x in range(new_w): @@ -81,7 +112,8 @@ def render_logo(canvas: Canvas, flight: Flight) -> None: continue if MONOCHROME_RED: lum = round(0.299 * r + 0.587 * g + 0.114 * b) - canvas.SetPixel(x_offset + x, y_offset + y, lum, 0, 0) + red = LOGO_RED_FLOOR + round(lum * (255 - LOGO_RED_FLOOR) / 255) + canvas.SetPixel(x_offset + x, y_offset + y, red, 0, 0) else: canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 192d80f..2e08342 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -114,5 +114,39 @@ def test_draws_pixels_for_known_airline(self) -> None: _scaled_logo.cache_clear() # so the patched load_logo is used with patch.object(canvas, "SetPixel") as mock_setpixel: render_logo(canvas, flight) + _scaled_logo.cache_clear() mock_setpixel.assert_called() + + def test_dim_logo_pixels_meet_brightness_floor(self) -> None: + # MONOCHROME_RED maps luminance onto [LOGO_RED_FLOOR, 255] so dark + # airline colors stay visible on the low-PWM faulty panel. + from unittest.mock import patch + + from PIL import Image + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions + + from jetset import renderer + from jetset.models import Flight + from jetset.renderer import LOGO_RED_FLOOR, _scaled_logo, render_logo + + dark = Image.new("RGBA", (renderer.LOGO_WIDTH, renderer.LOGO_HEIGHT), (0, 0, 40, 255)) + + options = RGBMatrixOptions() + options.cols = 64 + options.rows = 32 + matrix = RGBMatrix(options=options) + canvas = matrix.CreateFrameCanvas() + + reds: list[int] = [] + flight = Flight(callsign="UAL2337") + with ( + patch("jetset.renderer.MONOCHROME_RED", True), + patch("jetset.renderer.load_logo", return_value=dark), + ): + _scaled_logo.cache_clear() + with patch.object(canvas, "SetPixel", side_effect=lambda x, y, r, g, b: reds.append(r)): + render_logo(canvas, flight) + _scaled_logo.cache_clear() + + assert reds and min(reds) >= LOGO_RED_FLOOR From 515139c8b436a4606bf0721d4993956df114c87f Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Tue, 16 Jun 2026 23:55:05 -0500 Subject: [PATCH 25/36] CHORE: add LED panel logo diagnostic probes The hardware probes used to track down the logo failures, kept alongside panel-colors.py and probe-text.py for the next panel: - probe-logo.py: render a logo at varying brightness/floor to tune visibility - probe-card.py: render the full card through render_flight_card on hardware - diag-logo.py: compare logo-load access as root vs the pi user (found the drop_privileges bug) --- scripts/diag-logo.py | 40 ++++++++++++++ scripts/probe-card.py | 66 +++++++++++++++++++++++ scripts/probe-logo.py | 118 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 scripts/diag-logo.py create mode 100755 scripts/probe-card.py create mode 100755 scripts/probe-logo.py diff --git a/scripts/diag-logo.py b/scripts/diag-logo.py new file mode 100644 index 0000000..d6d21b8 --- /dev/null +++ b/scripts/diag-logo.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Diagnose why the root service can't load a logo that the pi user can. +Run as root the same way the service does: + sudo -E env PATH="$PATH" uv run python scripts/diag-logo.py +""" + +import os +from pathlib import Path + +print("euid:", os.geteuid(), "HOME:", os.environ.get("HOME")) +print("SUDO_USER:", os.environ.get("SUDO_USER")) +print("JETSET_LOGO_DIR (pre-set):", os.environ.get("JETSET_LOGO_DIR")) + +if "JETSET_LOGO_DIR" not in os.environ: + su = os.environ.get("SUDO_USER") + base = Path(f"/home/{su}") if su else Path.home() + os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") +print("JETSET_LOGO_DIR (effective):", os.environ["JETSET_LOGO_DIR"]) + +p = Path(os.environ["JETSET_LOGO_DIR"]) / "UAL.png" +print("path:", p) +print(" exists:", p.exists(), " os.access R_OK:", os.access(p, os.R_OK)) +for d in [Path("/home"), Path("/home/pi"), Path("/home/pi/.cache"), p.parent]: + print(" traverse", d, "X_OK:", os.access(d, os.X_OK), "R_OK:", os.access(d, os.R_OK)) + +from jetset.display import LOGO_DIR, load_logo # noqa: E402 + +print("LOGO_DIR (display):", LOGO_DIR) +print("LOGO_DIR == env?", str(LOGO_DIR) == os.environ["JETSET_LOGO_DIR"]) + +try: + from PIL import Image + + img = Image.open(p) + img.load() + print("PIL open: OK", img.size) +except Exception as e: # noqa: BLE001 - diagnostic + print("PIL open FAILED:", type(e).__name__, repr(e)) + +print("load_logo('UAL'):", load_logo("UAL")) diff --git a/scripts/probe-card.py b/scripts/probe-card.py new file mode 100755 index 0000000..f06a8e3 --- /dev/null +++ b/scripts/probe-card.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Confirm the privilege-drop fix on hardware: render the real full card +(text + render_logo) with drop_privileges=False, so the logo — loaded lazily in +the render path — can still be read after the matrix initializes. Cycles the 4 +metric pages so you can confirm the logo shows AND the text stays clean. + +Run on the Pi with the service stopped: + sudo systemctl stop jetset + sudo -E env PATH="$PATH" uv run python scripts/probe-card.py [airline] + sudo systemctl start jetset +""" + +import os +import sys +import time +from pathlib import Path + +if "JETSET_LOGO_DIR" not in os.environ: + sudo_user = os.environ.get("SUDO_USER") + base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() + os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") + +from jetset.backend import RGBMatrix, RGBMatrixOptions # noqa: E402 +from jetset.models import Airport, Flight, FlightRoute # noqa: E402 +from jetset.renderer import render_flight_card # noqa: E402 + +AIRLINE = sys.argv[1] if len(sys.argv) > 1 else "UAL" +CYCLES = int(sys.argv[2]) if len(sys.argv) > 2 else 3 +PAGE_HOLD = 4 + +flight = Flight( + callsign=f"{AIRLINE}2337", + aircraft="B738", + route=FlightRoute(Airport("IAH"), Airport("LAX")), + altitude=35000, + speed=450, + track=270.0, + vertical_rate=1500, +) + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 5 +options.multiplexing = 0 +options.row_address_type = 0 +options.panel_type = "" +options.pwm_bits = 6 +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True +options.drop_privileges = False # the fix — stay root so the render loop can read logos + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +print(f">>> {flight.callsign} IAH-LAX B738 — real card, drop_privileges=False, x{CYCLES}.") +print(f" Logo (airline {AIRLINE}) should now show top-right; text should stay clean.") +for _ in range(CYCLES): + for page in range(4): + render_flight_card(canvas, flight, metric_page=page) + canvas = matrix.SwapOnVSync(canvas) + time.sleep(PAGE_HOLD) + +matrix.Clear() +print(">>> done") diff --git a/scripts/probe-logo.py b/scripts/probe-logo.py new file mode 100755 index 0000000..ceea55b --- /dev/null +++ b/scripts/probe-logo.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Probe whether airline logos render on the panel's top-right corner. + +Phase 1 fills the logo box (the app's exact rect) solid red — confirms that +region of the (possibly faulty) panel is alive. Phase 2 draws a real logo +silhouette through the app's own render_logo, at its real position. So: + + - Phase 1 blank -> that corner of the panel is dead (hardware). + - Phase 1 red, Phase 2 blank -> the logo pipeline is at fault (software). + - both visible -> logos work; the issue is elsewhere. + +Run on the Pi with the service stopped (only one process can own the GPIO): + sudo systemctl stop jetset + sudo -E env PATH="$PATH" uv run python scripts/probe-logo.py [airline] [hold] + sudo systemctl start jetset +""" + +import os +import sys +import time +from pathlib import Path + +# Point the logo loader at the invoking user's cache before importing the app +# modules (LOGO_DIR is resolved at import time). Under sudo, Path.home() is +# /root, so prefer SUDO_USER's home where the logos were actually downloaded. +if "JETSET_LOGO_DIR" not in os.environ: + sudo_user = os.environ.get("SUDO_USER") + base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() + os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") + +from rgbmatrix import RGBMatrix, RGBMatrixOptions # noqa: E402 + +from jetset.display import LOGO_DIR # noqa: E402 +from jetset.renderer import ( # noqa: E402 + CANVAS_WIDTH, + LOGO_HEIGHT, + LOGO_WIDTH, + _scaled_logo, + render_logo, +) + +AIRLINE = sys.argv[1] if len(sys.argv) > 1 else "UAL" +HOLD = int(sys.argv[2]) if len(sys.argv) > 2 else 8 + +print(f"LOGO_DIR={LOGO_DIR}") + +# Brightness histogram of what the current (luminance) render would draw. +_scaled = _scaled_logo(AIRLINE) +if _scaled is not None: + buckets = {"1-63": 0, "64-127": 0, "128-191": 0, "192-255": 0} + for _y in range(_scaled.size[1]): + for _x in range(_scaled.size[0]): + _r, _g, _b, _a = _scaled.getpixel((_x, _y)) + if _a == 0 or (_r, _g, _b) == (0, 0, 0): + continue + lum = round(0.299 * _r + 0.587 * _g + 0.114 * _b) + key = ("1-63" if lum < 64 else "64-127" if lum < 128 + else "128-191" if lum < 192 else "192-255") + buckets[key] += 1 + print(f"{AIRLINE} luminance histogram (drawn px by red value): {buckets}") + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 5 +options.led_rgb_sequence = "RBG" +options.disable_hardware_pulsing = True +options.pwm_bits = 6 # match the service — this is what dims faint reds + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +box_left = CANVAS_WIDTH - LOGO_WIDTH + +# Phase 1: solid red block over the logo box. +canvas.Clear() +for y in range(LOGO_HEIGHT): + for x in range(LOGO_WIDTH): + canvas.SetPixel(box_left + x, y, 255, 0, 0) +canvas = matrix.SwapOnVSync(canvas) +print(f">>> Phase 1: solid red block at x{box_left}-{box_left + LOGO_WIDTH - 1}, " + f"y0-{LOGO_HEIGHT - 1} (holding {HOLD}s)") +print(" Solid red rectangle in the TOP-RIGHT corner?") +time.sleep(HOLD) + +# Phase 2: the current render_logo (luminance-scaled red — the suspect). +flight = type("ProbeFlight", (), {"airline": AIRLINE})() +canvas.Clear() +render_logo(canvas, flight) +canvas = matrix.SwapOnVSync(canvas) +print(f">>> Phase 2: {AIRLINE} via current render_logo (luminance) (holding {HOLD}s)") +print(" Any logo shape? (suspected too dim to see)") +time.sleep(HOLD) + +# Phase 3: proposed fix — luminance with a brightness FLOOR. Maps lum [0,255] +# onto [FLOOR,255], so dark logo colors lift above the panel's threshold while +# brighter pixels stay brighter (keeps the shape detail, unlike a flat fill). +FLOOR = 120 +scaled = _scaled_logo(AIRLINE) +new_w, new_h = scaled.size +x_off = CANVAS_WIDTH - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 +y_off = (LOGO_HEIGHT - new_h) // 2 +canvas.Clear() +for y in range(new_h): + for x in range(new_w): + _r, _g, _b, a = scaled.getpixel((x, y)) + if a == 0 or (_r, _g, _b) == (0, 0, 0): + continue + lum = round(0.299 * _r + 0.587 * _g + 0.114 * _b) + red = FLOOR + round(lum * (255 - FLOOR) / 255) + canvas.SetPixel(x_off + x, y_off + y, red, 0, 0) +canvas = matrix.SwapOnVSync(canvas) +print(f">>> Phase 3: {AIRLINE} luminance with floor={FLOOR} (holding {HOLD}s)") +print(" Bright AND keeps the logo's internal detail?") +time.sleep(HOLD) + +matrix.Clear() From 60ef0ce764babddc022e20b0399b4e14d303c29e Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Wed, 17 Jun 2026 16:12:59 -0500 Subject: [PATCH 26/36] FEATURE: restore full-colour rendering on a properly-powered panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was never faulty — it had been running on parasitic ribbon power (the HUB75 ribbon carries data + ground only, not the LEDs' 5V), which starved the higher-forward-voltage channels and mimicked a dead blue / white-collapses-to-red panel. Powered through its own VH4 header, every channel renders true. - renderer: MONOCHROME_RED = False — full ORANGE/CYAN/GREEN/BLUE palette and full-colour logos - config: hardware_rgb_sequence = "RGB" (standard panel, verified) - panel-colors.py: take gpio_slowdown/rgb_sequence/panel_type as args and default slowdown to 5 (the hardcoded 4 was itself unstable on the Pi 3 A+) - HARDWARE.md: replace the faulty-panel notes with the power-header lesson - tests: rows assert the colour palette; rgb_sequence default is RGB --- HARDWARE.md | 43 ++++++++++++++++++++++------------------- scripts/panel-colors.py | 27 +++++++++++++++++++++++--- src/jetset/config.py | 6 +++--- src/jetset/renderer.py | 9 +++++---- tests/test_config.py | 4 ++-- tests/test_renderer.py | 7 +++---- 6 files changed, 60 insertions(+), 36 deletions(-) diff --git a/HARDWARE.md b/HARDWARE.md index e9fab74..f0a40d8 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -81,16 +81,17 @@ Hard-won lessons from bring-up: defaults (`multiplexing=0`, `row_address_type=0`). **Avoid OUTDOOR / multiplexed panels** — they use scrambled internal wiring that needs a custom multiplex-mapper compiled into rpi-rgb-led-matrix (see hzeller issue #1640). -- **`rgb_sequence`** currently defaults to `"RBG"` for the deployed (faulty) - panel; a standard panel uses `"RGB"`. Override per-panel if red/green/blue - come out swapped. -- **The original test panel is defective** (a generic P2.5 64×32 "outdoor" - 1/16-scan unit): dead blue channel, underpowered green, and red-channel - crosstalk that garbled any combination color at full brightness. Single red - and green rendered fine — which proves it's a *faulty unit*, not an - incompatibility (it addresses correctly and drives red perfectly). The app - currently renders every row red as a workaround; replace with a standard - indoor panel and restore the palette. +- **`rgb_sequence`** defaults to `"RGB"` (standard panel). Override per-panel + only if red/green/blue come out *swapped* — a swap is a subpixel-order issue; + a *missing* channel is power or wiring, not this. +- **Power the panel through its OWN power header (VH4 / 4-pin), not the ribbon.** + The 16-pin HUB75 ribbon carries DATA + ground only — *not* the LEDs' 5V. Fed + only through the ribbon, the panel starves on parasitic ground current and the + symptom mimics a dead panel: red (lowest forward voltage) limps on, green + weak, blue dead, white collapses to red. We chased this as a "faulty panel" + across two panels + a new ribbon — it was always the VH4 power header not + being wired to 5V. Wire it and every channel works. Capacity isn't the issue + (the panel is ≤12W / 2.5A); a missing/starved power-header connection is. - **Bring-up tooling:** `scripts/panel-colors.py` (solid red/green/blue/white fills to confirm the three channels) and `scripts/probe-text.py` (the real 4-row text layout) drive a panel directly via rgbmatrix, independent of the @@ -102,14 +103,16 @@ Hard-won lessons from bring-up: When swapping in a replacement panel (reuse the same HAT + ribbon cable): -1. `make deploy` -2. **Channels:** `sudo -E env PATH=$PATH uv run python scripts/panel-colors.py` - - red→red, green→green, **blue→blue**. If a channel is swapped, set - `hardware.rgb_sequence`. If **blue is still dead** on a known-good panel - with this same HAT + cable, the fault is the **HAT or cable**, not panels. -3. **Geometry + combination colors:** `... scripts/probe-text.py white 5` +1. **Power:** wire the panel's own power header (VH4 / 4-pin) to 5V — the ribbon + does NOT carry LED power. A red-only / blue-dead / white→red panel is almost + always starved here, not faulty. +2. `make deploy` +3. **Channels:** `sudo -E env PATH=$PATH uv run python scripts/panel-colors.py 5` + - red→red, green→green, **blue→blue**, white→white. A *swapped* channel ⇒ set + `hardware.rgb_sequence`; a *dead/dim* channel ⇒ power (step 1) or the + HAT/cable, not subpixel order. +4. **Geometry + combination colors:** `... scripts/probe-text.py white 5` - four stable rows, and white renders white (no collapse-to-red / garbling). -4. **Restore the palette:** in `renderer.render_flight_card`, switch the four - rows back to `ORANGE` / `CYAN` / `GREEN` / `BLUE`, and set - `hardware_rgb_sequence` to `"RGB"` (or whatever step 2 found). -5. **Live app:** `make run-pi` — confirm flight cards render in full color. +5. **Restore full colour:** set `MONOCHROME_RED = False` in `renderer.py` and + `hardware_rgb_sequence = "RGB"` in `config.py` (or whatever step 3 found). +6. **Live app:** `make run-pi` — confirm flight cards render in full colour. diff --git a/scripts/panel-colors.py b/scripts/panel-colors.py index a89fe86..f7f7b60 100755 --- a/scripts/panel-colors.py +++ b/scripts/panel-colors.py @@ -3,22 +3,43 @@ A solid fill is immune to pixel-mapping/multiplexing scrambles, so this isolates the color channels themselves. If solid GREEN or BLUE does NOT light -the whole panel in that color, that channel isn't reaching the panel — a -hardware/wiring fault (ribbon cable, HAT seating, solder), not a config issue. +the whole panel in that color, that channel isn't reaching the panel. + +Whether that's a config or a hardware fault depends on the knobs below, so they +are all overridable — a "dead" channel at the wrong gpio_slowdown (signal +timing) or without a required panel_type init is a config issue, not the panel: + + panel-colors.py [gpio_slowdown] [rgb_sequence] [panel_type] + defaults: 5 RGB (none) + + gpio_slowdown : 5 is required on the Pi 3 A+ / Adafruit HAT; 4 is unstable and + can drop or garble a channel (masquerades as a dead channel). + rgb_sequence : RGB GRB RBG ... — only PERMUTES colors, never blanks one. + panel_type : "" or FM6126A — some panels need the FM6126A init or come up + with wrong/missing channels. """ +import sys import time from rgbmatrix import RGBMatrix, RGBMatrixOptions +SLOWDOWN = int(sys.argv[1]) if len(sys.argv) > 1 else 5 +RGB_SEQUENCE = sys.argv[2] if len(sys.argv) > 2 else "RGB" +PANEL_TYPE = sys.argv[3] if len(sys.argv) > 3 else "" + options = RGBMatrixOptions() options.cols = 64 options.rows = 32 options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 4 +options.gpio_slowdown = SLOWDOWN +options.led_rgb_sequence = RGB_SEQUENCE +options.panel_type = PANEL_TYPE options.disable_hardware_pulsing = True matrix = RGBMatrix(options=options) +print(f">>> gpio_slowdown={SLOWDOWN} rgb_sequence={RGB_SEQUENCE} " + f"panel_type={PANEL_TYPE or '(none)'}") for name, (r, g, b) in [ ("RED", (150, 0, 0)), diff --git a/src/jetset/config.py b/src/jetset/config.py index ae1d412..be0f9ff 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -23,9 +23,9 @@ class AppConfig: # Override only for "outdoor"/multiplexed panels. hardware_multiplexing: int = 0 hardware_row_address_type: int = 0 - # Physical subpixel order. The panel currently deployed needs "RBG"; a - # standard panel uses "RGB" (verify with scripts/panel-colors.py). - hardware_rgb_sequence: str = "RBG" + # Physical subpixel order; "RGB" for a standard panel (verified with + # scripts/panel-colors.py — solid red/green/blue all render true). + hardware_rgb_sequence: str = "RGB" @classmethod def load(cls, path=None): diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index 7e2cad8..0781a73 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -24,10 +24,11 @@ DIM_WHITE = (80, 80, 80) BLACK = (0, 0, 0) -# Faulty-panel workaround: render everything in red (the deployed panel -# suppresses other channels when red is present). Set False once a standard -# panel is installed to restore the full palette and full-colour logos. -MONOCHROME_RED = True +# Monochrome-red fallback for a panel that can't render full colour. Off — the +# panel renders the full palette and colour logos. (The earlier "dead channels" +# were the panel running on parasitic ribbon power, not a faulty panel; once it +# was powered properly through its own VH4 header, all channels worked.) +MONOCHROME_RED = False FONT_HEIGHT = 7 font = graphics.Font() diff --git a/tests/test_config.py b/tests/test_config.py index 862b604..cf08fc4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -46,9 +46,9 @@ def test_default_gpio_slowdown(self) -> None: assert config.hardware_gpio_slowdown == 5 def test_default_rgb_sequence(self) -> None: - # The panel currently deployed needs "RBG"; standard panels use "RGB". + # Standard panel: subpixels render true in RGB order. config = AppConfig.load() - assert config.hardware_rgb_sequence == "RBG" + assert config.hardware_rgb_sequence == "RGB" def test_default_multiplexing(self) -> None: config = AppConfig.load() diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 2e08342..7da2e65 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -32,9 +32,8 @@ def test_clears_canvas_before_drawing(self) -> None: assert canvas.Clear.called - def test_all_rows_are_red(self) -> None: - # The deployed panel suppresses other channels when red is present, so - # every row is red (single working channel) until a standard panel. + def test_each_row_uses_its_palette_colour(self) -> None: + # Full palette restored now that the panel renders colour (MONOCHROME_RED off). from unittest.mock import MagicMock, patch from jetset import renderer @@ -47,7 +46,7 @@ def test_all_rows_are_red(self) -> None: renderer.render_flight_card(canvas, flight) row_colors = [call.args[4] for call in mock_draw.call_args_list] - assert row_colors == [renderer.RED] * 4 + assert row_colors == [renderer.ORANGE, renderer.CYAN, renderer.GREEN, renderer.BLUE] def test_renders_all_metric_pages_without_error(self) -> None: from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions From f2e7035016c7930bbd71429468fc4fdd2fbd6a30 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Wed, 17 Jun 2026 17:34:14 -0500 Subject: [PATCH 27/36] FEATURE: switch airline logo provider to the RadarBox set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source logos from Jxck-S/airline-logos (radarbox_logos) instead of sexym0nk3y/airline-logos. Same convention — ICAO-keyed PNGs — so the {ICAO}.png lookup and the renderer are unchanged; only the download URLs move. Switching on a Pi needs the cache cleared first (the downloader skips existing files): rm -rf ~/.cache/jetset/logos, re-run download_logos.py, restart the service. Also add scripts/logo-gallery.py — cycles every cached logo on the panel (ICAO at the callsign spot, logo top-right, N seconds each, optional prefix filter) to review the whole set without waiting for a live match. --- scripts/download_logos.py | 10 +++--- scripts/logo-gallery.py | 68 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100755 scripts/logo-gallery.py diff --git a/scripts/download_logos.py b/scripts/download_logos.py index 0ece78f..316fd26 100755 --- a/scripts/download_logos.py +++ b/scripts/download_logos.py @@ -1,4 +1,4 @@ -"""Download airline logos from the sexym0nk3y/airline-logos repository. +"""Download airline logos from the Jxck-S/airline-logos repository (RadarBox set). Logos are cached locally in ~/.cache/jetset/logos/ as {ICAO}.png files. """ @@ -9,8 +9,8 @@ from jetset.display import LOGO_DIR -REPO_API = "https://api.github.com/repos/sexym0nk3y/airline-logos/contents/logos" -RAW_BASE = "https://raw.githubusercontent.com/sexym0nk3y/airline-logos/main/logos" +REPO_API = "https://api.github.com/repos/Jxck-S/airline-logos/contents/radarbox_logos" +RAW_BASE = "https://raw.githubusercontent.com/Jxck-S/airline-logos/main/radarbox_logos" def _fetch_logo_list() -> list[str]: @@ -24,8 +24,8 @@ def download_logos(icao_codes: list[str] | None = None) -> int: """Download airline logos to LOGO_DIR. Args: - icao_codes: Optional list of ICAO codes to download. If None, all - 993 logos from the repo are downloaded. + icao_codes: Optional list of ICAO codes to download. If None, every + logo in the repo is downloaded. Returns: Number of logos downloaded. diff --git a/scripts/logo-gallery.py b/scripts/logo-gallery.py new file mode 100755 index 0000000..3b06a67 --- /dev/null +++ b/scripts/logo-gallery.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Cycle through every cached airline logo on the panel so you can eyeball the +whole set without waiting for a live flight to match. Each logo is shown the way +a real card would: the ICAO code where the callsign goes (top-left), the logo +where it goes (top-right). + +Run on the Pi with the service stopped: + sudo systemctl stop jetset + sudo -E env PATH="$PATH" uv run python scripts/logo-gallery.py [seconds] [prefix] + sudo systemctl start jetset + + seconds : hold per logo (default 3) + prefix : only show codes starting with this (e.g. "U" or "DAL"); default all +""" + +import os +import sys +import time +from pathlib import Path + +# Point the loader at the invoking user's cache before importing app modules. +# Under sudo, Path.home() is /root, so prefer SUDO_USER's home. +if "JETSET_LOGO_DIR" not in os.environ: + sudo_user = os.environ.get("SUDO_USER") + base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() + os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") + +from jetset.backend import RGBMatrix, RGBMatrixOptions # noqa: E402 +from jetset.display import LOGO_DIR # noqa: E402 +from jetset.renderer import ORANGE, draw_text, render_logo # noqa: E402 + +HOLD = float(sys.argv[1]) if len(sys.argv) > 1 else 3.0 +PREFIX = sys.argv[2].upper() if len(sys.argv) > 2 else "" + +options = RGBMatrixOptions() +options.cols = 64 +options.rows = 32 +options.hardware_mapping = "adafruit-hat" +options.gpio_slowdown = 5 +options.pwm_bits = 6 +options.led_rgb_sequence = "RGB" +options.disable_hardware_pulsing = True +options.drop_privileges = False # stay root so logo files under /home stay readable + +matrix = RGBMatrix(options=options) +canvas = matrix.CreateFrameCanvas() + +logos = sorted(p for p in LOGO_DIR.glob("*.png") if p.stem.upper().startswith(PREFIX)) +if not logos: + raise SystemExit(f"No logos matching '{PREFIX}*' in {LOGO_DIR}") + +print(f"{len(logos)} logos in {LOGO_DIR} — {HOLD}s each (Ctrl-C to stop)") + +try: + for path in logos: + code = path.stem + flight = type("GalleryFlight", (), {"airline": code})() + canvas.Clear() + draw_text(canvas, 1, 7, code, ORANGE) # ICAO where the callsign goes + render_logo(canvas, flight) # logo where it goes + canvas = matrix.SwapOnVSync(canvas) + print(f">>> {code}") + time.sleep(HOLD) +except KeyboardInterrupt: + pass +finally: + matrix.Clear() + print("done") From 83170b9aca5dd3aca72bb12d7534cd46ac4f9264 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Wed, 17 Jun 2026 18:21:21 -0500 Subject: [PATCH 28/36] FEATURE: backfill missing RadarBox logos from the FlightAware set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RadarBox is missing some airlines (e.g. ASA/Alaska). Process logo sources in priority order — RadarBox, then FlightAware — and since the download skips files that already exist, RadarBox stays preferred while FlightAware only fills the gaps. Same ICAO {code}.png naming, so the lookup and renderer are unchanged. --- scripts/download_logos.py | 70 ++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/scripts/download_logos.py b/scripts/download_logos.py index 316fd26..c77f373 100755 --- a/scripts/download_logos.py +++ b/scripts/download_logos.py @@ -1,6 +1,8 @@ -"""Download airline logos from the Jxck-S/airline-logos repository (RadarBox set). +"""Download airline logos from the Jxck-S/airline-logos repository. -Logos are cached locally in ~/.cache/jetset/logos/ as {ICAO}.png files. +RadarBox is the preferred set; FlightAware fills the gaps RadarBox is missing +(e.g. ASA). Logos are cached in ~/.cache/jetset/logos/ as {ICAO}.png files; a +file already present (from a higher-priority source) is never overwritten. """ import json @@ -9,49 +11,63 @@ from jetset.display import LOGO_DIR -REPO_API = "https://api.github.com/repos/Jxck-S/airline-logos/contents/radarbox_logos" -RAW_BASE = "https://raw.githubusercontent.com/Jxck-S/airline-logos/main/radarbox_logos" +# (GitHub contents API, raw base) per source, in PRIORITY order — earlier +# sources win because the download skips files that already exist locally. +SOURCES = [ + ( + "https://api.github.com/repos/Jxck-S/airline-logos/contents/radarbox_logos", + "https://raw.githubusercontent.com/Jxck-S/airline-logos/main/radarbox_logos", + ), + ( + "https://api.github.com/repos/Jxck-S/airline-logos/contents/flightaware_logos", + "https://raw.githubusercontent.com/Jxck-S/airline-logos/main/flightaware_logos", + ), +] -def _fetch_logo_list() -> list[str]: - """Fetch the list of logo filenames from the GitHub API.""" - with urllib.request.urlopen(REPO_API) as resp: +def _fetch_logo_list(api_url: str) -> list[str]: + """Fetch the list of PNG logo filenames from a GitHub contents API URL.""" + with urllib.request.urlopen(api_url) as resp: entries = json.loads(resp.read().decode()) return [e["name"] for e in entries if e["name"].endswith(".png")] -def download_logos(icao_codes: list[str] | None = None) -> int: - """Download airline logos to LOGO_DIR. - - Args: - icao_codes: Optional list of ICAO codes to download. If None, every - logo in the repo is downloaded. - - Returns: - Number of logos downloaded. - """ - LOGO_DIR.mkdir(parents=True, exist_ok=True) - - if icao_codes is not None: - filenames = [f"{code}.png" for code in icao_codes] - else: - filenames = _fetch_logo_list() - +def _download_missing(filenames: list[str], raw_base: str) -> int: + """Download each filename from raw_base into LOGO_DIR, skipping existing.""" count = 0 for name in filenames: dest = LOGO_DIR / name if dest.exists(): continue - url = f"{RAW_BASE}/{name}" try: - urllib.request.urlretrieve(url, dest) + urllib.request.urlretrieve(f"{raw_base}/{name}", dest) count += 1 except (urllib.error.URLError, OSError): pass - return count +def download_logos(icao_codes: list[str] | None = None) -> int: + """Download airline logos to LOGO_DIR, preferring earlier SOURCES. + + Args: + icao_codes: Optional ICAO codes to fetch. If None, every logo each + source offers is fetched — RadarBox first, then FlightAware for the + codes RadarBox lacks. + + Returns: + Number of logos newly downloaded. + """ + LOGO_DIR.mkdir(parents=True, exist_ok=True) + explicit = [f"{code}.png" for code in icao_codes] if icao_codes is not None else None + + total = 0 + for api_url, raw_base in SOURCES: + filenames = explicit if explicit is not None else _fetch_logo_list(api_url) + total += _download_missing(filenames, raw_base) + return total + + if __name__ == "__main__": count = download_logos() print(f"Downloaded {count} airline logos to {LOGO_DIR}") From 8b161e2363ad53fd727b213018291ff9d835d357 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Wed, 17 Jun 2026 22:39:32 -0500 Subject: [PATCH 29/36] REFACTOR: drop dead panel tuning and centralize matrix setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the panel healthy on proper power, the hardware-tuning knobs and the monochrome-red fallback were dead weight. - __main__/config: remove gpio_slowdown, multiplexing, row_address_type, panel_type, pwm_bits, led_rgb_sequence, disable_hardware_pulsing and the config fields behind them; only adafruit-hat + drop_privileges remain - backend: add build_matrix() — one place for panel setup so the app and the scripts/ probes never drift - renderer: remove the MONOCHROME_RED / LOGO_RED_FLOOR fallback; always full colour - scripts: panel-colors/probe-text/logo-gallery use build_matrix(); delete the obsolete probe-logo/probe-card/diag-logo diagnostics - docs: strip the removed knobs from README/HARDWARE --- HARDWARE.md | 35 ++++++------ README.md | 14 +---- scripts/diag-logo.py | 40 -------------- scripts/logo-gallery.py | 14 +---- scripts/panel-colors.py | 43 +++------------ scripts/probe-card.py | 66 ---------------------- scripts/probe-logo.py | 118 ---------------------------------------- scripts/probe-text.py | 32 +++-------- src/jetset/__main__.py | 28 +--------- src/jetset/backend.py | 33 ++++++++++- src/jetset/config.py | 14 ----- src/jetset/renderer.py | 31 ++--------- tests/test_config.py | 38 ------------- tests/test_renderer.py | 35 +----------- 14 files changed, 78 insertions(+), 463 deletions(-) delete mode 100644 scripts/diag-logo.py delete mode 100755 scripts/probe-card.py delete mode 100755 scripts/probe-logo.py diff --git a/HARDWARE.md b/HARDWARE.md index f0a40d8..b6d855e 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -73,17 +73,16 @@ Hard-won lessons from bring-up: -- **`gpio_slowdown = 5` is required** on the Pi 3 A+ / Adafruit HAT. At `4` the - signal is unstable: a static image renders correctly for a moment, then - corrupts into scrambled pixels (it masquerades as a "bad pixel mapping"). - `5` holds steady. Configurable via `hardware.gpio_slowdown`. -- **Use INDOOR, standard 1/16-scan panels.** They map 1:1 and work with the - defaults (`multiplexing=0`, `row_address_type=0`). **Avoid OUTDOOR / - multiplexed panels** — they use scrambled internal wiring that needs a custom - multiplex-mapper compiled into rpi-rgb-led-matrix (see hzeller issue #1640). -- **`rgb_sequence`** defaults to `"RGB"` (standard panel). Override per-panel - only if red/green/blue come out *swapped* — a swap is a subpixel-order issue; - a *missing* channel is power or wiring, not this. +- **A healthy, properly-powered panel needs no tuning.** The app sets only the + `adafruit-hat` mapping and runs on rpi-rgb-led-matrix defaults (gpio_slowdown, + multiplexing, row_address_type, rgb_sequence, pwm_bits). Early bring-up *seemed* + to need gpio_slowdown 5 and an RBG sequence, but that was the power-starvation + artefact below — once the panel was powered through its own header, defaults + rendered cleanly. If a future panel ever misbehaves, those knobs live in + rpi-rgb-led-matrix; re-add the ones you need in `src/jetset/__main__.py`. +- **Use INDOOR, standard 1/16-scan panels.** They map 1:1 on the defaults. + **Avoid OUTDOOR / multiplexed panels** — their scrambled internal wiring needs + a custom multiplex-mapper compiled into rpi-rgb-led-matrix (hzeller #1640). - **Power the panel through its OWN power header (VH4 / 4-pin), not the ribbon.** The 16-pin HUB75 ribbon carries DATA + ground only — *not* the LEDs' 5V. Fed only through the ribbon, the panel starves on parasitic ground current and the @@ -107,12 +106,10 @@ When swapping in a replacement panel (reuse the same HAT + ribbon cable): does NOT carry LED power. A red-only / blue-dead / white→red panel is almost always starved here, not faulty. 2. `make deploy` -3. **Channels:** `sudo -E env PATH=$PATH uv run python scripts/panel-colors.py 5` - - red→red, green→green, **blue→blue**, white→white. A *swapped* channel ⇒ set - `hardware.rgb_sequence`; a *dead/dim* channel ⇒ power (step 1) or the - HAT/cable, not subpixel order. -4. **Geometry + combination colors:** `... scripts/probe-text.py white 5` +3. **Channels:** `sudo -E env PATH=$PATH uv run python scripts/panel-colors.py` + - red→red, green→green, **blue→blue**, white→white. A *dead/dim* channel ⇒ + power (step 1) or the HAT/cable. A *swapped* channel ⇒ the panel's subpixel + order differs; re-add `led_rgb_sequence` in `backend.build_matrix` to fix it. +4. **Geometry + combination colors:** `... scripts/probe-text.py white` - four stable rows, and white renders white (no collapse-to-red / garbling). -5. **Restore full colour:** set `MONOCHROME_RED = False` in `renderer.py` and - `hardware_rgb_sequence = "RGB"` in `config.py` (or whatever step 3 found). -6. **Live app:** `make run-pi` — confirm flight cards render in full colour. +5. **Live app:** `make run-pi` — confirm flight cards render in full colour. diff --git a/README.md b/README.md index 3f90ded..2cd92af 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,9 @@ uv sync ## Configuration Settings load from a YAML file pointed to by the `JETSET_CONFIG` environment -variable (defaults built in when unset). The `hardware` section tunes the -physical LED panel and is ignored by the emulator: - -```yaml -hardware: - panel_type: "" # empty = no chip-specific init; set "FM6126A" only if the panel comes up garbled - gpio_slowdown: 5 # raise if the image is unstable/corrupts; lower if the display lags - multiplexing: 0 # 0 for standard 1/16-scan panels; nonzero only for outdoor/multiplexed - row_address_type: 0 # 0 for standard panels; other values for some 1/16-scan panels (e.g. 3) - rgb_sequence: RBG # physical subpixel order; "RGB" for a standard panel, "RBG" for the current one -``` +variable (defaults built in when unset) — `home` coordinates, `range`, `pause`, +and `refresh`. The panel needs no tuning beyond the Adafruit HAT mapping, which +the app sets automatically on hardware. Flights come from a single source, **AirLabs** (`api_source: airlabs`): one `/flights?bbox=` call returns every nearby flight with positions, metrics, and diff --git a/scripts/diag-logo.py b/scripts/diag-logo.py deleted file mode 100644 index d6d21b8..0000000 --- a/scripts/diag-logo.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Diagnose why the root service can't load a logo that the pi user can. -Run as root the same way the service does: - sudo -E env PATH="$PATH" uv run python scripts/diag-logo.py -""" - -import os -from pathlib import Path - -print("euid:", os.geteuid(), "HOME:", os.environ.get("HOME")) -print("SUDO_USER:", os.environ.get("SUDO_USER")) -print("JETSET_LOGO_DIR (pre-set):", os.environ.get("JETSET_LOGO_DIR")) - -if "JETSET_LOGO_DIR" not in os.environ: - su = os.environ.get("SUDO_USER") - base = Path(f"/home/{su}") if su else Path.home() - os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") -print("JETSET_LOGO_DIR (effective):", os.environ["JETSET_LOGO_DIR"]) - -p = Path(os.environ["JETSET_LOGO_DIR"]) / "UAL.png" -print("path:", p) -print(" exists:", p.exists(), " os.access R_OK:", os.access(p, os.R_OK)) -for d in [Path("/home"), Path("/home/pi"), Path("/home/pi/.cache"), p.parent]: - print(" traverse", d, "X_OK:", os.access(d, os.X_OK), "R_OK:", os.access(d, os.R_OK)) - -from jetset.display import LOGO_DIR, load_logo # noqa: E402 - -print("LOGO_DIR (display):", LOGO_DIR) -print("LOGO_DIR == env?", str(LOGO_DIR) == os.environ["JETSET_LOGO_DIR"]) - -try: - from PIL import Image - - img = Image.open(p) - img.load() - print("PIL open: OK", img.size) -except Exception as e: # noqa: BLE001 - diagnostic - print("PIL open FAILED:", type(e).__name__, repr(e)) - -print("load_logo('UAL'):", load_logo("UAL")) diff --git a/scripts/logo-gallery.py b/scripts/logo-gallery.py index 3b06a67..d0d1ad8 100755 --- a/scripts/logo-gallery.py +++ b/scripts/logo-gallery.py @@ -25,24 +25,14 @@ base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") -from jetset.backend import RGBMatrix, RGBMatrixOptions # noqa: E402 +from jetset.backend import build_matrix # noqa: E402 from jetset.display import LOGO_DIR # noqa: E402 from jetset.renderer import ORANGE, draw_text, render_logo # noqa: E402 HOLD = float(sys.argv[1]) if len(sys.argv) > 1 else 3.0 PREFIX = sys.argv[2].upper() if len(sys.argv) > 2 else "" -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 5 -options.pwm_bits = 6 -options.led_rgb_sequence = "RGB" -options.disable_hardware_pulsing = True -options.drop_privileges = False # stay root so logo files under /home stay readable - -matrix = RGBMatrix(options=options) +matrix = build_matrix() canvas = matrix.CreateFrameCanvas() logos = sorted(p for p in LOGO_DIR.glob("*.png") if p.stem.upper().startswith(PREFIX)) diff --git a/scripts/panel-colors.py b/scripts/panel-colors.py index f7f7b60..3899fb5 100755 --- a/scripts/panel-colors.py +++ b/scripts/panel-colors.py @@ -1,45 +1,18 @@ #!/usr/bin/env python3 -"""Test each color channel with a solid full-panel fill. +"""Test each colour channel with a solid full-panel fill. -A solid fill is immune to pixel-mapping/multiplexing scrambles, so this -isolates the color channels themselves. If solid GREEN or BLUE does NOT light -the whole panel in that color, that channel isn't reaching the panel. - -Whether that's a config or a hardware fault depends on the knobs below, so they -are all overridable — a "dead" channel at the wrong gpio_slowdown (signal -timing) or without a required panel_type init is a config issue, not the panel: - - panel-colors.py [gpio_slowdown] [rgb_sequence] [panel_type] - defaults: 5 RGB (none) - - gpio_slowdown : 5 is required on the Pi 3 A+ / Adafruit HAT; 4 is unstable and - can drop or garble a channel (masquerades as a dead channel). - rgb_sequence : RGB GRB RBG ... — only PERMUTES colors, never blanks one. - panel_type : "" or FM6126A — some panels need the FM6126A init or come up - with wrong/missing channels. +A solid fill is immune to pixel-mapping scrambles, so this isolates the colour +channels themselves: if solid GREEN or BLUE doesn't light the whole panel in +that colour, that channel isn't reaching the panel — check the panel's power +feed (the ribbon carries data + ground only, so the panel needs 5V on its own +header) and the HAT. """ -import sys import time -from rgbmatrix import RGBMatrix, RGBMatrixOptions - -SLOWDOWN = int(sys.argv[1]) if len(sys.argv) > 1 else 5 -RGB_SEQUENCE = sys.argv[2] if len(sys.argv) > 2 else "RGB" -PANEL_TYPE = sys.argv[3] if len(sys.argv) > 3 else "" - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = SLOWDOWN -options.led_rgb_sequence = RGB_SEQUENCE -options.panel_type = PANEL_TYPE -options.disable_hardware_pulsing = True +from jetset.backend import build_matrix -matrix = RGBMatrix(options=options) -print(f">>> gpio_slowdown={SLOWDOWN} rgb_sequence={RGB_SEQUENCE} " - f"panel_type={PANEL_TYPE or '(none)'}") +matrix = build_matrix() for name, (r, g, b) in [ ("RED", (150, 0, 0)), diff --git a/scripts/probe-card.py b/scripts/probe-card.py deleted file mode 100755 index f06a8e3..0000000 --- a/scripts/probe-card.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -"""Confirm the privilege-drop fix on hardware: render the real full card -(text + render_logo) with drop_privileges=False, so the logo — loaded lazily in -the render path — can still be read after the matrix initializes. Cycles the 4 -metric pages so you can confirm the logo shows AND the text stays clean. - -Run on the Pi with the service stopped: - sudo systemctl stop jetset - sudo -E env PATH="$PATH" uv run python scripts/probe-card.py [airline] - sudo systemctl start jetset -""" - -import os -import sys -import time -from pathlib import Path - -if "JETSET_LOGO_DIR" not in os.environ: - sudo_user = os.environ.get("SUDO_USER") - base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() - os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") - -from jetset.backend import RGBMatrix, RGBMatrixOptions # noqa: E402 -from jetset.models import Airport, Flight, FlightRoute # noqa: E402 -from jetset.renderer import render_flight_card # noqa: E402 - -AIRLINE = sys.argv[1] if len(sys.argv) > 1 else "UAL" -CYCLES = int(sys.argv[2]) if len(sys.argv) > 2 else 3 -PAGE_HOLD = 4 - -flight = Flight( - callsign=f"{AIRLINE}2337", - aircraft="B738", - route=FlightRoute(Airport("IAH"), Airport("LAX")), - altitude=35000, - speed=450, - track=270.0, - vertical_rate=1500, -) - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 5 -options.multiplexing = 0 -options.row_address_type = 0 -options.panel_type = "" -options.pwm_bits = 6 -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True -options.drop_privileges = False # the fix — stay root so the render loop can read logos - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -print(f">>> {flight.callsign} IAH-LAX B738 — real card, drop_privileges=False, x{CYCLES}.") -print(f" Logo (airline {AIRLINE}) should now show top-right; text should stay clean.") -for _ in range(CYCLES): - for page in range(4): - render_flight_card(canvas, flight, metric_page=page) - canvas = matrix.SwapOnVSync(canvas) - time.sleep(PAGE_HOLD) - -matrix.Clear() -print(">>> done") diff --git a/scripts/probe-logo.py b/scripts/probe-logo.py deleted file mode 100755 index ceea55b..0000000 --- a/scripts/probe-logo.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""Probe whether airline logos render on the panel's top-right corner. - -Phase 1 fills the logo box (the app's exact rect) solid red — confirms that -region of the (possibly faulty) panel is alive. Phase 2 draws a real logo -silhouette through the app's own render_logo, at its real position. So: - - - Phase 1 blank -> that corner of the panel is dead (hardware). - - Phase 1 red, Phase 2 blank -> the logo pipeline is at fault (software). - - both visible -> logos work; the issue is elsewhere. - -Run on the Pi with the service stopped (only one process can own the GPIO): - sudo systemctl stop jetset - sudo -E env PATH="$PATH" uv run python scripts/probe-logo.py [airline] [hold] - sudo systemctl start jetset -""" - -import os -import sys -import time -from pathlib import Path - -# Point the logo loader at the invoking user's cache before importing the app -# modules (LOGO_DIR is resolved at import time). Under sudo, Path.home() is -# /root, so prefer SUDO_USER's home where the logos were actually downloaded. -if "JETSET_LOGO_DIR" not in os.environ: - sudo_user = os.environ.get("SUDO_USER") - base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() - os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") - -from rgbmatrix import RGBMatrix, RGBMatrixOptions # noqa: E402 - -from jetset.display import LOGO_DIR # noqa: E402 -from jetset.renderer import ( # noqa: E402 - CANVAS_WIDTH, - LOGO_HEIGHT, - LOGO_WIDTH, - _scaled_logo, - render_logo, -) - -AIRLINE = sys.argv[1] if len(sys.argv) > 1 else "UAL" -HOLD = int(sys.argv[2]) if len(sys.argv) > 2 else 8 - -print(f"LOGO_DIR={LOGO_DIR}") - -# Brightness histogram of what the current (luminance) render would draw. -_scaled = _scaled_logo(AIRLINE) -if _scaled is not None: - buckets = {"1-63": 0, "64-127": 0, "128-191": 0, "192-255": 0} - for _y in range(_scaled.size[1]): - for _x in range(_scaled.size[0]): - _r, _g, _b, _a = _scaled.getpixel((_x, _y)) - if _a == 0 or (_r, _g, _b) == (0, 0, 0): - continue - lum = round(0.299 * _r + 0.587 * _g + 0.114 * _b) - key = ("1-63" if lum < 64 else "64-127" if lum < 128 - else "128-191" if lum < 192 else "192-255") - buckets[key] += 1 - print(f"{AIRLINE} luminance histogram (drawn px by red value): {buckets}") - -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = 5 -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True -options.pwm_bits = 6 # match the service — this is what dims faint reds - -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() - -box_left = CANVAS_WIDTH - LOGO_WIDTH - -# Phase 1: solid red block over the logo box. -canvas.Clear() -for y in range(LOGO_HEIGHT): - for x in range(LOGO_WIDTH): - canvas.SetPixel(box_left + x, y, 255, 0, 0) -canvas = matrix.SwapOnVSync(canvas) -print(f">>> Phase 1: solid red block at x{box_left}-{box_left + LOGO_WIDTH - 1}, " - f"y0-{LOGO_HEIGHT - 1} (holding {HOLD}s)") -print(" Solid red rectangle in the TOP-RIGHT corner?") -time.sleep(HOLD) - -# Phase 2: the current render_logo (luminance-scaled red — the suspect). -flight = type("ProbeFlight", (), {"airline": AIRLINE})() -canvas.Clear() -render_logo(canvas, flight) -canvas = matrix.SwapOnVSync(canvas) -print(f">>> Phase 2: {AIRLINE} via current render_logo (luminance) (holding {HOLD}s)") -print(" Any logo shape? (suspected too dim to see)") -time.sleep(HOLD) - -# Phase 3: proposed fix — luminance with a brightness FLOOR. Maps lum [0,255] -# onto [FLOOR,255], so dark logo colors lift above the panel's threshold while -# brighter pixels stay brighter (keeps the shape detail, unlike a flat fill). -FLOOR = 120 -scaled = _scaled_logo(AIRLINE) -new_w, new_h = scaled.size -x_off = CANVAS_WIDTH - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 -y_off = (LOGO_HEIGHT - new_h) // 2 -canvas.Clear() -for y in range(new_h): - for x in range(new_w): - _r, _g, _b, a = scaled.getpixel((x, y)) - if a == 0 or (_r, _g, _b) == (0, 0, 0): - continue - lum = round(0.299 * _r + 0.587 * _g + 0.114 * _b) - red = FLOOR + round(lum * (255 - FLOOR) / 255) - canvas.SetPixel(x_off + x, y_off + y, red, 0, 0) -canvas = matrix.SwapOnVSync(canvas) -print(f">>> Phase 3: {AIRLINE} luminance with floor={FLOOR} (holding {HOLD}s)") -print(" Bright AND keeps the logo's internal detail?") -time.sleep(HOLD) - -matrix.Clear() diff --git a/scripts/probe-text.py b/scripts/probe-text.py index 3f3d829..44e2175 100755 --- a/scripts/probe-text.py +++ b/scripts/probe-text.py @@ -1,22 +1,17 @@ #!/usr/bin/env python3 -"""Final check: render the app's 4-row text card, held static, at a stable -slowdown, in any color (including combination colors that exercise multiple -channels at once). +"""Render the app's 4-row text card, held static, in any colour — a quick check +that text and combination colours render cleanly on the panel. -Uses the project's own fonts/5x7.bdf and the app's exact row positions. Run it -per color to confirm text renders reliably: - - red / green : single working channels - - yellow / cyan / white / orange : combination colors (exercise the - channel-collapse we saw with white->red) +Uses the project's own fonts/5x7.bdf and the app's exact row positions. -Usage: probe-text.py [color] [gpio_slowdown] [hold-seconds] - color: red green blue yellow cyan magenta white orange (default red 5 15) +Usage: probe-text.py [color] [hold-seconds] + color: red green blue yellow cyan magenta white orange (default red, 15s) """ import sys import time -from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics +from jetset.backend import build_matrix, graphics COLORS = { "red": (255, 0, 0), @@ -32,20 +27,11 @@ } NAME = sys.argv[1] if len(sys.argv) > 1 else "red" -SLOWDOWN = int(sys.argv[2]) if len(sys.argv) > 2 else 5 -HOLD = int(sys.argv[3]) if len(sys.argv) > 3 else 15 +HOLD = int(sys.argv[2]) if len(sys.argv) > 2 else 15 R, G, B = COLORS.get(NAME, (255, 0, 0)) FONT_HEIGHT = 7 -options = RGBMatrixOptions() -options.cols = 64 -options.rows = 32 -options.hardware_mapping = "adafruit-hat" -options.gpio_slowdown = SLOWDOWN -options.led_rgb_sequence = "RBG" -options.disable_hardware_pulsing = True - -matrix = RGBMatrix(options=options) +matrix = build_matrix() canvas = matrix.CreateFrameCanvas() font = graphics.Font() @@ -59,7 +45,7 @@ graphics.DrawText(canvas, font, 1, FONT_HEIGHT * 4 + 3, color, "35K ft") matrix.SwapOnVSync(canvas) -print(f">>> color={NAME} slowdown={SLOWDOWN}: four rows held {HOLD}s") +print(f">>> color={NAME}: four rows held {HOLD}s") print(" UAL123 / IAH-LAX / B738 / 35K ft — all four readable and stable?") time.sleep(HOLD) matrix.Clear() diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index fcd3b1d..2784c1a 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -7,9 +7,6 @@ from jetset.app import App from jetset.config import AppConfig -DISPLAY_WIDTH = 64 -DISPLAY_HEIGHT = 32 - logger = logging.getLogger(__name__) @@ -38,30 +35,9 @@ def main() -> None: config = AppConfig.load(os.environ.get("JETSET_CONFIG")) - from jetset.backend import IS_HARDWARE, RGBMatrix, RGBMatrixOptions - - options = RGBMatrixOptions() - options.cols = DISPLAY_WIDTH - options.rows = DISPLAY_HEIGHT - if IS_HARDWARE: - # Physical-panel tuning. These are hardware-only knobs; the emulator - # neither needs nor accepts them, so they stay behind IS_HARDWARE. - options.hardware_mapping = "adafruit-hat" - options.gpio_slowdown = config.hardware_gpio_slowdown - options.multiplexing = config.hardware_multiplexing - options.row_address_type = config.hardware_row_address_type - options.panel_type = config.hardware_panel_type - options.pwm_bits = 6 # lower PWM reduces flicker on Pi 3 A+ - options.led_rgb_sequence = config.hardware_rgb_sequence - options.disable_hardware_pulsing = True - # Keep running as root after GPIO init. By default the library drops to - # the 'daemon' user, which can't traverse /home/pi (mode 700) — so the - # logos, loaded lazily in the render loop, would fail to read and the - # cards would show text but never a logo. (Fonts escape this because - # they load at import, before the matrix drops privileges.) - options.drop_privileges = False + from jetset.backend import build_matrix - matrix = RGBMatrix(options=options) + matrix = build_matrix() canvas = matrix.CreateFrameCanvas() try: logger.info("Press Ctrl-C to stop") diff --git a/src/jetset/backend.py b/src/jetset/backend.py index 6106bce..2b2454f 100644 --- a/src/jetset/backend.py +++ b/src/jetset/backend.py @@ -30,4 +30,35 @@ IS_HARDWARE = False -__all__ = ["RGBMatrix", "RGBMatrixOptions", "graphics", "IS_HARDWARE"] +DISPLAY_WIDTH = 64 +DISPLAY_HEIGHT = 32 + + +def build_matrix() -> RGBMatrix: + """Create the 64x32 LED matrix with the project's standard options. + + The single place panel setup lives, so the app and the scripts/ probes never + drift. On hardware it sets the Adafruit HAT mapping and keeps the process as + root (drop_privileges off) so the render loop can still read the logo cache + under /home — the library otherwise drops to 'daemon', which can't traverse + /home/pi. The emulator rejects hardware-only knobs, so they stay behind + IS_HARDWARE. + """ + options = RGBMatrixOptions() + options.cols = DISPLAY_WIDTH + options.rows = DISPLAY_HEIGHT + if IS_HARDWARE: + options.hardware_mapping = "adafruit-hat" + options.drop_privileges = False + return RGBMatrix(options=options) + + +__all__ = [ + "RGBMatrix", + "RGBMatrixOptions", + "graphics", + "IS_HARDWARE", + "build_matrix", + "DISPLAY_WIDTH", + "DISPLAY_HEIGHT", +] diff --git a/src/jetset/config.py b/src/jetset/config.py index be0f9ff..3e19601 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -12,20 +12,6 @@ class AppConfig: pause: int = 2 refresh: int = 2700 # 45 min — one AirLabs bbox call per refresh ≈ 960/month api_source: str = "airlabs" - # Physical LED panel tuning (ignored by the emulator). panel_type is empty - # by default — the P2.5 64x32 panel needs no chip-specific init sequence. - # Set it (e.g. "FM6126A") only if a panel comes up garbled without one. - hardware_panel_type: str = "" - # Pi 3 A+ / Adafruit HAT needs slowdown 5 for a stable signal; 4 produced - # intermittent corruption that looked like scrambled pixels. - hardware_gpio_slowdown: int = 5 - # Standard 1/16-scan panels use multiplexing 0 / row_address_type 0. - # Override only for "outdoor"/multiplexed panels. - hardware_multiplexing: int = 0 - hardware_row_address_type: int = 0 - # Physical subpixel order; "RGB" for a standard panel (verified with - # scripts/panel-colors.py — solid red/green/blue all render true). - hardware_rgb_sequence: str = "RGB" @classmethod def load(cls, path=None): diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index 0781a73..bd870a3 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -24,12 +24,6 @@ DIM_WHITE = (80, 80, 80) BLACK = (0, 0, 0) -# Monochrome-red fallback for a panel that can't render full colour. Off — the -# panel renders the full palette and colour logos. (The earlier "dead channels" -# were the panel running on parasitic ribbon power, not a faulty panel; once it -# was powered properly through its own VH4 header, all channels worked.) -MONOCHROME_RED = False - FONT_HEIGHT = 7 font = graphics.Font() font.LoadFont("fonts/5x7.bdf") @@ -43,11 +37,6 @@ LOGO_HEIGHT = 23 LOGO_RIGHT_MARGIN = 1 # empty columns kept to the right of the logo LOGO_TOP_MARGIN = 1 # empty rows kept above the logo -# Faulty-panel visibility: map logo luminance onto [LOGO_RED_FLOOR, 255] so dark -# airline colors don't fall below the panel's low-PWM threshold (pwm_bits=6), -# while brighter pixels stay brighter (keeps the shape's detail). Set 0 to -# render true luminance (or once MONOCHROME_RED is off, on a standard panel). -LOGO_RED_FLOOR = 120 # Debug aid: outline the logo box on the panel so its bounds can be eyeballed # against the card. Set False for normal use. LOGO_DEBUG_BORDER = False @@ -90,15 +79,11 @@ def _draw_logo_border(canvas: Canvas) -> None: def render_logo(canvas: Canvas, flight: Flight) -> None: - """Draw the airline logo, centred in the top-right corner. - - Honours MONOCHROME_RED (renders the logo as a red-luminance silhouette - while the faulty panel can't do colour). Silently skips if no logo exists. - """ + """Draw the airline logo in full colour, top-right. Skips if none exists.""" if LOGO_DEBUG_BORDER: _draw_logo_border(canvas) - scaled = _scaled_logo(flight.airline) + scaled: Image.Image = _scaled_logo(flight.airline) if scaled is None: return @@ -111,19 +96,13 @@ def render_logo(canvas: Canvas, flight: Flight) -> None: r, g, b, a = scaled.getpixel((x, y)) if a == 0 or (r, g, b) == (0, 0, 0): continue - if MONOCHROME_RED: - lum = round(0.299 * r + 0.587 * g + 0.114 * b) - red = LOGO_RED_FLOOR + round(lum * (255 - LOGO_RED_FLOOR) / 255) - canvas.SetPixel(x_offset + x, y_offset + y, red, 0, 0) - else: - canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) + canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): canvas.Clear() - # y-values are based off of the font height. Colours come from the palette, - # but collapse to red while MONOCHROME_RED is set (faulty-panel workaround). + # y-values are based off of the font height; each row uses its palette colour. rows = ( (flight_label(flight), ORANGE), (route_label(flight), CYAN), @@ -131,7 +110,7 @@ def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): (metrics_label(flight, metric_page), BLUE), ) for i, (text, color) in enumerate(rows): - draw_text(canvas, 1, FONT_HEIGHT * (i + 1) + i, text, RED if MONOCHROME_RED else color) + draw_text(canvas, 1, FONT_HEIGHT * (i + 1) + i, text, color) render_logo(canvas, flight) diff --git a/tests/test_config.py b/tests/test_config.py index cf08fc4..9e2cd93 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,31 +34,6 @@ def test_default_refresh(self) -> None: config = AppConfig.load() assert config.refresh > 0 - def test_default_panel_type_is_empty(self) -> None: - # Empty = the library's default (no chip-specific init sequence). The - # P2.5 64x32 panel renders correctly without one; see panel-diag sweep. - config = AppConfig.load() - assert config.hardware_panel_type == "" - - def test_default_gpio_slowdown(self) -> None: - # Pi 3 A+ / Adafruit HAT needs 5 for a stable signal; 4 corrupted output. - config = AppConfig.load() - assert config.hardware_gpio_slowdown == 5 - - def test_default_rgb_sequence(self) -> None: - # Standard panel: subpixels render true in RGB order. - config = AppConfig.load() - assert config.hardware_rgb_sequence == "RGB" - - def test_default_multiplexing(self) -> None: - config = AppConfig.load() - assert config.hardware_multiplexing == 0 - - def test_default_row_address_type(self) -> None: - # Standard 1/16-scan addressing; 1 collapsed the image to two lines. - config = AppConfig.load() - assert config.hardware_row_address_type == 0 - class TestConfigFromYaml: def test_loads_custom_yaml(self, tmp_path: Path) -> None: @@ -79,19 +54,6 @@ def test_loads_custom_yaml(self, tmp_path: Path) -> None: assert config.pause == 4 assert config.refresh == 15 - def test_loads_custom_hardware_section(self, tmp_path: Path) -> None: - yaml_content = { - "hardware": {"panel_type": "FM6127", "gpio_slowdown": 2, "rgb_sequence": "RGB"} - } - config_path = tmp_path / "config.yaml" - with open(config_path, "w") as f: - yaml.dump(yaml_content, f) - - config = AppConfig.load(str(config_path)) - assert config.hardware_panel_type == "FM6127" - assert config.hardware_gpio_slowdown == 2 - assert config.hardware_rgb_sequence == "RGB" - def test_api_source_defaults_to_airlabs(self) -> None: config = AppConfig.load() assert config.api_source == "airlabs" diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 7da2e65..1d18b38 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -33,7 +33,7 @@ def test_clears_canvas_before_drawing(self) -> None: assert canvas.Clear.called def test_each_row_uses_its_palette_colour(self) -> None: - # Full palette restored now that the panel renders colour (MONOCHROME_RED off). + # Each row is drawn in its own palette colour. from unittest.mock import MagicMock, patch from jetset import renderer @@ -116,36 +116,3 @@ def test_draws_pixels_for_known_airline(self) -> None: _scaled_logo.cache_clear() mock_setpixel.assert_called() - - def test_dim_logo_pixels_meet_brightness_floor(self) -> None: - # MONOCHROME_RED maps luminance onto [LOGO_RED_FLOOR, 255] so dark - # airline colors stay visible on the low-PWM faulty panel. - from unittest.mock import patch - - from PIL import Image - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions - - from jetset import renderer - from jetset.models import Flight - from jetset.renderer import LOGO_RED_FLOOR, _scaled_logo, render_logo - - dark = Image.new("RGBA", (renderer.LOGO_WIDTH, renderer.LOGO_HEIGHT), (0, 0, 40, 255)) - - options = RGBMatrixOptions() - options.cols = 64 - options.rows = 32 - matrix = RGBMatrix(options=options) - canvas = matrix.CreateFrameCanvas() - - reds: list[int] = [] - flight = Flight(callsign="UAL2337") - with ( - patch("jetset.renderer.MONOCHROME_RED", True), - patch("jetset.renderer.load_logo", return_value=dark), - ): - _scaled_logo.cache_clear() - with patch.object(canvas, "SetPixel", side_effect=lambda x, y, r, g, b: reds.append(r)): - render_logo(canvas, flight) - _scaled_logo.cache_clear() - - assert reds and min(reds) >= LOGO_RED_FLOOR From f20161091f79aa04b20cb94f54ec141d8a3aa14c Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Wed, 17 Jun 2026 23:26:58 -0500 Subject: [PATCH 30/36] REFACTOR: global logo dir from config; project-wide lint clean Two cleanups that overlap in renderer/tests, landed together. Config-driven global logo directory: - AppConfig gains logo_dir, defaulting to the global /var/lib/jetset/logos (not a user home), so the root service and the deploy-user downloader resolve the same path - thread logo_dir App -> render_flight_card -> render_logo -> load_logo; download_logos and logo-gallery read it from AppConfig - drop the JETSET_LOGO_DIR env, the SUDO_USER juggling, and the home-cache default; the systemd unit no longer sets JETSET_LOGO_DIR and setup-pi.sh creates /var/lib/jetset/logos owned by the deploy user Lint clean across the whole project (ruff . + ty .): - run ruff and ty over . instead of src/ only - exclude .venv from ty; justified inline ignores for the rgbmatrix conditional import, the hardware-only drop_privileges attr, and the emulator's mistyped Font.CharacterWidth stub - real type fixes: Image.Resampling.LANCZOS, cast the RGBA getpixel tuple, widen the scaled annotation, http base_url as an instance attr (not ClassVar), route-compare dotenv no-op fallback - narrow Optional access in tests with assert ... is not None --- Makefile | 3 ++- mise.toml | 1 + pyproject.toml | 3 +++ scripts/download_logos.py | 28 ++++++++++++++--------- scripts/jetset.service.template | 1 - scripts/logo-gallery.py | 29 ++++++++++-------------- scripts/route-compare.py | 9 +++++--- scripts/setup-pi.sh | 7 +++++- src/jetset/app.py | 4 +++- src/jetset/backend.py | 10 +++++++-- src/jetset/config.py | 3 +++ src/jetset/display.py | 18 +++++---------- src/jetset/http.py | 3 +-- src/jetset/renderer.py | 40 +++++++++------------------------ tests/test_display.py | 4 +++- tests/test_fetcher.py | 14 ++++++++---- tests/test_main.py | 17 ++++++++++---- tests/test_renderer.py | 14 +++++++----- 18 files changed, 114 insertions(+), 94 deletions(-) diff --git a/Makefile b/Makefile index eebfcbb..e7fae87 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,8 @@ test: ## Run all tests uv run pytest -v lint: ## Lint and format check - uv run ruff check src/ + uv run ruff check . + uv run ty check . fixtures: ## Save a live AirLabs response as a test fixture uv run python scripts/save_fixtures.py diff --git a/mise.toml b/mise.toml index dd5d0a8..d460d99 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,4 @@ [tools] python = "3.14.6" +ty = "latest" uv = "0.11.20" diff --git a/pyproject.toml b/pyproject.toml index d3293db..d4de155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "N", "W"] +[tool.ty.src] +exclude = [".venv"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/scripts/download_logos.py b/scripts/download_logos.py index c77f373..38b3e95 100755 --- a/scripts/download_logos.py +++ b/scripts/download_logos.py @@ -1,15 +1,17 @@ """Download airline logos from the Jxck-S/airline-logos repository. RadarBox is the preferred set; FlightAware fills the gaps RadarBox is missing -(e.g. ASA). Logos are cached in ~/.cache/jetset/logos/ as {ICAO}.png files; a -file already present (from a higher-priority source) is never overwritten. +(e.g. ASA). Logos are cached in AppConfig.logo_dir as {ICAO}.png files; a file +already present (from a higher-priority source) is never overwritten. """ import json +import os import urllib.error import urllib.request +from pathlib import Path -from jetset.display import LOGO_DIR +from jetset.config import AppConfig # (GitHub contents API, raw base) per source, in PRIORITY order — earlier # sources win because the download skips files that already exist locally. @@ -25,6 +27,11 @@ ] +def _logo_dir() -> Path: + """The configured logo cache directory.""" + return Path(AppConfig.load(os.environ.get("JETSET_CONFIG")).logo_dir) + + def _fetch_logo_list(api_url: str) -> list[str]: """Fetch the list of PNG logo filenames from a GitHub contents API URL.""" with urllib.request.urlopen(api_url) as resp: @@ -32,11 +39,11 @@ def _fetch_logo_list(api_url: str) -> list[str]: return [e["name"] for e in entries if e["name"].endswith(".png")] -def _download_missing(filenames: list[str], raw_base: str) -> int: - """Download each filename from raw_base into LOGO_DIR, skipping existing.""" +def _download_missing(filenames: list[str], raw_base: str, dest_dir: Path) -> int: + """Download each filename from raw_base into dest_dir, skipping existing.""" count = 0 for name in filenames: - dest = LOGO_DIR / name + dest = dest_dir / name if dest.exists(): continue try: @@ -48,7 +55,7 @@ def _download_missing(filenames: list[str], raw_base: str) -> int: def download_logos(icao_codes: list[str] | None = None) -> int: - """Download airline logos to LOGO_DIR, preferring earlier SOURCES. + """Download airline logos to the configured logo dir, preferring earlier SOURCES. Args: icao_codes: Optional ICAO codes to fetch. If None, every logo each @@ -58,16 +65,17 @@ def download_logos(icao_codes: list[str] | None = None) -> int: Returns: Number of logos newly downloaded. """ - LOGO_DIR.mkdir(parents=True, exist_ok=True) + dest_dir = _logo_dir() + dest_dir.mkdir(parents=True, exist_ok=True) explicit = [f"{code}.png" for code in icao_codes] if icao_codes is not None else None total = 0 for api_url, raw_base in SOURCES: filenames = explicit if explicit is not None else _fetch_logo_list(api_url) - total += _download_missing(filenames, raw_base) + total += _download_missing(filenames, raw_base, dest_dir) return total if __name__ == "__main__": count = download_logos() - print(f"Downloaded {count} airline logos to {LOGO_DIR}") + print(f"Downloaded {count} airline logos to {_logo_dir()}") diff --git a/scripts/jetset.service.template b/scripts/jetset.service.template index 020ba6a..d4c6b15 100644 --- a/scripts/jetset.service.template +++ b/scripts/jetset.service.template @@ -11,7 +11,6 @@ Type=simple User=root WorkingDirectory=@HOME@/jetset Environment=PATH=@HOME@/.local/bin:/usr/bin:/bin -Environment=JETSET_LOGO_DIR=@HOME@/.cache/jetset/logos ExecStart=@HOME@/.local/bin/uv run python -m jetset Restart=on-failure RestartSec=5 diff --git a/scripts/logo-gallery.py b/scripts/logo-gallery.py index d0d1ad8..7882d08 100755 --- a/scripts/logo-gallery.py +++ b/scripts/logo-gallery.py @@ -18,36 +18,31 @@ import time from pathlib import Path -# Point the loader at the invoking user's cache before importing app modules. -# Under sudo, Path.home() is /root, so prefer SUDO_USER's home. -if "JETSET_LOGO_DIR" not in os.environ: - sudo_user = os.environ.get("SUDO_USER") - base = Path(f"/home/{sudo_user}") if sudo_user else Path.home() - os.environ["JETSET_LOGO_DIR"] = str(base / ".cache" / "jetset" / "logos") - -from jetset.backend import build_matrix # noqa: E402 -from jetset.display import LOGO_DIR # noqa: E402 -from jetset.renderer import ORANGE, draw_text, render_logo # noqa: E402 +from jetset.backend import build_matrix +from jetset.config import AppConfig +from jetset.models import Flight +from jetset.renderer import ORANGE, draw_text, render_logo HOLD = float(sys.argv[1]) if len(sys.argv) > 1 else 3.0 PREFIX = sys.argv[2].upper() if len(sys.argv) > 2 else "" +logo_dir = Path(AppConfig.load(os.environ.get("JETSET_CONFIG")).logo_dir) +logos = sorted(p for p in logo_dir.glob("*.png") if p.stem.upper().startswith(PREFIX)) +if not logos: + raise SystemExit(f"No logos matching '{PREFIX}*' in {logo_dir}") + matrix = build_matrix() canvas = matrix.CreateFrameCanvas() -logos = sorted(p for p in LOGO_DIR.glob("*.png") if p.stem.upper().startswith(PREFIX)) -if not logos: - raise SystemExit(f"No logos matching '{PREFIX}*' in {LOGO_DIR}") - -print(f"{len(logos)} logos in {LOGO_DIR} — {HOLD}s each (Ctrl-C to stop)") +print(f"{len(logos)} logos in {logo_dir} — {HOLD}s each (Ctrl-C to stop)") try: for path in logos: code = path.stem - flight = type("GalleryFlight", (), {"airline": code})() + flight = Flight(callsign=code) canvas.Clear() draw_text(canvas, 1, 7, code, ORANGE) # ICAO where the callsign goes - render_logo(canvas, flight) # logo where it goes + render_logo(canvas, flight, logo_dir) # logo where it goes canvas = matrix.SwapOnVSync(canvas) print(f">>> {code}") time.sleep(HOLD) diff --git a/scripts/route-compare.py b/scripts/route-compare.py index d128535..6c84358 100755 --- a/scripts/route-compare.py +++ b/scripts/route-compare.py @@ -27,7 +27,9 @@ try: from dotenv import load_dotenv except ImportError: # dotenv is a convenience; env vars work without it - load_dotenv = None + + def load_dotenv(*_args, **_kwargs) -> bool: # no-op fallback + return False from jetset.config import AppConfig from jetset.fetcher import AirLabsAdapter @@ -110,8 +112,7 @@ def line(row: tuple[str, ...]) -> str: def main() -> None: - if load_dotenv is not None: - load_dotenv() # load API keys from a .env file if present + load_dotenv() # load API keys from a .env file if present # AeroAPI costs money, so it is opt-in only. Default run uses free sources. use_truth = "--truth" in sys.argv @@ -140,6 +141,7 @@ def main() -> None: hexdb = RequestsAPI(base_url=HEXDB_BASE) aero = None if use_truth: + assert truth_key is not None # guaranteed by the use_truth check above aero = RequestsAPI(base_url=AEROAPI_BASE, headers={"x-apikey": truth_key}) rows = [] @@ -147,6 +149,7 @@ def main() -> None: ours = our_route(flight) hex_route = hexdb_route(hexdb, flight.callsign) if use_truth: + assert aero is not None truth = aeroapi_route(aero, flight.callsign) rows.append((flight.callsign, ours, truth, hex_route, agree(ours, truth))) else: diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh index c5c7ea5..8b00687 100755 --- a/scripts/setup-pi.sh +++ b/scripts/setup-pi.sh @@ -68,8 +68,13 @@ else echo "=== rgbmatrix wheel already built ===" fi -# 6. Download airline logos (cached in ~/.cache/jetset/logos; skips existing) +# 6. Download airline logos into the global cache (AppConfig.logo_dir = +# /var/lib/jetset/logos). Owned by the deploy user so this download can write; +# the root service only reads. A global dir means root and the deploy user +# resolve the same path — no per-home/JETSET_LOGO_DIR juggling. echo "=== Downloading airline logos ===" +sudo mkdir -p /var/lib/jetset/logos +sudo chown "$(whoami):$(whoami)" /var/lib/jetset/logos uv run python scripts/download_logos.py || echo "(logo download incomplete — re-run later)" # 7. Install + enable the systemd service (runs on boot) diff --git a/src/jetset/app.py b/src/jetset/app.py index 4fa1d66..0852322 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -1,6 +1,7 @@ import logging import threading import time +from pathlib import Path from typing import NamedTuple from RGBMatrixEmulator.emulation.canvas import Canvas @@ -27,6 +28,7 @@ class Frame(NamedTuple): def __init__(self, config: AppConfig) -> None: self.config = config + self.logo_dir = Path(config.logo_dir) self.buffer = FlightBuffer() self.frame = 0 self.last_fetch: float | None = None @@ -118,7 +120,7 @@ def _after_render(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: def _render_frame(self, matrix: RGBMatrix, canvas: Canvas, frame: Frame) -> Canvas: flight, metric_page = frame - render_flight_card(canvas, flight, metric_page) + render_flight_card(canvas, flight, self.logo_dir, metric_page) return self._after_render(matrix, canvas) def _render_loading(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: diff --git a/src/jetset/backend.py b/src/jetset/backend.py index 2b2454f..84a0f99 100644 --- a/src/jetset/backend.py +++ b/src/jetset/backend.py @@ -14,7 +14,10 @@ logger = logging.getLogger(__name__) try: - from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics + # rgbmatrix is installed only on the Pi; on the dev box (where ty runs) it's + # absent and the except below falls back to the emulator, so an unresolved + # import here is expected — not a bug. + from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics # ty: ignore[unresolved-import] IS_HARDWARE = True except ImportError as exc: @@ -49,7 +52,10 @@ def build_matrix() -> RGBMatrix: options.rows = DISPLAY_HEIGHT if IS_HARDWARE: options.hardware_mapping = "adafruit-hat" - options.drop_privileges = False + # drop_privileges exists on the real rpi-rgb-led-matrix RGBMatrixOptions + # (and is set only here, under IS_HARDWARE), but not on the emulator stub + # ty resolves to off-hardware — hence the ignore. + options.drop_privileges = False # ty: ignore[invalid-assignment] return RGBMatrix(options=options) diff --git a/src/jetset/config.py b/src/jetset/config.py index 3e19601..2509949 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -12,6 +12,9 @@ class AppConfig: pause: int = 2 refresh: int = 2700 # 45 min — one AirLabs bbox call per refresh ≈ 960/month api_source: str = "airlabs" + # Where airline logos are cached. A global path (not a user home) so the + # root service and the deploy-user downloader resolve the same directory. + logo_dir: str = "/var/lib/jetset/logos" @classmethod def load(cls, path=None): diff --git a/src/jetset/display.py b/src/jetset/display.py index 443e37a..f516105 100644 --- a/src/jetset/display.py +++ b/src/jetset/display.py @@ -1,17 +1,11 @@ """Display formatting functions for flight cards on the LED panel.""" -import os from pathlib import Path from PIL import Image from jetset.models import Flight -# Where airline logos live. Overridable via JETSET_LOGO_DIR because the systemd -# service runs as root (so Path.home() would be /root), while download_logos.py -# runs as the deploy user — the unit points both at the same dir. -LOGO_DIR = Path(os.environ.get("JETSET_LOGO_DIR") or Path.home() / ".cache" / "jetset" / "logos") - _COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -20,14 +14,14 @@ def _cardinal(track: float) -> str: return _COMPASS[int(track / 45 + 0.5) % 8] -def load_logo(airline_code: str) -> Image.Image | None: - """Load the airline logo for a given ICAO airline code. +def load_logo(airline_code: str, logo_dir: Path) -> Image.Image | None: + """Load the airline logo for a given ICAO airline code from logo_dir. - Looks for {airline_code}.png in LOGO_DIR. Returns the image - unchanged (caller is responsible for scaling and blitting). - Returns None if the file doesn't exist or can't be opened. + Looks for {airline_code}.png in logo_dir. Returns the image unchanged + (caller is responsible for scaling and blitting). Returns None if the file + doesn't exist or can't be opened. """ - path = LOGO_DIR / f"{airline_code}.png" + path = logo_dir / f"{airline_code}.png" try: return Image.open(path) except (FileNotFoundError, PermissionError): diff --git a/src/jetset/http.py b/src/jetset/http.py index 6456c06..02edb27 100644 --- a/src/jetset/http.py +++ b/src/jetset/http.py @@ -2,7 +2,6 @@ import os from pathlib import Path -from typing import ClassVar import requests @@ -34,7 +33,7 @@ class RequestsAPI(requests.Session): the scheme and hostname. """ - base_url: ClassVar[str | None] = None + base_url: str | None = None def __init__( self, base_url: str | None = None, headers: dict[str, str] | None = None, *args, **kwargs diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index bd870a3..8903d24 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -1,4 +1,6 @@ from functools import lru_cache +from pathlib import Path +from typing import cast from PIL import Image from RGBMatrixEmulator.emulation.canvas import Canvas @@ -37,9 +39,6 @@ LOGO_HEIGHT = 23 LOGO_RIGHT_MARGIN = 1 # empty columns kept to the right of the logo LOGO_TOP_MARGIN = 1 # empty rows kept above the logo -# Debug aid: outline the logo box on the panel so its bounds can be eyeballed -# against the card. Set False for normal use. -LOGO_DEBUG_BORDER = False def draw_text(canvas: Canvas, x: int, y: int, text: str, color: tuple[int, int, int]) -> None: @@ -48,12 +47,12 @@ def draw_text(canvas: Canvas, x: int, y: int, text: str, color: tuple[int, int, @lru_cache(maxsize=256) -def _scaled_logo(airline_code: str) -> Image.Image | None: +def _scaled_logo(airline_code: str, logo_dir: Path) -> Image.Image | None: """Load + proportionally scale an airline logo to fit the logo box, cached. - Decoding and resizing happen once per airline, not every frame. + Decoding and resizing happen once per (airline, dir), not every frame. """ - img = load_logo(airline_code) + img = load_logo(airline_code, logo_dir) if img is None: return None img = img.convert("RGBA") @@ -61,29 +60,12 @@ def _scaled_logo(airline_code: str) -> Image.Image | None: scale = min(LOGO_WIDTH / src_w, LOGO_HEIGHT / src_h) new_w = max(1, int(src_w * scale)) new_h = max(1, int(src_h * scale)) - return img.resize((new_w, new_h), Image.LANCZOS) + return img.resize((new_w, new_h), Image.Resampling.LANCZOS) -def _draw_logo_border(canvas: Canvas) -> None: - """Outline the logo box (debug aid for checking its bounds on the panel).""" - left = CANVAS_WIDTH - LOGO_RIGHT_MARGIN - LOGO_WIDTH - right = left + LOGO_WIDTH - 1 - top = LOGO_TOP_MARGIN - bottom = top + LOGO_HEIGHT - 1 - for x in range(left, right + 1): - canvas.SetPixel(x, top, 255, 0, 0) - canvas.SetPixel(x, bottom, 255, 0, 0) - for y in range(top, bottom + 1): - canvas.SetPixel(left, y, 255, 0, 0) - canvas.SetPixel(right, y, 255, 0, 0) - - -def render_logo(canvas: Canvas, flight: Flight) -> None: +def render_logo(canvas: Canvas, flight: Flight, logo_dir: Path) -> None: """Draw the airline logo in full colour, top-right. Skips if none exists.""" - if LOGO_DEBUG_BORDER: - _draw_logo_border(canvas) - - scaled: Image.Image = _scaled_logo(flight.airline) + scaled: Image.Image | None = _scaled_logo(flight.airline, logo_dir) if scaled is None: return @@ -93,13 +75,13 @@ def render_logo(canvas: Canvas, flight: Flight) -> None: for y in range(new_h): for x in range(new_w): - r, g, b, a = scaled.getpixel((x, y)) + r, g, b, a = cast(tuple[int, int, int, int], scaled.getpixel((x, y))) if a == 0 or (r, g, b) == (0, 0, 0): continue canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) -def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): +def render_flight_card(canvas: Canvas, flight: Flight, logo_dir: Path, metric_page=0): canvas.Clear() # y-values are based off of the font height; each row uses its palette colour. @@ -112,7 +94,7 @@ def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): for i, (text, color) in enumerate(rows): draw_text(canvas, 1, FONT_HEIGHT * (i + 1) + i, text, color) - render_logo(canvas, flight) + render_logo(canvas, flight, logo_dir) def render_loading(canvas: Canvas, page=0): diff --git a/tests/test_display.py b/tests/test_display.py index bc30dbd..27332bc 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -12,9 +12,11 @@ def test_returns_loading_text(self) -> None: class TestLogo: def test_returns_none_for_missing_airline(self) -> None: + from pathlib import Path + from jetset.display import load_logo - assert load_logo("NONEXIST") is None + assert load_logo("NONEXIST", Path("/nonexistent")) is None class TestFlightLabel: diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index 3f2acc1..24d2901 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -49,8 +49,10 @@ def test_to_flight_converts_units_and_route(self) -> None: assert flight.speed == 335 # km/h -> knots assert flight.track == 140.0 assert flight.vertical_rate == 0 - assert flight.route.origin.iata_code == "SFO" - assert flight.route.destination.iata_code == "LAX" + route = flight.route + assert route is not None + assert route.origin.iata_code == "SFO" + assert route.destination.iata_code == "LAX" def test_to_flight_no_route_when_endpoints_missing(self) -> None: from jetset.fetcher import AirLabsAdapter @@ -71,7 +73,9 @@ def test_nearby_flights_parses_bbox_response(self) -> None: assert len(flights) == 1 assert flights[0].callsign == "UAL1170" - assert flights[0].route.destination.iata_code == "LAX" + route = flights[0].route + assert route is not None + assert route.destination.iata_code == "LAX" def test_nearby_flights_raw_returns_dicts(self) -> None: from jetset.fetcher import AirLabsAdapter @@ -128,5 +132,7 @@ def test_maps_all_fixture_flights(self) -> None: assert isinstance(flight.track, (float, type(None))) assert isinstance(flight.vertical_rate, (int, type(None))) # First fixture flight has a route; the last (no dep/arr) does not. - assert flights[0].route.origin.iata_code == "IAH" + route = flights[0].route + assert route is not None + assert route.origin.iata_code == "IAH" assert flights[-1].route is None diff --git a/tests/test_main.py b/tests/test_main.py index dab6a2c..62b8321 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -98,6 +98,7 @@ def test_cycles_window_flights_through_metric_pages(self) -> None: for frame_idx in range(8): app.frame = frame_idx result = app._current_frame() + assert result is not None frames.append((result.flight.callsign, result.metric_page)) assert frames == [ @@ -115,11 +116,17 @@ def test_window_slides_over_the_refresh_interval(self) -> None: # slide_interval = 2700 / 10 = 270s; one slide advances window_start by 1. with patch("jetset.app.time.time", return_value=0.0): - assert app._current_frame().flight.callsign == "0" + frame = app._current_frame() + assert frame is not None + assert frame.flight.callsign == "0" with patch("jetset.app.time.time", return_value=270.0): - assert app._current_frame().flight.callsign == "1" + frame = app._current_frame() + assert frame is not None + assert frame.flight.callsign == "1" with patch("jetset.app.time.time", return_value=540.0): - assert app._current_frame().flight.callsign == "2" + frame = app._current_frame() + assert frame is not None + assert frame.flight.callsign == "2" def test_empty_buffer_returns_none(self) -> None: from jetset.app import App @@ -146,7 +153,9 @@ def test_renders_and_advances_frame(self) -> None: ): app._render_frame(mock_matrix, mock_canvas, frame) - mock_render.assert_called_once_with(mock_canvas, frame.flight, frame.metric_page) + mock_render.assert_called_once_with( + mock_canvas, frame.flight, app.logo_dir, frame.metric_page + ) mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) assert app.frame == 4 diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 1d18b38..47ef3c5 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -14,7 +14,9 @@ def test_font_loads_via_graphics(self) -> None: font = graphics.Font() font.LoadFont(str(Path("fonts/5x7.bdf").resolve())) assert font.height == 7 - assert font.CharacterWidth(ord("A")) == 5 + # ord() gives the codepoint int the real rpi-rgb-led-matrix API wants; + # the emulator stub mistypes the param as str. + assert font.CharacterWidth(ord("A")) == 5 # ty: ignore[invalid-argument-type] class TestRenderFlightCard: @@ -28,7 +30,7 @@ def test_clears_canvas_before_drawing(self) -> None: canvas.width = 64 flight = Flight(callsign="UAL2337") - render_flight_card(canvas, flight) + render_flight_card(canvas, flight, Path("logos")) assert canvas.Clear.called @@ -43,7 +45,7 @@ def test_each_row_uses_its_palette_colour(self) -> None: flight = Flight(callsign="UAL2337", altitude=35000) with patch("jetset.renderer.draw_text") as mock_draw: - renderer.render_flight_card(canvas, flight) + renderer.render_flight_card(canvas, flight, Path("logos")) row_colors = [call.args[4] for call in mock_draw.call_args_list] assert row_colors == [renderer.ORANGE, renderer.CYAN, renderer.GREEN, renderer.BLUE] @@ -67,7 +69,7 @@ def test_renders_all_metric_pages_without_error(self) -> None: ) for page in range(4): - render_flight_card(canvas, flight, metric_page=page) + render_flight_card(canvas, flight, Path("logos"), metric_page=page) canvas = matrix.SwapOnVSync(canvas) @@ -89,7 +91,7 @@ def test_skips_missing_airline(self) -> None: flight = Flight(callsign="UAL2337") with patch("jetset.renderer.load_logo", return_value=None): _scaled_logo.cache_clear() # so the patched load_logo is used - render_logo(canvas, flight) # no crash + render_logo(canvas, flight, Path("logos")) # no crash def test_draws_pixels_for_known_airline(self) -> None: from unittest.mock import patch @@ -112,7 +114,7 @@ def test_draws_pixels_for_known_airline(self) -> None: with patch("jetset.renderer.load_logo", return_value=test_logo): _scaled_logo.cache_clear() # so the patched load_logo is used with patch.object(canvas, "SetPixel") as mock_setpixel: - render_logo(canvas, flight) + render_logo(canvas, flight, Path("logos")) _scaled_logo.cache_clear() mock_setpixel.assert_called() From 28ab311cdc31942ad103f9e58eb7d0b9a589418d Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Wed, 17 Jun 2026 23:46:09 -0500 Subject: [PATCH 31/36] REFACTOR: extract a Renderer class owning the canvas and logo dir Most render functions threaded canvas + logo_dir through every call. Make them a Renderer that holds both as state, instantiated by App. - Renderer(matrix, logo_dir) owns the double-buffered canvas; flight_card() and loading() take neither canvas nor logo_dir, and present() encapsulates the VSync swap (clear() blanks the panel) - App.loop() builds the matrix + Renderer and the per-frame helpers (_render_frame/_render_loading/_after_render) collapse into the loop; __main__ just calls app.loop() - _scaled_logo stays a module-level lru_cache function (a cache on a method would key on self and leak instances); draw_text stays a module helper - logo-gallery renders via renderer.flight_card(Flight(callsign=code)) - tests drive the Renderer/loop directly instead of the removed free functions --- scripts/logo-gallery.py | 20 ++++----- src/jetset/__main__.py | 8 ++-- src/jetset/app.py | 50 ++++++++-------------- src/jetset/renderer.py | 91 +++++++++++++++++++++++++---------------- tests/test_main.py | 84 ++++++++++++------------------------- tests/test_renderer.py | 85 ++++++++++++++++---------------------- 6 files changed, 143 insertions(+), 195 deletions(-) diff --git a/scripts/logo-gallery.py b/scripts/logo-gallery.py index 7882d08..b5c70dd 100755 --- a/scripts/logo-gallery.py +++ b/scripts/logo-gallery.py @@ -21,7 +21,7 @@ from jetset.backend import build_matrix from jetset.config import AppConfig from jetset.models import Flight -from jetset.renderer import ORANGE, draw_text, render_logo +from jetset.renderer import Renderer HOLD = float(sys.argv[1]) if len(sys.argv) > 1 else 3.0 PREFIX = sys.argv[2].upper() if len(sys.argv) > 2 else "" @@ -31,23 +31,19 @@ if not logos: raise SystemExit(f"No logos matching '{PREFIX}*' in {logo_dir}") -matrix = build_matrix() -canvas = matrix.CreateFrameCanvas() - +renderer = Renderer(build_matrix(), logo_dir) print(f"{len(logos)} logos in {logo_dir} — {HOLD}s each (Ctrl-C to stop)") try: for path in logos: - code = path.stem - flight = Flight(callsign=code) - canvas.Clear() - draw_text(canvas, 1, 7, code, ORANGE) # ICAO where the callsign goes - render_logo(canvas, flight, logo_dir) # logo where it goes - canvas = matrix.SwapOnVSync(canvas) - print(f">>> {code}") + # A bare Flight shows just the code (callsign row) + the logo; the other + # rows render empty since route/aircraft/metrics are absent. + renderer.flight_card(Flight(callsign=path.stem)) + renderer.present() + print(f">>> {path.stem}") time.sleep(HOLD) except KeyboardInterrupt: pass finally: - matrix.Clear() + renderer.clear() print("done") diff --git a/src/jetset/__main__.py b/src/jetset/__main__.py index 2784c1a..2a1b8f6 100644 --- a/src/jetset/__main__.py +++ b/src/jetset/__main__.py @@ -22,6 +22,8 @@ def _configure_logging() -> None: # RGBMatrixEmulator has its own handler — stop propagation to avoid # duplicate INFO lines in the root logger. logging.getLogger("RGBME").propagate = False + + def main() -> None: _configure_logging() @@ -35,17 +37,13 @@ def main() -> None: config = AppConfig.load(os.environ.get("JETSET_CONFIG")) - from jetset.backend import build_matrix - - matrix = build_matrix() - canvas = matrix.CreateFrameCanvas() try: logger.info("Press Ctrl-C to stop") import time time.sleep(1) app = App(config) - app.loop(matrix, canvas) + app.loop() except KeyboardInterrupt: logger.info("Shutdown.") diff --git a/src/jetset/app.py b/src/jetset/app.py index 0852322..cb08f7a 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -4,13 +4,11 @@ from pathlib import Path from typing import NamedTuple -from RGBMatrixEmulator.emulation.canvas import Canvas -from RGBMatrixEmulator.emulation.matrix import RGBMatrix - +from jetset.backend import build_matrix from jetset.config import AppConfig from jetset.fetcher import AirLabsAdapter, FlightAPI from jetset.models import Flight, FlightBuffer -from jetset.renderer import render_flight_card, render_loading +from jetset.renderer import Renderer logger = logging.getLogger(__name__) @@ -109,46 +107,30 @@ def _current_frame(self) -> Frame | None: return self.Frame(flights[(window_start + flight_in_window) % n], metric_page) - def _after_render(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: - # This needs to be it's own method so that tests don't need to run the infinite loop method. - # SwapOnVSync returns the now-offscreen buffer to draw the *next* frame into; on real - # hardware this double-buffering is mandatory, or frames flash and tear over stale content. - next_canvas = matrix.SwapOnVSync(canvas) - self.frame += 1 - time.sleep(self.config.pause) - return next_canvas - - def _render_frame(self, matrix: RGBMatrix, canvas: Canvas, frame: Frame) -> Canvas: - flight, metric_page = frame - render_flight_card(canvas, flight, self.logo_dir, metric_page) - return self._after_render(matrix, canvas) - - def _render_loading(self, matrix: RGBMatrix, canvas: Canvas) -> Canvas: - # % 4 so the "LOADING." dots keep cycling instead of freezing on - # "LOADING" once the frame counter passes 3. - render_loading(canvas, self.frame % self.PAGES_PER_FLIGHT) - return self._after_render(matrix, canvas) - - def loop(self, matrix: RGBMatrix, canvas: Canvas): + def loop(self) -> None: + matrix = build_matrix() + # Fetching runs on a background thread; this loop only renders, so the - # display keeps cycling smoothly while a fetch is in flight. - canvas = self._render_loading(matrix, canvas) + # display keeps cycling smoothly while a fetch is in flight. The Renderer + # owns the double-buffered canvas; present() does the VSync swap. + renderer = Renderer(matrix, self.logo_dir) - fetch_thread = threading.Thread( - target=self._fetch_loop, name="jetset-fetch", daemon=True - ) + fetch_thread = threading.Thread(target=self._fetch_loop, name="jetset-fetch", daemon=True) fetch_thread.start() try: while True: if frame := self._current_frame(): - canvas = self._render_frame(matrix, canvas, frame) + renderer.flight_card(frame.flight, frame.metric_page) else: - canvas = self._render_loading(matrix, canvas) + # % 4 so the "LOADING." dots keep cycling instead of freezing. + renderer.loading(self.frame % self.PAGES_PER_FLIGHT) + renderer.present() + self.frame += 1 + time.sleep(self.config.pause) except KeyboardInterrupt: self._stop.set() - canvas.Clear() - matrix.SwapOnVSync(canvas) + renderer.clear() def _create_adapter(config: AppConfig): diff --git a/src/jetset/renderer.py b/src/jetset/renderer.py index 8903d24..f240968 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -4,6 +4,7 @@ from PIL import Image from RGBMatrixEmulator.emulation.canvas import Canvas +from RGBMatrixEmulator.emulation.matrix import RGBMatrix from jetset.backend import graphics from jetset.display import ( @@ -63,41 +64,59 @@ def _scaled_logo(airline_code: str, logo_dir: Path) -> Image.Image | None: return img.resize((new_w, new_h), Image.Resampling.LANCZOS) -def render_logo(canvas: Canvas, flight: Flight, logo_dir: Path) -> None: - """Draw the airline logo in full colour, top-right. Skips if none exists.""" - scaled: Image.Image | None = _scaled_logo(flight.airline, logo_dir) - if scaled is None: - return +class Renderer: + """Owns the LED matrix's double-buffered canvas and the logo directory. - new_w, new_h = scaled.size - x_offset = CANVAS_WIDTH - LOGO_RIGHT_MARGIN - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 - y_offset = LOGO_TOP_MARGIN + (LOGO_HEIGHT - new_h) // 2 - - for y in range(new_h): - for x in range(new_w): - r, g, b, a = cast(tuple[int, int, int, int], scaled.getpixel((x, y))) - if a == 0 or (r, g, b) == (0, 0, 0): - continue - canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) - - -def render_flight_card(canvas: Canvas, flight: Flight, logo_dir: Path, metric_page=0): - canvas.Clear() - - # y-values are based off of the font height; each row uses its palette colour. - rows = ( - (flight_label(flight), ORANGE), - (route_label(flight), CYAN), - (aircraft_label(flight), GREEN), - (metrics_label(flight, metric_page), BLUE), - ) - for i, (text, color) in enumerate(rows): - draw_text(canvas, 1, FONT_HEIGHT * (i + 1) + i, text, color) - - render_logo(canvas, flight, logo_dir) - - -def render_loading(canvas: Canvas, page=0): - canvas.Clear() + Holding the canvas and logo_dir as state keeps them out of every draw call. + present() performs the VSync buffer swap; the rest of the app just asks for + a frame and presents it. + """ - draw_text(canvas, 1, FONT_HEIGHT * 1 + 0, loading_label(page), RED) + def __init__(self, matrix: RGBMatrix, logo_dir: Path) -> None: + self._matrix = matrix + self._logo_dir = logo_dir + self._canvas = matrix.CreateFrameCanvas() + + def flight_card(self, flight: Flight, metric_page: int = 0) -> None: + canvas = self._canvas + canvas.Clear() + # y-values are based off the font height; each row uses its palette colour. + rows = ( + (flight_label(flight), ORANGE), + (route_label(flight), CYAN), + (aircraft_label(flight), GREEN), + (metrics_label(flight, metric_page), BLUE), + ) + for i, (text, color) in enumerate(rows): + draw_text(canvas, 1, FONT_HEIGHT * (i + 1) + i, text, color) + self._logo(flight) + + def loading(self, page: int = 0) -> None: + self._canvas.Clear() + draw_text(self._canvas, 1, FONT_HEIGHT * 1 + 0, loading_label(page), RED) + + def present(self) -> None: + """Swap the drawn frame onto the panel and ready the back buffer.""" + self._canvas = self._matrix.SwapOnVSync(self._canvas) + + def clear(self) -> None: + """Blank the panel.""" + self._canvas.Clear() + self.present() + + def _logo(self, flight: Flight) -> None: + """Draw the airline logo in full colour, top-right. Skips if none exists.""" + scaled: Image.Image | None = _scaled_logo(flight.airline, self._logo_dir) + if scaled is None: + return + + new_w, new_h = scaled.size + x_offset = CANVAS_WIDTH - LOGO_RIGHT_MARGIN - LOGO_WIDTH + (LOGO_WIDTH - new_w) // 2 + y_offset = LOGO_TOP_MARGIN + (LOGO_HEIGHT - new_h) // 2 + + for y in range(new_h): + for x in range(new_w): + r, g, b, a = cast(tuple[int, int, int, int], scaled.getpixel((x, y))) + if a == 0 or (r, g, b) == (0, 0, 0): + continue + self._canvas.SetPixel(x_offset + x, y_offset + y, r, g, b) diff --git a/tests/test_main.py b/tests/test_main.py index 62b8321..d123d4f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ """Tests for the main loop.""" -from unittest.mock import MagicMock, patch +import time +from unittest.mock import patch from jetset.config import AppConfig from jetset.models import Flight @@ -135,82 +136,49 @@ def test_empty_buffer_returns_none(self) -> None: assert app._current_frame() is None -class TestAppRenderFrame: - def test_renders_and_advances_frame(self) -> None: - from jetset.app import App - - config = AppConfig() - app = App(config) - app.frame = 3 - - mock_matrix = MagicMock() - mock_canvas = MagicMock() - frame = app.Frame(Flight(callsign="UAL2337"), 1) +class TestAppLoop: + def _run_once(self, app): + """Run App.loop for one iteration; sleep raises to break out cleanly. + last_fetch is set so the background fetch thread stays off the network + for the duration of the test. + """ + app.last_fetch = time.time() with ( - patch("jetset.app.render_flight_card") as mock_render, - patch("jetset.app.time.sleep"), + patch("jetset.app.build_matrix"), + patch("jetset.app.Renderer") as mock_renderer, + patch("jetset.app.time.sleep", side_effect=KeyboardInterrupt), ): - app._render_frame(mock_matrix, mock_canvas, frame) - - mock_render.assert_called_once_with( - mock_canvas, frame.flight, app.logo_dir, frame.metric_page - ) - mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) - assert app.frame == 4 + app.loop() + return mock_renderer.return_value - def test_returns_swapped_canvas_for_next_frame(self) -> None: + def test_renders_flight_card_and_advances_frame(self) -> None: from jetset.app import App app = App(AppConfig()) - mock_matrix = MagicMock() - mock_canvas = MagicMock() - frame = app.Frame(Flight(callsign="UAL2337"), 1) + app.buffer.set_all([Flight(callsign="UAL2337")]) - with ( - patch("jetset.app.render_flight_card"), - patch("jetset.app.time.sleep"), - ): - next_canvas = app._render_frame(mock_matrix, mock_canvas, frame) - - # SwapOnVSync hands back the now-offscreen buffer; the next frame must - # be drawn into it, not into the canvas we just displayed. - assert next_canvas is mock_matrix.SwapOnVSync.return_value + renderer = self._run_once(app) + renderer.flight_card.assert_called_once_with(Flight(callsign="UAL2337"), 0) + renderer.present.assert_called_once() + assert app.frame == 1 -class TestAppLoading: def test_renders_loading_when_buffer_empty(self) -> None: from jetset.app import App - config = AppConfig() - app = App(config) - app.frame = 0 - - mock_matrix = MagicMock() - mock_canvas = MagicMock() - - with ( - patch("jetset.app.render_loading") as mock_render, - patch("jetset.app.time.sleep"), - ): - next_canvas = app._render_loading(mock_matrix, mock_canvas) + renderer = self._run_once(App(AppConfig())) - mock_render.assert_called_once_with(mock_canvas, 0) - mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) - assert app.frame == 1 - assert next_canvas is mock_matrix.SwapOnVSync.return_value + renderer.loading.assert_called_once_with(0) + renderer.present.assert_called_once() def test_loading_animation_cycles_with_frame(self) -> None: from jetset.app import App - app = App(AppConfig()) + app = App(AppConfig()) # empty buffer app.frame = 5 # past the first cycle — must wrap, not freeze on "LOADING" - with ( - patch("jetset.app.render_loading") as mock_render, - patch("jetset.app.time.sleep"), - ): - app._render_loading(MagicMock(), MagicMock()) + renderer = self._run_once(app) # 5 % 4 == 1 → "LOADING." so the dots keep animating - assert mock_render.call_args.args[1] == 1 + renderer.loading.assert_called_once_with(1) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 47ef3c5..3ddfae0 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -19,102 +19,87 @@ def test_font_loads_via_graphics(self) -> None: assert font.CharacterWidth(ord("A")) == 5 # ty: ignore[invalid-argument-type] -class TestRenderFlightCard: +def _renderer(logo_dir: str = "logos"): + """A Renderer backed by an emulator matrix, for tests.""" + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions + + from jetset.renderer import Renderer + + options = RGBMatrixOptions() + options.cols = 64 + options.rows = 32 + return Renderer(RGBMatrix(options=options), Path(logo_dir)) + + +class TestFlightCard: def test_clears_canvas_before_drawing(self) -> None: - from unittest.mock import MagicMock + from unittest.mock import patch from jetset.models import Flight - from jetset.renderer import render_flight_card - canvas = MagicMock() - canvas.width = 64 - flight = Flight(callsign="UAL2337") - - render_flight_card(canvas, flight, Path("logos")) + renderer = _renderer() + with patch.object(renderer._canvas, "Clear") as mock_clear: + renderer.flight_card(Flight(callsign="UAL2337")) - assert canvas.Clear.called + mock_clear.assert_called() def test_each_row_uses_its_palette_colour(self) -> None: # Each row is drawn in its own palette colour. - from unittest.mock import MagicMock, patch + from unittest.mock import patch - from jetset import renderer + from jetset import renderer as r from jetset.models import Flight - canvas = MagicMock() - flight = Flight(callsign="UAL2337", altitude=35000) - + rnd = _renderer() with patch("jetset.renderer.draw_text") as mock_draw: - renderer.render_flight_card(canvas, flight, Path("logos")) + rnd.flight_card(Flight(callsign="UAL2337", altitude=35000)) row_colors = [call.args[4] for call in mock_draw.call_args_list] - assert row_colors == [renderer.ORANGE, renderer.CYAN, renderer.GREEN, renderer.BLUE] + assert row_colors == [r.ORANGE, r.CYAN, r.GREEN, r.BLUE] def test_renders_all_metric_pages_without_error(self) -> None: - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions - from jetset.models import Flight - from jetset.renderer import render_flight_card - - options = RGBMatrixOptions() - options.cols = 64 - options.rows = 32 - matrix = RGBMatrix(options=options) - canvas = matrix.CreateFrameCanvas() + renderer = _renderer() flight = Flight( callsign="UAL2337", altitude=35000, speed=450, vertical_rate=1500, track=270, ) - for page in range(4): - render_flight_card(canvas, flight, Path("logos"), metric_page=page) - canvas = matrix.SwapOnVSync(canvas) + renderer.flight_card(flight, page) + renderer.present() -class TestRenderLogo: +class TestLogo: def test_skips_missing_airline(self) -> None: from unittest.mock import patch - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions - from jetset.models import Flight - from jetset.renderer import _scaled_logo, render_logo - - options = RGBMatrixOptions() - options.cols = 64 - options.rows = 32 - matrix = RGBMatrix(options=options) - canvas = matrix.CreateFrameCanvas() + from jetset.renderer import _scaled_logo + renderer = _renderer() flight = Flight(callsign="UAL2337") with patch("jetset.renderer.load_logo", return_value=None): _scaled_logo.cache_clear() # so the patched load_logo is used - render_logo(canvas, flight, Path("logos")) # no crash + renderer._logo(flight) # no crash + _scaled_logo.cache_clear() def test_draws_pixels_for_known_airline(self) -> None: from unittest.mock import patch from PIL import Image - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions from jetset.models import Flight - from jetset.renderer import LOGO_WIDTH, _scaled_logo, render_logo + from jetset.renderer import LOGO_WIDTH, _scaled_logo test_logo = Image.new("RGB", (LOGO_WIDTH, 20), (255, 0, 0)) - - options = RGBMatrixOptions() - options.cols = 64 - options.rows = 32 - matrix = RGBMatrix(options=options) - canvas = matrix.CreateFrameCanvas() - + renderer = _renderer() flight = Flight(callsign="UAL2337", aircraft="B738") with patch("jetset.renderer.load_logo", return_value=test_logo): _scaled_logo.cache_clear() # so the patched load_logo is used - with patch.object(canvas, "SetPixel") as mock_setpixel: - render_logo(canvas, flight, Path("logos")) + with patch.object(renderer._canvas, "SetPixel") as mock_setpixel: + renderer._logo(flight) _scaled_logo.cache_clear() mock_setpixel.assert_called() From 4164f65b8fbf885d136a3cbb185c706e41d92f1d Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Thu, 18 Jun 2026 00:11:24 -0500 Subject: [PATCH 32/36] CHORE: add bin/ for ops tooling and a bin/script helper runner - bin/script: run a scripts/ helper as root for GPIO on the Pi (stops the jetset service for the run, restarts it on exit). e.g. bin/script logo-gallery 3 UAL - move setup-pi.sh and install-service.sh from scripts/ to bin/ (ops/lifecycle tooling); scripts/ keeps the domain helpers (probes, download_logos, route-compare, save_fixtures) - update Makefile + setup-pi.sh references to the new paths --- Makefile | 6 +++--- {scripts => bin}/install-service.sh | 0 bin/script | 28 ++++++++++++++++++++++++++++ {scripts => bin}/setup-pi.sh | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) rename {scripts => bin}/install-service.sh (100%) create mode 100755 bin/script rename {scripts => bin}/setup-pi.sh (99%) diff --git a/Makefile b/Makefile index e7fae87..f3cfd07 100644 --- a/Makefile +++ b/Makefile @@ -29,13 +29,13 @@ deploy: ## rsync to the Pi, run first-time setup if needed, restart the service . $(JETSET_USER)@$(JETSET_HOST):$(JETSET_PATH)/ ssh -t $(JETSET_USER)@$(JETSET_HOST) \ "cd $(JETSET_PATH) && \ - { [ -f /etc/systemd/system/jetset.service ] || bash scripts/setup-pi.sh; } && \ + { [ -f /etc/systemd/system/jetset.service ] || bash bin/setup-pi.sh; } && \ /home/$(JETSET_USER)/.local/bin/uv add ./wheels/rgbmatrix-*.whl && \ - bash scripts/install-service.sh && \ + bash bin/install-service.sh && \ sudo systemctl restart jetset" setup-pi: ## Install deps and build rpi-rgb-led-matrix (run on the Pi) - bash scripts/setup-pi.sh + bash bin/setup-pi.sh run-pi: ## (Re)start the jetset service on the Pi sudo systemctl restart jetset diff --git a/scripts/install-service.sh b/bin/install-service.sh similarity index 100% rename from scripts/install-service.sh rename to bin/install-service.sh diff --git a/bin/script b/bin/script new file mode 100755 index 0000000..698fa60 --- /dev/null +++ b/bin/script @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Run a scripts/ helper as root for GPIO on the Pi, with the jetset service +# stopped for the duration (restarted on exit — even on Ctrl-C or error). +# +# bin/script [args...] e.g. bin/script logo-gallery 3 UAL +# +# is the helper's filename without the .py extension. +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "usage: $(basename "$0") [args...] e.g. $(basename "$0") logo-gallery 3 UAL" >&2 + exit 1 +fi + +# Run from the project root so fonts/ and uv resolve. +cd "$(dirname "$0")/.." + +name="$1" +shift +target="scripts/${name}.py" +if [ ! -f "$target" ]; then + echo "no such script: $target" >&2 + exit 1 +fi + +sudo systemctl stop jetset +trap 'sudo systemctl start jetset' EXIT +sudo -E env PATH="$PATH" uv run python "$target" "$@" diff --git a/scripts/setup-pi.sh b/bin/setup-pi.sh similarity index 99% rename from scripts/setup-pi.sh rename to bin/setup-pi.sh index 8b00687..2e01d68 100755 --- a/scripts/setup-pi.sh +++ b/bin/setup-pi.sh @@ -79,7 +79,7 @@ uv run python scripts/download_logos.py || echo "(logo download incomplete — r # 7. Install + enable the systemd service (runs on boot) echo "=== Installing jetset systemd service ===" -bash scripts/install-service.sh +bash bin/install-service.sh sudo systemctl restart jetset echo "=== Setup complete! The jetset service is enabled and running. ===" From 18248681eb5f200cd5786e7628dfa4605f011f79 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Thu, 18 Jun 2026 00:15:34 -0500 Subject: [PATCH 33/36] CHORE: Add make target for easy SSH-ing to the Pi --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index f3cfd07..b166579 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,9 @@ deploy: ## rsync to the Pi, run first-time setup if needed, restart the service bash bin/install-service.sh && \ sudo systemctl restart jetset" +ssh: + ssh -t $(JETSET_USER)@$(JETSET_HOST) "cd $(JETSET_PATH)/ && exec $$SHELL -l" + setup-pi: ## Install deps and build rpi-rgb-led-matrix (run on the Pi) bash bin/setup-pi.sh From a3b6f74c6227af242b64ed657c716019374fbdfe Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Thu, 18 Jun 2026 00:31:17 -0500 Subject: [PATCH 34/36] FIX: replace black-silhouette logos with a colour source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RadarBox is the preferred logo set, but it ships a handful of pure-black silhouette placeholders (e.g. ASA). The colour renderer skips black pixels, so those airlines drew no logo at all — and the downloader's skip-if-exists rule meant FlightAware's real colour logo never replaced them. Treat an all-black logo as a gap: a file already on disk is kept only if it is usable, so a later source supplies a real colour logo. Re-running healed 10 silhouettes that were silently blank. --- scripts/download_logos.py | 28 ++++++++++++++-- tests/test_download_logos.py | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/test_download_logos.py diff --git a/scripts/download_logos.py b/scripts/download_logos.py index 38b3e95..0c0f535 100755 --- a/scripts/download_logos.py +++ b/scripts/download_logos.py @@ -10,6 +10,9 @@ import urllib.error import urllib.request from pathlib import Path +from typing import cast + +from PIL import Image from jetset.config import AppConfig @@ -39,12 +42,33 @@ def _fetch_logo_list(api_url: str) -> list[str]: return [e["name"] for e in entries if e["name"].endswith(".png")] +def _is_blank_silhouette(path: Path) -> bool: + """True if every opaque pixel is pure black — a silhouette the colour + renderer skips entirely (it drops black pixels), so it shows as nothing. + + RadarBox ships a few such placeholders (e.g. ASA); treating them as gaps + lets a later source (FlightAware) supply a real colour logo instead. + """ + try: + with Image.open(path) as img: + rgba = img.convert("RGBA") + except OSError: + return False + # PIL types pixels loosely; in RGBA mode each is an (r, g, b, a) tuple. + pixels = cast("list[tuple[int, int, int, int]]", list(rgba.get_flattened_data())) + return all(pixel[:3] == (0, 0, 0) for pixel in pixels if pixel[3] != 0) + + def _download_missing(filenames: list[str], raw_base: str, dest_dir: Path) -> int: - """Download each filename from raw_base into dest_dir, skipping existing.""" + """Download each filename from raw_base into dest_dir. + + A file already present is kept — unless it's a blank silhouette, which this + source is given the chance to replace with a usable colour logo. + """ count = 0 for name in filenames: dest = dest_dir / name - if dest.exists(): + if dest.exists() and not _is_blank_silhouette(dest): continue try: urllib.request.urlretrieve(f"{raw_base}/{name}", dest) diff --git a/tests/test_download_logos.py b/tests/test_download_logos.py new file mode 100644 index 0000000..0153210 --- /dev/null +++ b/tests/test_download_logos.py @@ -0,0 +1,64 @@ +"""Tests for the airline logo downloader.""" + +from pathlib import Path + +from PIL import Image + +BLACK_OPAQUE = (0, 0, 0, 255) +TEAL_OPAQUE = (11, 67, 104, 255) + + +def _save_solid(path: Path, color: tuple[int, int, int, int]) -> None: + """Write a tiny solid-colour RGBA PNG.""" + Image.new("RGBA", (4, 4), color).save(path) + + +class TestIsBlankSilhouette: + def test_true_when_every_opaque_pixel_is_black(self, tmp_path: Path) -> None: + from scripts.download_logos import _is_blank_silhouette + + path = tmp_path / "ASA.png" + _save_solid(path, BLACK_OPAQUE) + assert _is_blank_silhouette(path) is True + + def test_false_when_an_opaque_pixel_is_coloured(self, tmp_path: Path) -> None: + from scripts.download_logos import _is_blank_silhouette + + path = tmp_path / "UAL.png" + _save_solid(path, TEAL_OPAQUE) + assert _is_blank_silhouette(path) is False + + +class TestDownloadMissing: + def _coloured_downloader(self): + """A urlretrieve stand-in that always writes a coloured logo.""" + + def fake(_url: str, dest: Path) -> None: + _save_solid(Path(dest), TEAL_OPAQUE) + + return fake + + def test_keeps_an_existing_coloured_logo(self, tmp_path: Path) -> None: + from unittest.mock import patch + + from scripts.download_logos import _download_missing + + _save_solid(tmp_path / "UAL.png", TEAL_OPAQUE) + with patch("scripts.download_logos.urllib.request.urlretrieve") as mock_get: + _download_missing(["UAL.png"], "https://raw/base", tmp_path) + + mock_get.assert_not_called() + + def test_replaces_an_existing_black_silhouette(self, tmp_path: Path) -> None: + from unittest.mock import patch + + from scripts.download_logos import _download_missing + + _save_solid(tmp_path / "ASA.png", BLACK_OPAQUE) + with patch( + "scripts.download_logos.urllib.request.urlretrieve", + side_effect=self._coloured_downloader(), + ) as mock_get: + _download_missing(["ASA.png"], "https://raw/base", tmp_path) + + mock_get.assert_called_once() From 0a5d0e1ede60589b14f51800f934582655ef779d Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Thu, 18 Jun 2026 00:44:52 -0500 Subject: [PATCH 35/36] STYLE: tighten metric labels and rework vertical-rate display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5x7 panel font is monospace, so a single space is a full character cell and read as an oversized gap between each value and its unit. Drop the spaces (35000ft, 450kn, 270°W) so units sit against their numbers. Vertical rate: move the trend marker before the number and add a level marker, so the three states read in parallel — ▲1500ft/min, ▼1200ft/min, ▬0ft/min — replacing the standalone LVL. Spell the unit ft/min, and use the ISO knot symbol kn for speed. --- src/jetset/display.py | 32 +++++++++++++++++--------------- tests/test_display.py | 16 ++++++++-------- tests/test_fetcher.py | 2 +- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/jetset/display.py b/src/jetset/display.py index f516105..d832465 100644 --- a/src/jetset/display.py +++ b/src/jetset/display.py @@ -8,6 +8,9 @@ _COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] +# Vertical-rate markers: filled triangles for climb/descend, a bar for level. +_CLIMB, _DESCEND, _LEVEL = "▲", "▼", "▬" + def _cardinal(track: float) -> str: """Nearest 8-point compass direction for a heading in degrees.""" @@ -62,29 +65,28 @@ def metrics_label(flight: Flight, page: int = 0) -> str: """Format one metric page for the flight card. Pages: - 0 = altitude (e.g. "35K ft") - 1 = speed (e.g. "450 kt") - 2 = vertical rate (e.g. "1500▲ ft/m" or "LVL") - 3 = track (e.g. "270°") + 0 = altitude (e.g. "35000ft") + 1 = speed (e.g. "450kn") + 2 = vertical rate (e.g. "▲1500ft/min", "▼1200ft/min", or "▬0ft/min") + 3 = track (e.g. "270°W") Returns an empty string if the relevant field is missing. """ data = "" if page == 0 and flight.altitude: - data += f"{flight.altitude} ft" + data += f"{flight.altitude}ft" elif page == 1 and flight.speed: - data += f"{flight.speed} kt" + data += f"{flight.speed}kn" elif page == 2 and flight.vertical_rate is not None: - if flight.vertical_rate == 0: - data += "LVL" + rate = flight.vertical_rate + if rate > 0: + marker = _CLIMB + elif rate < 0: + marker = _DESCEND else: - formatted_rate = str(abs(flight.vertical_rate)) - if flight.vertical_rate > 0: - formatted_rate += "\u25b2" - elif flight.vertical_rate < 0: - formatted_rate += "\u25bc" - data += f"{formatted_rate} ft/m" + marker = _LEVEL + data += f"{marker}{abs(rate)}ft/min" elif page == 3 and flight.track is not None: - data += f"{int(flight.track)}\u00b0 {_cardinal(flight.track)}" + data += f"{int(flight.track)}\u00b0{_cardinal(flight.track)}" return data diff --git a/tests/test_display.py b/tests/test_display.py index 27332bc..15b4e30 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -63,7 +63,7 @@ def test_metrics_label_defaults_to_page_0(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", altitude=35000, speed=450) - assert metrics_label(flight) == "35000 ft" + assert metrics_label(flight) == "35000ft" def test_metrics_label_empty_when_no_data(self) -> None: from jetset.display import metrics_label @@ -76,49 +76,49 @@ def test_page_0_shows_altitude(self) -> None: flight = Flight(callsign="UAL2337", altitude=35000, speed=450) label = metrics_label(flight, page=0) - assert label == "35000 ft" + assert label == "35000ft" def test_page_1_shows_speed(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", altitude=35000, speed=450) label = metrics_label(flight, page=1) - assert label == "450 kt" + assert label == "450kn" def test_page_2_shows_vertical_rate(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", vertical_rate=1500) label = metrics_label(flight, page=2) - assert label == "1500▲ ft/m" + assert label == "▲1500ft/min" def test_page_2_descending_shows_down_arrow(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="SWA450", vertical_rate=-1200) label = metrics_label(flight, page=2) - assert label == "1200▼ ft/m" + assert label == "▼1200ft/min" def test_page_2_level_flight(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", vertical_rate=0) label = metrics_label(flight, page=2) - assert label == "LVL" + assert label == "▬0ft/min" def test_page_3_shows_track(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", track=270) label = metrics_label(flight, page=3) - assert label == "270° W" + assert label == "270°W" def test_track_format_as_int(self) -> None: from jetset.display import metrics_label flight = Flight(callsign="UAL2337", track=280.12) label = metrics_label(flight, page=3) - assert label == "280° W" + assert label == "280°W" def test_four_pages_rotates(self) -> None: from jetset.display import metrics_label diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index 24d2901..741b8ab 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -29,7 +29,7 @@ def test_adapter_satisfies_protocol(self) -> None: "flight_icao": "UAL1170", "aircraft_icao": "B772", "alt": 5559, # meters -> ~18238 ft - "speed": 621, # km/h -> ~335 kt + "speed": 621, # km/h -> ~335 kn "dir": 140.0, # degrees "v_speed": 0, # m/s -> 0 ft/min "dep_iata": "SFO", From cc4650d5e10ec178d66525feae006413502ac50c Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Thu, 18 Jun 2026 00:47:31 -0500 Subject: [PATCH 36/36] CHORE: run ty in CI via make lint/test targets CI ran ruff check src/ only, so type errors and lint issues outside src/ (scripts/, tests/) slipped past CI even though make lint catches them locally. Point the CI lint and test jobs at the make targets so CI and local checks share one definition and can't drift. Add ty to the dev dependency group so uv sync installs it in CI. --- .github/workflows/ci.yml | 6 +++--- pyproject.toml | 1 + uv.lock | 27 +++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0ae9c..368a3f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ jobs: - name: Install dependencies run: uv sync - - name: Run linter - run: uv run ruff check src/ + - name: Run linters + run: make lint test: runs-on: ubuntu-latest @@ -38,4 +38,4 @@ jobs: run: uv sync - name: Run tests - run: uv run pytest -v + run: make test diff --git a/pyproject.toml b/pyproject.toml index d4de155..8d8b345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,4 +43,5 @@ filterwarnings = [ dev = [ "pytest>=9.0.3", "ruff>=0.15.16", + "ty>=0.0.49", ] diff --git a/uv.lock b/uv.lock index 7285625..77ab3a6 100644 --- a/uv.lock +++ b/uv.lock @@ -104,6 +104,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -119,6 +120,7 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.15.16" }, + { name = "ty", specifier = ">=0.0.49" }, ] [[package]] @@ -333,6 +335,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] +[[package]] +name = "ty" +version = "0.0.50" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/930ab48010e89fd1ecccc8f588afc9a79d540a1e8a379cf9cb3a41812254/ty-0.0.50.tar.gz", hash = "sha256:74b8c0df3e7d3294110e9862b7f8a3767f0e073dcb6ffa27f69fd63fd876149c", size = 5935862, upload-time = "2026-06-17T21:36:42.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/81/2161de593e722ba27d04ee394d974604e8041c2246e2f75012d612c9934a/ty-0.0.50-py3-none-linux_armv6l.whl", hash = "sha256:b04a7717c22b9c66e9161e5af608669194cdd099c5ba0c507aeb479e6c1f9176", size = 11917031, upload-time = "2026-06-17T21:37:20.615Z" }, + { url = "https://files.pythonhosted.org/packages/05/95/70c0f1915c91c9ed68b89a8f16741d73ad65628e334e1ae5691972003702/ty-0.0.50-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cd8204f3a8df8fe68581e0b978124a90a143f35e3e7a7725a6e247b5ce1dcb33", size = 11675310, upload-time = "2026-06-17T21:36:51.627Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6c/d0318ed6f52a6b184f6480137f5d1d3d6032fa81e4bd7eff495ccf4d2977/ty-0.0.50-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d336bbad38a68f16f16f84bb18f67215049b33196050c8ff67503e79619e70d2", size = 11060709, upload-time = "2026-06-17T21:36:47.226Z" }, + { url = "https://files.pythonhosted.org/packages/13/13/aff2242a51d66e4b99b71bb24081cf274b58db2909f82041ebd1f4bb2e35/ty-0.0.50-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3933810d0360a108c60cd3ea56e6c2eba2f5ecf7ee99d66ff30775d2ae9ed29", size = 11577192, upload-time = "2026-06-17T21:37:13.953Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/46ceb4dd1f6a8a89027e5af6bc7171301e545e7d179403c06e4067c24dee/ty-0.0.50-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:88eed477756c7a0280a38de60bcabc64b3c6dcea1ac8b2a41c9210896358f6b9", size = 11693109, upload-time = "2026-06-17T21:37:09.589Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9c/347e3d9959cd39641a54fc74dd4c152863f55c9079b9e16e9b4c35dd5775/ty-0.0.50-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caa5d1c76f75cb6d3105ec8bf835c0a8fbc0950ccf15e3d1e9c52cb99b0ab2f5", size = 12190755, upload-time = "2026-06-17T21:36:49.193Z" }, + { url = "https://files.pythonhosted.org/packages/85/ca/d8226604f57a8f1ead1973b01f2f8c987a60d0fc09f726cbc9a7ef074dad/ty-0.0.50-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d323f2a663e260923c11434655e95f37e04865b1e1641288a23cbcbca53074ee", size = 12761345, upload-time = "2026-06-17T21:36:55.81Z" }, + { url = "https://files.pythonhosted.org/packages/c4/23/0adcbd4676b30f6b58741ce2c1218c73988fdc8fb8c276005df2fe1817a3/ty-0.0.50-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d61cf42b8517e774354466252b085d83cdd0b51d771a63f99a4f21d5a44afc52", size = 12387850, upload-time = "2026-06-17T21:36:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/254fd544e95ec6f2368aab9cc9f7ffdf1329ad95d2c9f62dea22f986bee2/ty-0.0.50-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbcd0ee844fd551bd946bb9d4fd2baf39a12aa0ad2f9f64db48352a82758abb3", size = 12220520, upload-time = "2026-06-17T21:37:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/51/cd/711e649397e34d14f63d42586aebede0d4006a1a77c8c4c63bf4fb36bcbe/ty-0.0.50-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8acf55714ec075997edfbf4dfd7ba3241c18c773e96f41398bb6d8008b83751a", size = 12439071, upload-time = "2026-06-17T21:36:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/b5/48/743d3ff46307e904377ac264d6c365ef25fd6e36cbc12c10c73437fef3de/ty-0.0.50-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8b1e02ff71af62d7a1d9b8bfef98847f5c8bfd5bb8ae6da691cea405eb5a5e98", size = 11532092, upload-time = "2026-06-17T21:37:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/5589976874b04a62de124f8f79fc11a45207d27f1c1fd2e7b7ddfa55aeea/ty-0.0.50-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7781ace006ab6b8bb9a7591dc20d1aaa79549d9d4e8169e5dc5cf8eef0754cd7", size = 11706451, upload-time = "2026-06-17T21:37:02.5Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f4/e15ea290712e61528287a353cb3103b744a092ea0a59039f17a035960717/ty-0.0.50-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e8015a10f4caf07edc9245f178e64ea388bddb9fb8d2d73d2dc1cfe6b9790493", size = 11842752, upload-time = "2026-06-17T21:37:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/f5/35/323b949d29cf6be2a71638498a1c43e4a2b84a1c1d206228cf3415384604/ty-0.0.50-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:87289648401648f455823334f2a8c67bbc341d502033591c8b044e67537e661b", size = 12325834, upload-time = "2026-06-17T21:37:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0a/5128b055493e41cb37f4f2a24a8ffce657e8c0d01086000c5f234d9b4622/ty-0.0.50-py3-none-win32.whl", hash = "sha256:ca73efc88be2942c1733e88b026f1cea88cafeca0ee63742dd673971d9a96642", size = 11171705, upload-time = "2026-06-17T21:36:45.191Z" }, + { url = "https://files.pythonhosted.org/packages/0b/65/91ffda139aa2b1d5c0fd321c859c5d10b7a912779d426422c4b25bca4362/ty-0.0.50-py3-none-win_amd64.whl", hash = "sha256:229d08c069beb2d896cc5556c3ba0e7f4c1b6d6a885297fabf2e6bcafa382a71", size = 12319493, upload-time = "2026-06-17T21:37:11.738Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/0c1ca628c5da7840e16801caa0bfeed1241e1113d8a5156a34245d4fa927/ty-0.0.50-py3-none-win_arm64.whl", hash = "sha256:96a84d970b59f2eddb92a4af3ba9906f24bda118cf487d923765ccd4ca24627b", size = 11635811, upload-time = "2026-06-17T21:37:07.191Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"