A handheld, Pi-powered ATC-style radar scope for visualizing nearby aircraft via ADS-B decoding, with north-up polar display, GPS positioning, and IMU heading—entirely offline.
Version: 0.2.0 | Python: 3.11+ | Architecture: Clean Architecture + Event-Driven
PocketScope decodes 1090 MHz ADS-B signals to display real-time aircraft traffic on a north-up polar (PPI) radar display. Designed for Raspberry Pi hardware with TFT screens, it also runs on desktop for development and testing with full deterministic simulation support.
- Embedded deployment: Raspberry Pi with ILI9341 SPI TFT + XPT2046 touch + RTL-SDR dongle
- Desktop development: Pygame window with mouse input and keyboard shortcuts
- Automated testing: Deterministic playback with simulated time and golden-frame validation
- Remote monitoring: Web UI with WebSocket streaming
- Language: Python 3.11+ (strict type hints, mypy-clean)
- Core Libraries:
asyncio,pydanticv2,numpy,msgpack,PyYAML - Display:
pygame(desktop) orpygame-ce(3.13+),spidev+RPi.GPIO(Pi) - Data Processing:
aiohttp(dump1090 polling), NumPy (geometry/spatial) - Quality Tools:
pytest,black,ruff,isort,mypy,hypothesis
- Modularity: Domain logic independent of I/O, GUI, or hardware
- Determinism: Everything runnable with mocks and simulated time
- Performance: Target ≥5 FPS with 20 aircraft on Pi Zero 2 W
- Offline-first: No internet dependencies beyond local dump1090
Clean Architecture + Ports & Adapters + Event-Driven
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (CLI, Boot, DI/Wiring) │
└─────────────────────┬───────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ UI Layer │ │ Ingest Layer │
│ (Controllers, │ │ (ADS-B, GPS, │
│ Soft Keys, │◄──────►│ IMU Sources) │
│ Status) │ Bus │ │
└────────┬─────────┘ └────────┬─────────┘
│ │
└────────────┬──────────────┘
▼
┌──────────────┐
│ Event Bus │
│ (Topics, Sub)│
└──────┬───────┘
│
┌───────────┴───────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Domain Layer │ │ Render Layer │
│ (Tracks, Geo, │ │ (Canvas, PPI, │
│ Models, Time) │ │ Layers, Labels) │
└──────────────────┘ └────────┬─────────┘
│
┌───────┴────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ Platform │ │ Data │
│ (Display, │ │ (Spatial, │
│ Input, │ │ Map DB, │
│ Storage) │ │ Cache) │
└───────────┘ └───────────┘
- Purpose: Async pub/sub messaging with backpressure
- Features: Topic-based, bounded queues, msgpack serialization, graceful shutdown
- Topics:
"adsb.raw","tracks.updates","gps.position","imu.heading", etc.
- TrackService (
tracks.py): Aircraft state management, history buffers, expiry - GeoUtilities (
geo.py): Haversine distance, bearing, coordinate transforms - TimeProvider (
time.py): Real vs simulated clock, deterministic scheduling - Models (
models.py): Pydantic data contracts (Aircraft, Position, Track)
- ADS-B: dump1090 JSON polling, SBS TCP, BEAST binary, JSONL playback
- GPS: NMEA serial parser, mock GPS for testing
- IMU: ICM-20948 9-axis sensor, heading fusion
- Canvas API (
canvas.py): Framework-agnostic drawing primitives - PPI View (
view_ppi.py): North-up polar display, range rings, aircraft glyphs - Layers: Airports, sectors, aircraft trails, labels (collision-avoiding)
- Labels (
labels.py): ATC-style data blocks, typography, layout
- Display Backends: Pygame (desktop), ILI9341 SPI TFT (Pi), Web (WebSocket)
- Input Backends: Mouse, XPT2046 touch, soft keys
- Storage: Settings persistence, SQLite caching
- Spatial DB (
spatial.py,db.py): R-tree indexed GeoJSON (airports, sectors) - Map Ingestion: Convert GeoJSON to SQLite with spatial queries
- Cache: Persistent track history, rendering optimizations
Package root and CLI entry point. Version defined in __init__.__version__.
- boot.py: Application bootstrapping, dependency injection, wiring
- cli.py: Command-line argument parsing (
--playback,--fps,--tft, etc.)
- Configuration loading (TOML, YAML, JSON)
- Pydantic schemas for settings validation
- Hot-reload support with file watchers
- Palette management (colors for aircraft, UI elements, overlays)
- Theme inheritance and merging
- Live reload during development
Event bus implementation (EventBus, Envelope, pack/unpack utilities)
Pydantic v2 data models (Aircraft, Position, Track, Ownship, etc.)
TrackService for managing aircraft state, history buffers, expiry timers
Geometric utilities: haversine distance, bearing, coordinate conversion, polar ↔ cartesian
TimeProvider abstraction (real clock vs simulated clock for deterministic testing)
Additional domain services and value objects
dump1090_json.py: HTTP polling from dump1090/data/aircraft.jsonsbs_tcp.py: SBS-1 BaseStation format over TCP:30003beast.py: Binary BEAST protocol decoderplayback.py: JSONL file replay with simulated time
nmea_serial.py: UART/USB GPS receiver (GPGGA, GPRMC)mock_gps.py: Fixed position for testing
icm20948.py: I²C 9-axis IMU driverfusion.py: Sensor fusion for heading/attitudemock_imu.py: Simulated heading for desktop
Abstract canvas API (draw_line, draw_circle, draw_text, etc.). All backends implement this protocol.
North-up PPI radar display. Draws range rings, ownship marker, aircraft glyphs, trails.
ATC-style label rendering: multi-line data blocks, collision avoidance, leader lines.
Caching for geographic projections and viewport clipping.
Overlay layers for airports and airspace sectors from spatial DB.
Additional overlay implementations (trails, focus rings, etc.)
pygame_backend.py: Desktop windowing (dev mode)ili9341_backend.py: SPI TFT for Raspberry Pi (RGB565, DMA transfers)web_backend.py: WebSocket streaming for remote monitoring
mouse_input.py: Desktop mouse/keyboardxpt2046_touch.py: Resistive touchscreen for Pisoftkey_input.py: Physical buttons or on-screen soft keys
Storage, file watchers, network utilities
Main UI controller: focus management, gesture handling, layer composition
Soft key bar (range, mode, filter controls)
Top bar displaying ownship position, heading, GPS status, FPS
Side panel showing altitude distribution of nearby aircraft
UI-specific theme application and color utilities
SQLite database schema for airports, sectors, runways
R-tree spatial indexing for efficient viewport queries
Tool to convert GeoJSON datasets to SQLite with spatial indexes
Data access layers for airspace and airport data
In-memory caching for frequently accessed spatial data
Main application entry: wires together all services, starts event loop
Recording/replay, data conversion, performance profiling, screenshot automation
Settings persistence, validation, hot-reload, migration utilities
Unit tests for domain logic (geo, tracks, models, time, events)
Tests for ADS-B parsers, GPS, IMU, playback
Golden-frame tests, label layout, layer composition
Display backend tests, input handling, integration smoke tests
Spatial DB queries, GeoJSON ingestion
Controller behavior, soft key actions, theme application
Sample data files (JSONL traces, GeoJSON, airports, sectors)
overview.md: Architecture summary and doc mapevent-bus.md: Messaging patterns and topicstime-and-simulation.md: Deterministic testing with simulated timedata-ingestion.md: Source adapters and parserstrack-service.md: Aircraft state managementrendering.md: Canvas API, layers, backendssettings-and-softkeys.md: Configuration and UI controlsrecording-and-replay.md: JSONL capture and playbackspatial.md: GeoJSON ingestion and spatial queriestheming.md: Palette system and customizationlogging-and-telemetry.md: Structured logging, performance metricssystemd-setup.md: Pi deployment and service configuration
pocketscope.service: systemd unit file for Pipocketscope.env: Environment variables for productionsettings.yml: Default user settings template
demo_adsb.jsonl: Sample ADS-B trace for playback modeairports_ma.json: Massachusetts airports GeoJSONsectors_sample.json: Airspace sectors GeoJSON
settings.example.yml: Annotated configuration file
- Line length: 88 characters (enforced by ruff E501)
- Formatter:
black(line-length=120 in pyproject.toml, but E501 enforces 88) - Import sorting:
isort(black profile) - Linting:
ruff(checks E501, type annotations, unused imports)
- black: Code formatting (auto-fix)
- ruff: Linting (line length, type hints)
- isort: Import sorting (auto-fix)
- mypy: Optional but recommended for type checks in CI
- pytest: Tests must pass before merging
- Prefer pure functions in
core/and keep side effects in adapters. - Type annotations required for all functions; async functions that
don't return should use
-> None. - Handle Optional types explicitly: check
if obj is not None:before use.
# ✅ Good: Explicit return types, Optional handling
def calculate_bearing(
lat1: float, lon1: float, lat2: float, lon2: float
) -> float:
"""Compute initial bearing between two points."""
...
async def publish_track(track: Track) -> None:
"""Publish track update to event bus."""
...
def get_subscription() -> Optional[AsyncIterator[Envelope]]:
"""Return subscription or None if unavailable."""
...
# ❌ Bad: Missing return type, unhandled Optional
def some_function():
subscription = get_subscription()
async for item in subscription: # Error if None!
pass# ✅ Always check for None before accessing Optional types
subscription = bus.subscribe("adsb.raw")
if subscription is not None:
async for envelope in subscription:
process(envelope)
# ✅ Use walrus operator for concise checks
if (track := track_service.get(icao)) is not None:
return track.altitudedef haversine_distance(lat1: float, lon1: float,
lat2: float, lon2: float) -> float:
"""
Compute great-circle distance in nautical miles.
Assumes WGS84 ellipsoid and inputs in decimal degrees.
"""
...- Pure functions in domain: No side effects, no I/O, testable
- Async sources: Use
async defgenerators for streaming data - Dependency injection: Pass interfaces (ABC/Protocol), not concrete classes
- E501 Line too long: Keep lines ≤88 chars; break long docstrings and calls.
- Missing return type annotations: Add
-> Noneor the correct type. - Optional/Union misuse: Check for
Nonebefore iterating or accessing. - Untyped function calls: Ensure functions have type annotations to avoid mypy issues.
Immutable message wrapper with metadata:
topic: String identifier (e.g.,"adsb.raw")timestamp: Unix epoch seconds (real or simulated)sender: Source component identifierpayload: msgpack-encoded bytes
- State: Current position, altitude, speed, heading
- History: Ring buffer of recent positions for trail rendering
- Expiry: Tracks removed after N seconds without updates
- Focus: User can "pin" an aircraft for detailed info
For deterministic testing, replace real clock with SimulatedClock:
clock = SimulatedClock(start_time=0.0)
await clock.sleep(1.0) # Instant in test, pauses event loop
clock.advance(10.0) # Jump forward 10 secondsAll rendering code uses abstract canvas API. Backends implement:
draw_line(x1, y1, x2, y2, color, width)draw_circle(x, y, radius, color, fill)draw_text(x, y, text, font, color, align)clear(color),present()
Rendering is compositional. Each layer:
- Subscribes to relevant topics (tracks, airports, etc.)
- Computes drawable elements in
update() - Renders to canvas in
draw(canvas)
On-screen or physical buttons for common actions:
- Range +/-: Zoom in/out (5nm, 10nm, 25nm, 50nm)
- Mode: Switch label format (simple, full, callsign-only)
- Filter: Altitude/distance thresholds
- Screenshot: Capture current frame
- Create class in
ingest/adsb/implementing async generator - Publish decoded messages to
"adsb.raw"topic - Add CLI flag in
cli.pyto select source - Wire in
boot.pydependency injection - Add unit tests with fixtures in
tests/ingest/
- Create class in
render/layers/withupdate()anddraw(canvas) - Subscribe to relevant topics in
__init__ - Register layer in
view_ppi.pylayer stack - Add theme colors in
themes.ymlif needed - Add golden-frame test in
tests/render/
- Implement canvas protocol in
platform/display/ - Add CLI flag for backend selection
- Wire in
boot.pywith conditional import - Test with deterministic playback:
--playback --backend new_backend
- Add field to Pydantic schema in
settings_schema.py - Update default
settings.ymlandexamples/settings.example.yml - Add validation/migration logic if needed
- Wire to relevant service in
boot.py - Add test in
tests/settings/
# Full suite
pytest
# Specific module
pytest tests/core/test_geo_unit.py -v
# With coverage
pytest --cov=src/pocketscope --cov-report=html
# Property-based tests only
pytest -k "property" -vpre-commit run --all-files
# OR manually:
black .
isort .
ruff check .
mypy src/
pytest# Desktop with demo data
python -m pocketscope --playback sample_data/demo_adsb.jsonl
# Live from dump1090
python -m pocketscope --adsb-src JSON
# Embedded on Pi
python -m pocketscope --tft --adsb-src JSON --gps /dev/ttyUSB0
# Web UI for remote monitoring
python -m pocketscope --web-ui --playback sample_data/demo_adsb.jsonl
# Performance profiling (5 seconds at 12 FPS)
POCKETSCOPE_LOGGING_LEVEL=INFO python -m pocketscope \
--playback sample_data/demo_adsb.jsonl \
--fps 12 --run-seconds 5 | grep 'ui.perf'src/pocketscope/__main__.py: CLI entry (python -m pocketscope)src/pocketscope/cli.py: Argument parsingsrc/pocketscope/boot.py: Dependency injection and wiringsrc/pocketscope/app/live_view.py: Main application loop
~/.pocketscope/settings.json: User settings (runtime editable)src/pocketscope/themes.yml: Color palettesbootstrap_assets/settings.yml: Production defaultspyproject.toml: Build config, tool settings, dependencies
src/pocketscope/core/events.py: Event bus (hub of all communication)src/pocketscope/core/tracks.py: Aircraft state managementsrc/pocketscope/core/geo.py: Geometric calculationssrc/pocketscope/core/time.py: Real vs simulated clock
src/pocketscope/render/canvas.py: Drawing abstractionsrc/pocketscope/render/view_ppi.py: PPI display logicsrc/pocketscope/render/labels.py: Label layout and collision
src/pocketscope/platform/display/pygame_backend.py: Desktopsrc/pocketscope/platform/display/ili9341_backend.py: Raspberry Pi TFTsrc/pocketscope/platform/input/xpt2046_touch.py: Pi touchscreen
src/pocketscope/data/db.py: SQLite schema and queriessrc/pocketscope/data/spatial.py: R-tree spatial indexingsrc/pocketscope/data/ingest_geojson_to_sqlite.py: Data import tool
- Assume: Pure domain logic, no I/O, no framework imports
- Pattern: Pure functions or stateless services with dependency injection
- Testing: Unit tests with mocks, property-based tests preferred
- Avoid: Importing from
platform/,ingest/,render/(except interfaces)
- Assume: Adapter pattern, async generators, publish to event bus
- Pattern:
async def run(bus: EventBus) -> None:withasync forloops - Testing: Use fixtures from
tests/fixtures/, deterministic playback - Avoid: Direct calls to domain services (use event bus)
- Assume: Framework-agnostic, use canvas protocol only
- Pattern: Layers with
update()anddraw(canvas)methods - Testing: Golden-frame comparisons with simulated time
- Avoid: Direct hardware access, blocking I/O in draw loops
- Assume: Hardware/OS-specific code, conditional imports for Pi
- Pattern: Implement abstract protocols from
core/orrender/ - Testing: Mocks for hardware, integration tests on target device
- Avoid: Business logic (delegate to domain services)
- Assume: Coordinating layers, handling input events, theme application
- Pattern: Controllers subscribe to input events, dispatch to services
- Testing: Event-driven tests with simulated input
- Avoid: Direct rendering (use canvas layers)
- Assume: Persistent storage, spatial queries, caching
- Pattern: Repository pattern, R-tree indexing, SQLite transactions
- Testing: Fixtures with sample GeoJSON, query result validation
- Avoid: Mutable global state (use dependency injection)
- Always: Use simulated time for deterministic behavior
- Always: Run with timeout (10s default) to catch hangs
- Prefer: Property-based tests (Hypothesis) for geometry/math
- Prefer: Golden-frame tests for rendering validation
- Use: Fixtures from
tests/fixtures/for sample data - Avoid: Real hardware in unit tests (use mocks)
- E501 Line too long: Break long lines (especially comments) at 88 chars
- Missing return type: Add
-> Noneor explicit return type to all functions - Optional without None check: Always check
if obj is not None:before use - Unused imports: Remove or use all imported names
- Import sorting: Let
isorthandle it automatically
- Hot paths: Avoid allocations in per-frame rendering (reuse buffers)
- Geometry: Use NumPy for vectorized calculations
- Spatial queries: Use R-tree indexes, not linear scans
- Event bus: Tune queue sizes for backpressure (default 256)
- Rendering: Target ≥5 FPS with 20 aircraft on Pi Zero 2 W
- Offline-first: No outbound network except local dump1090
- No secrets: Don't embed API keys or credentials
- Run as non-root: Use systemd user services on Pi
- License headers: Respect MIT license in all new files
# Development with demo data
python -m pocketscope --playback sample_data/demo_adsb.jsonl
# Live on desktop
python -m pocketscope --adsb-src JSON
# Raspberry Pi embedded
python -m pocketscope --tft --adsb-src JSON --gps /dev/ttyUSB0
# Web monitoring
python -m pocketscope --web-ui --playback sample_data/demo_adsb.jsonl
# Performance profiling
POCKETSCOPE_LOGGING_LEVEL=INFO python -m pocketscope \
--playback sample_data/demo_adsb.jsonl --fps 12 --run-seconds 5pytest # Full suite
pytest -v -k "geo" # Geometry tests only
pytest --cov=src/pocketscope # With coverage
pytest -x # Stop on first failure
pytest tests/render/test_golden_ppi.py -v # Specific test fileblack . # Auto-format
isort . # Sort imports
ruff check . # Lint
ruff check --fix . # Auto-fix lint issues
mypy src/ # Type check
pre-commit run --all-files # All checks"adsb.raw": Raw ADS-B messages (JSON/SBS/BEAST decoded)"tracks.updates": Aircraft track state changes"gps.position": Ownship GPS position"imu.heading": Heading from IMU sensor"ui.input": User input events (touch, keys)"ui.screenshot": Screenshot capture requests"settings.changed": Configuration hot-reload events"theme.changed": Palette update events
.py: Python source (strict type hints required).jsonl: JSONL traces for playback (one JSON per line).json: GeoJSON data, settings files.yml: Theme definitions, configuration.toml: Build config (pyproject.toml).sql: Database schema.md: Documentation
POCKETSCOPE_HOME: Override default~/.pocketscope/config directoryPOCKETSCOPE_LOGGING_LEVEL: Set log level (DEBUG, INFO, WARNING, ERROR)PYTHONPATH: Ensuresrc/is in path for development
- Start with
docs/overview.mdfor architecture summary - See
docs/event-bus.mdfor messaging patterns - Consult
docs/rendering.mdfor display details - Read
AGENTS.mdfor AI-assistant coding guidelines
- ADS-B Protocol: Mode S overview
- dump1090: FlightAware dump1090-fa
- Raspberry Pi: GPIO pinout
- Pydantic: v2 docs
- Pygame: pygame-ce docs
- Fork and create feature branches
- Keep PRs small and focused
- Include tests and documentation
- Run pre-commit checks before pushing
- Reference related issues and docs in PR descriptions
Last Updated: 2025-11-17 | Maintainer: PocketScope Team | License: MIT`