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/HARDWARE.md b/HARDWARE.md index b6c7558..b6d855e 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -68,3 +68,48 @@ 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: + +- **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 + 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 + 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 + +When swapping in a replacement panel (reuse the same HAT + ribbon cable): + +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` + - 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. **Live app:** `make run-pi` — confirm flight cards render in full colour. diff --git a/Makefile b/Makefile index c586897..b166579 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ -.PHONY: run debug test lint fixtures +.PHONY: run debug test lint fixtures deploy setup-pi run-pi logs-pi debug-pi +# Local dev run: ## Run the emulator smoke test uv run python -m jetset @@ -10,7 +11,41 @@ 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 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 ?= pi +JETSET_HOST ?= jetset.local +JETSET_PATH ?= /home/$(JETSET_USER)/jetset + +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 bin/setup-pi.sh; } && \ + /home/$(JETSET_USER)/.local/bin/uv add ./wheels/rgbmatrix-*.whl && \ + 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 + +run-pi: ## (Re)start the jetset service on the Pi + sudo systemctl restart jetset + +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 f392e1c..2cd92af 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,44 @@ 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` | (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 + +Settings load from a YAML file pointed to by the `JETSET_CONFIG` environment +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 +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/bin/install-service.sh b/bin/install-service.sh new file mode 100755 index 0000000..ccc65d9 --- /dev/null +++ b/bin/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/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/bin/setup-pi.sh b/bin/setup-pi.sh new file mode 100755 index 0000000..2e01d68 --- /dev/null +++ b/bin/setup-pi.sh @@ -0,0 +1,87 @@ +#!/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 (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 + 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 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 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 "$HOME/rpi-rgb-led-matrix" + cd "$HOME/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 + +# 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) +echo "=== Installing jetset systemd service ===" +bash bin/install-service.sh +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)" 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 5884009..8d8b345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "rgbmatrixemulator>=0.18.1", "pyyaml>=6", "requests>=2.34.2", + "python-dotenv>=1.2.2", + "pillow>=11", ] [project.scripts] @@ -20,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" @@ -35,4 +40,8 @@ filterwarnings = [ ] [dependency-groups] -dev = ["pytest>=9.0.3", "ruff>=0.15.16"] +dev = [ + "pytest>=9.0.3", + "ruff>=0.15.16", + "ty>=0.0.49", +] diff --git a/scripts/download_logos.py b/scripts/download_logos.py new file mode 100755 index 0000000..0c0f535 --- /dev/null +++ b/scripts/download_logos.py @@ -0,0 +1,105 @@ +"""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 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 typing import cast + +from PIL import Image + +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. +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 _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: + entries = json.loads(resp.read().decode()) + 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. + + 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() and not _is_blank_silhouette(dest): + continue + try: + 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 the configured 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. + """ + 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, dest_dir) + return total + + +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 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/logo-gallery.py b/scripts/logo-gallery.py new file mode 100755 index 0000000..b5c70dd --- /dev/null +++ b/scripts/logo-gallery.py @@ -0,0 +1,49 @@ +#!/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 + +from jetset.backend import build_matrix +from jetset.config import AppConfig +from jetset.models import Flight +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 "" + +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}") + +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: + # 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: + renderer.clear() + print("done") diff --git a/scripts/panel-colors.py b/scripts/panel-colors.py new file mode 100755 index 0000000..3899fb5 --- /dev/null +++ b/scripts/panel-colors.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Test each colour channel with a solid full-panel fill. + +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 time + +from jetset.backend import build_matrix + +matrix = build_matrix() + +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/probe-text.py b/scripts/probe-text.py new file mode 100755 index 0000000..44e2175 --- /dev/null +++ b/scripts/probe-text.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""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. + +Usage: probe-text.py [color] [hold-seconds] + color: red green blue yellow cyan magenta white orange (default red, 15s) +""" + +import sys +import time + +from jetset.backend import build_matrix, 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" +HOLD = int(sys.argv[2]) if len(sys.argv) > 2 else 15 +R, G, B = COLORS.get(NAME, (255, 0, 0)) +FONT_HEIGHT = 7 + +matrix = build_matrix() +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}: 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/route-compare.py b/scripts/route-compare.py new file mode 100755 index 0000000..6c84358 --- /dev/null +++ b/scripts/route-compare.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Validate the app's routes (AirLabs) against AeroAPI ground truth. + +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 ✓/✗ +score. Hard-capped at 5 callsigns. + +Provide keys via the environment or a .env file: + 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: 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 + + def load_dotenv(*_args, **_kwargs) -> bool: # no-op fallback + return False + +from jetset.config import AppConfig +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" +HEXDB_BASE = "https://hexdb.io/api/v1" +MISSING = "—" + + +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 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 == MISSING or truth.startswith("error"): + return "?" # no ground truth to compare against + if candidate == MISSING 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: + 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) + + config = AppConfig.load(os.environ.get("JETSET_CONFIG")) + 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 (check AIRLABS_API_KEY) — 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" + ) + + 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 = [] + for flight in flights: + 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: + rows.append((flight.callsign, ours, hex_route)) + + if use_truth: + 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", "ours (AirLabs)", "hexdb.io")) + print("Codes: AirLabs/AeroAPI = IATA; hexdb.io = ICAO.") + + +if __name__ == "__main__": + 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 417ea93..2a1b8f6 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 @@ -6,9 +7,6 @@ from jetset.app import App from jetset.config import AppConfig -DISPLAY_WIDTH = 64 -DISPLAY_HEIGHT = 32 - logger = logging.getLogger(__name__) @@ -24,29 +22,28 @@ 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() + + # Load AIRLABS_API_KEY (and friends) from a .env file if present. try: - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions - except ImportError: - print("RGBMatrixEmulator not installed. Run: uv add RGBMatrixEmulator") - return + from dotenv import load_dotenv - _configure_logging() + load_dotenv() + except ImportError: + pass - options = RGBMatrixOptions() - options.cols = DISPLAY_WIDTH - options.rows = DISPLAY_HEIGHT + config = AppConfig.load(os.environ.get("JETSET_CONFIG")) - matrix = RGBMatrix(options=options) - canvas = matrix.CreateFrameCanvas() try: logger.info("Press Ctrl-C to stop") import time time.sleep(1) - config = AppConfig() 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 2bb8db3..cb08f7a 100644 --- a/src/jetset/app.py +++ b/src/jetset/app.py @@ -1,26 +1,41 @@ +import logging +import threading import time +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 AdsbLolAdapter, FlightAPI +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__) 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 metric_page: int 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 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() @@ -30,64 +45,95 @@ def _should_fetch(self) -> bool: return False def _fetch(self): + # 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: + self.buffer.set_all(flights) - for f in flights: - self.buffer.push(f) + self.last_fetch = time.time() - for existing in self.buffer.flights: - if existing.callsign not in live_callsigns: - self.buffer.replace(existing.callsign, self.adapter.refresh_flight(existing)) + def _safe_fetch(self) -> None: + """Run a fetch cycle, swallowing unexpected errors. - self.last_fetch = time.time() + 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 _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 + + n = len(flights) + if n == 0: return None - idx = self.frame // 4 % len(self.buffer.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 - return self.Frame(self.buffer.flights[idx], metric_page) + # 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 - 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) - self.frame += 1 - time.sleep(self.config.pause) + return self.Frame(flights[(window_start + flight_in_window) % n], metric_page) - def _render_frame(self, matrix: RGBMatrix, canvas: Canvas, frame: Frame): - flight, metric_page = frame - render_flight_card(canvas, flight, metric_page) - self._after_render(matrix, canvas) + def loop(self) -> None: + matrix = build_matrix() - def _render_loading(self, matrix: RGBMatrix, canvas: Canvas): - render_loading(canvas, self.frame) - self._after_render(matrix, canvas) + # Fetching runs on a background thread; this loop only renders, so the + # 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) - def loop(self, matrix: RGBMatrix, canvas: Canvas): - # Show LOADING immediately while the first fetch runs - 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._fetch() - if frame := self._current_frame(): - self._render_frame(matrix, canvas, frame) + renderer.flight_card(frame.flight, frame.metric_page) else: - 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: - canvas.Clear() - matrix.SwapOnVSync(canvas) + self._stop.set() + renderer.clear() 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/backend.py b/src/jetset/backend.py new file mode 100644 index 0000000..84a0f99 --- /dev/null +++ b/src/jetset/backend.py @@ -0,0 +1,70 @@ +"""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. +""" + +import logging + +logger = logging.getLogger(__name__) + +try: + # 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: + # 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 + +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" + # 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) + + +__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 1648534..2509949 100644 --- a/src/jetset/config.py +++ b/src/jetset/config.py @@ -10,8 +10,11 @@ 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" + # 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 f75b229..d832465 100644 --- a/src/jetset/display.py +++ b/src/jetset/display.py @@ -1,7 +1,35 @@ """Display formatting functions for flight cards on the LED panel.""" +from pathlib import Path + +from PIL import Image + from jetset.models import Flight +_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.""" + return _COMPASS[int(track / 45 + 0.5) % 8] + + +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. + """ + 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: @@ -37,30 +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: - 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" + 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" + data += f"{int(flight.track)}\u00b0{_cardinal(flight.track)}" return data diff --git a/src/jetset/fetcher.py b/src/jetset/fetcher.py index f0945db..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 = data.get("ac", [])[0] - if aircraft: - updated = self.json_to_flight(aircraft) - 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/http.py b/src/jetset/http.py index 8d39f33..02edb27 100644 --- a/src/jetset/http.py +++ b/src/jetset/http.py @@ -1,24 +1,48 @@ """HTTP utilities for API communication.""" -from typing import ClassVar +import os +from pathlib import Path 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. """ - 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 ): 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: 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/src/jetset/renderer.py b/src/jetset/renderer.py index af6fb9e..f240968 100644 --- a/src/jetset/renderer.py +++ b/src/jetset/renderer.py @@ -1,7 +1,20 @@ -from RGBMatrixEmulator import graphics +from functools import lru_cache +from pathlib import Path +from typing import cast + +from PIL import Image from RGBMatrixEmulator.emulation.canvas import Canvas +from RGBMatrixEmulator.emulation.matrix import RGBMatrix -from jetset.display import aircraft_label, flight_label, loading_label, metrics_label, route_label +from jetset.backend import graphics +from jetset.display import ( + aircraft_label, + flight_label, + load_logo, + loading_label, + metrics_label, + route_label, +) from jetset.models import Flight # Colour palette @@ -18,23 +31,92 @@ font = graphics.Font() font.LoadFont("fonts/5x7.bdf") +CANVAS_WIDTH = 64 +# 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 + 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) -def render_flight_card(canvas: Canvas, flight: Flight, metric_page=0): - canvas.Clear() +@lru_cache(maxsize=256) +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, dir), not every frame. + """ + img = load_logo(airline_code, logo_dir) + 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.Resampling.LANCZOS) + + +class Renderer: + """Owns the LED matrix's double-buffered canvas and the logo directory. + + 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. + """ + + 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) - # 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) + 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 -def render_loading(canvas: Canvas, page=0): - canvas.Clear() + 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 - draw_text(canvas, 1, FONT_HEIGHT * 1 + 0, loading_label(page), RED) + 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/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_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 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 bdb20e6..9e2cd93 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -54,6 +54,6 @@ def test_loads_custom_yaml(self, tmp_path: Path) -> None: assert config.pause == 4 assert config.refresh == 15 - 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..15b4e30 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: @@ -10,6 +10,15 @@ def test_returns_loading_text(self) -> None: assert loading_label() == "LOADING" +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", Path("/nonexistent")) is None + + class TestFlightLabel: def test_returns_callsign(self) -> None: from jetset.display import flight_label @@ -38,10 +47,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 +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) == "35K ft" + assert metrics_label(flight) == "35000ft" def test_metrics_label_empty_when_no_data(self) -> None: from jetset.display import metrics_label @@ -70,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 == "35K 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°" + 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_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() diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index b407a05..741b8ab 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -1,485 +1,138 @@ -"""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 kn + "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.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 + assert flight.vertical_rate == 0 + route = flight.route + assert route is not None + assert route.origin.iata_code == "SFO" + assert route.destination.iata_code == "LAX" - adapter = AdsbLolAdapter() + def test_to_flight_no_route_when_endpoints_missing(self) -> None: + from jetset.fetcher import AirLabsAdapter - # 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)), - ) + 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 - with patch.object(adapter, "_fetch_route", return_value=lax_ito): - result = adapter._enrich_routes([aircraft]) + def test_nearby_flights_parses_bbox_response(self) -> None: + from jetset.fetcher import AirLabsAdapter - assert len(result) == 0 - assert "route" not in aircraft - - 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" + route = flights[0].route + assert route is not None + assert 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, - } + mock_resp.json.return_value = {"error": {"code": "unknown_api_key"}} - 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._api, "get", return_value=mock_resp): + assert adapter.nearby_flights(29.99, -95.34, 200) == [] - with patch.object(adapter._flight_api, "get", return_value=mock_resp): - refreshed = adapter.refresh_flight(existing) + def test_bbox_covers_the_range(self) -> None: + from jetset.fetcher import AirLabsAdapter - 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 + 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 +class TestAirLabsFixture: + def test_maps_all_fixture_flights(self) -> None: + from jetset.fetcher import AirLabsAdapter - fixture = json.loads(ADSB_LOL_FIXTURE_PATH.read_text()) - assert len(fixture) >= 1 - - 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. + 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_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 3563e36..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 @@ -49,110 +50,135 @@ def test_fetches_and_pushes_to_buffer(self) -> None: assert app.last_fetch == 500 -class TestAppCurrentFrame: - def test_selects_flight_and_metric_page(self) -> None: +class TestAppSafeFetch: + def test_swallows_fetch_errors_so_the_loop_survives(self) -> None: + from unittest.mock import patch + 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()) + # 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() - 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)) + assert len(app.buffer) == 0 - assert frames == [ - ("UAL2337", 0), - ("UAL2337", 1), - ("UAL2337", 2), - ("UAL2337", 3), - ("SWA45", 0), - ("SWA45", 1), - ("SWA45", 2), - ("SWA45", 3), - ] - def test_empty_buffer_returns_none(self) -> None: +class TestAppFetchLoop: + def test_runs_a_fetch_cycle_then_honors_stop(self) -> None: + from unittest.mock import patch + from jetset.app import App - config = AppConfig() - app = App(config) + app = App(AppConfig()) - result = app._current_frame() - assert result is None + # 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 TestAppHistory: - def test_refreshes_stale_flights_after_fetch(self) -> None: - from unittest.mock import patch +class TestAppCurrentFrame: + def test_cycles_window_flights_through_metric_pages(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")] + app = App(AppConfig()) + app.buffer.set_all([Flight(callsign="UAL2337"), Flight(callsign="SWA45")]) + app.last_fetch = 1000.0 - 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() + frames: list[tuple[str, int]] = [] + # 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() + assert result is not None + frames.append((result.flight.callsign, result.metric_page)) - # SWA45 was pushed to the buffer - assert len(app.buffer) == 2 - # UAL2337 was refreshed via refresh_flight - mock_refresh.assert_called_once_with(stale) + assert frames == [ + ("UAL2337", 0), ("UAL2337", 1), ("UAL2337", 2), ("UAL2337", 3), + ("SWA45", 0), ("SWA45", 1), ("SWA45", 2), ("SWA45", 3), + ] + + def test_window_slides_over_the_refresh_interval(self) -> None: + from jetset.app import App + + 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): + frame = app._current_frame() + assert frame is not None + assert frame.flight.callsign == "0" + with patch("jetset.app.time.time", return_value=270.0): + frame = app._current_frame() + assert frame is not None + assert frame.flight.callsign == "1" + with patch("jetset.app.time.time", return_value=540.0): + frame = app._current_frame() + assert frame is not None + assert frame.flight.callsign == "2" -class TestAppRenderFrame: - def test_renders_and_advances_frame(self) -> None: + def test_empty_buffer_returns_none(self) -> None: from jetset.app import App - config = AppConfig() - app = App(config) - app.frame = 3 + app = App(AppConfig()) + assert app._current_frame() is None + - 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) + app.loop() + return mock_renderer.return_value + + def test_renders_flight_card_and_advances_frame(self) -> None: + from jetset.app import App + + app = App(AppConfig()) + app.buffer.set_all([Flight(callsign="UAL2337")]) - mock_render.assert_called_once_with(mock_canvas, frame.flight, frame.metric_page) - mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) - assert app.frame == 4 + 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 + renderer = self._run_once(App(AppConfig())) - mock_matrix = MagicMock() - mock_canvas = MagicMock() + renderer.loading.assert_called_once_with(0) + renderer.present.assert_called_once() - with ( - patch("jetset.app.render_loading") as mock_render, - patch("jetset.app.time.sleep"), - ): - app._render_loading(mock_matrix, mock_canvas) + def test_loading_animation_cycles_with_frame(self) -> None: + from jetset.app import App - mock_render.assert_called_once_with(mock_canvas, 0) - mock_matrix.SwapOnVSync.assert_called_once_with(mock_canvas) - assert app.frame == 1 + app = App(AppConfig()) # empty buffer + app.frame = 5 # past the first cycle — must wrap, not freeze on "LOADING" + + renderer = self._run_once(app) + + # 5 % 4 == 1 → "LOADING." so the dots keep animating + renderer.loading.assert_called_once_with(1) 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_renderer.py b/tests/test_renderer.py index b87b2af..3ddfae0 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -14,42 +14,92 @@ 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: +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") + renderer = _renderer() + with patch.object(renderer._canvas, "Clear") as mock_clear: + renderer.flight_card(Flight(callsign="UAL2337")) - render_flight_card(canvas, flight) + mock_clear.assert_called() - assert canvas.Clear.called - - def test_renders_all_metric_pages_without_error(self) -> None: - from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions + def test_each_row_uses_its_palette_colour(self) -> None: + # Each row is drawn in its own palette colour. + from unittest.mock import patch + from jetset import renderer as r 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() + rnd = _renderer() + with patch("jetset.renderer.draw_text") as mock_draw: + rnd.flight_card(Flight(callsign="UAL2337", altitude=35000)) + + row_colors = [call.args[4] for call in mock_draw.call_args_list] + assert row_colors == [r.ORANGE, r.CYAN, r.GREEN, r.BLUE] + + def test_renders_all_metric_pages_without_error(self) -> None: + from jetset.models import Flight + 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, metric_page=page) - canvas = matrix.SwapOnVSync(canvas) + renderer.flight_card(flight, page) + renderer.present() + + +class TestLogo: + def test_skips_missing_airline(self) -> None: + from unittest.mock import patch + + from jetset.models import Flight + 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 + 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 jetset.models import Flight + from jetset.renderer import LOGO_WIDTH, _scaled_logo + + test_logo = Image.new("RGB", (LOGO_WIDTH, 20), (255, 0, 0)) + 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(renderer._canvas, "SetPixel") as mock_setpixel: + renderer._logo(flight) + _scaled_logo.cache_clear() + + mock_setpixel.assert_called() 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() diff --git a/uv.lock b/uv.lock index 5100bbc..77ab3a6 100644 --- a/uv.lock +++ b/uv.lock @@ -93,6 +93,8 @@ name = "jetset" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pillow" }, + { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, { name = "rgbmatrixemulator" }, @@ -102,10 +104,13 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [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" }, { name = "rgbmatrixemulator", specifier = ">=0.18.1" }, @@ -115,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]] @@ -222,6 +228,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" @@ -320,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"