Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aa4b69318 | |||
| 7f34a7b926 | |||
| 8e57f2d927 | |||
| 66884270a8 | |||
| ddde3358ef | |||
| 8d05c67a48 | |||
| d97c66a53e | |||
| 833a7f0917 | |||
| 316e7aa9a8 | |||
| d759f4e575 | |||
| 9f6d442df8 | |||
| cbe87d36f9 | |||
| 709053debf | |||
| 25076dc1f3 | |||
| 5d307c33b0 | |||
| 48a3a1c7dd | |||
| f530185e31 | |||
| e2e4e885d1 | |||
| be32469284 | |||
| 2c86ffd422 | |||
| b9ccdc4916 | |||
| 2ba3d03c96 | |||
| e8ce0602a4 | |||
| 5a18d0867a | |||
| 34e3736c10 | |||
| 037ce3e193 | |||
| f8e763d734 | |||
| 2fdb58c516 | |||
| 6208134a1c | |||
| 7d89166880 | |||
| 9c53ccb524 | |||
| 4aeeefb488 | |||
| a6a6a2796d | |||
| 6216e933a6 | |||
| 6231e3157e | |||
| d388cca478 | |||
| b2b55ac11b | |||
| 563b0d4665 | |||
| 76c2d66ed1 | |||
| c08ec9fc89 | |||
| 826f1d98fa | |||
| 8682b8714e | |||
| 1ff68512f9 | |||
| f3e6586a7a | |||
| 3afb7f5f1e | |||
| 85c8acf767 | |||
| 0612e0fe02 | |||
| b2afa7fb4b |
+13
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.swp
|
||||
.DS_Store
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Story 1.1: Project Scaffold & Verified Entry Points
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a developer,
|
||||
I want a verified project scaffold with the `src/planemapper/` layout, both console entry points installable, all module stubs in place, systemd unit files, and `pytest` running without error,
|
||||
So that every subsequent story has a consistent, working foundation to build on.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** the repository is cloned on a Pi Zero 2W running Raspberry Pi OS Bookworm **When** `pip install -e .` is run **Then** it completes without errors and both `planemapper-provision` and `planemapper-radar` commands are available on PATH **And** running either command logs "not implemented" and exits with code 0
|
||||
|
||||
2. **Given** the project is installed **When** `pytest` is run **Then** the test suite discovers tests and exits with 0 failures (empty stubs acceptable)
|
||||
|
||||
3. **Given** the project structure **When** a developer inspects the repository **Then** all files from the Architecture directory structure exist: `src/planemapper/` with `__init__.py`, `constants.py`, `models.py`, `main.py`, `provision.py`, `fetcher.py`, `gpio_ctrl.py`, `display.py`, `provisioning/` (7 modules), `renderer/` (8 modules), `data/airports.csv`; `systemd/` with both `.service` files; `pyproject.toml`, `requirements.txt`, `requirements-dev.txt` **And** `src/planemapper/data/airports.csv` is accessible via `importlib.resources` **And** `ruff check .` passes with zero violations
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Create `pyproject.toml` (AC: #1, #3)
|
||||
- [x] 1.1 Set `[build-system]` to use `setuptools` with `find_packages`
|
||||
- [x] 1.2 Set `requires-python = ">=3.11"` and list pinned runtime dependencies (Pillow==12.2.0, gpiozero==2.0.1, Flask==3.1.3, requests==2.33.1)
|
||||
- [x] 1.3 Add `[project.scripts]` with `planemapper-radar = "planemapper.main:main"` and `planemapper-provision = "planemapper.provision:main"`
|
||||
- [x] 1.4 Add `[tool.setuptools.package-data]` entry so `planemapper/data/airports.csv` is included in the installed package
|
||||
- [x] 1.5 Add `[tool.ruff]` section: `line-length = 100`, `target-version = "py311"`, and an import boundary rule preventing `planemapper.main` from importing `planemapper.provisioning.*`
|
||||
|
||||
- [x] Task 2: Create `requirements.txt` and `requirements-dev.txt` (AC: #1, #3)
|
||||
- [x] 2.1 `requirements.txt`: pin Pillow==12.2.0, gpiozero==2.0.1, Flask==3.1.3, requests==2.33.1
|
||||
- [x] 2.2 `requirements-dev.txt`: pin pytest==9.0.3, ruff==0.15.11, add `gpiozero[mock]`
|
||||
|
||||
- [x] Task 3: Create top-level `src/planemapper/` module stubs (AC: #1, #3)
|
||||
- [x] 3.1 `src/planemapper/__init__.py` — empty or version string only
|
||||
- [x] 3.2 `src/planemapper/constants.py` — stub with module docstring and placeholder constants
|
||||
- [x] 3.3 `src/planemapper/models.py` — stub with module docstring
|
||||
- [x] 3.4 `src/planemapper/fetcher.py` — stub with module docstring
|
||||
- [x] 3.5 `src/planemapper/gpio_ctrl.py` — stub with module docstring
|
||||
- [x] 3.6 `src/planemapper/display.py` — stub with module docstring
|
||||
- [x] 3.7 `src/planemapper/main.py` — `main()` function that logs "not implemented" and returns; must NOT import from `planemapper.provisioning.*`
|
||||
- [x] 3.8 `src/planemapper/provision.py` — `main()` function that logs "not implemented" and returns
|
||||
|
||||
- [x] Task 4: Create `provisioning/` subpackage stubs (AC: #3)
|
||||
- [x] 4.1 `src/planemapper/provisioning/__init__.py`
|
||||
- [x] 4.2 `src/planemapper/provisioning/portal.py`
|
||||
- [x] 4.3 `src/planemapper/provisioning/location.py`
|
||||
- [x] 4.4 `src/planemapper/provisioning/tiles.py`
|
||||
- [x] 4.5 `src/planemapper/provisioning/airspace.py`
|
||||
- [x] 4.6 `src/planemapper/provisioning/wifi.py`
|
||||
- [x] 4.7 `src/planemapper/provisioning/config.py`
|
||||
|
||||
- [x] Task 5: Create `renderer/` subpackage stubs (AC: #3)
|
||||
- [x] 5.1 `src/planemapper/renderer/__init__.py`
|
||||
- [x] 5.2 `src/planemapper/renderer/renderer.py`
|
||||
- [x] 5.3 `src/planemapper/renderer/projection.py`
|
||||
- [x] 5.4 `src/planemapper/renderer/basemap.py`
|
||||
- [x] 5.5 `src/planemapper/renderer/aircraft.py`
|
||||
- [x] 5.6 `src/planemapper/renderer/airspace.py`
|
||||
- [x] 5.7 `src/planemapper/renderer/colours.py`
|
||||
- [x] 5.8 `src/planemapper/renderer/icons.py`
|
||||
|
||||
- [x] Task 6: Bundle `airports.csv` data file (AC: #3)
|
||||
- [x] 6.1 Download `airports.csv` from OurAirports (https://ourairports.com/data/airports.csv) and place at `src/planemapper/data/airports.csv`
|
||||
- [x] 6.2 Confirm `pyproject.toml` package-data entry covers `data/airports.csv`
|
||||
- [x] 6.3 Smoke-test `importlib.resources` access in a scratch script or test to confirm the file is reachable after `pip install -e .`
|
||||
|
||||
- [x] Task 7: Create `systemd/` unit files (AC: #3)
|
||||
- [x] 7.1 `systemd/planemapper-provision.service`: `Type=oneshot`, runs `planemapper-provision`; intended to run at first boot / post-reset; include `[Install]` target
|
||||
- [x] 7.2 `systemd/planemapper-radar.service`: `Restart=always`, `After=planemapper-provision.service`; runs `planemapper-radar`
|
||||
|
||||
- [x] Task 8: Create `tests/` structure (AC: #2)
|
||||
- [x] 8.1 `tests/conftest.py` — empty or with a minimal shared fixture comment
|
||||
- [x] 8.2 `tests/fixtures/aircraft_sample.json` — minimal valid JSON stub (empty list acceptable)
|
||||
- [x] 8.3 `tests/fixtures/airspace_sample.geojson` — minimal valid GeoJSON stub
|
||||
- [x] 8.4 Top-level test stubs: `test_fetcher.py`, `test_models.py`, `test_projection.py`, `test_colours.py`, `test_icons.py`, `test_renderer.py`, `test_pipeline.py`, `test_gpio_ctrl.py` — each contains at least one `pass`-body test function so pytest can discover them
|
||||
- [x] 8.5 `tests/provisioning/__init__.py` (empty) plus `test_location.py`, `test_tiles.py`, `test_config.py`, `test_provision_loop.py` with stub test functions
|
||||
|
||||
- [x] Task 9: Verify quality gates pass (AC: #1, #2, #3)
|
||||
- [x] 9.1 Run `pip install -e .` and confirm both entry-point commands exist on PATH
|
||||
- [x] 9.2 Run `planemapper-radar` and `planemapper-provision`; confirm each logs "not implemented" and exits 0
|
||||
- [x] 9.3 Run `pytest` and confirm zero failures
|
||||
- [x] 9.4 Run `ruff check .` and confirm zero violations
|
||||
- [x] 9.5 Run `ruff format --check .` and confirm zero formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Critical Context
|
||||
|
||||
**Architecture constraints:**
|
||||
- Package layout uses `src/` layout: `src/planemapper/` — ensure `pyproject.toml` specifies `package-dir = {"" = "src"}` so `pip install -e .` finds the package.
|
||||
- The `main.py` entry point **must not** import anything from `planemapper.provisioning.*`. This boundary is enforced by ruff; any violation will cause `ruff check .` to fail.
|
||||
- `airports.csv` must be bundled as package data and accessed via `importlib.resources`, not via a raw file path relative to the source tree, so it works correctly after installation.
|
||||
- All stub `main()` functions must log "not implemented" (using Python `logging`, not `print`) and return (exit code 0). Do not raise `NotImplementedError`.
|
||||
|
||||
**Pinned dependency versions (do not deviate):**
|
||||
- Runtime: `Pillow==12.2.0`, `gpiozero==2.0.1`, `Flask==3.1.3`, `requests==2.33.1`
|
||||
- Dev: `pytest==9.0.3`, `ruff==0.15.11`, `gpiozero[mock]`
|
||||
- Python: `>=3.11`
|
||||
|
||||
**Systemd unit design:**
|
||||
- `planemapper-provision.service`: `Type=oneshot` — runs once and exits; guards `planemapper-radar.service` startup
|
||||
- `planemapper-radar.service`: `Restart=always`, `After=planemapper-provision.service` — long-running radar loop
|
||||
|
||||
**ruff configuration:**
|
||||
- `line-length = 100`
|
||||
- `target-version = "py311"`
|
||||
- Import boundary rule: `planemapper.main` may not import from `planemapper.provisioning.*` (use `ruff`'s `flake8-tidy-imports` or equivalent `banned-module-level-imports` setting)
|
||||
|
||||
**Testing stance for this story:**
|
||||
- Tests are stubs only — each file must have at least one discoverable test function (even if it just calls `pass`) so pytest exits 0 with no collection errors.
|
||||
- No actual logic needs to be tested in Story 1.1; that begins in Story 1.2 onwards.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Story 1.2: Configuration Read/Write/Wipe
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a provisioning system,
|
||||
I want a single config module that reads, writes, and wipes `/etc/planemapper/config.json`,
|
||||
So that all components share one reliable config boundary with no direct filesystem access elsewhere.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** no config file exists at `CONFIG_PATH` **When** `config.read()` is called **Then** it raises `FileNotFoundError`
|
||||
|
||||
2. **Given** a valid config dict with home lat/lon, coverage radius, WiFi SSID/password, and `provisioned` flag **When** `config.write(data)` is called **Then** the file is created at `CONFIG_PATH` with correct JSON content and all expected keys present
|
||||
|
||||
3. **Given** an existing config file **When** `config.wipe()` is called **Then** the config file is deleted and a subsequent `config.read()` raises `FileNotFoundError`
|
||||
|
||||
4. **Given** a test using `conftest.py` **When** `CONFIG_PATH` is patched to `tmp_path` **Then** all config operations work without touching `/etc/planemapper/`
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `config.read()` in `src/planemapper/provisioning/config.py` (AC: #1, #4)
|
||||
- [x] 1.1 Import `CONFIG_PATH` from `planemapper.constants`
|
||||
- [x] 1.2 Implement `read() -> dict` that opens `CONFIG_PATH` and parses JSON
|
||||
- [x] 1.3 Let `FileNotFoundError` propagate naturally if the file does not exist — no bare `except`
|
||||
|
||||
- [x] Task 2: Implement `config.write(data)` in `src/planemapper/provisioning/config.py` (AC: #2, #4)
|
||||
- [x] 2.1 Implement `write(data: dict) -> None`
|
||||
- [x] 2.2 Call `CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)` before writing to ensure `/etc/planemapper/` exists
|
||||
- [x] 2.3 Write JSON to `CONFIG_PATH` with `indent=2`
|
||||
|
||||
- [x] Task 3: Implement `config.wipe()` in `src/planemapper/provisioning/config.py` (AC: #3, #4)
|
||||
- [x] 3.1 Implement `wipe() -> None` that deletes `CONFIG_PATH` if it exists
|
||||
- [x] 3.2 Use `CONFIG_PATH.unlink(missing_ok=True)` so wipe is idempotent
|
||||
|
||||
- [x] Task 4: Update `tests/conftest.py` with `CONFIG_PATH` patch fixture (AC: #4)
|
||||
- [x] 4.1 Add a `monkeypatch` fixture (or autouse session fixture) that patches `planemapper.provisioning.config.CONFIG_PATH` to `tmp_path / "config.json"` for every test
|
||||
- [x] 4.2 Confirm the fixture is applied so no test ever touches `/etc/planemapper/`
|
||||
|
||||
- [x] Task 5: Write tests in `tests/provisioning/test_config.py` covering all 4 ACs (AC: #1, #2, #3, #4)
|
||||
- [x] 5.1 Test AC1: call `config.read()` with no file present; assert `FileNotFoundError` is raised
|
||||
- [x] 5.2 Test AC2: call `config.write(data)` with a valid dict; assert file exists and JSON round-trips correctly with all expected keys (`home_lat`, `home_lon`, `coverage_radius_nm`, `wifi_ssid`, `wifi_password`, `provisioned`)
|
||||
- [x] 5.3 Test AC3: write a config, call `config.wipe()`, assert file is gone, then assert `config.read()` raises `FileNotFoundError`
|
||||
- [x] 5.4 Test AC4: confirm `CONFIG_PATH` resolves inside `tmp_path` (not `/etc/planemapper/`) during test execution
|
||||
|
||||
- [x] Task 6: Run quality gates
|
||||
- [x] 6.1 `pytest tests/` — all tests pass, 0 failures
|
||||
- [x] 6.2 `ruff check .` — zero violations
|
||||
- [x] 6.3 `ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### CONFIG_PATH patching
|
||||
`CONFIG_PATH` is a module-level `Path` constant defined in `planemapper.constants` and imported into `planemapper.provisioning.config`. Patch the name **in the config module's namespace** — `monkeypatch.setattr("planemapper.provisioning.config.CONFIG_PATH", tmp_path / "config.json")` — not in `planemapper.constants`, so that all three functions pick up the patched value.
|
||||
|
||||
### Directory creation in `config.write()`
|
||||
The directory `/etc/planemapper/` will not exist in CI or on a fresh OS install. `config.write()` must call `CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)` before opening the file for writing. In tests, `tmp_path` already exists, so this call is a safe no-op.
|
||||
|
||||
### JSON formatting
|
||||
Write config files with `json.dump(data, f, indent=2)` for human readability. No extra dependencies — use the Python stdlib `json` module only.
|
||||
|
||||
### Error handling policy
|
||||
Do not catch any exceptions inside `read()`, `write()`, or `wipe()`. Let `FileNotFoundError`, `PermissionError`, `json.JSONDecodeError`, etc. propagate to callers. Provisioning code higher up the stack is responsible for user-facing error messages.
|
||||
|
||||
### Config schema
|
||||
The canonical key set written by `config.write()` and expected by all consumers:
|
||||
|
||||
```
|
||||
home_lat float — decimal degrees, WGS-84
|
||||
home_lon float — decimal degrees, WGS-84
|
||||
coverage_radius_nm int — display radius in nautical miles
|
||||
wifi_ssid str — WiFi network name
|
||||
wifi_password str — WiFi passphrase
|
||||
provisioned bool — True once provisioning has completed
|
||||
```
|
||||
|
||||
### File location constants
|
||||
`CONFIG_PATH = Path("/etc/planemapper/config.json")` is defined in `src/planemapper/constants.py`. Do not hard-code the path string anywhere in `config.py` or test files; always reference the module-level `CONFIG_PATH` symbol so the monkeypatch can redirect it cleanly.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Story 1.3: WiFi Hotspot & Captive Portal Form
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user setting up the device for the first time,
|
||||
I want to connect my phone to the `planeMapper-setup` hotspot and be automatically redirected to a setup page where I can enter my location, coverage radius, and home WiFi credentials,
|
||||
So that I can configure the device without a keyboard or monitor.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** the device boots with no config file present **When** `planemapper-provision` starts **Then** `hostapd` and `dnsmasq` are started and the `planeMapper-setup` SSID is broadcast **And** any DNS query from a connected client resolves to the Pi's IP (triggering captive portal detection on phones)
|
||||
|
||||
2. **Given** a phone connected to `planeMapper-setup` **When** the phone attempts to load any URL **Then** the Flask portal page is served (captive portal detection triggers automatically)
|
||||
|
||||
3. **Given** the portal page is displayed **When** the user views the form **Then** the form contains: location field (ICAO code or address/postcode), coverage radius field (default 100nm), WiFi SSID field, WiFi password field, and a "Find location" button separate from the final submit
|
||||
|
||||
4. **Given** `wifi.start_ap()` fails (e.g. hostapd not installed or subprocess returns non-zero) **When** the failure occurs **Then** a `ProvisioningError` is raised, an ERROR is logged, and the provisioning loop resets to portal state
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `wifi.start_ap()` in `src/planemapper/provisioning/wifi.py` (AC: #1, #4)
|
||||
- [x] 1.1 Import `ProvisioningError` from `planemapper.provisioning`
|
||||
- [x] 1.2 Write the hostapd config to `/etc/hostapd/hostapd.conf` before calling the subprocess
|
||||
- [x] 1.3 Call `subprocess.run(["hostapd", "/etc/hostapd/hostapd.conf", ...], check=False)` and inspect `result.returncode` explicitly
|
||||
- [x] 1.4 Call `subprocess.run(["dnsmasq", "--no-daemon", ...], check=False)` and inspect `result.returncode` explicitly
|
||||
- [x] 1.5 Log `log.error(...)` before raising `ProvisioningError` on any non-zero return code
|
||||
- [x] 1.6 Annotate the function signature: `def start_ap() -> None`
|
||||
|
||||
- [x] Task 2: Implement `wifi.stop_ap()` in `src/planemapper/provisioning/wifi.py` (AC: #1)
|
||||
- [x] 2.1 Stop `hostapd` and `dnsmasq` processes (e.g. `subprocess.run(["pkill", "-f", "hostapd"], check=False)`)
|
||||
- [x] 2.2 Log at INFO level when AP is stopped
|
||||
- [x] 2.3 Annotate: `def stop_ap() -> None`
|
||||
|
||||
- [x] Task 3: Implement the Flask app in `src/planemapper/provisioning/portal.py` (AC: #2, #3)
|
||||
- [x] 3.1 Create a Flask app instance; import `ProvisioningError` from `planemapper.provisioning`
|
||||
- [x] 3.2 Implement `GET /` — serve the setup form HTML inline (no templates dir needed for MVP): location field, coverage radius field with default `100`, WiFi SSID field, WiFi password field, a "Find location" button (separate action), and a "Set up device" submit button
|
||||
- [x] 3.3 Implement `GET /generate_204` → redirect to `/` (Android captive portal probe)
|
||||
- [x] 3.4 Implement `GET /hotspot-detect.html` → redirect to `/` (iOS captive portal probe)
|
||||
- [x] 3.5 Implement `GET /ncsi.txt` → redirect to `/` (Windows captive portal probe)
|
||||
- [x] 3.6 Add `@app.errorhandler(404)` wildcard redirect → `/` so any unrecognised URL served by Flask goes to the portal form
|
||||
- [x] 3.7 Annotate all route functions with return type `str | Response`
|
||||
|
||||
- [x] Task 4: Update `provision.py` `main()` to call `wifi.start_ap()` inside the provisioning loop (AC: #4)
|
||||
- [x] 4.1 Import `wifi` from `planemapper.provisioning`
|
||||
- [x] 4.2 Call `wifi.start_ap()` at the start of the provisioning sequence inside the existing `while not provisioned` loop
|
||||
- [x] 4.3 Ensure the existing `except ProvisioningError` handler catches failures from `wifi.start_ap()`, logs the error, and resets to portal state via `reset_to_portal_state()` (or equivalent)
|
||||
|
||||
- [x] Task 5: Write tests (AC: #1, #2, #3, #4)
|
||||
- [x] 5.1 In `tests/provisioning/test_provision_loop.py` — add a test that patches `subprocess.run` to return a non-zero exit code, calls `wifi.start_ap()`, and asserts `ProvisioningError` is raised
|
||||
- [x] 5.2 In `tests/provisioning/test_provision_loop.py` — add a test that a `ProvisioningError` raised during the provisioning loop is caught, logged at ERROR, and the loop continues (does not crash)
|
||||
- [x] 5.3 Create `tests/provisioning/test_portal.py` — test `GET /` returns 200 with a form containing location, radius, SSID, password fields and a "Find location" button using Flask test client (`app.test_client()`)
|
||||
- [x] 5.4 In `tests/provisioning/test_portal.py` — test `GET /generate_204`, `GET /hotspot-detect.html`, `GET /ncsi.txt` each return a redirect (3xx) to `/`
|
||||
- [x] 5.5 In `tests/provisioning/test_portal.py` — test that a request to an unknown route (e.g. `GET /unknown-path`) returns a redirect to `/`
|
||||
|
||||
- [x] Task 6: Run quality gates
|
||||
- [x] 6.1 `pytest tests/` — all tests pass, 0 failures
|
||||
- [x] 6.2 `ruff check .` — zero violations
|
||||
- [x] 6.3 `ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### `wifi.start_ap()` subprocess pattern
|
||||
Always use `check=False` and inspect `result.returncode` explicitly — never use `check=True`. This gives a controlled error message and lets us log before raising:
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
import logging
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def start_ap() -> None:
|
||||
_write_hostapd_conf()
|
||||
result = subprocess.run(["hostapd", "/etc/hostapd/hostapd.conf"], check=False)
|
||||
if result.returncode != 0:
|
||||
log.error("hostapd failed with return code %d", result.returncode)
|
||||
raise ProvisioningError(f"hostapd failed: returncode={result.returncode}")
|
||||
result = subprocess.run(
|
||||
["dnsmasq", "--no-daemon", "--address=/#/192.168.4.1"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("dnsmasq failed with return code %d", result.returncode)
|
||||
raise ProvisioningError(f"dnsmasq failed: returncode={result.returncode}")
|
||||
```
|
||||
|
||||
### hostapd config file
|
||||
Write `/etc/hostapd/hostapd.conf` before calling `hostapd`. Minimal config for `planeMapper-setup`:
|
||||
|
||||
```
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
ssid=planeMapper-setup
|
||||
hw_mode=g
|
||||
channel=6
|
||||
wmm_enabled=0
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
```
|
||||
|
||||
### dnsmasq DNS redirect
|
||||
Start dnsmasq with `--address=/#/192.168.4.1` to resolve every DNS query to the Pi's AP IP. This is the mechanism that triggers captive portal detection on connecting phones.
|
||||
|
||||
### Flask wildcard redirect
|
||||
Use `@app.errorhandler(404)` to catch any route Flask doesn't recognise and redirect to `/`. Also register explicit routes for known captive portal probe paths:
|
||||
|
||||
```python
|
||||
from flask import Flask, redirect, url_for
|
||||
from typing import Union
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def index() -> str:
|
||||
return FORM_HTML # inline HTML string
|
||||
|
||||
@app.route("/generate_204")
|
||||
@app.route("/hotspot-detect.html")
|
||||
@app.route("/ncsi.txt")
|
||||
def captive_redirect() -> Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.errorhandler(404)
|
||||
def catch_all(e: Exception) -> Response:
|
||||
return redirect(url_for("index"))
|
||||
```
|
||||
|
||||
### Portal form HTML
|
||||
Keep the setup form as an inline string in `portal.py` (no `templates/` directory needed for MVP). The form must include:
|
||||
- A text input named `location` (ICAO code or address/postcode)
|
||||
- A "Find location" button (type `submit`, form action `/find-location` or JS — exact routing handled in Story 1.4)
|
||||
- A number input named `radius` with value `100`
|
||||
- A text input named `wifi_ssid`
|
||||
- A password input named `wifi_password`
|
||||
- A "Set up device" submit button (form action `POST /submit`)
|
||||
|
||||
The "Find location" button and the "Set up device" submit are separate actions — Story 1.4 wires the location resolution; this story only needs the form fields and buttons to be present and correctly named.
|
||||
|
||||
### Tests: mocking subprocess
|
||||
Use `unittest.mock.patch` to mock `subprocess.run`:
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
from planemapper.provisioning import wifi
|
||||
|
||||
def test_start_ap_raises_on_hostapd_failure():
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(ProvisioningError):
|
||||
wifi.start_ap()
|
||||
```
|
||||
|
||||
### Tests: Flask test client
|
||||
```python
|
||||
import pytest
|
||||
from planemapper.provisioning.portal import app
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app.config["TESTING"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
def test_index_returns_form(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
data = resp.data.decode()
|
||||
assert "location" in data
|
||||
assert "radius" in data
|
||||
assert "wifi_ssid" in data
|
||||
assert "wifi_password" in data
|
||||
assert "Find location" in data
|
||||
```
|
||||
|
||||
### Separation of concerns
|
||||
- `wifi.py`: all subprocess calls (`hostapd`, `dnsmasq`, `rfkill`) — no Flask imports
|
||||
- `portal.py`: Flask app and routes only — no subprocess calls; imports `ProvisioningError` from `planemapper.provisioning`
|
||||
- Never call `subprocess.run` from within `portal.py`
|
||||
@@ -0,0 +1,141 @@
|
||||
# Story 1.4: Location Resolution (ICAO & Address)
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user setting up the device,
|
||||
I want to type my home airfield ICAO code or my home address/postcode and have the device resolve it to coordinates and show the result for confirmation,
|
||||
So that I can verify the device is centred on the correct location before committing.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** the user enters a valid ICAO code (e.g. `EGLL`) **When** "Find location" is pressed **Then** the bundled `airports.csv` is queried via `importlib.resources` and the matching lat/lon is returned **And** the resolved location name and coordinates are displayed on the portal for confirmation
|
||||
|
||||
2. **Given** the user enters an address or postcode (e.g. `OX1 1AA`) **When** "Find location" is pressed **Then** the Nominatim API is called once with the input and the resolved lat/lon is displayed for confirmation
|
||||
|
||||
3. **Given** the user enters an ICAO code not present in `airports.csv` **When** "Find location" is pressed **Then** the portal displays: "ICAO code not found — try an address instead"
|
||||
|
||||
4. **Given** Nominatim returns no results **When** "Find location" is pressed **Then** the portal displays: "Location not found — try a different search term"
|
||||
|
||||
5. **Given** tests run in CI **When** location tests execute **Then** Nominatim calls are mocked — no real network calls required in the test suite
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `location.resolve(query)` in `src/planemapper/provisioning/location.py` (AC: #1, #2, #3, #4)
|
||||
- [x] 1.1 Normalise the input: `query = query.strip().upper()`
|
||||
- [x] 1.2 Detect ICAO heuristic: `len(query) == 4 and query.isalpha()` — if true, attempt ICAO lookup first
|
||||
- [x] 1.3 ICAO lookup: open `airports.csv` via `importlib.resources.files("planemapper.data").joinpath("airports.csv").open("r", encoding="utf-8")`; parse with `csv.DictReader`; search for row where `row["ident"] == query`; return `(float(row["latitude_deg"]), float(row["longitude_deg"]), row["name"])`
|
||||
- [x] 1.4 If ICAO lookup finds no match, raise `ValueError("ICAO code not found — try an address instead")`
|
||||
- [x] 1.5 Non-ICAO path: call `requests.get("https://nominatim.openstreetmap.org/search", params={"q": query, "format": "json", "limit": 1}, headers={"User-Agent": "planemapper/0.1 (https://github.com/football2801/planeMapper)"}, timeout=10)`
|
||||
- [x] 1.6 Parse Nominatim response: if `results` list is non-empty, return `(float(results[0]["lat"]), float(results[0]["lon"]), results[0]["display_name"])`
|
||||
- [x] 1.7 If Nominatim returns an empty list, raise `ValueError("Location not found — try a different search term")`
|
||||
- [x] 1.8 Annotate the function signature: `def resolve(query: str) -> tuple[float, float, str]`
|
||||
|
||||
- [x] Task 2: Add `POST /find-location` route to `src/planemapper/provisioning/portal.py` (AC: #1, #2, #3, #4)
|
||||
- [x] 2.1 Import `location` from `planemapper.provisioning` at the top of `portal.py`
|
||||
- [x] 2.2 Implement `POST /find-location` — read `request.form["location"]` field
|
||||
- [x] 2.3 Call `location.resolve(query)` inside a `try/except ValueError`
|
||||
- [x] 2.4 On success: return updated form HTML showing the resolved name and coordinates (e.g. a confirmation section with `lat`, `lon`, `name` values visible) and hidden fields pre-populated with `lat`/`lon` for subsequent form submit
|
||||
- [x] 2.5 On `ValueError`: return updated form HTML with the error message displayed inline (no 4xx status — keep the form usable)
|
||||
- [x] 2.6 Annotate the route function with return type `str | Response`
|
||||
|
||||
- [x] Task 3: Write tests in `tests/provisioning/test_location.py` (AC: #1, #2, #3, #4, #5)
|
||||
- [x] 3.1 Test ICAO lookup hit: call `resolve("EGLL")` against the real `airports.csv`; assert returned `(lat, lon, name)` is a `tuple[float, float, str]` with plausible UK coordinates
|
||||
- [x] 3.2 Test ICAO lookup miss: call `resolve("ZZZZ")`; assert `ValueError` is raised with message `"ICAO code not found — try an address instead"`
|
||||
- [x] 3.3 Test Nominatim success: patch `planemapper.provisioning.location.requests.get` with `unittest.mock.patch`; mock return value `.json()` returns `[{"lat": "51.5", "lon": "-0.1", "display_name": "London"}]`; call `resolve("OX1 1AA")`; assert `(51.5, -0.1, "London")` returned
|
||||
- [x] 3.4 Test Nominatim empty response: patch `requests.get` as above but `.json()` returns `[]`; call `resolve("nonsense query")`; assert `ValueError` is raised with message `"Location not found — try a different search term"`
|
||||
- [x] 3.5 Assert the mock was called exactly once with the expected URL and `User-Agent` header (confirms no real HTTP in CI)
|
||||
|
||||
- [x] Task 4: Update portal tests in `tests/provisioning/test_portal.py` (AC: #1, #2, #3, #4)
|
||||
- [x] 4.1 Add test for `POST /find-location` with a successful resolve (mock `location.resolve` to return `(51.5, -0.1, "London")`); assert 200 and that the response body contains the resolved name and coordinates
|
||||
- [x] 4.2 Add test for `POST /find-location` with a `ValueError` from `location.resolve`; assert 200 and that the response body contains the error message text
|
||||
|
||||
- [x] Task 5: Run quality gates
|
||||
- [x] 5.1 `pytest tests/` — all tests pass, 0 failures
|
||||
- [x] 5.2 `ruff check .` — zero violations
|
||||
- [x] 5.3 `ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### CSV parsing via `importlib.resources`
|
||||
|
||||
Use `csv.DictReader` and access the bundled file through `importlib.resources`:
|
||||
|
||||
```python
|
||||
import csv
|
||||
import importlib.resources
|
||||
|
||||
def _lookup_icao(code: str) -> tuple[float, float, str] | None:
|
||||
traversable = importlib.resources.files("planemapper.data").joinpath("airports.csv")
|
||||
with traversable.open("r", encoding="utf-8") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
for row in reader:
|
||||
if row["ident"] == code:
|
||||
return float(row["latitude_deg"]), float(row["longitude_deg"]), row["name"]
|
||||
return None
|
||||
```
|
||||
|
||||
OurAirports CSV columns used: `ident` (4-letter ICAO code), `name`, `latitude_deg`, `longitude_deg`.
|
||||
|
||||
### ICAO detection heuristic
|
||||
|
||||
```python
|
||||
query = query.strip().upper()
|
||||
if len(query) == 4 and query.isalpha():
|
||||
# try ICAO first
|
||||
```
|
||||
|
||||
This is not a perfect ICAO validator but is sufficient for MVP. A 4-letter all-alpha string is almost certainly an ICAO code in this context.
|
||||
|
||||
### Nominatim call
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
USER_AGENT = "planemapper/0.1 (https://github.com/football2801/planeMapper)"
|
||||
|
||||
def _geocode(query: str) -> tuple[float, float, str] | None:
|
||||
resp = requests.get(
|
||||
NOMINATIM_URL,
|
||||
params={"q": query, "format": "json", "limit": 1},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
results = resp.json()
|
||||
if not results:
|
||||
return None
|
||||
r = results[0]
|
||||
return float(r["lat"]), float(r["lon"]), r["display_name"]
|
||||
```
|
||||
|
||||
The `User-Agent` header is required by Nominatim's usage policy.
|
||||
|
||||
### Return type and coordinate convention
|
||||
|
||||
`resolve(query: str) -> tuple[float, float, str]` — always `(lat, lon, name)`, never `(lon, lat)`. This convention is used throughout the codebase.
|
||||
|
||||
### Test mock path
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@patch("planemapper.provisioning.location.requests.get")
|
||||
def test_nominatim_success(mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = [{"lat": "51.5", "lon": "-0.1", "display_name": "London"}]
|
||||
mock_get.return_value = mock_resp
|
||||
lat, lon, name = resolve("OX1 1AA")
|
||||
assert lat == 51.5
|
||||
assert lon == -0.1
|
||||
assert name == "London"
|
||||
mock_get.assert_called_once()
|
||||
```
|
||||
|
||||
Patching at `planemapper.provisioning.location.requests.get` (the module where `requests` is imported, not the `requests` package directly) ensures no real HTTP calls reach Nominatim in CI.
|
||||
|
||||
### `airports.csv` file path
|
||||
|
||||
The CSV must be declared as package data in `pyproject.toml` (or `setup.cfg`) so that `importlib.resources` can find it at runtime and during tests. Confirm `[tool.setuptools.package-data]` includes `"planemapper.data" = ["*.csv"]` (this should already be in place from Story 1.1 scaffold).
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
# Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user who has confirmed their location and entered WiFi credentials,
|
||||
I want the device to automatically join my home WiFi, download all map tiles and airspace data, validate the cache, confirm success on screen, and kill the WiFi radio without further interaction,
|
||||
So that the device is fully provisioned and permanently offline from that point.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** the user submits the portal form with valid location, radius, and WiFi credentials **When** the form is submitted **Then** the portal updates to show: "Downloading map data — this may take a few minutes. Do not power off." **And** the device joins the user's home WiFi network
|
||||
|
||||
2. **Given** the device has joined home WiFi **When** tile download runs **Then** all OSM tiles for the configured area and zoom level are downloaded and composited into `background.png` (800×480) saved at `/etc/planemapper/background.png` **And** OpenAIP airspace GeoJSON is downloaded and saved to `/etc/planemapper/airspace.geojson`
|
||||
|
||||
3. **Given** tile download is complete **When** cache validation runs **Then** `background.png` is confirmed non-zero size and readable as a valid PNG **And** total tile data is confirmed within 2GB (NFR8, NFR9) **And** if validation fails, the device remains in provisioning state and the portal displays a retry prompt
|
||||
|
||||
4. **Given** cache validation passes **When** provisioning completes **Then** `config.write()` saves home lat/lon, coverage radius, WiFi credentials, and `provisioned: true` **And** `rfkill block wifi` is called and returns exit code 0 **And** the portal displays: "Setup complete. The device will now start displaying radar." **And** if `rfkill` fails, a `ProvisioningError` is raised and the provisioning loop resets
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `wifi.join_home_wifi(ssid, password)` in `src/planemapper/provisioning/wifi.py` (AC: #1)
|
||||
- [x] 1.1 Add `def join_home_wifi(ssid: str, password: str) -> None:` to `wifi.py`
|
||||
- [x] 1.2 Run `subprocess.run(["nmcli", "device", "wifi", "connect", ssid, "password", password], capture_output=True)` with a reasonable timeout (30s)
|
||||
- [x] 1.3 Check `result.returncode`; if non-zero raise `ProvisioningError(f"nmcli failed (rc={result.returncode}): {result.stderr.decode()}")`
|
||||
- [x] 1.4 Log `INFO` on success: `log.info("joined home WiFi: %s", ssid)`
|
||||
- [x] 1.5 Annotate all parameters and return type
|
||||
|
||||
- [x] Task 2: Implement `tiles.download_and_composite(lat, lon, radius_nm)` in `src/planemapper/provisioning/tiles.py` (AC: #2, #3)
|
||||
- [x] 2.1 Add helper `def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:` using the standard Web Mercator OSM formula:
|
||||
- `x = int((lon + 180.0) / 360.0 * (1 << zoom))`
|
||||
- `lat_r = math.radians(lat)`
|
||||
- `y = int((1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) / 2.0 * (1 << zoom))`
|
||||
- [x] 2.2 Add helper `def _zoom_for_radius(radius_nm: float) -> int:` — returns 8 for >200nm, 9 for 100–200nm, 10 for 50–100nm, 11 for <50nm
|
||||
- [x] 2.3 Add `def download_and_composite(lat: float, lon: float, radius_nm: float) -> None:`
|
||||
- [x] 2.4 Calculate zoom from `_zoom_for_radius(radius_nm)`
|
||||
- [x] 2.5 Compute tile bounds: convert the four corners of the bounding box (lat ± radius_deg, lon ± radius_deg, where `radius_deg = radius_nm / 60.0`) to tile coordinates; derive `x_min, x_max, y_min, y_max`
|
||||
- [x] 2.6 Create a `PIL.Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), COLOUR_WHITE)` canvas
|
||||
- [x] 2.7 For each `(tx, ty)` in the tile grid, call `requests.get(f"https://tile.openstreetmap.org/{zoom}/{tx}/{ty}.png", headers={"User-Agent": "planemapper/0.1 (https://github.com/football2801/planeMapper)"}, timeout=10)` and open the response content as a PIL Image; paste at the calculated pixel offset onto the canvas
|
||||
- [x] 2.8 Save the composited image to `BACKGROUND_PATH` using `canvas.save(BACKGROUND_PATH)` — ensure parent directory exists first (`BACKGROUND_PATH.parent.mkdir(parents=True, exist_ok=True)`)
|
||||
- [x] 2.9 Log `INFO` with tile count and path on success
|
||||
- [x] 2.10 Import `BACKGROUND_PATH`, `DISPLAY_WIDTH`, `DISPLAY_HEIGHT`, `COLOUR_WHITE` from `planemapper.constants`
|
||||
|
||||
- [x] Task 3: Implement `airspace.download(lat, lon, radius_nm)` in `src/planemapper/provisioning/airspace.py` (AC: #2)
|
||||
- [x] 3.1 Add `def download(lat: float, lon: float, radius_nm: float) -> None:`
|
||||
- [x] 3.2 Check for `OPENAIP_API_KEY = os.environ.get("OPENAIP_API_KEY")`
|
||||
- [x] 3.3 If no API key: write empty GeoJSON `{"type": "FeatureCollection", "features": []}` to `AIRSPACE_PATH` and log `WARNING("OPENAIP_API_KEY not set — writing empty airspace cache; airspace outlines will not be shown")`; return early
|
||||
- [x] 3.4 If API key present: compute bounding box from lat/lon/radius_nm (`radius_deg = radius_nm / 60.0`; `bbox = [lon - radius_deg, lat - radius_deg, lon + radius_deg, lat + radius_deg]`)
|
||||
- [x] 3.5 Call OpenAIP API: `requests.get("https://api.openaip.net/api/airspaces", params={"bbox": ",".join(map(str, bbox))}, headers={"x-openaip-api-key": OPENAIP_API_KEY}, timeout=30)`
|
||||
- [x] 3.6 Raise `ProvisioningError` on non-2xx response
|
||||
- [x] 3.7 Write response JSON to `AIRSPACE_PATH` (`AIRSPACE_PATH.parent.mkdir(parents=True, exist_ok=True)`)
|
||||
- [x] 3.8 Log `INFO` on success with path
|
||||
- [x] 3.9 Import `AIRSPACE_PATH` from `planemapper.constants`
|
||||
|
||||
- [x] Task 4: Implement cache validation in `src/planemapper/provisioning/tiles.py` (AC: #3)
|
||||
- [x] 4.1 Add `def validate_cache() -> None:` (raises `ProvisioningError` on any failure; no return value on success)
|
||||
- [x] 4.2 Check `BACKGROUND_PATH.exists()` — raise `ProvisioningError("background.png not found")` if missing
|
||||
- [x] 4.3 Check `BACKGROUND_PATH.stat().st_size > 0` — raise `ProvisioningError("background.png is empty")` if zero-byte
|
||||
- [x] 4.4 Attempt `Image.open(BACKGROUND_PATH).verify()` — raise `ProvisioningError(f"background.png is not a valid PNG: {e}")` if it raises
|
||||
- [x] 4.5 Check `BACKGROUND_PATH.stat().st_size < 2 * 1024 ** 3` (2GB) — raise `ProvisioningError("background.png exceeds 2GB limit")` if over limit
|
||||
- [x] 4.6 Log `INFO("cache validation passed: background.png %.1f KB", size_kb)` on success
|
||||
|
||||
- [x] Task 5: Add `POST /submit` route to `src/planemapper/provisioning/portal.py` (AC: #1, #2, #3, #4)
|
||||
- [x] 5.1 Import `wifi`, `tiles`, `airspace`, `config` from `planemapper.provisioning` at top of `portal.py`
|
||||
- [x] 5.2 Add `POST /submit` route function with signature `def submit() -> str:`
|
||||
- [x] 5.3 Read form fields: `confirmed_lat = float(request.form["confirmed_lat"])`, `confirmed_lon = float(request.form["confirmed_lon"])`, `confirmed_name = request.form["confirmed_name"]`, `radius = float(request.form["radius"])`, `wifi_ssid = request.form["wifi_ssid"]`, `wifi_password = request.form["wifi_password"]`
|
||||
- [x] 5.4 Immediately return a "Downloading…" status page (HTML string) — this is the synchronous MVP approach; the browser waits while provisioning runs
|
||||
- [x] 5.5 After returning the status page response (or within a single synchronous handler): call `wifi.join_home_wifi(wifi_ssid, wifi_password)`
|
||||
- [x] 5.6 Call `tiles.download_and_composite(confirmed_lat, confirmed_lon, radius)`
|
||||
- [x] 5.7 Call `airspace.download(confirmed_lat, confirmed_lon, radius)`
|
||||
- [x] 5.8 Call `tiles.validate_cache()`; on `ProvisioningError`, return retry HTML: `"<p>Cache validation failed: {e}. <a href='/'>Try again</a></p>"`
|
||||
- [x] 5.9 Call `config.write({"lat": confirmed_lat, "lon": confirmed_lon, "radius_nm": radius, "wifi_ssid": wifi_ssid, "wifi_password": wifi_password, "provisioned": True})`
|
||||
- [x] 5.10 Call `wifi.kill_wifi()`; on `ProvisioningError`, re-raise so the provisioning loop resets
|
||||
- [x] 5.11 On full success, return: `"<p>Setup complete. The device will now start displaying radar.</p>"`
|
||||
- [x] 5.12 Wrap steps 5.5–5.11 in `try/except ProvisioningError` where appropriate (validation fails → retry HTML; rfkill failure → re-raise)
|
||||
|
||||
- [x] Task 6: Wire `provision.py` `main()` to run the full provisioning sequence (AC: #1, #2, #3, #4)
|
||||
- [x] 6.1 Remove the placeholder `provisioned = True` line from `provision.py`
|
||||
- [x] 6.2 Ensure the provisioning loop calls `portal.run()` (the Flask blocking call) which now includes the `POST /submit` route
|
||||
- [x] 6.3 Confirm the `ProvisioningError` from `wifi.kill_wifi()` propagates up to the `except ProvisioningError` in the main loop, which calls `reset_to_portal_state()`
|
||||
- [x] 6.4 Confirm the loop sets `provisioned = True` only after `portal.run()` returns without raising
|
||||
|
||||
- [x] Task 7: Write tests (AC: #1, #2, #3, #4)
|
||||
- [x] 7.1 `tests/provisioning/test_wifi.py` — `test_join_home_wifi_success`: mock `subprocess.run` to return `CompletedProcess(returncode=0)`; assert no exception raised
|
||||
- [x] 7.2 `tests/provisioning/test_wifi.py` — `test_join_home_wifi_failure`: mock `subprocess.run` to return `CompletedProcess(returncode=1, stderr=b"error")`; assert `ProvisioningError` raised
|
||||
- [x] 7.3 `tests/provisioning/test_tiles.py` — `test_download_and_composite`: mock `requests.get` to return a fake 256×256 PNG bytes response; use `tmp_path` to patch `BACKGROUND_PATH`; assert `background.png` is created and is a valid PNG
|
||||
- [x] 7.4 `tests/provisioning/test_tiles.py` — `test_lat_lon_to_tile_known_values`: assert known tile coordinates for a documented lat/lon/zoom triple (e.g. lat=51.5, lon=-0.1, zoom=10 → well-known London tile)
|
||||
- [x] 7.5 `tests/provisioning/test_tiles.py` — `test_validate_cache_passes`: write a real 1×1 PNG to `tmp_path/background.png`; patch `BACKGROUND_PATH`; assert no exception
|
||||
- [x] 7.6 `tests/provisioning/test_tiles.py` — `test_validate_cache_missing`: patch `BACKGROUND_PATH` to a non-existent path; assert `ProvisioningError`
|
||||
- [x] 7.7 `tests/provisioning/test_tiles.py` — `test_validate_cache_empty`: write a zero-byte file; assert `ProvisioningError`
|
||||
- [x] 7.8 `tests/provisioning/test_tiles.py` — `test_validate_cache_corrupt`: write non-PNG bytes; assert `ProvisioningError`
|
||||
- [x] 7.9 `tests/provisioning/test_airspace.py` — `test_download_no_api_key`: unset `OPENAIP_API_KEY`; patch `AIRSPACE_PATH` to `tmp_path`; call `download(51.5, -0.1, 100)`; assert empty GeoJSON written
|
||||
- [x] 7.10 `tests/provisioning/test_airspace.py` — `test_download_with_api_key`: set `OPENAIP_API_KEY="test"`; mock `requests.get` to return a minimal GeoJSON response; patch `AIRSPACE_PATH` to `tmp_path`; assert file written with response content
|
||||
- [x] 7.11 `tests/provisioning/test_portal.py` — `test_submit_success`: mock `wifi.join_home_wifi`, `tiles.download_and_composite`, `airspace.download`, `tiles.validate_cache`, `config.write`, `wifi.kill_wifi`; POST to `/submit` with valid form data; assert 200 and response contains "Setup complete"
|
||||
- [x] 7.12 `tests/provisioning/test_portal.py` — `test_submit_validation_failure`: mock `tiles.validate_cache` to raise `ProvisioningError("bad png")`; POST to `/submit`; assert 200 and response contains "Try again"
|
||||
- [x] 7.13 All mocks use `unittest.mock.patch`; no real network or filesystem access beyond `tmp_path`
|
||||
|
||||
- [x] Task 8: Run quality gates
|
||||
- [x] 8.1 `pytest tests/` — all tests pass, 0 failures
|
||||
- [x] 8.2 `ruff check .` — zero violations
|
||||
- [x] 8.3 `ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### OSM tile URL pattern and User-Agent
|
||||
|
||||
Tile URL: `https://tile.openstreetmap.org/{z}/{x}/{y}.png`
|
||||
|
||||
OSM requires a `User-Agent` header (same policy as Nominatim). Use the same agent string established in Story 1.4:
|
||||
|
||||
```python
|
||||
TILE_USER_AGENT = "planemapper/0.1 (https://github.com/football2801/planeMapper)"
|
||||
```
|
||||
|
||||
### Tile coordinate math (Web Mercator)
|
||||
|
||||
Standard OSM formula for converting lat/lon to tile (x, y) at a given zoom level:
|
||||
|
||||
```python
|
||||
import math
|
||||
|
||||
def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
||||
x = int((lon + 180.0) / 360.0 * (1 << zoom))
|
||||
lat_r = math.radians(lat)
|
||||
y = int((1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) / 2.0 * (1 << zoom))
|
||||
return x, y
|
||||
```
|
||||
|
||||
Reference: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
|
||||
|
||||
### Zoom level selection
|
||||
|
||||
```python
|
||||
def _zoom_for_radius(radius_nm: float) -> int:
|
||||
if radius_nm > 200:
|
||||
return 8
|
||||
if radius_nm > 100:
|
||||
return 9
|
||||
if radius_nm > 50:
|
||||
return 10
|
||||
return 11
|
||||
```
|
||||
|
||||
Zoom 10 ≈ 100nm coverage; zoom 8 gives wider coverage for large radii.
|
||||
|
||||
### background.png compositing
|
||||
|
||||
The compositing logic must:
|
||||
1. Determine the tile bounding box: convert the four corners of the geographic bounding box to tile coordinates, then derive `x_min, x_max, y_min, y_max`
|
||||
2. Calculate a pixel offset for each tile: `pixel_x = (tx - x_min) * 256`, `pixel_y = (ty - y_min) * 256`
|
||||
3. Create a canvas sized to fit all tiles: `canvas_w = (x_max - x_min + 1) * 256`, `canvas_h = (y_max - y_min + 1) * 256`
|
||||
4. Paste each 256×256 tile at its pixel offset
|
||||
5. Crop or resize the result to `(DISPLAY_WIDTH, DISPLAY_HEIGHT)` = `(800, 480)` — centre the crop on the home location pixel
|
||||
|
||||
The home location pixel is: `home_x = (home_tile_x - x_min) * 256 + home_pixel_offset_x` (and similarly for y), where `home_pixel_offset_x` is the sub-tile offset computed from the fractional tile coordinate. Crop symmetrically around this point; clamp to canvas bounds.
|
||||
|
||||
### `BACKGROUND_PATH` and `AIRSPACE_PATH` from constants
|
||||
|
||||
Both paths must be imported from `planemapper.constants`, never hardcoded:
|
||||
|
||||
```python
|
||||
from planemapper.constants import BACKGROUND_PATH, AIRSPACE_PATH
|
||||
```
|
||||
|
||||
### `POST /submit` synchronous approach
|
||||
|
||||
For MVP, the browser connection stays open while provisioning runs (no background thread). The handler executes: join WiFi → download tiles → download airspace → validate → write config → kill WiFi. This means the browser may wait 2–5 minutes. The "Downloading…" page is returned as the HTTP response body in a single blocking `return` — but given Flask's synchronous mode, this means we must render the "Downloading…" response first and then run provisioning. The simplest approach is to use Flask's `Response` with a generator or to use `after_this_request`. A pragmatic MVP alternative: return the status page as a streamed response (use `flask.stream_with_context` or a plain generator response) so the browser renders "Downloading…" immediately, then provisioning runs synchronously before the stream closes.
|
||||
|
||||
If streaming is overly complex, an acceptable fallback is to use `threading.Thread` to run provisioning in the background and return the status page immediately. The test should mock all provisioning calls regardless.
|
||||
|
||||
### OpenAIP API key fallback
|
||||
|
||||
If `OPENAIP_API_KEY` is not set in the environment, write an empty GeoJSON and degrade gracefully — airspace outlines are a cosmetic overlay and the device is fully functional without them. This avoids a hard dependency on a third-party API key for provisioning to succeed.
|
||||
|
||||
```python
|
||||
EMPTY_GEOJSON = '{"type": "FeatureCollection", "features": []}'
|
||||
```
|
||||
|
||||
### Patching paths in tests
|
||||
|
||||
Tests must patch `BACKGROUND_PATH` and `AIRSPACE_PATH` to `tmp_path` entries to avoid any writes to `/etc/planemapper/`. Use `unittest.mock.patch` targeting the module-level name:
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
def test_download_and_composite(tmp_path, mock_tile_response):
|
||||
bg_path = tmp_path / "background.png"
|
||||
with patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path):
|
||||
download_and_composite(51.5, -0.1, 100)
|
||||
assert bg_path.exists()
|
||||
```
|
||||
|
||||
Similarly for `AIRSPACE_PATH` in airspace tests.
|
||||
|
||||
### subprocess mock for `nmcli` and `rfkill`
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
import subprocess
|
||||
|
||||
@patch("planemapper.provisioning.wifi.subprocess.run")
|
||||
def test_join_home_wifi_success(mock_run):
|
||||
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0)
|
||||
join_home_wifi("MySSID", "MyPass") # should not raise
|
||||
mock_run.assert_called_once()
|
||||
```
|
||||
|
||||
Mock `subprocess.run` at the module where it is used (`planemapper.provisioning.wifi.subprocess.run`), not at the `subprocess` package level.
|
||||
|
||||
### Coordinate convention
|
||||
|
||||
All internal functions use `(lat, lon)` order — never `(lon, lat)`. The bounding box computation (`lat ± radius_deg`, `lon ± radius_deg`) follows this convention. The radius in degrees is approximated as `radius_nm / 60.0` (1 arcminute ≈ 1 nautical mile).
|
||||
@@ -0,0 +1,176 @@
|
||||
# Story 2.1: Aircraft Data Model & Fetcher
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As the radar system,
|
||||
I want an `Aircraft` dataclass with safe-default optional fields and a `FetcherInterface` with both an `HttpFetcher` (live dump1090) and a `FileFixtureFetcher` (for testing),
|
||||
So that all downstream rendering code works with typed `Aircraft` objects and the fetch boundary is cleanly isolated from raw JSON.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** a valid dump1090 JSON response with all fields present **When** `HttpFetcher.fetch()` is called **Then** it returns a `list[Aircraft]` with all fields populated correctly
|
||||
|
||||
AC2: **Given** the dump1090 response contains aircraft with missing `callsign`, `altitude`, or `category` **When** `HttpFetcher.fetch()` is called **Then** the corresponding fields use safe defaults (`callsign=""`, `altitude_ft=0`, `category=""`) and no exception is raised
|
||||
|
||||
AC3: **Given** the dump1090 HTTP request exceeds `FETCH_TIMEOUT_S` (5 seconds) **When** `HttpFetcher.fetch()` is called **Then** a `requests.Timeout` is raised (not caught here — the loop boundary handles it)
|
||||
|
||||
AC4: **Given** an aircraft entry has the MLAT flag set in the JSON **When** `HttpFetcher.fetch()` is called **Then** the resulting `Aircraft` has `is_mlat=True`
|
||||
|
||||
AC5: **Given** a `FileFixtureFetcher` pointed at `tests/fixtures/aircraft_sample.json` **When** `.fetch()` is called **Then** it returns the equivalent `list[Aircraft]` with no network call made
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `Aircraft` dataclass in `src/planemapper/models.py` (AC: #1, #2, #4)
|
||||
- [x] 1.1 Replace the existing stub with the full dataclass as specified in architecture: `icao: str`, `lat: float`, `lon: float`, `heading: float = 0.0`, `altitude_ft: int = 0`, `callsign: str = ""`, `category: str = ""`, `is_mlat: bool = False`, `is_stale: bool = False`
|
||||
- [x] 1.2 Add `from __future__ import annotations` and `from dataclasses import dataclass` imports
|
||||
- [x] 1.3 Confirm `ruff check .` passes with zero violations after change
|
||||
|
||||
- [x] Task 2: Add `DUMP1090_URL` constant to `src/planemapper/constants.py` (AC: #1, #3)
|
||||
- [x] 2.1 Add `DUMP1090_URL = "http://localhost:8080/data/aircraft.json"` to `constants.py`
|
||||
- [x] 2.2 Confirm existing constants are unaffected and `ruff check .` passes
|
||||
|
||||
- [x] Task 3: Implement `HttpFetcher` in `src/planemapper/fetcher.py` (AC: #1, #2, #3, #4)
|
||||
- [x] 3.1 Add imports: `import requests`, `from pathlib import Path`, `from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S`
|
||||
- [x] 3.2 Implement `_parse_aircraft(entry: dict) -> Aircraft` private module-level helper:
|
||||
- Map `hex` → `icao`
|
||||
- Map `lat` → `lat`, `lon` → `lon`
|
||||
- Map `flight` → `callsign` with `.strip()` and default `""`
|
||||
- Map `altitude` → `altitude_ft`: use `int(val)` if `isinstance(val, int)` else `0` (handles string `"ground"` and missing)
|
||||
- Map `category` → `category` with default `""`
|
||||
- Map `mlat` → `is_mlat`: `bool(entry.get("mlat"))` (empty list → `False`, non-empty → `True`)
|
||||
- `is_stale` always defaults to `False`
|
||||
- [x] 3.3 Implement `HttpFetcher` class:
|
||||
- `fetch(self) -> list[Aircraft]` calls `requests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S)`
|
||||
- Parses top-level JSON key `"aircraft"` as a list
|
||||
- Skips entries missing `lat` or `lon` (cannot be plotted)
|
||||
- Returns `[_parse_aircraft(e) for e in entries]`
|
||||
- Does NOT catch `requests.Timeout` — let it propagate
|
||||
- [x] 3.4 Confirm `HttpFetcher` satisfies `FetcherInterface` structurally (no explicit inheritance needed)
|
||||
|
||||
- [x] Task 4: Implement `FileFixtureFetcher` in `src/planemapper/fetcher.py` (AC: #5)
|
||||
- [x] 4.1 Add `FileFixtureFetcher` class after `HttpFetcher`:
|
||||
- Constructor: `__init__(self, path: Path)` stores `self._path = path`
|
||||
- `fetch(self) -> list[Aircraft]` reads JSON from `self._path`, parses with same `_parse_aircraft` helper
|
||||
- Same skip logic for missing `lat`/`lon`
|
||||
- [x] 4.2 Confirm `FileFixtureFetcher` satisfies `FetcherInterface` structurally
|
||||
|
||||
- [x] Task 5: Update `tests/fixtures/aircraft_sample.json` with realistic dump1090 data (AC: #1, #2, #4, #5)
|
||||
- [x] 5.1 Replace the empty `{"aircraft": []}` stub with a JSON object containing four aircraft entries:
|
||||
- Entry 1: complete aircraft — all fields present (`hex`, `lat`, `lon`, `flight`, `altitude`, `category`, `mlat: []`)
|
||||
- Entry 2: missing `flight` (callsign) — should default to `""`
|
||||
- Entry 3: missing `altitude` — should default to `altitude_ft=0`
|
||||
- Entry 4: MLAT aircraft — `mlat` is a non-empty list (e.g. `["lat", "lon"]`)
|
||||
- [x] 5.2 Ensure all four entries have `lat` and `lon` so none are skipped
|
||||
|
||||
- [x] Task 6: Write tests in `tests/test_fetcher.py` covering all 5 ACs (AC: #1–#5)
|
||||
- [x] 6.1 Test AC1: use `responses` library or `unittest.mock.patch` to mock `requests.get`; assert all fields on a fully-populated aircraft are correct
|
||||
- [x] 6.2 Test AC2: mock a response with missing `callsign`, `altitude`, `category`; assert defaults are applied and no exception raised
|
||||
- [x] 6.3 Test AC3: mock `requests.get` to raise `requests.Timeout`; assert `HttpFetcher.fetch()` propagates it (does not catch it)
|
||||
- [x] 6.4 Test AC4: mock a response where the MLAT aircraft has `"mlat": ["lat", "lon"]`; assert `is_mlat=True`
|
||||
- [x] 6.5 Test AC5: point `FileFixtureFetcher` at `tests/fixtures/aircraft_sample.json`; assert the expected `list[Aircraft]` is returned with no `requests.get` call made
|
||||
|
||||
- [x] Task 7: Update `tests/test_models.py` — verify dataclass (AC: #1, #2)
|
||||
- [x] 7.1 Confirm `test_aircraft_defaults` and `test_aircraft_full` (already present from story 1.1 QA) still pass after the full dataclass is in place — no stub test needed, these are real assertions
|
||||
- [x] 7.2 Add a test for the `altitude` edge-case if not already covered: create `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=0)` and assert `altitude_ft == 0`
|
||||
|
||||
- [x] Task 8: Run quality gates
|
||||
- [x] 8.1 `pytest tests/` — all tests pass, 0 failures
|
||||
- [x] 8.2 `ruff check .` — zero violations
|
||||
- [x] 8.3 `ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Critical Context
|
||||
|
||||
**DUMP1090_URL constant:**
|
||||
Add to `src/planemapper/constants.py`:
|
||||
```python
|
||||
DUMP1090_URL = "http://localhost:8080/data/aircraft.json"
|
||||
```
|
||||
`FETCH_TIMEOUT_S = 5` is already present in `constants.py` — do not duplicate it.
|
||||
|
||||
**Aircraft dataclass — already partially stubbed:**
|
||||
`src/planemapper/models.py` already contains the full `Aircraft` dataclass as specified in the architecture. Verify it matches exactly before replacing. The `test_models.py` tests (`test_aircraft_defaults`, `test_aircraft_full`) are also already in place from story 1.1 and test the real dataclass — confirm they pass.
|
||||
|
||||
**`altitude` edge case:**
|
||||
dump1090 can return `altitude` as an integer (feet), the string `"ground"`, or omit the field entirely. The safe pattern:
|
||||
```python
|
||||
raw_alt = entry.get("altitude", 0)
|
||||
altitude_ft = int(raw_alt) if isinstance(raw_alt, int) else 0
|
||||
```
|
||||
Do NOT do `int(str_val)` — the string `"ground"` will raise `ValueError`.
|
||||
|
||||
**`flight` field whitespace:**
|
||||
dump1090 pads callsigns with trailing spaces (e.g. `"BAW123 "`). Always `.strip()`:
|
||||
```python
|
||||
callsign = entry.get("flight", "").strip()
|
||||
```
|
||||
|
||||
**MLAT detection:**
|
||||
```python
|
||||
is_mlat = bool(entry.get("mlat"))
|
||||
```
|
||||
An empty list `[]` is falsy → `False`. A non-empty list `["lat", "lon"]` is truthy → `True`. A missing key defaults to `None` → `False`.
|
||||
|
||||
**Aircraft entries without position:**
|
||||
Some dump1090 entries lack `lat`/`lon` (e.g. aircraft heard only via Mode S squawk with no ADS-B position). Skip them — they cannot be rendered on the map:
|
||||
```python
|
||||
if "lat" not in entry or "lon" not in entry:
|
||||
continue
|
||||
```
|
||||
|
||||
**FetcherInterface is a Protocol — no explicit inheritance:**
|
||||
`HttpFetcher` and `FileFixtureFetcher` satisfy `FetcherInterface` structurally. Do NOT write `class HttpFetcher(FetcherInterface)` — `Protocol` subclassing for implementation is an antipattern in Python typing.
|
||||
|
||||
**Shared parse logic:**
|
||||
Both `HttpFetcher` and `FileFixtureFetcher` must use the same `_parse_aircraft(entry: dict) -> Aircraft` helper. Do not duplicate field-mapping logic between the two classes.
|
||||
|
||||
**Test isolation for `FileFixtureFetcher`:**
|
||||
In the AC5 test, pass the actual path to `tests/fixtures/aircraft_sample.json`. Use `pathlib.Path(__file__).parent / "fixtures" / "aircraft_sample.json"` to get an absolute path that works regardless of the working directory when pytest is invoked.
|
||||
|
||||
**Mock strategy for `HttpFetcher` tests:**
|
||||
Use `unittest.mock.patch("planemapper.fetcher.requests.get")` to intercept the HTTP call. Configure the mock's return value with `.json.return_value = {"aircraft": [...]}`. For the timeout test, use `side_effect=requests.Timeout`.
|
||||
|
||||
**dump1090 JSON shape (top-level):**
|
||||
```json
|
||||
{
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "4ca7f2",
|
||||
"lat": 53.3498,
|
||||
"lon": -6.2603,
|
||||
"flight": "EIN123 ",
|
||||
"altitude": 12000,
|
||||
"category": "A3",
|
||||
"mlat": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Fixture file — recommended four-entry shape:**
|
||||
```json
|
||||
{
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "4ca7f2", "lat": 53.3498, "lon": -6.2603,
|
||||
"flight": "EIN123 ", "altitude": 12000, "category": "A3", "mlat": []
|
||||
},
|
||||
{
|
||||
"hex": "4001a1", "lat": 53.4200, "lon": -6.1100,
|
||||
"altitude": 5000, "category": "A1", "mlat": []
|
||||
},
|
||||
{
|
||||
"hex": "4002b2", "lat": 53.2800, "lon": -6.4000,
|
||||
"flight": "RYR456 ", "category": "A3", "mlat": []
|
||||
},
|
||||
{
|
||||
"hex": "4003c3", "lat": 53.5000, "lon": -5.9000,
|
||||
"flight": "MIL001 ", "altitude": 1500, "category": "B1",
|
||||
"mlat": ["lat", "lon"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Story 2.2: Coordinate Projection & Base Map Loading
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As the renderer,
|
||||
I want a `MapBounds` dataclass and a `project()` function converting `(lat, lon)` to pixel `(x, y)`, and a basemap module that loads `background.png` into memory once,
|
||||
So that all rendering uses consistent coordinates and the base map is always available without disk I/O in the loop.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** a `MapBounds` from home lat/lon and coverage radius **When** `project(lat, lon, bounds)` is called with the home location **Then** it returns pixel coordinates at the centre of the 800×480 display (±2px)
|
||||
|
||||
AC2: **Given** `project()` is called with a position outside the map bounds **When** the result is used **Then** the returned pixel coordinate is outside display dimensions — no clamping, callers handle clipping
|
||||
|
||||
AC3: **Given** `background.png` exists at `BACKGROUND_PATH` **When** `basemap.load()` is called **Then** it returns a `PIL.Image` (800×480) loaded into memory
|
||||
|
||||
AC4: **Given** `background.png` does not exist at `BACKGROUND_PATH` **When** `basemap.load()` is called **Then** it raises `FileNotFoundError` (logged as ERROR by the caller)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Define `MapBounds` dataclass in `src/planemapper/renderer/projection.py` (AC: #1, #2)
|
||||
- [x] 1.1 Replace `# stub` with full implementation
|
||||
- [x] 1.2 Add imports: `from __future__ import annotations`, `from dataclasses import dataclass, field`, `import math`
|
||||
- [x] 1.3 Define `MapBounds` dataclass fields: `home_lat: float`, `home_lon: float`, `radius_nm: float`, `width: int = DISPLAY_WIDTH`, `height: int = DISPLAY_HEIGHT`
|
||||
- [x] 1.4 Import `DISPLAY_WIDTH` and `DISPLAY_HEIGHT` from `planemapper.constants`
|
||||
|
||||
- [x] Task 2: Implement `project()` in `src/planemapper/renderer/projection.py` (AC: #1, #2)
|
||||
- [x] 2.1 Signature: `def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]:`
|
||||
- [x] 2.2 Equirectangular linear mapping: map `(home_lat, home_lon)` to `(width//2, height//2)`
|
||||
- [x] 2.3 Scale: `deg_per_nm_lat = 1/60`; `deg_per_nm_lon = 1/(60 * math.cos(math.radians(bounds.home_lat)))`
|
||||
- [x] 2.4 Pixel scale: `px_per_nm_x = (bounds.width / 2) / bounds.radius_nm`; `px_per_nm_y = (bounds.height / 2) / bounds.radius_nm`
|
||||
- [x] 2.5 Convert: `x = bounds.width // 2 + int((lon - bounds.home_lon) / deg_per_nm_lon * px_per_nm_x)`; `y = bounds.height // 2 - int((lat - bounds.home_lat) / deg_per_nm_lat * px_per_nm_y)` (y-axis inverted — screen Y increases downward)
|
||||
- [x] 2.6 Return `(x, y)`
|
||||
|
||||
- [x] Task 3: Implement `basemap.load()` in `src/planemapper/renderer/basemap.py` (AC: #3, #4)
|
||||
- [x] 3.1 Replace `# stub` with full implementation
|
||||
- [x] 3.2 Add imports: `from PIL import Image`; `from planemapper.constants import BACKGROUND_PATH`
|
||||
- [x] 3.3 Signature: `def load() -> Image.Image:`
|
||||
- [x] 3.4 Open with `Image.open(BACKGROUND_PATH)`, call `.copy()` to force full load into memory (avoids lazy read)
|
||||
- [x] 3.5 Let `FileNotFoundError` propagate naturally — do NOT catch it
|
||||
|
||||
- [x] Task 4: Write tests in `tests/test_projection.py` (AC: #1, #2)
|
||||
- [x] 4.1 Replace the existing placeholder test (`def test_placeholder(): pass`)
|
||||
- [x] 4.2 Test AC1: Create `MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)`, call `project(53.0, -6.0, bounds)`, assert result is `(400, 240)` exactly (home maps to centre)
|
||||
- [x] 4.3 Test AC2: Call `project` with a point well outside bounds (e.g. 10 degrees away), assert returned pixel is outside display dimensions (< 0 or > 800 or > 480)
|
||||
|
||||
- [x] Task 5: Write tests in `tests/test_basemap.py` (AC: #3, #4)
|
||||
- [x] 5.1 Create `tests/test_basemap.py`
|
||||
- [x] 5.2 Test AC3: Create a real 800×480 PNG in `tmp_path`, monkeypatch `planemapper.renderer.basemap.BACKGROUND_PATH` to that path, call `basemap.load()`, assert returns `Image` of size `(800, 480)`
|
||||
- [x] 5.3 Test AC4: Monkeypatch `BACKGROUND_PATH` to a nonexistent path, call `basemap.load()`, assert `FileNotFoundError` is raised
|
||||
|
||||
- [x] Task 6: Run quality gates
|
||||
- [x] 6.1 `python -m pytest tests/` — all tests pass, 0 failures
|
||||
- [x] 6.2 `python -m ruff check .` — zero violations
|
||||
- [x] 6.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Critical Context
|
||||
|
||||
**Module locations (both exist as `# stub`):**
|
||||
- `src/planemapper/renderer/projection.py` — add `MapBounds` dataclass + `project()` function
|
||||
- `src/planemapper/renderer/basemap.py` — add `load()` function
|
||||
|
||||
**Constants already defined in `src/planemapper/constants.py`:**
|
||||
```python
|
||||
DISPLAY_WIDTH = 800
|
||||
DISPLAY_HEIGHT = 480
|
||||
BACKGROUND_PATH = Path("/etc/planemapper/background.png")
|
||||
```
|
||||
|
||||
**Projection formula detail:**
|
||||
The equirectangular projection maps a ~100nm radius around home to the full display. Latitude is simple (1 nm = 1/60 degree). Longitude must account for convergence at non-equatorial latitudes: `deg_per_nm_lon = 1 / (60 * cos(home_lat_radians))`. The y-axis is inverted because screen pixel Y increases downward while geographic latitude increases upward.
|
||||
|
||||
**`MapBounds` default field values** use `DISPLAY_WIDTH` (800) and `DISPLAY_HEIGHT` (480) as defaults — these are module-level constants, safe to use as default values in a dataclass without `field(default_factory=...)`.
|
||||
|
||||
**`basemap.load()` must force pixels into memory.** `PIL.Image.open()` is lazy by default — the file handle stays open and the pixel data is not read until accessed. Calling `.copy()` forces an immediate full decode and returns a new in-memory `Image`. Do not use `.load()` alone (it reads pixels but keeps the original file handle open).
|
||||
|
||||
**`FileNotFoundError` from `basemap.load()`.** `PIL.Image.open()` raises `FileNotFoundError` naturally when the path does not exist. Do not add a try/except — the caller (the radar loop) is responsible for logging ERROR and handling the failure.
|
||||
|
||||
**Coordinate convention:** internal code uses `(lat, lon)` order throughout — do not swap to `(lon, lat)`.
|
||||
|
||||
**Existing test file:** `tests/test_projection.py` contains only `def test_placeholder(): pass` — replace it entirely; do not add alongside the placeholder.
|
||||
|
||||
**No existing `tests/test_basemap.py`** — create this file from scratch.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Story 2.3: Home Marker & Airspace Outlines
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user glancing at the display,
|
||||
I want to see my home location marked on the map and published airspace boundaries shown as outlines,
|
||||
So that I have immediate spatial context for all aircraft positions.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** a loaded base map image and home lat/lon **When** the home marker is drawn **Then** a distinct `COLOUR_HOME_MARKER` (red) cross/circle marker is drawn at the projected pixel position of the home location
|
||||
|
||||
AC2: **Given** a valid `airspace.geojson` at `AIRSPACE_PATH` **When** airspace outlines are drawn **Then** each feature's boundary is drawn as an outline in `COLOUR_AIRSPACE` (blue) on the image **And** GeoJSON `[lon, lat]` coordinates are reversed to `(lat, lon)` at the parse boundary before any projection
|
||||
|
||||
AC3: **Given** `airspace.geojson` does not exist at `AIRSPACE_PATH` **When** airspace draw is called **Then** no exception is raised — the map renders without airspace outlines and a WARNING is logged
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `draw_home_marker(image, bounds)` in `src/planemapper/renderer/overlay.py` (AC: #1)
|
||||
- [x] 1.1 New file; imports: `from PIL import Image, ImageDraw`; `from planemapper.constants import COLOUR_HOME_MARKER`; `from planemapper.renderer.projection import MapBounds, project`
|
||||
- [x] 1.2 Signature: `def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:`
|
||||
- [x] 1.3 Project `(bounds.home_lat, bounds.home_lon)` to `(cx, cy)`
|
||||
- [x] 1.4 Draw cross: horizontal line `(cx-10, cy)` to `(cx+10, cy)`, vertical line `(cx, cy-10)` to `(cx, cy+10)`, fill `COLOUR_HOME_MARKER`, width 3
|
||||
- [x] 1.5 Use `ImageDraw.Draw(image).line(...)`
|
||||
|
||||
- [x] Task 2: Implement `draw_airspace(image, bounds)` in `src/planemapper/renderer/airspace.py` (AC: #2, #3)
|
||||
- [x] 2.1 Replace `# stub` with full implementation
|
||||
- [x] 2.2 Imports: `import json`, `import logging`; `from PIL import Image, ImageDraw`; `from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE`; `from planemapper.renderer.projection import MapBounds, project`
|
||||
- [x] 2.3 `log = logging.getLogger(__name__)`
|
||||
- [x] 2.4 Try to open `AIRSPACE_PATH`; on `FileNotFoundError`: `log.warning("airspace.geojson not found")` and return
|
||||
- [x] 2.5 Parse JSON, iterate `data["features"]`
|
||||
- [x] 2.6 For each feature with `geometry["type"] == "Polygon"`: get `coords = feature["geometry"]["coordinates"][0]`
|
||||
- [x] 2.7 Reverse GeoJSON `[lon, lat]` → `(lat, lon)` at parse boundary: `points = [project(lat, lon, bounds) for lon, lat in coords]`
|
||||
- [x] 2.8 Draw with `ImageDraw.Draw(image).line(points, fill=COLOUR_AIRSPACE, width=2)`; skip features with fewer than 2 points
|
||||
|
||||
- [x] Task 3: Update `tests/fixtures/airspace_sample.geojson` with a sample polygon (AC: #2)
|
||||
- [x] 3.1 Replace empty features list with one `Polygon` feature having 5 `[lon, lat]` coordinate pairs forming a closed ring near lat=53, lon=-6
|
||||
|
||||
- [x] Task 4: Write tests in `tests/test_airspace.py` (AC: #1, #2, #3)
|
||||
- [x] 4.1 Test AC1 (home marker): Create 800×480 white RGB image, create `MapBounds(53.0, -6.0, 100.0)`, call `draw_home_marker(image, bounds)`, assert pixel at (400, 240) is red
|
||||
- [x] 4.2 Test AC2 (airspace drawn): Monkeypatch `AIRSPACE_PATH` to `tests/fixtures/airspace_sample.geojson` path, create image, call `draw_airspace(image, bounds)`, assert no exception raised and function returns normally (drawing occurred without crash)
|
||||
- [x] 4.3 Test AC3 (missing geojson): Monkeypatch `AIRSPACE_PATH` to a nonexistent path, call `draw_airspace(image, bounds)`, assert no exception raised
|
||||
|
||||
- [x] Task 5: Run quality gates
|
||||
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||||
- [x] 5.2 `python -m ruff check .` — zero violations
|
||||
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Critical Context
|
||||
|
||||
**Module locations:**
|
||||
- `src/planemapper/renderer/overlay.py` — NEW file; implement `draw_home_marker()`
|
||||
- `src/planemapper/renderer/airspace.py` — currently `# stub`; replace with full `draw_airspace()` implementation
|
||||
|
||||
**Constants already defined in `src/planemapper/constants.py`:**
|
||||
```python
|
||||
COLOUR_HOME_MARKER = COLOUR_RED # (255, 0, 0)
|
||||
COLOUR_AIRSPACE = COLOUR_BLUE # (0, 0, 255)
|
||||
AIRSPACE_PATH = Path("/etc/planemapper/airspace.geojson")
|
||||
```
|
||||
|
||||
**Projection:** `project(lat, lon, bounds)` from `planemapper.renderer.projection` — lat first, lon second. `MapBounds` is the bounds object.
|
||||
|
||||
**GeoJSON coordinate convention:** GeoJSON uses `[lon, lat]` — MUST reverse at parse boundary. This is critical and easy to get wrong:
|
||||
```python
|
||||
# CORRECT — unpack GeoJSON [lon, lat] order, then pass as (lat, lon) to project()
|
||||
points = [project(lat, lon, bounds) for lon, lat in coords]
|
||||
```
|
||||
|
||||
**Airspace GeoJSON structure from OpenAIP:**
|
||||
FeatureCollection with features having `geometry.type = "Polygon"`. `coordinates[0]` is the exterior ring as a list of `[lon, lat]` pairs. For MVP, only handle `Polygon` features — skip all others silently.
|
||||
|
||||
**Home marker drawing detail:**
|
||||
The cross is drawn as two separate `line()` calls (or one call with both segments). The centre pixel at `(400, 240)` must be red after drawing when home is at the projected centre — tests assert this directly.
|
||||
|
||||
**Existing airspace fixture** at `tests/fixtures/airspace_sample.geojson` is currently `{"type": "FeatureCollection", "features": []}` — must be updated with a real polygon so AC2 test exercises the drawing path.
|
||||
|
||||
**Sample polygon for fixture:** Use 5 points forming a closed square ring near lat=53, lon=-6 (e.g. corners at ±0.1 degrees). Coordinates must be in GeoJSON `[lon, lat]` order with the first and last point identical to close the ring.
|
||||
|
||||
**Test file:** `tests/test_airspace.py` is a new file — create from scratch.
|
||||
|
||||
**Monkeypatching `AIRSPACE_PATH`:** The path constant is imported into `planemapper.renderer.airspace`, so patch it there:
|
||||
```python
|
||||
monkeypatch.setattr("planemapper.renderer.airspace.AIRSPACE_PATH", Path("tests/fixtures/airspace_sample.geojson"))
|
||||
```
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Story 2.4: Altitude Colour Bands & Aircraft Type Icons
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As the renderer,
|
||||
I want pure functions mapping an aircraft's altitude to a display colour and its ADS-B category/callsign to an icon type,
|
||||
So that every aircraft is consistently colour-coded and type-classified with all logic centralised.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** `altitude_ft` values at the exact boundaries in `ALTITUDE_BANDS_FT` **When** `altitude_to_colour(altitude_ft)` is called **Then** the correct `ALTITUDE_COLOURS` entry is returned for each boundary and above/below it — all 6 palette colours reachable
|
||||
|
||||
AC2: **Given** an `Aircraft` with `category="A1"` (light aircraft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.GA_LIGHT`
|
||||
|
||||
AC3: **Given** an `Aircraft` with a BA callsign pattern (e.g. `"BAW123"`) and no category **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.COMMERCIAL`
|
||||
|
||||
AC4: **Given** an `Aircraft` with `category="A7"` (helicopter) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.HELICOPTER`
|
||||
|
||||
AC5: **Given** an `Aircraft` with no category and `altitude_ft=5000` (< 10,000ft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.GA_LIGHT` (altitude fallback)
|
||||
|
||||
AC6: **Given** an `Aircraft` with no category and `altitude_ft=18000` (10,000–30,000ft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.PRIVATE_JET`
|
||||
|
||||
AC7: **Given** an `Aircraft` with no category and `altitude_ft=38000` (> 30,000ft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.AIRLINER`
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement `altitude_to_colour()` in `src/planemapper/renderer/colours.py` (AC: #1)
|
||||
- [x] 1.1 Replace `# stub` with full implementation
|
||||
- [x] 1.2 Imports: `from planemapper.constants import ALTITUDE_BANDS_FT, ALTITUDE_COLOURS`
|
||||
- [x] 1.3 Signature: `def altitude_to_colour(altitude_ft: int) -> tuple[int, int, int]:`
|
||||
- [x] 1.4 Iterate `ALTITUDE_BANDS_FT` with `enumerate`; return `ALTITUDE_COLOURS[i]` for the first band where `altitude_ft <= band`
|
||||
|
||||
- [x] Task 2: Implement `AircraftType` enum and `classify_aircraft_type()` in `src/planemapper/renderer/icons.py` (AC: #2–#7)
|
||||
- [x] 2.1 Replace `# stub` with full implementation
|
||||
- [x] 2.2 Imports: `from enum import Enum`; `from planemapper.models import Aircraft`
|
||||
- [x] 2.3 Define `AircraftType` enum:
|
||||
```python
|
||||
from enum import Enum
|
||||
class AircraftType(Enum):
|
||||
GA_LIGHT = "ga_light"
|
||||
COMMERCIAL = "commercial"
|
||||
PRIVATE_JET = "private_jet"
|
||||
AIRLINER = "airliner"
|
||||
HELICOPTER = "helicopter"
|
||||
MILITARY = "military"
|
||||
UNKNOWN = "unknown"
|
||||
```
|
||||
- [x] 2.4 Define `AIRLINE_PREFIXES` as a `frozenset` of 3-letter ICAO codes: `{"BAW", "EIN", "RYR", "EZY", "THY", "DLH", "AFR", "IBE", "KLM", "UAE", "SWR"}`
|
||||
- [x] 2.5 Signature: `def classify_aircraft_type(aircraft: Aircraft) -> AircraftType:`
|
||||
- [x] 2.6 Implement priority logic (in order):
|
||||
1. `category == "A7"` → `HELICOPTER`
|
||||
2. `category` in `{"A1", "A2"}` → `GA_LIGHT`
|
||||
3. `category` in `{"A3", "A4", "A5"}` → `COMMERCIAL`
|
||||
4. Callsign starts with a known airline prefix (first 3 chars in `AIRLINE_PREFIXES`) → `COMMERCIAL`
|
||||
5. Altitude fallback (no category, no recognised callsign):
|
||||
- `altitude_ft < 10000` → `GA_LIGHT`
|
||||
- `10000 <= altitude_ft < 30000` → `PRIVATE_JET`
|
||||
- `altitude_ft >= 30000` → `AIRLINER`
|
||||
6. Default → `UNKNOWN`
|
||||
|
||||
- [x] Task 3: Write tests in `tests/test_colours.py` (AC: #1)
|
||||
- [x] 3.1 Replace `def test_placeholder(): pass` with real tests
|
||||
- [x] 3.2 Test boundary values: 0, 1500, 1501, 5000, 5001, 10000, 10001, 20000, 20001, 35000, 35001 — assert each returns the expected colour from `ALTITUDE_COLOURS`
|
||||
- `0` → `COLOUR_GREEN` (≤ 1500)
|
||||
- `1500` → `COLOUR_GREEN` (boundary inclusive)
|
||||
- `1501` → `COLOUR_BLUE` (≤ 5000)
|
||||
- `5000` → `COLOUR_BLUE` (boundary inclusive)
|
||||
- `5001` → `COLOUR_YELLOW` (≤ 10000)
|
||||
- `10000` → `COLOUR_YELLOW` (boundary inclusive)
|
||||
- `10001` → `COLOUR_RED` (≤ 20000)
|
||||
- `20000` → `COLOUR_RED` (boundary inclusive)
|
||||
- `20001` → `COLOUR_BLACK` (≤ 35000)
|
||||
- `35000` → `COLOUR_BLACK` (boundary inclusive)
|
||||
- `35001` → `COLOUR_WHITE` (≤ 99999, last band catches all)
|
||||
- [x] 3.3 Verify all 6 colours are reachable (assert the union of test return values equals the full `ALTITUDE_COLOURS` set)
|
||||
|
||||
- [x] Task 4: Write tests in `tests/test_icons.py` (AC: #2–#7)
|
||||
- [x] 4.1 Replace `def test_placeholder(): pass` with real tests
|
||||
- [x] 4.2 One test function per AC:
|
||||
- `test_category_a1_returns_ga_light` — `Aircraft(icao="X", lat=0.0, lon=0.0, category="A1")` → `AircraftType.GA_LIGHT` (AC2)
|
||||
- `test_ba_callsign_returns_commercial` — `Aircraft(icao="X", lat=0.0, lon=0.0, callsign="BAW123")` → `AircraftType.COMMERCIAL` (AC3)
|
||||
- `test_category_a7_returns_helicopter` — `Aircraft(icao="X", lat=0.0, lon=0.0, category="A7")` → `AircraftType.HELICOPTER` (AC4)
|
||||
- `test_no_category_low_altitude_returns_ga_light` — `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=5000)` → `AircraftType.GA_LIGHT` (AC5)
|
||||
- `test_no_category_mid_altitude_returns_private_jet` — `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=18000)` → `AircraftType.PRIVATE_JET` (AC6)
|
||||
- `test_no_category_high_altitude_returns_airliner` — `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=38000)` → `AircraftType.AIRLINER` (AC7)
|
||||
- [x] 4.3 Use `Aircraft(icao="X", lat=0.0, lon=0.0, ...)` to construct test aircraft; supply only the fields relevant to each AC
|
||||
|
||||
- [x] Task 5: Run quality gates
|
||||
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||||
- [x] 5.2 `python -m ruff check .` — zero violations
|
||||
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### `altitude_to_colour()` boundary behaviour
|
||||
|
||||
`ALTITUDE_BANDS_FT = [1500, 5000, 10000, 20000, 35000, 99999]`
|
||||
|
||||
The function iterates the list and returns the colour for the **first** band where `altitude_ft <= band`. The final band `99999` is a catch-all, so values above 35000ft still map to `COLOUR_WHITE` (index 5). Values above 99999ft (if any) would also resolve to `COLOUR_WHITE` via the last band.
|
||||
|
||||
### `classify_aircraft_type()` priority
|
||||
|
||||
Category takes precedence over callsign, which takes precedence over altitude fallback. The `altitude_ft` field may be `None` on the `Aircraft` model; guard with `is not None` before numeric comparison in the altitude fallback branch.
|
||||
|
||||
### `AIRLINE_PREFIXES` frozenset
|
||||
|
||||
Using `frozenset` ensures O(1) membership testing and signals the constant is immutable:
|
||||
```python
|
||||
AIRLINE_PREFIXES: frozenset[str] = frozenset(
|
||||
{"BAW", "EIN", "RYR", "EZY", "THY", "DLH", "AFR", "IBE", "KLM", "UAE", "SWR"}
|
||||
)
|
||||
```
|
||||
|
||||
Callsign prefix matching: `aircraft.callsign and aircraft.callsign[:3].upper() in AIRLINE_PREFIXES`.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT)
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user looking at the display,
|
||||
I want each aircraft drawn with a heading arrow, callsign/altitude label, a 5-dot position trail with the oldest dot smallest, and MLAT aircraft visually distinct,
|
||||
So that I can read direction, identity, altitude, recent path, and data confidence at a glance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** an `Aircraft` with `heading=90.0` (due east) **When** the heading arrow is drawn **Then** the arrow points east on the display, correctly rotated from north-up reference
|
||||
|
||||
AC2: **Given** an `Aircraft` with `callsign="BAW1"` and `altitude_ft=28000` **When** the label is drawn **Then** callsign and altitude are rendered near the aircraft position **And** the label colour matches the aircraft's altitude colour band
|
||||
|
||||
AC3: **Given** a trail `deque` with 3 entries **When** the trail is drawn **Then** 3 dots are rendered with decreasing size from most-recent to oldest (interpolated between `TRAIL_DOT_SIZE_MAX` and `TRAIL_DOT_SIZE_MIN`) **And** dot colour is `COLOUR_TRAIL`
|
||||
|
||||
AC4: **Given** an `Aircraft` with `is_mlat=True` **When** the aircraft is drawn **Then** it is rendered in a visually distinct style (dashed/dotted outline instead of filled triangle)
|
||||
|
||||
AC5: **Given** an `Aircraft` with `callsign=""` **When** the label is drawn **Then** altitude only is rendered with no blank callsign prefix, and no exception is raised
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Implement drawing functions in `src/planemapper/renderer/aircraft.py` (AC: #1–#5)
|
||||
- [x] 1.1 Replace `# stub` with full implementation
|
||||
- [x] 1.2 Implement `_rotate_point(x, y, angle_deg) -> tuple[float, float]`:
|
||||
```python
|
||||
import math
|
||||
def _rotate_point(x, y, angle_deg):
|
||||
r = math.radians(angle_deg)
|
||||
return (x * math.cos(r) - y * math.sin(r),
|
||||
x * math.sin(r) + y * math.cos(r))
|
||||
```
|
||||
- [x] 1.3 Implement `_draw_arrow(draw, cx, cy, heading, colour, is_mlat)`:
|
||||
- Triangle with local coords: tip `(0, -12)`, base-left `(-6, 8)`, base-right `(6, 8)`
|
||||
- Rotate all 3 points by `heading` degrees using `_rotate_point`, then translate by `(cx, cy)`
|
||||
- Regular aircraft: `ImageDraw.polygon(points, fill=colour)`
|
||||
- MLAT aircraft: `ImageDraw.polygon(points, fill=None, outline=colour)`
|
||||
- [x] 1.4 Implement `_draw_label(draw, cx, cy, aircraft, colour)`:
|
||||
- If `aircraft.callsign` is non-empty: text = `f"{aircraft.callsign}\n{aircraft.altitude_ft}ft"`
|
||||
- If `aircraft.callsign` is empty: text = `f"{aircraft.altitude_ft}ft"`
|
||||
- Position: `(cx + 12, cy - 8)`
|
||||
- Font: `ImageFont.load_default()`
|
||||
- Colour: `altitude_to_colour(aircraft.altitude_ft)`
|
||||
- [x] 1.5 Implement `_draw_trail(draw, trail)`:
|
||||
- Iterate the trail deque; `i=0` is most recent
|
||||
- Size formula: `size = TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / max(len(trail) - 1, 1)`
|
||||
- Draw `ImageDraw.ellipse` centred at trail point with half-width/height `size // 2`
|
||||
- Colour: `COLOUR_TRAIL`
|
||||
- [x] 1.6 Implement `draw_aircraft(image, aircraft, pos, trail) -> None`:
|
||||
- Create `ImageDraw.Draw(image)`
|
||||
- Resolve colour via `altitude_to_colour(aircraft.altitude_ft)`
|
||||
- Unpack `pos` as `(cx, cy)`
|
||||
- Call `_draw_trail(draw, trail)`
|
||||
- Call `_draw_arrow(draw, cx, cy, aircraft.heading or 0.0, colour, aircraft.is_mlat)`
|
||||
- Call `_draw_label(draw, cx, cy, aircraft, colour)`
|
||||
|
||||
- [x] Task 2: Write tests in `tests/test_aircraft_draw.py` (AC: #1–#5)
|
||||
- [x] 2.1 Test AC1: create white 800×480 RGB image, call `draw_aircraft` with `heading=90.0`; assert pixel at `(cx+12, cy)` is not white (arrow painted over white background)
|
||||
- [x] 2.2 Test AC2: call `draw_aircraft` with `callsign="BAW1"`, `altitude_ft=28000`; assert no exception, return value is `None`
|
||||
- [x] 2.3 Test AC3: call `draw_aircraft` with a `deque` of 3 `(x, y)` trail points; assert no exception raised
|
||||
- [x] 2.4 Test AC4: call `draw_aircraft` with `is_mlat=True`; assert no exception raised
|
||||
- [x] 2.5 Test AC5: call `draw_aircraft` with `callsign=""`; assert no exception raised
|
||||
|
||||
- [x] Task 3: Run quality gates
|
||||
- [x] 3.1 `python -m pytest tests/` — all tests pass
|
||||
- [x] 3.2 `python -m ruff check .` — zero violations
|
||||
- [x] 3.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Required imports for `aircraft.py`
|
||||
|
||||
```python
|
||||
import collections
|
||||
import math
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.constants import COLOUR_TRAIL, TRAIL_DOT_SIZE_MAX, TRAIL_DOT_SIZE_MIN
|
||||
from planemapper.renderer.colours import altitude_to_colour
|
||||
```
|
||||
|
||||
### Arrow geometry detail
|
||||
|
||||
The arrow is drawn in "north-up" local coordinates before rotation. The tip points north (negative y) and the base is south (positive y):
|
||||
|
||||
- Tip: `(0, -12)`
|
||||
- Base left: `(-6, 8)`
|
||||
- Base right: `(6, 8)`
|
||||
|
||||
The rotation matrix for clockwise-from-north bearing (matching compass/ADS-B heading convention) is the standard 2D rotation — no sign inversion required. A `heading=0` leaves the triangle pointing up (north). A `heading=90` rotates the tip to point right (east).
|
||||
|
||||
### Trail dot size interpolation
|
||||
|
||||
With `i=0` (most recent):
|
||||
- `size = TRAIL_DOT_SIZE_MAX` (largest)
|
||||
|
||||
With `i = len(trail) - 1` (oldest):
|
||||
- `size = TRAIL_DOT_SIZE_MIN` (smallest)
|
||||
|
||||
The formula `size = TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / max(len(trail) - 1, 1)` handles the edge case of a single-point trail via `max(..., 1)`.
|
||||
|
||||
### MLAT visual distinction
|
||||
|
||||
MLAT aircraft use `fill=None, outline=colour` in `polygon()`, producing a hollow/outlined triangle rather than a filled one. This signals lower positional accuracy to the viewer without requiring a separate icon asset.
|
||||
|
||||
### Label callsign guard
|
||||
|
||||
Guard with `if aircraft.callsign:` (falsy check covers both `None` and `""`). Do not use `is not None` alone as an empty string should also suppress the callsign line.
|
||||
|
||||
### Constants required in `src/planemapper/constants.py`
|
||||
|
||||
Ensure the following are present (add if missing):
|
||||
|
||||
```python
|
||||
TRAIL_MAX_DOTS = 5
|
||||
TRAIL_DOT_SIZE_MAX = 6
|
||||
TRAIL_DOT_SIZE_MIN = 2
|
||||
COLOUR_TRAIL = COLOUR_BLACK
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
# Story 2.6: Stateful Renderer & Display Interface
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As the radar loop,
|
||||
I want a stateful `Renderer` owning the in-memory tile composite and per-aircraft trail history, and a `DisplayInterface` protocol with `WaveshareDisplay` (SPI) and `NullDisplay` (tests),
|
||||
So that the render pipeline is fully isolated, testable without hardware, and trail history persists across cycles.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** a `Renderer` initialised with a loaded base map **When** `renderer.render(aircraft_list)` is called **Then** it returns a `PIL.Image` (800×480) with base map, airspace outlines, home marker, and all aircraft drawn
|
||||
|
||||
AC2: **Given** an aircraft appears in two consecutive calls to `renderer.render()` **When** the second call is made **Then** its previous position appears as a trail dot **And** trail length never exceeds `TRAIL_MAX_DOTS` (5)
|
||||
|
||||
AC3: **Given** an aircraft was present last cycle but absent from current list **When** `renderer.render()` is called **Then** the aircraft does not appear on display **And** its trail history is retained in `dict[str, deque]` for when it reappears
|
||||
|
||||
AC4: **Given** a `NullDisplay` **When** `display.show(image)` is called **Then** it logs image dimensions at DEBUG level and returns without error
|
||||
|
||||
AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Renderer → NullDisplay`) **When** one full cycle runs **Then** it completes without exception and the returned image is 800×480
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Update `NullDisplay` in `src/planemapper/display.py` (AC: #4)
|
||||
- [x] 1.1 Add `import logging` and `log = logging.getLogger(__name__)`
|
||||
- [x] 1.2 Update `NullDisplay.show()` to log at DEBUG level:
|
||||
```python
|
||||
log.debug("NullDisplay.show: %dx%d", image.width, image.height)
|
||||
```
|
||||
- [x] 1.3 Add `WaveshareDisplay` stub:
|
||||
```python
|
||||
class WaveshareDisplay:
|
||||
def show(self, image: Image.Image) -> None:
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
- [x] Task 2: Implement `Renderer` in `src/planemapper/renderer/renderer.py` (AC: #1, #2, #3)
|
||||
- [x] 2.1 Replace `# stub` with full implementation
|
||||
- [x] 2.2 Imports:
|
||||
```python
|
||||
import collections
|
||||
from PIL import Image
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.constants import TRAIL_MAX_DOTS
|
||||
from planemapper.renderer.projection import MapBounds, project
|
||||
from planemapper.renderer.airspace import draw_airspace
|
||||
from planemapper.renderer.overlay import draw_home_marker
|
||||
from planemapper.renderer.aircraft import draw_aircraft
|
||||
```
|
||||
- [x] 2.3 Implement `__init__` with `base_map`, `bounds`, `_trails` dict:
|
||||
```python
|
||||
def __init__(
|
||||
self,
|
||||
base_map: Image.Image,
|
||||
bounds: MapBounds,
|
||||
) -> None:
|
||||
self._base_map = base_map
|
||||
self._bounds = bounds
|
||||
self._trails: dict[str, collections.deque[tuple[int, int]]] = {}
|
||||
```
|
||||
- [x] 2.4 Implement `render(aircraft_list)` with full pipeline:
|
||||
```python
|
||||
def render(self, aircraft_list: list[Aircraft]) -> Image.Image:
|
||||
image = self._base_map.copy()
|
||||
draw_airspace(image, self._bounds)
|
||||
draw_home_marker(image, self._bounds)
|
||||
for aircraft in aircraft_list:
|
||||
pos = project(aircraft.lat, aircraft.lon, self._bounds)
|
||||
trail = self._trails.get(aircraft.icao, collections.deque())
|
||||
draw_aircraft(image, aircraft, pos, trail)
|
||||
trail.appendleft(pos)
|
||||
while len(trail) > TRAIL_MAX_DOTS:
|
||||
trail.pop()
|
||||
self._trails[aircraft.icao] = trail
|
||||
return image
|
||||
```
|
||||
|
||||
- [x] Task 3: Write tests in `tests/test_renderer.py` (AC: #1, #2, #3)
|
||||
- [x] 3.1 Replace placeholder with full test module
|
||||
- [x] 3.2 Test AC1: create white 800×480 RGB base map, call `render([])` with empty aircraft list, assert returned image is `PIL.Image.Image` with size `(800, 480)`
|
||||
- [x] 3.3 Test AC2: call `render()` twice with same aircraft; after second call, assert `renderer._trails` has an entry keyed on the aircraft's ICAO
|
||||
- [x] 3.4 Test AC3: call `render()` with one aircraft, then call `render([])` with empty list; assert `renderer._trails` still has the aircraft ICAO entry
|
||||
|
||||
- [x] Task 4: Write/update pipeline smoke test in `tests/test_pipeline.py` (AC: #5)
|
||||
- [x] 4.1 Replace placeholder with full smoke test:
|
||||
- Use `FileFixtureFetcher(Path("tests/fixtures/aircraft_sample.json"))`
|
||||
- Create a fake 800×480 white RGB base map (`Image.new("RGB", (800, 480), "white")`)
|
||||
- Create `Renderer(base_map, bounds)` where `bounds = MapBounds(53.0, -6.0, 100.0)`
|
||||
- Monkeypatch `planemapper.renderer.airspace.AIRSPACE_PATH` to `tests/fixtures/airspace_sample.geojson`
|
||||
- Call `renderer.render(fetcher.fetch())`
|
||||
- Assert returned image is `PIL.Image.Image` with size `(800, 480)`
|
||||
- Call `NullDisplay().show(image)` — assert no exception
|
||||
- [x] 4.2 Ensure monkeypatch of `AIRSPACE_PATH` is applied before `render()` is called
|
||||
|
||||
- [x] Task 5: Run quality gates
|
||||
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||||
- [x] 5.2 `python -m ruff check .` — zero violations
|
||||
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### `Renderer` class — trail management detail
|
||||
|
||||
Trail entries are `(x, y)` pixel positions stored most-recent-first (index 0). After drawing each aircraft:
|
||||
|
||||
1. `trail.appendleft(pos)` — prepend current position
|
||||
2. `while len(trail) > TRAIL_MAX_DOTS: trail.pop()` — trim oldest entries from the right
|
||||
3. `self._trails[aircraft.icao] = trail` — write back (no-op if already the same deque object, but keeps the dict consistent)
|
||||
|
||||
Aircraft absent from `aircraft_list` are not iterated, so their trail deque remains in `self._trails` untouched, ready to resume when the aircraft reappears.
|
||||
|
||||
### `WaveshareDisplay` stub
|
||||
|
||||
This story adds only the stub. The real SPI driver implementation (using the Waveshare Python library) is deferred to story 2-7. The stub class must satisfy the `DisplayInterface` protocol structurally — it has a `show(self, image: Image.Image) -> None` method — but raises `NotImplementedError` so it cannot be called accidentally in tests.
|
||||
|
||||
### `NullDisplay` logging
|
||||
|
||||
`NullDisplay` lives in `src/planemapper/display.py`. The module-level logger uses `__name__` (`planemapper.display`). Log format: `"NullDisplay.show: %dx%d"` with `image.width` and `image.height` as positional args (uses `%`-style lazy formatting, not f-strings, to avoid string construction overhead when DEBUG is not enabled).
|
||||
|
||||
### `render()` pipeline order
|
||||
|
||||
The pipeline order is significant:
|
||||
|
||||
1. Copy base map — ensures each cycle starts from the clean pre-rendered tile composite
|
||||
2. Draw airspace outlines — static geometry, drawn once per cycle over the base copy
|
||||
3. Draw home marker — static overlay
|
||||
4. Draw aircraft (with trails) — dynamic, per-cycle
|
||||
|
||||
All drawing mutates the `image` copy in-place. `self._base_map` is never mutated.
|
||||
|
||||
### Test fixtures
|
||||
|
||||
`tests/test_renderer.py` does not need any fixture files — it constructs a minimal white `Image.new("RGB", (800, 480), "white")` base map and uses a handcrafted `Aircraft` object. Use `unittest.mock.patch` or `pytest` monkeypatch to suppress `draw_airspace` and `draw_home_marker` if they have external dependencies (e.g. airspace file path), or monkeypatch `AIRSPACE_PATH` as done in the pipeline smoke test.
|
||||
|
||||
### Existing files affected
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/planemapper/display.py` | Add logging; update `NullDisplay.show()`; add `WaveshareDisplay` stub |
|
||||
| `src/planemapper/renderer/renderer.py` | Replace `# stub` with full `Renderer` class |
|
||||
| `tests/test_renderer.py` | Replace placeholder with AC1/AC2/AC3 tests |
|
||||
| `tests/test_pipeline.py` | Replace placeholder with end-to-end smoke test |
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a device operator,
|
||||
I want the device to show a startup screen during boot, then enter a 60-second radar refresh loop that runs indefinitely and resumes automatically after power cycling,
|
||||
So that the display is always current with zero manual intervention.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** the device boots with a valid config **When** `planemapper-radar` starts **Then** a startup screen is displayed before the first radar render begins
|
||||
|
||||
AC2: **Given** the radar loop is running **When** each 60-second cycle completes **Then** `fetcher.fetch()` → `renderer.render()` → `display.show()` executes in sequence **And** render phase timings are logged at INFO level
|
||||
|
||||
AC3: **Given** total render time exceeds `RENDER_WARN_S` (40s) **When** the cycle completes **Then** a WARNING is logged
|
||||
|
||||
AC4: **Given** `planemapper-radar.service` **When** the service file is inspected **Then** it has `Restart=always` and `After=planemapper-provision.service`
|
||||
|
||||
AC5: **Given** device reboots with `provisioned: true` in config **When** `planemapper-provision.service` starts **Then** it detects the flag and exits immediately without running the portal
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Update `src/planemapper/main.py` with full implementation (AC: #1, #2, #3)
|
||||
- [x] 1.1 Add `"src/planemapper/main.py"` to TID251 per-file-ignores in `pyproject.toml` (alongside the existing `provision.py` entry) so that `main.py` may import from `planemapper.provisioning.config`
|
||||
- [x] 1.2 Implement `_make_startup_screen() -> Image.Image` — returns a white 800×480 RGB `PIL.Image` with `"planeMapper starting..."` drawn using `ImageFont.load_default()`
|
||||
- [x] 1.3 Implement `_run_one_cycle(renderer, fetcher, display) -> None` with per-phase timing and slow-render warning:
|
||||
```python
|
||||
def _run_one_cycle(renderer, fetcher, display):
|
||||
t0 = time.monotonic()
|
||||
aircraft_list = fetcher.fetch()
|
||||
t1 = time.monotonic()
|
||||
image = renderer.render(aircraft_list)
|
||||
t2 = time.monotonic()
|
||||
display.show(image)
|
||||
t3 = time.monotonic()
|
||||
total = t3 - t0
|
||||
log.info("cycle: fetch=%.1fs render=%.1fs spi=%.1fs total=%.1fs",
|
||||
t1 - t0, t2 - t1, t3 - t2, total)
|
||||
if total > RENDER_WARN_S:
|
||||
log.warning("render slow: %.1fs > %ds threshold", total, RENDER_WARN_S)
|
||||
```
|
||||
- [x] 1.4 Implement `main()`: read config → build `MapBounds` + load base map → init `HttpFetcher`, `Renderer`, `WaveshareDisplay` → show startup screen → enter `while True` loop calling `_run_one_cycle` then `time.sleep(REFRESH_INTERVAL_S)`:
|
||||
```python
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
from planemapper.provisioning.config import read as read_config
|
||||
cfg = read_config() # raises FileNotFoundError if not provisioned
|
||||
|
||||
bounds = MapBounds(
|
||||
home_lat=cfg["home_lat"],
|
||||
home_lon=cfg["home_lon"],
|
||||
radius_nm=cfg["coverage_radius_nm"],
|
||||
)
|
||||
base_map = basemap.load() # raises FileNotFoundError if missing
|
||||
|
||||
fetcher = HttpFetcher()
|
||||
renderer = Renderer(base_map, bounds)
|
||||
display = WaveshareDisplay()
|
||||
|
||||
# Startup screen
|
||||
startup = _make_startup_screen()
|
||||
display.show(startup)
|
||||
|
||||
# Radar loop
|
||||
while True:
|
||||
_run_one_cycle(renderer, fetcher, display)
|
||||
time.sleep(REFRESH_INTERVAL_S)
|
||||
```
|
||||
- [x] 1.5 Add module-level constants: `REFRESH_INTERVAL_S = 60` and `RENDER_WARN_S = 40`
|
||||
- [x] 1.6 Add required imports: `import logging`, `import time`, `from PIL import Image, ImageDraw, ImageFont`, `from planemapper.fetcher import HttpFetcher`, `from planemapper.renderer.projection import MapBounds`, `from planemapper.renderer.renderer import Renderer`, `from planemapper.renderer import basemap`, `from planemapper.display import WaveshareDisplay`
|
||||
|
||||
- [x] Task 2: Update `src/planemapper/provision.py` to exit early if already provisioned (AC: #5)
|
||||
- [x] 2.1 Add import for `planemapper.provisioning.config` at the top of `provision.py` (already in TID251 per-file-ignores)
|
||||
- [x] 2.2 At the top of `main()` in `provision.py`, before the provisioning loop, add:
|
||||
```python
|
||||
try:
|
||||
cfg = config.read()
|
||||
if cfg.get("provisioned"):
|
||||
log.info("already provisioned — exiting")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass # no config — proceed to portal
|
||||
```
|
||||
|
||||
- [x] Task 3: Verify/update systemd service files (AC: #4)
|
||||
- [x] 3.1 Verify `systemd/planemapper-radar.service` has `Restart=always` and `After=planemapper-provision.service` — these already exist in the file; confirm `User=root` is present (currently absent — add it)
|
||||
- [x] 3.2 Verify `systemd/planemapper-provision.service` has `Type=oneshot` and `RemainAfterExit=yes` — these already exist in the file; confirm `User=root` is present (currently absent — add it)
|
||||
|
||||
- [x] Task 4: Write tests in `tests/test_main.py` (AC: #2, #3, #5)
|
||||
- [x] 4.1 Test AC2: call `_run_one_cycle` with `NullDisplay`, a `FileFixtureFetcher` (or `unittest.mock.MagicMock` returning a list), and a `Renderer` backed by a fake 800×480 white base map; assert `display.show()` was called exactly once
|
||||
- [x] 4.2 Test AC3: mock `time.monotonic` to return values simulating a total elapsed time > 40s (e.g. return sequence `[0, 1, 2, 43]`); assert `log.warning` is called (use `pytest` `caplog` fixture at WARNING level); assert the log message contains `"render slow"`
|
||||
- [x] 4.3 Test AC5: patch `planemapper.provisioning.config.read` to return `{"provisioned": True}` and patch `planemapper.provisioning.wifi.start_ap`; call `planemapper.provision.main()`; assert `wifi.start_ap` was NOT called
|
||||
|
||||
- [x] Task 5: Run quality gates
|
||||
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||||
- [x] 5.2 `python -m ruff check .` — zero violations
|
||||
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### TID251 per-file-ignores
|
||||
|
||||
`pyproject.toml` currently lists:
|
||||
```toml
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"src/planemapper/provision.py" = ["TID251"]
|
||||
"src/planemapper/provisioning/*.py" = ["TID251"]
|
||||
"tests/provisioning/*.py" = ["TID251"]
|
||||
"tests/conftest.py" = ["TID251"]
|
||||
```
|
||||
|
||||
Add `"src/planemapper/main.py" = ["TID251"]` to this block. The banned-api rule (`TID251`) blocks any import from `planemapper.provisioning.*` in files not listed here. `main.py` must be added because it imports `planemapper.provisioning.config.read` to load the stored configuration.
|
||||
|
||||
### `_make_startup_screen()`
|
||||
|
||||
Returns a new `Image.new("RGB", (800, 480), "white")`. Draw `"planeMapper starting..."` at a central position using `ImageDraw.Draw(img)` and `ImageFont.load_default()`. Exact pixel position is not prescribed — somewhere near centre is sufficient.
|
||||
|
||||
### `_run_one_cycle()` timing
|
||||
|
||||
Uses `time.monotonic()` for wall-clock elapsed measurement (not affected by NTP adjustments during boot). Four timestamps: `t0` before fetch, `t1` after fetch, `t2` after render, `t3` after display. The INFO log line reports each phase independently and a `total`. The WARNING fires when `total > RENDER_WARN_S` (strict greater-than, not >=).
|
||||
|
||||
### Config schema (from story 1.2)
|
||||
|
||||
```json
|
||||
{
|
||||
"home_lat": float,
|
||||
"home_lon": float,
|
||||
"coverage_radius_nm": int,
|
||||
"wifi_ssid": str,
|
||||
"wifi_password": str,
|
||||
"provisioned": bool
|
||||
}
|
||||
```
|
||||
|
||||
`read_config()` raises `FileNotFoundError` if the config file does not exist. `main()` lets this propagate — systemd `Restart=always` will retry on failure.
|
||||
|
||||
### `provision.py` early-exit pattern
|
||||
|
||||
The early-exit check must be at the very top of `main()`, before `wifi.start_ap()` is called. The `config` module is already accessible since `provision.py` is in the TID251 per-file-ignores. Import as `from planemapper.provisioning import config` (adding to the existing import line or adding a separate line).
|
||||
|
||||
### Systemd service files — current state
|
||||
|
||||
Both service files already exist at `systemd/planemapper-radar.service` and `systemd/planemapper-provision.service`. The radar service already has `Restart=always` and `After=planemapper-provision.service`. The provision service already has `Type=oneshot` and `RemainAfterExit=yes`. The only gap vs. the story spec is the absence of `User=root` in both files — add it under the `[Service]` section.
|
||||
|
||||
### `WaveshareDisplay` in `main()`
|
||||
|
||||
`WaveshareDisplay.show()` currently raises `NotImplementedError` (stub from story 2.6). When this story is implemented, `main()` will call `display.show(startup)` on startup — this will raise in a test context. Tests should therefore test `_run_one_cycle` directly with a `NullDisplay`, not by calling `main()` end-to-end.
|
||||
|
||||
### Test structure for AC3
|
||||
|
||||
```python
|
||||
def test_run_one_cycle_warns_when_slow(caplog):
|
||||
import unittest.mock as mock
|
||||
from planemapper.main import _run_one_cycle
|
||||
from planemapper.display import NullDisplay
|
||||
|
||||
fetcher = mock.MagicMock()
|
||||
fetcher.fetch.return_value = []
|
||||
renderer = mock.MagicMock()
|
||||
renderer.render.return_value = Image.new("RGB", (800, 480), "white")
|
||||
display = NullDisplay()
|
||||
|
||||
# Simulate t0=0, t1=1, t2=2, t3=43 → total=43s > RENDER_WARN_S
|
||||
monotonic_values = iter([0.0, 1.0, 2.0, 43.0])
|
||||
with mock.patch("planemapper.main.time.monotonic", side_effect=monotonic_values):
|
||||
with caplog.at_level(logging.WARNING, logger="planemapper.main"):
|
||||
_run_one_cycle(renderer, fetcher, display)
|
||||
|
||||
assert any("render slow" in r.message for r in caplog.records)
|
||||
```
|
||||
|
||||
### Existing files affected
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/planemapper/main.py` | Replace stub `main()` with full implementation; add `_run_one_cycle()` and `_make_startup_screen()` |
|
||||
| `src/planemapper/provision.py` | Add early-exit check at top of `main()` for `provisioned: true` |
|
||||
| `pyproject.toml` | Add `"src/planemapper/main.py" = ["TID251"]` to per-file-ignores |
|
||||
| `systemd/planemapper-radar.service` | Add `User=root` under `[Service]` |
|
||||
| `systemd/planemapper-provision.service` | Add `User=root` under `[Service]` |
|
||||
| `tests/test_main.py` | New file: AC2, AC3, AC5 tests |
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# Story 3.1: Stale State Detection & Dimmed Display
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user whose RTL-SDR has temporarily lost signal,
|
||||
I want the display to retain the last known aircraft positions shown as outlines when dump1090 stops delivering fresh data,
|
||||
So that I know the display is stale without a crash or blank screen.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** the radar loop is running with a previous successful fetch **When** `HttpFetcher.fetch()` raises `requests.Timeout` **Then** the exception propagates to the loop boundary, which catches it and marks all retained aircraft as `is_stale=True`
|
||||
|
||||
AC2: **Given** the dump1090 response returns an empty aircraft list when the previous cycle had aircraft **When** the fetcher processes the response **Then** the previous aircraft list is retained with `is_stale=True` on each entry (not replaced with empty)
|
||||
|
||||
AC3: **Given** aircraft with `is_stale=True` are passed to the renderer **When** `renderer.render()` is called **Then** each stale aircraft is drawn as outline only using `COLOUR_STALE_OUTLINE` **And** heading arrow, label, and trail are still rendered at last known positions
|
||||
|
||||
AC4: **Given** a stale render cycle **When** the render loop timing is measured **Then** the loop does not crash (stale path is not a crash path)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Update `_run_one_cycle()` and `main()` in `src/planemapper/main.py` (AC: #1, #2, #4)
|
||||
- [x] 1.1 Add `last_aircraft: list[Aircraft]` parameter to `_run_one_cycle`; change return type to `list[Aircraft]`
|
||||
- [x] 1.2 Wrap `fetcher.fetch()` in `try/except requests.Timeout`
|
||||
- [x] 1.3 Implement stale logic: use `last_aircraft` with `is_stale=True` when timeout or empty+had-previous
|
||||
- [x] 1.4 Update `main()` to initialise `last: list[Aircraft] = []` and track return value of `_run_one_cycle`
|
||||
- [x] 1.5 Add `import dataclasses` and `import requests` to `main.py`
|
||||
|
||||
- [x] Task 2: Update `draw_aircraft()` in `src/planemapper/renderer/aircraft.py` (AC: #3)
|
||||
- [x] 2.1 Import `COLOUR_STALE_OUTLINE` from `planemapper.constants`
|
||||
- [x] 2.2 Check `aircraft.is_stale` before calling `_draw_arrow`: if stale, use `COLOUR_STALE_OUTLINE` with forced outline mode
|
||||
|
||||
- [x] Task 3: Write tests in `tests/test_stale.py` (AC: #1, #2, #3, #4)
|
||||
- [x] 3.1 Test AC1: mock `fetcher.fetch()` to raise `requests.Timeout`; call `_run_one_cycle` with `last_aircraft=[some_aircraft]`; assert returned list has `is_stale=True`
|
||||
- [x] 3.2 Test AC2: mock `fetcher.fetch()` to return `[]`; call with non-empty `last_aircraft`; assert returned list has `is_stale=True`
|
||||
- [x] 3.3 Test AC3: render stale aircraft; assert pixel at aircraft position is not the altitude colour (stale colour is `COLOUR_STALE_OUTLINE` = black)
|
||||
- [x] 3.4 Test AC4: full stale cycle with renderer completes without exception
|
||||
|
||||
- [x] Task 4: Run quality gates
|
||||
- [x] 4.1 `python -m pytest tests/` — all tests pass
|
||||
- [x] 4.2 `python -m ruff check .` — zero violations
|
||||
- [x] 4.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Key insight: `is_stale` already exists
|
||||
|
||||
The `is_stale` field already exists on the `Aircraft` dataclass (defaults to `False`). No model changes are needed. The `is_mlat` field already drives outline rendering in `_draw_arrow` — stale aircraft reuse the same outline path but with a different colour.
|
||||
|
||||
### Stale vs MLAT rendering distinction
|
||||
|
||||
| Condition | Fill | Outline colour | Meaning |
|
||||
|---|---|---|---|
|
||||
| Normal | altitude colour | — | Direct ADS-B, current data |
|
||||
| MLAT (`is_mlat=True`) | None | altitude colour | Uncertain position, current data |
|
||||
| Stale (`is_stale=True`) | None | `COLOUR_STALE_OUTLINE` (black) | Last known position, data age unknown |
|
||||
|
||||
Stale takes priority over MLAT in the rendering check: if `aircraft.is_stale` is true, always render outline in `COLOUR_STALE_OUTLINE` regardless of `is_mlat`.
|
||||
|
||||
### `_run_one_cycle` signature change
|
||||
|
||||
```python
|
||||
def _run_one_cycle(
|
||||
renderer: Renderer,
|
||||
fetcher: HttpFetcher,
|
||||
display: DisplayInterface,
|
||||
last_aircraft: list[Aircraft],
|
||||
) -> list[Aircraft]:
|
||||
```
|
||||
|
||||
Returns the aircraft list used for this cycle (caller passes it back as `last_aircraft` next cycle).
|
||||
|
||||
### Stale detection logic in `_run_one_cycle`
|
||||
|
||||
```python
|
||||
try:
|
||||
fresh = fetcher.fetch()
|
||||
except requests.Timeout:
|
||||
log.warning("fetch timeout — using stale data")
|
||||
fresh = []
|
||||
stale_needed = True
|
||||
else:
|
||||
stale_needed = (len(fresh) == 0 and len(last_aircraft) > 0)
|
||||
|
||||
if stale_needed:
|
||||
aircraft_list = [dataclasses.replace(a, is_stale=True) for a in last_aircraft]
|
||||
else:
|
||||
aircraft_list = fresh
|
||||
```
|
||||
|
||||
`dataclasses.replace()` creates a new `Aircraft` instance with only `is_stale` changed — the original `last_aircraft` entries are not mutated.
|
||||
|
||||
### `main()` loop update
|
||||
|
||||
```python
|
||||
last: list[Aircraft] = []
|
||||
while True:
|
||||
last = _run_one_cycle(renderer, fetcher, display, last)
|
||||
time.sleep(REFRESH_INTERVAL_S)
|
||||
```
|
||||
|
||||
Initialise `last` as empty list before the loop. On first cycle, `last_aircraft=[]` means a timeout or empty result produces an empty stale list (no aircraft to retain), which renders a clean empty map — correct behaviour.
|
||||
|
||||
### `draw_aircraft()` stale check
|
||||
|
||||
In `src/planemapper/renderer/aircraft.py`, inside `draw_aircraft()`, before calling `_draw_arrow`:
|
||||
|
||||
```python
|
||||
if aircraft.is_stale:
|
||||
_draw_arrow(draw, cx, cy, aircraft.heading, COLOUR_STALE_OUTLINE, is_mlat=True)
|
||||
else:
|
||||
_draw_arrow(draw, cx, cy, aircraft.heading, colour, aircraft.is_mlat)
|
||||
```
|
||||
|
||||
The `is_mlat=True` argument reuses the existing outline-only code path in `_draw_arrow`. The label and trail draw unconditionally at the last known positions — stale state does not suppress them.
|
||||
|
||||
### Required imports in `main.py`
|
||||
|
||||
- `import dataclasses` — for `dataclasses.replace()`
|
||||
- `import requests` — to name `requests.Timeout` in the `except` clause
|
||||
|
||||
Both should be added to the top-level imports alongside the existing ones.
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/planemapper/main.py` | Add params/return to `_run_one_cycle`; stale detection logic; update `main()` loop; add imports |
|
||||
| `src/planemapper/renderer/aircraft.py` | Import `COLOUR_STALE_OUTLINE`; stale check before `_draw_arrow` |
|
||||
| `tests/test_stale.py` | New test module covering all four ACs |
|
||||
@@ -0,0 +1,63 @@
|
||||
# Story 3.2: Automatic Recovery on Fresh Decode
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user whose RTL-SDR has recovered,
|
||||
I want the display to automatically return to normal filled aircraft rendering on the next successful fetch,
|
||||
So that recovery requires no intervention.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** the display is in stale state **When** `HttpFetcher.fetch()` returns a non-empty aircraft list **Then** all fetched aircraft have `is_stale=False` and are drawn with normal filled icons
|
||||
|
||||
AC2: **Given** the display has recovered **When** the next render cycle runs **Then** no stale outline rendering occurs for the recovered aircraft
|
||||
|
||||
AC3: **Given** a stale-then-recovery sequence **When** `FileFixtureFetcher` first returns `[]` (simulating stale) then returns a populated list **Then** the first cycle produces stale aircraft and the second produces normal fresh aircraft
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] Task 1: Add recovery tests to `tests/test_stale.py` (AC: #1, #2, #3)
|
||||
- [ ] 1.1 Add `test_recovery_after_timeout_returns_fresh`
|
||||
- [ ] 1.2 Add `test_recovery_after_empty_returns_fresh`
|
||||
- [ ] 1.3 Verify no code changes needed in `main.py` or `aircraft.py` — recovery is already correct
|
||||
|
||||
- [ ] Task 2: Run quality gates
|
||||
- [ ] 2.1 `python -m pytest tests/` — all tests pass
|
||||
- [ ] 2.2 `python -m ruff check .` — zero violations
|
||||
- [ ] 2.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Recovery logic already correct from story 3-1
|
||||
|
||||
The `_run_one_cycle()` stale detection logic in `main.py` is:
|
||||
|
||||
```python
|
||||
try:
|
||||
fresh = fetcher.fetch()
|
||||
except requests.Timeout:
|
||||
log.warning("fetch timeout — using stale data")
|
||||
fresh = []
|
||||
stale_needed = True
|
||||
else:
|
||||
stale_needed = (len(fresh) == 0 and len(last_aircraft) > 0)
|
||||
|
||||
if stale_needed:
|
||||
aircraft_list = [dataclasses.replace(a, is_stale=True) for a in last_aircraft]
|
||||
else:
|
||||
aircraft_list = fresh
|
||||
```
|
||||
|
||||
When `len(fresh) > 0`, `stale_needed` is `False` and `aircraft_list = fresh`. All `Aircraft` instances from a fresh fetch default `is_stale=False`, so recovery is automatic — no code changes required.
|
||||
|
||||
### No changes to `main.py` or `aircraft.py`
|
||||
|
||||
Recovery is already handled correctly. The only deliverable is two additional tests in `tests/test_stale.py` confirming the recovery path for both the timeout-then-fresh and empty-then-fresh scenarios.
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tests/test_stale.py` | Add `test_recovery_after_timeout_returns_fresh` and `test_recovery_after_empty_returns_fresh` |
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Story 4.1: GPIO Button Hold Detection & LED Feedback
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user wanting to reconfigure the device,
|
||||
I want to hold the reset button for 3 seconds and receive immediate LED confirmation,
|
||||
So that I know the reset was registered before anything else changes.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** the button is held for `RESET_HOLD_S` (3s) **When** `ButtonHoldDetector.check()` is called **Then** it returns `True`
|
||||
|
||||
AC2: **Given** the button is held for less than 3s **When** `ButtonHoldDetector.check()` is called **Then** it returns `False`
|
||||
|
||||
AC3: **Given** `ButtonHoldDetector.check()` returns `True` **When** the main loop processes the result **Then** `LEDController.on()` is called immediately
|
||||
|
||||
AC4: **Given** gpiozero `MockFactory` is active **When** button/LED tests run **Then** they pass without physical GPIO hardware
|
||||
|
||||
AC5: **Given** `check()` is called once per render cycle **When** the render loop runs **Then** the call is non-blocking
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] Task 1: Implement `ButtonHoldDetector` and `LEDController` in `src/planemapper/gpio_ctrl.py` (AC: #1, #2, #3, #5)
|
||||
- [ ] 1.1 Add imports: `import logging`, `from gpiozero import Button, LED`, `from planemapper.constants import RESET_HOLD_S`
|
||||
- [ ] 1.2 Define `BUTTON_GPIO_PIN = 17`, `LED_GPIO_PIN = 27` as module-level constants
|
||||
- [ ] 1.3 Implement `ButtonHoldDetector.__init__(self, pin=BUTTON_GPIO_PIN)`: `self._button = Button(pin, hold_time=RESET_HOLD_S)`
|
||||
- [ ] 1.4 Implement `ButtonHoldDetector.check(self) -> bool`: `return self._button.is_held`
|
||||
- [ ] 1.5 Implement `LEDController.__init__(self, pin=LED_GPIO_PIN)`: `self._led = LED(pin)`
|
||||
- [ ] 1.6 Implement `LEDController.on(self) -> None` and `LEDController.off(self) -> None`
|
||||
|
||||
- [ ] Task 2: Update `tests/test_gpio_ctrl.py` (AC: #4)
|
||||
- [ ] 2.1 Add `from gpiozero import Device`, `from gpiozero.pins.mock import MockFactory`
|
||||
- [ ] 2.2 Set `Device.pin_factory = MockFactory()` before instantiating classes in each test or in a fixture
|
||||
- [ ] 2.3 Test `ButtonHoldDetector.check()` returns `False` when button not pressed (MockFactory default)
|
||||
- [ ] 2.4 Test `LEDController.on()` and `LEDController.off()` work without exception
|
||||
|
||||
- [ ] Task 3: Run quality gates
|
||||
- [ ] 3.1 `python -m pytest tests/` — all tests pass
|
||||
- [ ] 3.2 `python -m ruff check .` — zero violations
|
||||
- [ ] 3.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### gpiozero integration
|
||||
|
||||
`ButtonHoldDetector` wraps `gpiozero.Button` with `hold_time=RESET_HOLD_S`. The `is_held` property returns `True` only after the button has been continuously held for `hold_time` seconds — this is non-blocking (no sleep, no polling loop), satisfying AC5.
|
||||
|
||||
`LEDController` wraps `gpiozero.LED`. Calls to `on()` and `off()` delegate directly to the underlying device.
|
||||
|
||||
### MockFactory for tests
|
||||
|
||||
`gpiozero.Device.pin_factory = MockFactory()` substitutes all GPIO pin access with in-process mock objects. Tests construct `ButtonHoldDetector` and `LEDController` without needing real hardware. The mock button's default state is not-held, so `check()` returns `False` unless the mock pin is explicitly driven.
|
||||
|
||||
### Module-level pin constants
|
||||
|
||||
`BUTTON_GPIO_PIN = 17` and `LED_GPIO_PIN = 27` are defined at module level in `gpio_ctrl.py`. This makes them visible and easy to change for a hardware revision without touching any logic.
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/planemapper/gpio_ctrl.py` | Full implementation replacing stubs |
|
||||
| `tests/test_gpio_ctrl.py` | Update to use MockFactory |
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# Story 4.2: Config Wipe, Setup Screen & Return to Provisioning
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a user who has triggered a reset,
|
||||
I want the device to wipe its configuration, show a setup screen on the e-ink display, and restart into the provisioning flow,
|
||||
So that I can re-configure the device from scratch.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
AC1: **Given** `ButtonHoldDetector.check()` returns `True` **When** the reset handler runs **Then** `config.wipe()` is called
|
||||
|
||||
AC2: **Given** config has been wiped **When** the reset handler continues **Then** `display.show(setup_screen_image)` is called
|
||||
|
||||
AC3: **Given** setup screen is shown **When** reset handler completes **Then** `os.execvp('planemapper-provision', ['planemapper-provision'])` replaces the current process
|
||||
|
||||
AC4: **Given** `config.wipe()` raises an unexpected error **When** the reset handler encounters it **Then** ERROR is logged and `os.execvp` is NOT called
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] Task 1: Implement `_handle_reset()` and `_make_setup_screen()` in `src/planemapper/main.py` (AC: #1–#4)
|
||||
- [ ] 1.1 Add `import os` to imports
|
||||
- [ ] 1.2 Add `from planemapper.provisioning.config import wipe as wipe_config`
|
||||
- [ ] 1.3 Add `from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController`
|
||||
- [ ] 1.4 Implement `_make_setup_screen() -> Image.Image`
|
||||
- [ ] 1.5 Implement `_handle_reset(display, led) -> None`
|
||||
- [ ] 1.6 Update `main()` to instantiate `ButtonHoldDetector` and `LEDController`
|
||||
- [ ] 1.7 Add reset check at top of loop
|
||||
|
||||
- [ ] Task 2: Write tests in `tests/test_reset.py` (AC: #1–#4)
|
||||
|
||||
- [ ] Task 3: Run quality gates
|
||||
- [ ] 3.1 `python -m pytest tests/` — all tests pass
|
||||
- [ ] 3.2 `python -m ruff check .` — zero violations
|
||||
- [ ] 3.3 `python -m ruff format --check .` — no formatting issues
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### `_handle_reset` flow
|
||||
|
||||
1. `led.on()` — immediate LED feedback before any wipe attempt
|
||||
2. `wipe_config()` — deletes config file; on failure, log ERROR, call `led.off()`, return
|
||||
3. `display.show(_make_setup_screen())` — render "Resetting..." screen
|
||||
4. `os.execvp("planemapper-provision", ["planemapper-provision"])` — replaces process (never returns)
|
||||
|
||||
### `_make_setup_screen()`
|
||||
|
||||
Returns a white 800×480 PIL Image with "Resetting..." text. Same pattern as `_make_startup_screen()`.
|
||||
|
||||
### main() loop update
|
||||
|
||||
`ButtonHoldDetector` and `LEDController` are instantiated once in `main()` before the loop. Inside the loop, `button.check()` is called once per cycle (non-blocking). If `True`, `_handle_reset` is called (which never returns on success).
|
||||
|
||||
### TID251 per-file-ignore
|
||||
|
||||
`main.py` already has `TID251` in per-file-ignores in `pyproject.toml`, so importing from `planemapper.provisioning.config` is allowed.
|
||||
|
||||
### Patching in tests
|
||||
|
||||
- `wipe_config` is patched at `planemapper.main.wipe_config`
|
||||
- `os.execvp` is patched at `planemapper.main.os.execvp`
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/planemapper/main.py` | Add `import os`, new imports, `_make_setup_screen()`, `_handle_reset()`, update `main()` |
|
||||
| `tests/test_reset.py` | New file with 3 tests |
|
||||
@@ -0,0 +1,230 @@
|
||||
# Deferred Work Manifest
|
||||
|
||||
Tracks blocked, deferred, and tech-debt items across sprints.
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure / environment setup
|
||||
|
||||
### [1-1] systemd unit installation
|
||||
Story: `1-1-project-scaffold-and-verified-entry-points`
|
||||
Task: 7.1, 7.2
|
||||
Description: Unit files created at `systemd/`. Must be symlinked or copied to `/etc/systemd/system/` on the Pi and `systemctl daemon-reload` run before they take effect. Cannot be automated without root access to target device.
|
||||
|
||||
### [1-1] Pi Zero 2W runtime verification
|
||||
Story: `1-1-project-scaffold-and-verified-entry-points`
|
||||
Task: 9.1, 9.2
|
||||
Description: Entry points verified on host (Pi 5, Linux). Full AC1 verification on Pi Zero 2W hardware requires physical deployment.
|
||||
|
||||
---
|
||||
|
||||
## Story 1.2 review — no new deferred items
|
||||
|
||||
Story `1-2-configuration-read-write-wipe` reviewed 2026-04-22. All 4 ACs pass, all 7 tests pass, ruff check and format clean. No deferred items required: `config.write()` already handles directory creation via `mkdir(parents=True, exist_ok=True)`, so deployment to a fresh device with no `/etc/planemapper/` directory is covered at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Story 1.3: WiFi Hotspot & Captive Portal Form
|
||||
|
||||
### [1-3] hostapd and dnsmasq system packages
|
||||
Story: `1-3-wifi-hotspot-and-captive-portal-form`
|
||||
Category: Infrastructure/environment
|
||||
Description: `hostapd` and `dnsmasq` require system packages installed on the Pi; AP mode requires `wlan0` in AP-capable state. Cannot be verified without hardware.
|
||||
|
||||
### [1-3] Captive portal device testing
|
||||
Story: `1-3-wifi-hotspot-and-captive-portal-form`
|
||||
Category: Runtime verification
|
||||
Description: Actual captive portal detection behaviour (iOS/Android/Windows triggering) requires physical device testing. Automated tests confirm redirect routes are correct but cannot simulate OS-level captive portal probe behaviour.
|
||||
|
||||
### [1-3] Provisioning loop placeholder
|
||||
Story: `1-3-wifi-hotspot-and-captive-portal-form`
|
||||
Category: Infrastructure/environment
|
||||
Description: `provision.py` provisioning loop currently exits after one iteration (placeholder `provisioned = True`) — full sequence wired in Story 1.5.
|
||||
|
||||
---
|
||||
|
||||
## Story 1.4: Location Resolution (ICAO & Address)
|
||||
|
||||
### [1-4] Nominatim geocoding runtime verification
|
||||
Story: `1-4-location-resolution-icao-and-address`
|
||||
Category: Runtime verification
|
||||
Description: Nominatim geocoding verified in tests with mocks only; real geocoding requires internet access and can only be confirmed on device at provisioning time. No automated test covers live HTTP to Nominatim.
|
||||
|
||||
### [1-4] ICAO heuristic false-positive risk
|
||||
Story: `1-4-location-resolution-icao-and-address`
|
||||
Category: Technical debt
|
||||
Description: ICAO heuristic (`len(query) == 4 and query.isalpha()`) may misclassify 4-letter words (e.g. "BATH", "YORK") as ICAO codes, causing them to be looked up in `airports.csv` before falling back to Nominatim. Acceptable for MVP given the provisioning context, but noted for future hardening (e.g. validate against a known ICAO prefix list).
|
||||
|
||||
---
|
||||
|
||||
## Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill
|
||||
|
||||
### [1-5] nmcli / NetworkManager dependency
|
||||
Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill`
|
||||
Category: Infrastructure/environment
|
||||
Description: `nmcli` requires NetworkManager to be installed and running on the Pi; the `wlan0` interface must support managed mode. Raspberry Pi OS Lite uses `dhcpcd` by default — NetworkManager must be installed and enabled before `join_home_wifi()` will work.
|
||||
|
||||
### [1-5] rfkill permission requirement
|
||||
Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill`
|
||||
Category: Infrastructure/environment
|
||||
Description: `rfkill block wifi` requires the process to have permission to block the WiFi interface. The user running the provisioning service must be root or have the `CAP_NET_ADMIN` capability. The systemd unit must be configured accordingly.
|
||||
|
||||
### [1-5] OSM tile download and OpenAIP API runtime verification
|
||||
Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill`
|
||||
Category: Runtime verification
|
||||
Description: OSM tile download, OpenAIP API call, and the full provisioning sequence (WiFi join → tile download → airspace download → validate → write config → rfkill) can only be end-to-end verified on device with real network access. All tests use mocks only.
|
||||
|
||||
### [1-5] provision.py port 80 requires root
|
||||
Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill`
|
||||
Category: Infrastructure/environment
|
||||
Description: `provision.py` calls `app.run(port=80)` which requires root privileges or the `CAP_NET_BIND_SERVICE` capability to bind to a port below 1024. The systemd unit for the provisioning service must run as root or be granted the appropriate capability.
|
||||
|
||||
### [1-5] Synchronous POST /submit — browser waits during provisioning
|
||||
Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill`
|
||||
Category: Technical debt
|
||||
Description: The `POST /submit` handler is fully synchronous — the browser connection stays open while tile download, airspace download, and cache validation complete (potentially 2–5 minutes). This is acceptable for MVP but a streaming response (using `flask.stream_with_context` or a background thread with server-sent events) would improve UX by allowing the browser to render progress feedback without holding an open connection.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.1: Aircraft Data Model & Fetcher
|
||||
|
||||
### [2-1] HttpFetcher live dump1090 runtime verification
|
||||
Story: `2-1-aircraft-data-model-and-fetcher`
|
||||
Category: Runtime verification
|
||||
Description: `HttpFetcher` is tested with mocks only. Live feed at `http://localhost:8080/data/aircraft.json` can only be verified on device with an RTL-SDR dongle connected and dump1090 running. No automated test covers the real HTTP path to dump1090.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.2: Coordinate Projection & Base Map Loading
|
||||
|
||||
### [2-2] Equirectangular projection distortion at high latitudes or large radius
|
||||
Story: `2-2-coordinate-projection-and-base-map-loading`
|
||||
Category: Technical debt
|
||||
Description: The equirectangular projection corrects only for longitude convergence at the home latitude (`cos(home_lat)`). For large radius values (e.g. >150nm) or locations above ~60°N, distortion accumulates toward the display edges. Aircraft positions at the map boundary can be displaced by several pixels from their true screen location. Acceptable for a ~100nm display centred on a UK airfield, but worth revisiting if radius or latitude range is extended.
|
||||
|
||||
### [2-2] basemap.load() does not verify image dimensions
|
||||
Story: `2-2-coordinate-projection-and-base-map-loading`
|
||||
Category: Technical debt
|
||||
Description: `basemap.load()` opens and returns whatever image is at `BACKGROUND_PATH` without asserting it is 800×480. A mismatched tile composite (e.g. from a re-provisioning at a different zoom level) will be silently accepted and the rendered output will be corrupted. Future hardening: add a dimension assertion and raise `ValueError` if the image does not match `DISPLAY_WIDTH × DISPLAY_HEIGHT`.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.3: Home Marker & Airspace Outlines
|
||||
|
||||
### [2-3] draw_airspace() silently skips non-Polygon geometry types
|
||||
Story: `2-3-home-marker-and-airspace-outlines`
|
||||
Category: Technical debt
|
||||
Description: `draw_airspace()` skips any GeoJSON feature whose `geometry.type` is not `"Polygon"` (e.g. Point, LineString, MultiPolygon). This is intentional for MVP per spec, but MultiPolygon airspace features from OpenAIP will be silently ignored. Future hardening: add support for MultiPolygon by iterating each ring, and log a debug message when an unsupported geometry type is encountered.
|
||||
|
||||
### [2-3] draw_airspace() does not handle null geometry features
|
||||
Story: `2-3-home-marker-and-airspace-outlines`
|
||||
Category: Technical debt
|
||||
Description: If a GeoJSON feature has `"geometry": null` (valid per GeoJSON spec for featureless features), `feature.get("geometry", {})` returns `None` rather than `{}`, and the subsequent `.get("type")` call raises `AttributeError`. Acceptable for MVP given controlled OpenAIP input, but real-world GeoJSON files can contain null geometry. Future hardening: guard with `if not geom or not isinstance(geom, dict): continue`.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.4: Altitude Colour Bands & Aircraft Type Icons
|
||||
|
||||
### [2-4] _AIRLINE_PREFIXES is a hardcoded subset of ICAO airline codes
|
||||
Story: `2-4-altitude-colour-bands-and-aircraft-type-icons`
|
||||
Category: Technical debt
|
||||
Description: `_AIRLINE_PREFIXES` contains 23 hand-picked ICAO 3-letter designators. Any airline callsign not in this set (e.g. "SXS", "WJA", "FDX") will fall through to the altitude fallback and may be misclassified as GA_LIGHT, PRIVATE_JET, or AIRLINER depending on altitude rather than as COMMERCIAL. Acceptable for MVP display purposes, but notable for any use case that relies on accurate airline/GA distinction. Future hardening: source the full ICAO airline prefix list from a static CSV bundled with the package.
|
||||
|
||||
### [2-4] Military aircraft with A-category ADS-B codes are misclassified
|
||||
Story: `2-4-altitude-colour-bands-and-aircraft-type-icons`
|
||||
Category: Technical debt
|
||||
Description: `_CATEGORY_MAP` maps only ADS-B B-categories (B1–B4) to `AircraftType.MILITARY`. Military aircraft that transmit A-category ADS-B codes (e.g. training jets advertising as A3) or no category at all will fall through to the callsign/altitude fallback and be misclassified. A military callsign prefix list (e.g. "RRR", "GAF", "USAF") would improve detection but is not required by any story AC.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.5: Per-Aircraft Drawing
|
||||
|
||||
### [2-5] Default font is 8px — may be unreadable on physical hardware
|
||||
Story: `2-5-per-aircraft-drawing`
|
||||
Category: Technical debt
|
||||
Description: `_draw_label` uses `ImageFont.load_default()` which renders at approximately 8px on the 800×480 display. Callsign and altitude labels may be too small to read at arm's length on the physical e-ink panel. Future hardening: load a bundled bitmap or TrueType font at 12–14px (e.g. Pillow's built-in `ImageFont.load_default(size=14)` on Pillow ≥10, or a small `.ttf` bundled under `src/planemapper/assets/`).
|
||||
|
||||
### [2-5] Arrow geometry constants are hardcoded inline
|
||||
Story: `2-5-per-aircraft-drawing`
|
||||
Category: Technical debt
|
||||
Description: Arrow tip distance (12px), base half-width (6px), and base offset (8px) are hardcoded inline in `_draw_arrow`. These control icon size and aspect ratio. For Pi Zero 2W or larger displays these values may need tuning. Future hardening: extract to named constants in `constants.py` (e.g. `ARROW_TIP`, `ARROW_BASE_HALF`, `ARROW_BASE_OFFSET`) so they can be adjusted without touching drawing logic.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.6: Stateful Renderer & Display Interface
|
||||
|
||||
### [2-6] WaveshareDisplay.show() raises NotImplementedError
|
||||
Story: `2-6-stateful-renderer-and-display-interface`
|
||||
Category: Infrastructure/environment
|
||||
Description: `WaveshareDisplay.show()` is a stub that raises `NotImplementedError`. The real SPI driver implementation (using the Waveshare Python library) is deferred to story 2-7. The stub satisfies the `DisplayInterface` protocol structurally but cannot be used in production or tests until story 2-7 wires in the hardware driver.
|
||||
|
||||
### [2-6] Trail positions are in pixel space — stale after map re-provisioning
|
||||
Story: `2-6-stateful-renderer-and-display-interface`
|
||||
Category: Technical debt
|
||||
Description: Trail entries are `(x, y)` pixel coordinates computed against the `MapBounds` in use at render time. If map bounds change (e.g. after re-provisioning at a different home location or radius), any trails accumulated before the change will plot at incorrect pixel positions on the new map. At runtime this is unlikely — bounds are fixed at startup — but a future enhancement that supports live re-provisioning without restart would need to flush `Renderer._trails` whenever bounds change.
|
||||
|
||||
---
|
||||
|
||||
## Story 3.1: Stale State Detection & Dimmed Display
|
||||
|
||||
### [3-1] Only requests.Timeout is caught — other fetch errors propagate without stale marking
|
||||
Story: `3-1-stale-state-detection-and-dimmed-display`
|
||||
Category: Technical debt
|
||||
Description: `_run_one_cycle` catches only `requests.Timeout`. Other fetch failures — `requests.ConnectionError`, `requests.HTTPError`, and JSON decode errors from dump1090 — propagate to the outer loop boundary and trigger the `except Exception: log.error(...)` handler. That handler retains the last rendered frame but does NOT mark aircraft as `is_stale=True`. As a result, if dump1090 is unreachable (connection refused) rather than slow (timeout), the display will silently show the previous frame without any staleness indication. Intentional limitation for MVP; future hardening would broaden the except clause or add a separate ConnectionError stale path.
|
||||
|
||||
### [3-1] _run_one_cycle parameter count will grow — consider RendererState dataclass
|
||||
Story: `3-1-stale-state-detection-and-dimmed-display`
|
||||
Category: Technical debt
|
||||
Description: `_run_one_cycle` now takes 4 parameters (`renderer`, `fetcher`, `display`, `last_aircraft`). If further per-cycle state is needed (e.g. a stale-cycle counter for escalating display feedback, or a last-successful-fetch timestamp), the signature will grow awkwardly. Future hardening: introduce a `RendererState` dataclass to bundle mutable per-loop state so `_run_one_cycle` receives one state object rather than an expanding parameter list.
|
||||
|
||||
---
|
||||
|
||||
## Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
|
||||
|
||||
### [2-7] WaveshareDisplay SPI driver not yet wired — key production blocker
|
||||
Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring`
|
||||
Category: Infrastructure/environment
|
||||
Description: `WaveshareDisplay.show()` is still a `NotImplementedError` stub — the actual Waveshare SPI driver wiring (using the Waveshare Python library) is not yet done. On a Pi without the HAT attached, or until the driver is wired, calling `main()` will crash immediately on `display.show(startup)`. This is the key production blocker before the radar loop can run on real hardware.
|
||||
|
||||
### [2-7] main() crashes immediately on Pi without HAT
|
||||
Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring`
|
||||
Category: Infrastructure/environment
|
||||
Description: `main()` instantiates `WaveshareDisplay` unconditionally and calls `display.show(startup)` before the radar loop. On a Pi without the Waveshare HAT physically attached, the service will crash immediately. This is correct for production deployment but means the service cannot be run without the HAT even for integration testing. Systemd `Restart=always` will retry indefinitely until hardware is attached.
|
||||
|
||||
### [2-7] Dumb fixed sleep — no compensation for render time
|
||||
Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring`
|
||||
Category: Technical debt
|
||||
Description: `time.sleep(REFRESH_INTERVAL_S)` is a dumb fixed sleep appended after each cycle. There is no compensation for render time: if `_run_one_cycle` takes 50 seconds, the next cycle starts 110 seconds after the previous one began rather than 60 seconds. Future hardening: compute the remaining sleep as `max(0, REFRESH_INTERVAL_S - cycle_duration)` so the loop stays on a consistent 60-second cadence.
|
||||
|
||||
### [2-7] Startup screen text position is hardcoded — may not be visually centred
|
||||
Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring`
|
||||
Category: Technical debt
|
||||
Description: The startup screen text is drawn at `DISPLAY_WIDTH // 2 - 60, DISPLAY_HEIGHT // 2` (i.e. x=340). The offset of −60 is a pixel-counted approximation, not derived from actual font metrics. Depending on the Pillow version and the default font's rendered width, the text may appear left-biased. Future hardening: use `ImageDraw.textlength()` (Pillow ≥9.2) to compute the real string width and centre precisely.
|
||||
|
||||
---
|
||||
|
||||
## Story 4.1: GPIO Button Hold Detection & LED Feedback
|
||||
|
||||
### [4-1] GPIO pin numbers belong in constants.py
|
||||
Story: `4-1-gpio-button-hold-detection-and-led-feedback`
|
||||
Category: Technical debt
|
||||
Description: `BUTTON_GPIO_PIN = 17` and `LED_GPIO_PIN = 27` are module-level constants in `gpio_ctrl.py`. For production tuning and hardware revision they belong in `constants.py` alongside other hardware-facing constants. Future hardening: move both to `constants.py` and import them into `gpio_ctrl.py` from there.
|
||||
|
||||
### [4-1] ButtonHoldDetector instantiates real gpiozero.Button at __init__
|
||||
Story: `4-1-gpio-button-hold-detection-and-led-feedback`
|
||||
Category: Infrastructure/environment
|
||||
Description: `ButtonHoldDetector.__init__` constructs a `gpiozero.Button` immediately. The radar main loop must construct `ButtonHoldDetector` at startup (not at import time), or the application will fail if GPIO is unavailable when the module is imported. This is currently safe because `gpio_ctrl.py` is not imported at module level in `main.py`, but any future reorganisation that imports it at the top of a module that runs on non-GPIO hardware will raise a `BadPinFactory` error unless a `MockFactory` is active.
|
||||
|
||||
---
|
||||
|
||||
## Story 4.2: Config Wipe, Setup Screen & Return to Provisioning
|
||||
|
||||
### [4-2] ButtonHoldDetector and LEDController raise at startup on Pi without GPIO
|
||||
Story: `4-2-config-wipe-setup-screen-and-return-to-provisioning`
|
||||
Category: Infrastructure/environment
|
||||
Description: `ButtonHoldDetector` and `LEDController` are now instantiated unconditionally in `main()` before the loop begins. On a Pi without GPIO hardware (or without a `MockFactory` active), both constructors will raise a `BadPinFactory` error at startup, crashing the radar service before it can display anything. Same concern as story 4-1. Future hardening: wrap construction in a try/except and fall back to no-op stubs, or defer construction until first use.
|
||||
|
||||
### [4-2] os.execvp replaces process — no cleanup before re-exec
|
||||
Story: `4-2-config-wipe-setup-screen-and-return-to-provisioning`
|
||||
Category: Technical debt
|
||||
Description: `os.execvp` replaces the current process image immediately. Any cleanup that would normally happen at shutdown — flushing log handlers, closing the SPI connection to the e-ink display, releasing GPIO pins — is not performed. Acceptable for MVP: the SPI and GPIO resources will be re-acquired by the provisioning process, and the OS reclaims file descriptors. A future improvement could flush logs and call `display.close()` (if such a method exists) before exec.
|
||||
@@ -0,0 +1,75 @@
|
||||
# generated: 2026-04-22
|
||||
# last_updated: 2026-04-22
|
||||
# project: planeMapper
|
||||
# project_key: NOKEY
|
||||
# tracking_system: file-system
|
||||
# story_location: _bmad-output/implementation-artifacts
|
||||
|
||||
# STATUS DEFINITIONS:
|
||||
# ==================
|
||||
# Epic Status:
|
||||
# - backlog: Epic not yet started
|
||||
# - in-progress: Epic actively being worked on
|
||||
# - done: All stories in epic completed
|
||||
#
|
||||
# Epic Status Transitions:
|
||||
# - backlog → in-progress: Automatically when first story is created (via create-story)
|
||||
# - in-progress → done: Manually when all stories reach 'done' status
|
||||
#
|
||||
# Story Status:
|
||||
# - backlog: Story only exists in epic file
|
||||
# - ready-for-dev: Story file created in stories folder
|
||||
# - in-progress: Developer actively working on implementation
|
||||
# - review: Ready for code review (via Dev's code-review workflow)
|
||||
# - done: Story completed
|
||||
#
|
||||
# Retrospective Status:
|
||||
# - optional: Can be completed but not required
|
||||
# - done: Retrospective has been completed
|
||||
#
|
||||
# WORKFLOW NOTES:
|
||||
# ===============
|
||||
# - Epic transitions to 'in-progress' automatically when first story is created
|
||||
# - Stories can be worked in parallel if team capacity allows
|
||||
# - SM typically creates next story after previous one is 'done' to incorporate learnings
|
||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||
|
||||
generated: 2026-04-22
|
||||
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 done, 2-7 done, epic-2 done, epic-3 done, 3-1 done, 3-2 done, 4-1 done, 4-2 done, epic-4 done
|
||||
project: planeMapper
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
story_location: _bmad-output/implementation-artifacts
|
||||
|
||||
development_status:
|
||||
# Epic 1: Device Setup & Provisioning
|
||||
epic-1: done
|
||||
1-1-project-scaffold-and-verified-entry-points: done
|
||||
1-2-configuration-read-write-wipe: done
|
||||
1-3-wifi-hotspot-and-captive-portal-form: done
|
||||
1-4-location-resolution-icao-and-address: done
|
||||
1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill: done
|
||||
epic-1-retrospective: optional
|
||||
|
||||
# Epic 2: Live Radar Display
|
||||
epic-2: done
|
||||
2-1-aircraft-data-model-and-fetcher: done
|
||||
2-2-coordinate-projection-and-base-map-loading: done
|
||||
2-3-home-marker-and-airspace-outlines: done
|
||||
2-4-altitude-colour-bands-and-aircraft-type-icons: done
|
||||
2-5-per-aircraft-drawing: done
|
||||
2-6-stateful-renderer-and-display-interface: done
|
||||
2-7-operational-radar-loop-startup-screen-and-systemd-wiring: done
|
||||
epic-2-retrospective: optional
|
||||
|
||||
# Epic 3: Stale Data Resilience
|
||||
epic-3: done
|
||||
3-1-stale-state-detection-and-dimmed-display: done
|
||||
3-2-automatic-recovery-on-fresh-decode: done
|
||||
epic-3-retrospective: optional
|
||||
|
||||
# Epic 4: Reset & Reconfiguration
|
||||
epic-4: done
|
||||
4-1-gpio-button-hold-detection-and-led-feedback: done
|
||||
4-2-config-wipe-setup-screen-and-return-to-provisioning: done
|
||||
epic-4-retrospective: optional
|
||||
@@ -0,0 +1,756 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
lastStep: 8
|
||||
status: 'complete'
|
||||
completedAt: '2026-04-22'
|
||||
inputDocuments: ['prd.md']
|
||||
workflowType: 'architecture'
|
||||
project_name: 'planeMapper'
|
||||
user_name: 'Matt.edholm'
|
||||
date: '2026-04-22'
|
||||
---
|
||||
|
||||
# Architecture Decision Document
|
||||
|
||||
_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._
|
||||
|
||||
## Project Context Analysis
|
||||
|
||||
### Requirements Overview
|
||||
|
||||
**Functional Requirements:**
|
||||
33 FRs across 6 functional areas:
|
||||
- Device Setup & Provisioning (FR1–FR11): captive portal, location resolution (ICAO/address), tile download, cache validation, WiFi radio kill
|
||||
- Reset & Recovery (FR12–FR15): GPIO button hold, LED feedback, config wipe, setup screen
|
||||
- Map Display (FR16–FR19): OSM base map, home marker, OpenAIP airspace outlines
|
||||
- Aircraft Display (FR20–FR26): dump1090 fetch, heading arrow, callsign/altitude label, altitude colour bands, type icons, 5-dot trail, MLAT distinction
|
||||
- Stale Data Handling (FR27–FR29): decode gap detection, stale visual indicator, recovery
|
||||
- Refresh Loop & Boot (FR30–FR33): 60s cycle, indefinite loop, power-cycle resume, startup screen
|
||||
|
||||
**Non-Functional Requirements:**
|
||||
- Performance: Full radar render ≤45s on Pi Zero 2W; base map layer pre-composited and cached in memory; dump1090 fetch timeout 5s; SPI transfer only after render complete
|
||||
- Reliability: 72+ hours continuous operation; recovery within 5min of unclean power loss; dump1090 failure must not crash refresh loop
|
||||
- Storage: Tile cache ≤2GB on 16GB SD card; validated at provisioning before WiFi kill
|
||||
- Integration: dump1090 (local JSON), Nominatim (provisioning only), OurAirports (bundled), OpenAIP (cached at provisioning)
|
||||
- Security: WiFi off in operational state; no external calls in operational mode; plaintext config on SD acceptable for single-user personal device
|
||||
|
||||
**Scale & Complexity:**
|
||||
|
||||
- Primary domain: IoT/Embedded Python
|
||||
- Complexity level: Medium
|
||||
- Estimated architectural components: ~6 subsystems
|
||||
|
||||
### Technical Constraints & Dependencies
|
||||
|
||||
- Pi Zero 2W: quad-core Cortex-A53 @ 1GHz, 512MB RAM — strict render budget (45s)
|
||||
- Single USB port — RTL-SDR via OTG adapter; no other USB peripherals
|
||||
- Waveshare 7.3" 6-colour e-ink HAT — SPI interface, 800×480, full-panel refresh only
|
||||
- 16GB SD card — OS + software + tile cache must fit within 2GB tile budget
|
||||
- dump1090 JSON feed is best-effort — callsign, category, altitude may be absent
|
||||
- Permanently offline post-provisioning — all runtime dependencies must be pre-cached
|
||||
|
||||
### Cross-Cutting Concerns Identified
|
||||
|
||||
- **Offline-first:** Every runtime dependency must be pre-resolved and locally available
|
||||
- **Graceful degradation:** Missing ADS-B fields, dump1090 failure, and stale data handled without crash or blank display at every layer
|
||||
- **State isolation:** Provisioning and Operational modes are architecturally distinct; shared code should be minimal and explicit
|
||||
- **Hardware resource budget:** Memory and CPU constraints affect render pipeline design, caching strategy, and tile format choices
|
||||
- **GPIO/render loop concurrency:** Button hold detection and LED feedback must be non-blocking alongside the 60s render cycle
|
||||
|
||||
## Starter Template Evaluation
|
||||
|
||||
### Primary Technology Domain
|
||||
|
||||
IoT/Embedded Python — no formal scaffold generator. Baseline established here.
|
||||
|
||||
### Selected Foundation: src/ layout, pip, pytest
|
||||
|
||||
**Rationale:** `src/` layout prevents import shadowing and supports `pip install -e .` for development. pip + requirements.txt is the correct deployment tool on Pi Zero 2W — no lock-file resolution overhead on-device. gpiozero chosen over RPi.GPIO for its MockFactory support, enabling off-hardware GPIO testing.
|
||||
|
||||
**Runtime:**
|
||||
- Python 3.11 (Raspberry Pi OS Bookworm default)
|
||||
- Pure Python, no compilation step — git pull on Pi is the deployment path
|
||||
|
||||
**Dependencies (current versions):**
|
||||
- Pillow 12.2.0 — image composition and rendering
|
||||
- gpiozero 2.0.1 — GPIO button/LED; MockFactory for off-hardware testing
|
||||
- Flask 3.1.3 — captive portal HTTP server (provisioning only)
|
||||
- requests 2.33.1 — dump1090 JSON fetch, Nominatim geocoding (provisioning only)
|
||||
|
||||
**Testing:**
|
||||
- pytest 9.0.3
|
||||
- gpiozero MockFactory for GPIO boundary tests
|
||||
- `DisplayInterface` protocol (ABC) — real `WaveshareDisplay` + `NullDisplay` for testing
|
||||
- `FetcherInterface` protocol — real HTTP fetcher + `FileFixtureFetcher` from JSON fixture
|
||||
- Stateful `Renderer` owns tile composite cache and trail history — enables isolated unit tests
|
||||
|
||||
**Linting/Formatting:**
|
||||
- ruff 0.15.11 — single-tool replacement for flake8 + black + isort
|
||||
|
||||
**Project Scaffold:**
|
||||
```
|
||||
planeMapper/
|
||||
├── src/
|
||||
│ └── planemapper/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # entry point — operational radar loop
|
||||
│ ├── provision.py # entry point — captive portal + provisioning
|
||||
│ ├── provisioning/ # portal, geocoding, tile download, WiFi kill
|
||||
│ ├── renderer/ # stateful Renderer: tile composite + trail history
|
||||
│ ├── fetcher.py # FetcherInterface + HTTP impl + FileFixtureFetcher
|
||||
│ ├── gpio_ctrl.py # button hold detection + LED via gpiozero
|
||||
│ └── display.py # DisplayInterface + WaveshareDisplay + NullDisplay
|
||||
├── tests/
|
||||
├── pyproject.toml
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
**Key structural decisions:**
|
||||
- Two process entry points: `planemapper-provision` and `planemapper-radar` — provisioning and operational code never share a runtime context
|
||||
- `Renderer` is long-lived across the 60s loop — owns tile composite (cached in memory) and trail history `dict[str, deque[Position]]`; state is lost on restart (acceptable)
|
||||
- Render pipeline is phase-instrumented: tile retrieval, overlay render, SPI transfer logged individually; warn at 40s total, alert at 50s
|
||||
- Systemd `Restart=always` — no state persistence across restarts; tile files on disk are the only durable operational state
|
||||
|
||||
**Service structure:**
|
||||
- Two systemd units: `planemapper-provision.service` (runs once at first boot / post-reset, exits on completion) and `planemapper-radar.service` (perpetual, `After=` provision)
|
||||
|
||||
## Core Architectural Decisions
|
||||
|
||||
### Decision Priority Analysis
|
||||
|
||||
**Critical Decisions (Block Implementation):**
|
||||
- Map background strategy: pre-composited PNG at provisioning
|
||||
- Config file format and location
|
||||
- Stale data definition and visual treatment
|
||||
- Captive portal technology stack
|
||||
|
||||
**Important Decisions (Shape Architecture):**
|
||||
- Airspace data format and caching strategy
|
||||
- Logging destination
|
||||
- Render pipeline instrumentation thresholds
|
||||
|
||||
**Deferred Decisions (Post-MVP):**
|
||||
- SD card image build automation (manual flash acceptable for MVP)
|
||||
- OSM tile zoom level tuning (implementation detail, tuned during development)
|
||||
|
||||
---
|
||||
|
||||
### Data Architecture
|
||||
|
||||
**Map Background**
|
||||
- Strategy: pre-composited single `background.png` (800×480) generated during provisioning; loaded once into Renderer memory at radar startup
|
||||
- Tile download source: tile.openstreetmap.org (single bulk download at provisioning, acceptable for personal device use)
|
||||
- Zoom level: determined at provisioning time from coverage radius; baked into background.png; not stored separately in config
|
||||
- Rationale: eliminates all tile I/O from the operational render loop; background is fixed for a given home location and radius
|
||||
|
||||
**Config File**
|
||||
- Format: JSON (Python stdlib, zero extra deps)
|
||||
- Path: `/etc/planemapper/config.json`
|
||||
- Contents: home lat/lon, coverage radius (nm), WiFi SSID/password, provisioning state flag
|
||||
- Accessible to both `planemapper-provision` and `planemapper-radar` services
|
||||
- On reset: config file wiped by provision service before returning to portal state
|
||||
|
||||
**Airspace Data**
|
||||
- Format: GeoJSON (OpenAIP API, downloaded during provisioning)
|
||||
- Path: `/etc/planemapper/airspace.geojson`
|
||||
- Rendered as circular outlines only (MVP); colour fills deferred to Phase 2
|
||||
- No runtime network dependency — purely cached local data
|
||||
|
||||
**Trail History**
|
||||
- Storage: in-memory only — `dict[str, deque[Position]]` inside Renderer, max 5 entries per ICAO hex
|
||||
- Persistence: none — lost on restart (acceptable; cosmetic data only)
|
||||
|
||||
---
|
||||
|
||||
### Authentication & Security
|
||||
|
||||
All decisions established by PRD:
|
||||
- WiFi radio killed via `rfkill block wifi` after successful provisioning
|
||||
- Captive portal is open, local-only, and short-lived — no auth required
|
||||
- Config stored plaintext on SD card — acceptable for single-user personal device
|
||||
- No external network calls in operational state — network attack surface is zero
|
||||
|
||||
---
|
||||
|
||||
### Captive Portal Stack
|
||||
|
||||
- **hostapd** — manages Wi-Fi AP mode (`planeMapper-setup` SSID)
|
||||
- **dnsmasq** — DHCP server + DNS resolver (resolves all queries to Pi IP, triggering captive portal detection on phones)
|
||||
- **Flask 3.1.3** — serves setup UI, handles form submission, orchestrates provisioning sequence
|
||||
- Portal flow: AP up → user connects → dnsmasq redirects DNS → Flask intercepts HTTP probe → portal page served → user submits → Flask joins home WiFi, downloads tiles, validates, kills WiFi radio
|
||||
|
||||
---
|
||||
|
||||
### Stale Data Handling
|
||||
|
||||
- **Threshold:** 1 missed fetch cycle (60 seconds) = stale state
|
||||
- **Trigger:** dump1090 HTTP fetch returns error, times out (>5s), or returns empty aircraft list when previously non-empty
|
||||
- **Visual indicator:** stale aircraft rendered as outlines only (no fill) — effectively dimmed; last known positions retained on display
|
||||
- **Recovery:** next successful fetch restores normal filled rendering automatically
|
||||
- **Unambiguous parity:** slow render (>60s) treated identically to decode gap — same stale path, no separate handling
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
**Systemd Units**
|
||||
- `planemapper-provision.service` — runs at first boot or post-reset; exits cleanly on completion; Type=oneshot
|
||||
- `planemapper-radar.service` — perpetual operational loop; `After=planemapper-provision.service`; `Restart=always`
|
||||
|
||||
**Logging**
|
||||
- Destination: stdout → systemd journal (journald captures automatically)
|
||||
- Access: `journalctl -u planemapper-radar -f`
|
||||
- No log rotation config needed — journald handles retention
|
||||
|
||||
**Render Pipeline Instrumentation**
|
||||
- Phase timing logged each cycle: tile load, aircraft overlay, SPI transfer
|
||||
- Warn threshold: total render > 40s
|
||||
- Alert threshold: total render > 50s
|
||||
- Stale path triggered if render exceeds 60s cycle boundary
|
||||
|
||||
**Deployment**
|
||||
- Git pull on Pi — no build step required (pure Python)
|
||||
- `pip install -e .` for dev; `pip install .` for production install
|
||||
- SD card reflash is the update path for OS-level changes
|
||||
|
||||
## Implementation Patterns & Consistency Rules
|
||||
|
||||
### Critical Conflict Points Identified
|
||||
|
||||
7 areas where AI agents could make different choices without explicit rules.
|
||||
|
||||
---
|
||||
|
||||
### Data Type Patterns
|
||||
|
||||
**Aircraft Data — `@dataclass` with optional fields:**
|
||||
```python
|
||||
@dataclass
|
||||
class Aircraft:
|
||||
icao: str
|
||||
lat: float
|
||||
lon: float
|
||||
heading: float = 0.0
|
||||
altitude_ft: int = 0
|
||||
callsign: str = ""
|
||||
category: str = ""
|
||||
is_mlat: bool = False
|
||||
is_stale: bool = False
|
||||
```
|
||||
- All ADS-B optional fields default to a safe sentinel value
|
||||
- Stale flag carried on the dataclass, not inferred at render time
|
||||
- All internal code works with `Aircraft` instances, never raw dicts
|
||||
- Fetcher converts dump1090 JSON → `Aircraft` at the boundary; nothing downstream touches raw JSON
|
||||
|
||||
**Position trail:**
|
||||
```python
|
||||
from collections import deque
|
||||
trails: dict[str, deque[tuple[float, float]]] = {} # icao → deque[(lat, lon)]
|
||||
```
|
||||
Max 5 entries per aircraft, oldest entry at index 0.
|
||||
|
||||
---
|
||||
|
||||
### Coordinate Patterns
|
||||
|
||||
**Convention: `(lat, lon)` throughout all internal code.**
|
||||
|
||||
- All `Aircraft` fields, all internal function signatures, all pixel projection calls use `(lat, lon)` order
|
||||
- GeoJSON parsing (airspace data) explicitly reverses at the parse boundary:
|
||||
`lat, lon = feature["geometry"]["coordinates"][1], feature["geometry"]["coordinates"][0]`
|
||||
- The projection function in `renderer/` is the single location where `(lat, lon)` → `(x, y)` pixel conversion happens; nothing else does projection
|
||||
- Anti-pattern: never pass `(lon, lat)` to any internal function
|
||||
|
||||
---
|
||||
|
||||
### Units Patterns
|
||||
|
||||
**Altitude: feet throughout — preserve dump1090 native units.**
|
||||
|
||||
- Altitude band thresholds defined in feet in `constants.py`
|
||||
- No metres conversion anywhere in the codebase
|
||||
- `altitude_ft: int` field name makes units explicit
|
||||
|
||||
---
|
||||
|
||||
### Interface Patterns
|
||||
|
||||
**Hardware boundaries use `typing.Protocol`:**
|
||||
```python
|
||||
from typing import Protocol
|
||||
|
||||
class DisplayInterface(Protocol):
|
||||
def show(self, image: Image.Image) -> None: ...
|
||||
|
||||
class FetcherInterface(Protocol):
|
||||
def fetch(self) -> list[Aircraft]: ...
|
||||
```
|
||||
- No explicit inheritance required — `NullDisplay`, `WaveshareDisplay`, `FileFixtureFetcher`, and `HttpFetcher` simply implement the method signatures
|
||||
- Protocols live in their respective module files (`display.py`, `fetcher.py`)
|
||||
- All production code typed against the Protocol, never the concrete class
|
||||
|
||||
---
|
||||
|
||||
### Constants Patterns
|
||||
|
||||
**Single `src/planemapper/constants.py` — all project-wide fixed values live here:**
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
# Display geometry
|
||||
DISPLAY_WIDTH = 800
|
||||
DISPLAY_HEIGHT = 480
|
||||
|
||||
# Timing
|
||||
REFRESH_INTERVAL_S = 60
|
||||
FETCH_TIMEOUT_S = 5
|
||||
RENDER_WARN_S = 40
|
||||
RENDER_ALERT_S = 50
|
||||
STALE_CYCLES = 1
|
||||
RESET_HOLD_S = 3
|
||||
|
||||
# Altitude band upper bounds (feet) — index maps to ALTITUDE_COLOURS
|
||||
ALTITUDE_BANDS_FT = [1500, 5000, 10000, 20000, 35000, 99999]
|
||||
|
||||
# E-ink 6-colour palette (Waveshare Spectra 6: black, white, red, yellow, blue, green)
|
||||
# Each tuple is an RGB value as used by Pillow
|
||||
COLOUR_BLACK = (0, 0, 0)
|
||||
COLOUR_WHITE = (255, 255, 255)
|
||||
COLOUR_RED = (255, 0, 0)
|
||||
COLOUR_YELLOW = (255, 255, 0)
|
||||
COLOUR_BLUE = (0, 0, 255)
|
||||
COLOUR_GREEN = (0, 255, 0)
|
||||
|
||||
# Altitude band → display colour (index aligns with ALTITUDE_BANDS_FT)
|
||||
ALTITUDE_COLOURS = [
|
||||
COLOUR_GREEN, # surface – 1,500ft
|
||||
COLOUR_BLUE, # 1,500 – 5,000ft
|
||||
COLOUR_YELLOW, # 5,000 – 10,000ft
|
||||
COLOUR_RED, # 10,000 – 20,000ft
|
||||
COLOUR_BLACK, # 20,000 – 35,000ft
|
||||
COLOUR_WHITE, # 35,000ft+
|
||||
]
|
||||
|
||||
# UI colours
|
||||
COLOUR_STALE_OUTLINE = COLOUR_BLACK # outline-only colour for stale aircraft
|
||||
COLOUR_HOME_MARKER = COLOUR_RED
|
||||
COLOUR_AIRSPACE = COLOUR_BLUE
|
||||
COLOUR_TRAIL = COLOUR_BLACK
|
||||
|
||||
# Trail
|
||||
TRAIL_MAX_DOTS = 5
|
||||
TRAIL_DOT_SIZE_MAX = 6 # px, most recent dot
|
||||
TRAIL_DOT_SIZE_MIN = 2 # px, oldest dot
|
||||
|
||||
# Paths
|
||||
CONFIG_PATH = Path("/etc/planemapper/config.json")
|
||||
AIRSPACE_PATH = Path("/etc/planemapper/airspace.geojson")
|
||||
BACKGROUND_PATH = Path("/etc/planemapper/background.png")
|
||||
```
|
||||
- No module hardcodes a value that appears in `constants.py` — colours, sizes,
|
||||
paths, timing, and thresholds all live here
|
||||
- Anti-pattern: `(255, 0, 0)` inline anywhere; `if altitude > 10000` outside
|
||||
constants logic; `time.sleep(60)` with a literal
|
||||
|
||||
---
|
||||
|
||||
### Type Annotation Patterns
|
||||
|
||||
**All function signatures annotated, all dataclass fields typed:**
|
||||
```python
|
||||
# Correct
|
||||
def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]: ...
|
||||
|
||||
# Anti-pattern
|
||||
def project(lat, lon, bounds): ...
|
||||
```
|
||||
- ruff enforces annotation presence
|
||||
- Return types always specified
|
||||
- `Optional[X]` used where None is a valid return
|
||||
|
||||
---
|
||||
|
||||
### Logging Patterns
|
||||
|
||||
**Levels:**
|
||||
|
||||
| Level | When |
|
||||
|---|---|
|
||||
| `DEBUG` | Per-aircraft render decisions, individual fetch field parsing |
|
||||
| `INFO` | Each render cycle start/complete with phase timings |
|
||||
| `WARNING` | Render > 40s, stale state entered or exited |
|
||||
| `ERROR` | Fetch failure, SPI transfer failure, required file not found |
|
||||
|
||||
**Format:** stdlib `logging` module, no custom formatter — journald adds timestamps and service context automatically.
|
||||
|
||||
```python
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
# Usage: log.info("render complete: tile=%.1fs overlay=%.1fs spi=%.1fs", t1, t2, t3)
|
||||
```
|
||||
|
||||
Anti-pattern: `print()` anywhere in production code; `log.error()` for expected conditions (stale data is WARNING, not ERROR).
|
||||
|
||||
---
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**Render loop must not crash — single try/except at the loop boundary:**
|
||||
```python
|
||||
while True:
|
||||
try:
|
||||
_run_one_cycle(renderer, fetcher, display)
|
||||
except Exception:
|
||||
log.error("cycle failed", exc_info=True)
|
||||
# stale path — renderer retains last good frame
|
||||
time.sleep(REFRESH_INTERVAL_S)
|
||||
```
|
||||
- Internal functions raise exceptions normally; the loop catches all
|
||||
- Fetcher raises on failure; main loop catches and triggers stale path
|
||||
- No bare `except:` anywhere except the top-level loop boundary
|
||||
|
||||
---
|
||||
|
||||
### All AI Agents MUST:
|
||||
|
||||
1. Use `Aircraft` dataclass — never pass raw dicts beyond the fetch boundary
|
||||
2. Use `(lat, lon)` order — reverse GeoJSON at parse time only
|
||||
3. Import all magic numbers from `constants.py` — no inline literals for thresholds, paths, or timing values
|
||||
4. Annotate all function signatures
|
||||
5. Log at the correct level per the table above
|
||||
6. Use `typing.Protocol` for hardware interfaces — never type against concrete classes
|
||||
7. Raise exceptions from internal functions; catch only at the render loop boundary
|
||||
|
||||
## Project Structure & Boundaries
|
||||
|
||||
### Complete Project Directory Structure
|
||||
|
||||
```
|
||||
planeMapper/
|
||||
├── pyproject.toml # Package metadata + entry points + ruff config
|
||||
├── requirements.txt # Pinned runtime deps for pip install on Pi
|
||||
├── requirements-dev.txt # pytest, ruff, gpiozero[mock]
|
||||
├── .gitignore
|
||||
├── systemd/
|
||||
│ ├── planemapper-provision.service # Type=oneshot; runs at first boot / post-reset
|
||||
│ └── planemapper-radar.service # Restart=always; After=planemapper-provision
|
||||
├── data/
|
||||
│ └── airports.csv # Bundled OurAirports DB for ICAO lookup (FR4)
|
||||
├── src/
|
||||
│ └── planemapper/
|
||||
│ ├── __init__.py
|
||||
│ ├── constants.py # All magic numbers, paths, thresholds
|
||||
│ ├── models.py # Aircraft dataclass only — cross-boundary types
|
||||
│ ├── main.py # Entry: operational radar loop (FR30–FR33)
|
||||
│ ├── provision.py # Entry: provisioning loop with ProvisioningError
|
||||
│ │ # recovery — never imports from renderer/
|
||||
│ ├── fetcher.py # FetcherInterface Protocol + HttpFetcher
|
||||
│ │ # + FileFixtureFetcher (FR20, FR27–FR29)
|
||||
│ ├── gpio_ctrl.py # ButtonHoldDetector + LEDController via
|
||||
│ │ # gpiozero (FR12–FR13)
|
||||
│ ├── display.py # DisplayInterface Protocol + WaveshareDisplay
|
||||
│ │ # + NullDisplay (FR30, FR33)
|
||||
│ ├── provisioning/
|
||||
│ │ ├── __init__.py # ProvisioningError exception definition
|
||||
│ │ ├── portal.py # Flask app + routes + form handling (FR1–FR2,
|
||||
│ │ │ # FR6–FR8, FR11)
|
||||
│ │ ├── location.py # ICAO lookup (OurAirports) + Nominatim
|
||||
│ │ │ # geocoding (FR3–FR5)
|
||||
│ │ ├── tiles.py # OSM tile download + background.png
|
||||
│ │ │ # composition + cache validation (FR9, FR9a)
|
||||
│ │ ├── airspace.py # OpenAIP GeoJSON download + cache (FR19 data)
|
||||
│ │ ├── wifi.py # hostapd/dnsmasq/rfkill — raises
|
||||
│ │ │ # ProvisioningError on subprocess failure
|
||||
│ │ └── config.py # Config read/write/wipe — single module
|
||||
│ │ # touching /etc/planemapper/config.json
|
||||
│ └── renderer/
|
||||
│ ├── __init__.py
|
||||
│ ├── renderer.py # Stateful Renderer: owns PIL composite +
|
||||
│ │ # trail history dict (FR21–FR29)
|
||||
│ ├── projection.py # (lat,lon) → (x,y) pixel + MapBounds dataclass
|
||||
│ ├── basemap.py # background.png load + memory hold (FR16–FR17)
|
||||
│ ├── aircraft.py # Per-aircraft draw: arrow, label, trail,
|
||||
│ │ # stale outline (FR21–FR26, FR28)
|
||||
│ ├── airspace.py # Airspace GeoJSON → outline draw (FR19)
|
||||
│ ├── colours.py # altitude_ft → display colour (FR23)
|
||||
│ └── icons.py # ADS-B category + callsign → icon type (FR24–FR24a)
|
||||
└── tests/
|
||||
├── conftest.py # Shared fixtures: MockFactory, NullDisplay,
|
||||
│ # FileFixtureFetcher, sample_config (patches
|
||||
│ # CONFIG_PATH to tmp_path — no /etc/ dependency)
|
||||
├── fixtures/
|
||||
│ ├── aircraft_sample.json # Fixture library: happy path, missing callsign,
|
||||
│ │ # missing altitude, MLAT flag, empty list,
|
||||
│ │ # altitude band boundary values
|
||||
│ └── airspace_sample.geojson # OpenAIP-format fixture
|
||||
├── test_fetcher.py # HttpFetcher timeout, stale trigger, field parsing
|
||||
├── test_models.py # Aircraft defaults, optional field handling
|
||||
├── test_projection.py # Projection correctness, boundary cases
|
||||
├── test_colours.py # Altitude band thresholds and edge values
|
||||
├── test_icons.py # Type classification: category, callsign, altitude fallback
|
||||
├── test_renderer.py # Trail accumulation, stale flag, outline render
|
||||
├── test_pipeline.py # Smoke: FileFixtureFetcher → Renderer → NullDisplay
|
||||
│ # one full cycle end-to-end
|
||||
├── test_gpio_ctrl.py # Button hold timing, LED state via MockFactory
|
||||
└── provisioning/
|
||||
├── test_location.py # ICAO lookup, Nominatim response parsing
|
||||
├── test_tiles.py # Tile compositing, cache validation logic
|
||||
├── test_config.py # Config read/write/wipe cycle (uses sample_config)
|
||||
└── test_provision_loop.py # ProvisioningError → reset_to_portal_state()
|
||||
```
|
||||
|
||||
### Entry Points (pyproject.toml)
|
||||
|
||||
```toml
|
||||
[project.scripts]
|
||||
planemapper-radar = "planemapper.main:main"
|
||||
planemapper-provision = "planemapper.provision:main"
|
||||
```
|
||||
|
||||
Systemd units invoke these console scripts directly. No shell wrappers.
|
||||
|
||||
---
|
||||
|
||||
### Architectural Boundaries
|
||||
|
||||
**Fetch boundary — `fetcher.py`**
|
||||
Converts dump1090 JSON → `list[Aircraft]`. Raises on timeout/error. Nothing beyond touches raw JSON.
|
||||
|
||||
**Render boundary — `renderer/renderer.py`**
|
||||
Accepts `list[Aircraft]`, returns `PIL.Image`. Owns trail history and stale state. Nothing outside `renderer/` calls Pillow draw primitives.
|
||||
|
||||
**Display boundary — `display.py`**
|
||||
Accepts `PIL.Image`, drives SPI. `NullDisplay` logs + no-ops for tests.
|
||||
|
||||
**GPIO boundary — `gpio_ctrl.py`**
|
||||
`ButtonHoldDetector.check() -> bool` — non-blocking, polled once per cycle.
|
||||
|
||||
**Config boundary — `provisioning/config.py`**
|
||||
Single module reading/writing `/etc/planemapper/config.json`. Tests patch `CONFIG_PATH` to `tmp_path` via `conftest.py`.
|
||||
|
||||
**Provisioning boundary — `provision.py`**
|
||||
Never imported by `main.py`. Separate process entry point. Loop structure:
|
||||
```python
|
||||
while not provisioned:
|
||||
try:
|
||||
run_provisioning_sequence()
|
||||
provisioned = True
|
||||
except ProvisioningError as e:
|
||||
log.error("provisioning failed: %s", e)
|
||||
reset_to_portal_state()
|
||||
```
|
||||
|
||||
**wifi.py subprocess boundary**
|
||||
Every `rfkill`/`hostapd`/`dnsmasq` call checks return code explicitly. Raises `ProvisioningError` on failure. No silent partial state.
|
||||
|
||||
---
|
||||
|
||||
### Requirements → Structure Mapping
|
||||
|
||||
| FR Group | FRs | Primary Location |
|
||||
|---|---|---|
|
||||
| Device Setup & Provisioning | FR1–FR11 | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py` |
|
||||
| Reset & Recovery | FR12–FR15 | `gpio_ctrl.py`, `provisioning/config.py`, `provision.py` |
|
||||
| Map Display | FR16–FR19 | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` |
|
||||
| Aircraft Display | FR20–FR26 | `fetcher.py`, `renderer/aircraft.py`, `renderer/colours.py`, `renderer/icons.py` |
|
||||
| Stale Data Handling | FR27–FR29 | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) |
|
||||
| Refresh Loop & Boot | FR30–FR33 | `main.py`, `display.py` |
|
||||
|
||||
---
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
main.py (60s loop)
|
||||
│
|
||||
├─→ fetcher.fetch() → list[Aircraft] (raises on failure → stale path)
|
||||
├─→ renderer.render(aircraft) → PIL.Image (holds composite + trail in memory)
|
||||
├─→ display.show(image) (SPI; NullDisplay in tests)
|
||||
└─→ gpio_ctrl.check() → bool (reset? → exec provision.py)
|
||||
|
||||
provision.py (one-shot loop)
|
||||
│
|
||||
├─→ wifi.start_ap() → hostapd + dnsmasq (raises ProvisioningError on fail)
|
||||
├─→ portal.run() → Flask blocks until user submits
|
||||
├─→ location.resolve() → (lat, lon)
|
||||
├─→ wifi.join_home() → connects home WiFi
|
||||
├─→ tiles.download() → background.png composited + validated
|
||||
├─→ airspace.download() → airspace.geojson cached
|
||||
├─→ config.write() → /etc/planemapper/config.json
|
||||
└─→ wifi.kill() → rfkill block wifi (raises ProvisioningError on fail)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### External Integration Points
|
||||
|
||||
| Integration | When | Module |
|
||||
|---|---|---|
|
||||
| dump1090 JSON (`localhost:8080`) | Every 60s in operational mode | `fetcher.py` |
|
||||
| Nominatim geocoding API | Once during provisioning | `provisioning/location.py` |
|
||||
| tile.openstreetmap.org | Once during provisioning | `provisioning/tiles.py` |
|
||||
| OpenAIP API | Once during provisioning | `provisioning/airspace.py` |
|
||||
| OurAirports CSV | Bundled; read at provisioning | `provisioning/location.py` |
|
||||
|
||||
## Architecture Validation Results
|
||||
|
||||
### Coherence Validation ✅
|
||||
|
||||
**Decision Compatibility:**
|
||||
All packages compatible on Pi Zero 2W / Raspberry Pi OS Bookworm: Python 3.11,
|
||||
Pillow 12.2.0, gpiozero 2.0.1, Flask 3.1.3, requests 2.33.1. All runtime
|
||||
dependencies are pip-installable with no build toolchain. `typing.Protocol`,
|
||||
`dataclasses`, and `json` are Python 3.11 stdlib — zero dependency risk.
|
||||
|
||||
**Pattern Consistency:**
|
||||
`Aircraft` dataclass flows cleanly through fetch → render → display pipeline.
|
||||
`(lat, lon)` convention documented with single explicit reversal point at GeoJSON
|
||||
parse boundary. `constants.py` is the single source for all thresholds, paths,
|
||||
colours, sizes, and timing values. `typing.Protocol` hardware interfaces align
|
||||
with test double strategy. Logging levels, error handling boundary, and type
|
||||
annotation rules are internally consistent.
|
||||
|
||||
**Structure Alignment:**
|
||||
Two entry points map directly to two systemd units. `provisioning/` import
|
||||
boundary enforced by ruff rule. `renderer/` owns all Pillow draw calls. `tests/`
|
||||
mirrors `src/` structure with full fixture library. `conftest.py` patches
|
||||
`CONFIG_PATH` to `tmp_path` — no `/etc/` dependency in CI.
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage Validation ✅
|
||||
|
||||
**Functional Requirements (33/33 covered):**
|
||||
|
||||
| FR Group | Coverage |
|
||||
|---|---|
|
||||
| Device Setup & Provisioning (FR1–FR11) | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py`, `config.py` |
|
||||
| Reset & Recovery (FR12–FR15) | `gpio_ctrl.py`, `config.py`, reset flow via `os.execvp` in `main.py` |
|
||||
| Map Display (FR16–FR19) | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` |
|
||||
| Aircraft Display (FR20–FR26) | `fetcher.py`, `renderer/aircraft.py`, `colours.py`, `icons.py` |
|
||||
| Stale Data Handling (FR27–FR29) | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) |
|
||||
| Refresh Loop & Boot (FR30–FR33) | `main.py`, `display.py` |
|
||||
|
||||
**Non-Functional Requirements:**
|
||||
- Performance: base map cached in memory; render phases instrumented; dump1090 fetch timeout 5s; SPI transfer after render complete ✅
|
||||
- Reliability: `Restart=always`; loop boundary try/except isolates dump1090 failures; power recovery via systemd ✅
|
||||
- Storage: tile cache ≤2GB validated in `tiles.py` before WiFi kill ✅
|
||||
- Security: WiFi killed via `rfkill`; no external calls in operational state ✅
|
||||
|
||||
---
|
||||
|
||||
### Gaps Found & Resolved
|
||||
|
||||
**Gap 1 — Reset flow mechanics (FR12–FR15)** — RESOLVED
|
||||
`main.py` reset handler:
|
||||
1. Calls `config.wipe()`
|
||||
2. Calls `display.show(setup_screen_image)` (FR15)
|
||||
3. Calls `os.execvp('planemapper-provision', ['planemapper-provision'])`
|
||||
|
||||
systemd sees `planemapper-radar` exit → restarts → provision runs → writes config → exits → systemd restarts radar into operational mode. No IPC required.
|
||||
|
||||
**Gap 2 — OurAirports bundled data** — RESOLVED
|
||||
`airports.csv` moved to `src/planemapper/data/airports.csv`. Accessed via `importlib.resources`. pyproject.toml:
|
||||
```toml
|
||||
[tool.setuptools.package-data]
|
||||
"planemapper" = ["data/airports.csv"]
|
||||
```
|
||||
|
||||
**Gap 3 — constants.py scope clarification** — RESOLVED
|
||||
`constants.py` scope expanded to include: full 6-colour palette with semantic
|
||||
mappings, trail dot sizing, reset hold time, and all UI colours. No inline RGB
|
||||
tuples, no literal sleeps, no hardcoded paths anywhere in the codebase.
|
||||
|
||||
---
|
||||
|
||||
### Corrected Project Structure (delta from step 6)
|
||||
|
||||
```
|
||||
src/
|
||||
└── planemapper/
|
||||
├── data/
|
||||
│ └── airports.csv # Moved here from top-level data/; accessed via
|
||||
│ # importlib.resources in provisioning/location.py
|
||||
└── ...
|
||||
```
|
||||
Top-level `data/` directory removed.
|
||||
|
||||
`main.py` reset sequence:
|
||||
```
|
||||
gpio_ctrl.check() → True
|
||||
→ config.wipe()
|
||||
→ display.show(setup_screen)
|
||||
→ os.execvp('planemapper-provision', ['planemapper-provision'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Architecture Completeness Checklist
|
||||
|
||||
**✅ Requirements Analysis**
|
||||
- [x] Project context thoroughly analysed
|
||||
- [x] Scale and complexity assessed (Medium, IoT/Embedded Python)
|
||||
- [x] Technical constraints identified (Pi Zero 2W, 512MB RAM, 45s budget)
|
||||
- [x] Cross-cutting concerns mapped (offline-first, graceful degradation, state isolation)
|
||||
|
||||
**✅ Architectural Decisions**
|
||||
- [x] Critical decisions documented with verified versions
|
||||
- [x] Technology stack fully specified (Python 3.11, all deps pinned)
|
||||
- [x] Integration patterns defined (fetch/render/display/GPIO boundaries)
|
||||
- [x] Performance considerations addressed (phase instrumentation, memory caching)
|
||||
|
||||
**✅ Implementation Patterns**
|
||||
- [x] Data type convention established (Aircraft dataclass)
|
||||
- [x] Coordinate convention defined ((lat, lon) throughout)
|
||||
- [x] Units convention defined (feet throughout)
|
||||
- [x] Interface style defined (typing.Protocol)
|
||||
- [x] Constants centralised (constants.py) — includes colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing
|
||||
- [x] Type annotations required throughout
|
||||
- [x] Logging levels defined
|
||||
- [x] Error handling pattern defined (raise inside, catch at loop boundary)
|
||||
|
||||
**✅ Project Structure**
|
||||
- [x] Complete directory structure defined with all files
|
||||
- [x] Component boundaries established and enforced
|
||||
- [x] Integration points mapped to specific modules
|
||||
- [x] All 33 FRs mapped to specific files
|
||||
- [x] Test structure mirrors src with fixture library
|
||||
|
||||
---
|
||||
|
||||
### Architecture Readiness Assessment
|
||||
|
||||
**Overall Status: READY FOR IMPLEMENTATION**
|
||||
|
||||
**Confidence Level: High**
|
||||
|
||||
**Key Strengths:**
|
||||
- Hard boundary between provisioning and operational modes eliminates the largest class of runtime bugs for this type of device
|
||||
- Stateful Renderer with in-memory composite eliminates tile I/O from the hot path
|
||||
- Hardware interfaces (Protocol) enable full test coverage without physical hardware
|
||||
- `ProvisioningError` + loop recovery ensures no silent partial-provisioning state
|
||||
- Reset flow via `os.execvp` is clean, testable, and requires no additional service dependencies
|
||||
- `constants.py` as single source of truth for all project-wide values prevents colour/threshold drift across modules
|
||||
|
||||
**Areas for Future Enhancement (post-MVP):**
|
||||
- Airspace colour fills (Phase 2 per PRD)
|
||||
- Own squawk code highlighting (Phase 2)
|
||||
- Aircraft size coding (Phase 2)
|
||||
- SD card image build automation
|
||||
- **E-ink refresh speed experiment:** once working prototype exists, benchmark SPI
|
||||
clock speed (Waveshare library default vs. 10–20MHz) and test any fast/partial
|
||||
refresh modes available on the Spectra 6 HAT. `REFRESH_INTERVAL_S` in
|
||||
`constants.py` is the only change needed if cycle time can be reduced.
|
||||
|
||||
---
|
||||
|
||||
### Implementation Handoff
|
||||
|
||||
**First implementation task:** project scaffold — `src/` layout, `pyproject.toml` with both entry points and package data, `requirements.txt`, `requirements-dev.txt`, empty module files with correct imports, `systemd/` unit files, and `pip install -e .` verified.
|
||||
|
||||
**All AI Agents MUST:**
|
||||
- Follow all architectural decisions exactly as documented
|
||||
- Use implementation patterns in Section 5 consistently — 7 mandatory rules apply
|
||||
- `main.py` must not import from `planemapper.provisioning.*` — ruff enforces this
|
||||
- All hardware boundaries typed against Protocols, never concrete classes
|
||||
- All fixed values — numbers, colours, paths, sizes — imported from `constants.py`
|
||||
@@ -0,0 +1,609 @@
|
||||
---
|
||||
stepsCompleted: [step-01-validate-prerequisites, step-02-design-epics, step-03-create-stories, step-04-final-validation]
|
||||
inputDocuments:
|
||||
- _bmad-output/planning-artifacts/prd.md
|
||||
- _bmad-output/planning-artifacts/architecture.md
|
||||
---
|
||||
|
||||
# planeMapper - Epic Breakdown
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides the complete epic and story breakdown for planeMapper, decomposing the requirements from the PRD, UX Design if it exists, and Architecture requirements into implementable stories.
|
||||
|
||||
## Requirements Inventory
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
FR1: The device broadcasts a WiFi hotspot on first boot and after reset
|
||||
FR2: The user can connect to the device hotspot and be served a setup interface automatically (captive portal)
|
||||
FR3: The user can enter a location as an ICAO code or address/postcode
|
||||
FR4: The device resolves an ICAO code to coordinates using a bundled airport database
|
||||
FR5: The device resolves an address or postcode to coordinates using a geocoding service
|
||||
FR6: The device displays the resolved location for user confirmation before proceeding
|
||||
FR7: The user can set a coverage radius
|
||||
FR8: The user can enter home WiFi credentials during setup
|
||||
FR9: The device connects to the user's home WiFi and downloads and caches map tiles for the configured area
|
||||
FR9a: After tile download, the device validates cache completeness and size before killing the WiFi radio; on failure, the device remains in provisioning state and prompts retry
|
||||
FR10: The device kills the WiFi radio after successful provisioning
|
||||
FR11: The setup interface confirms provisioning status to the user before the WiFi hotspot is dropped
|
||||
FR12: The user can trigger a device reset by holding the reset button for 3 seconds
|
||||
FR13: The device provides immediate visual feedback via LED when a reset hold is detected
|
||||
FR14: A confirmed reset wipes device configuration and returns to provisioning state
|
||||
FR15: The device displays a setup screen on the e-ink display after reset
|
||||
FR16: The device renders an OpenStreetMap base map centred on the configured home location
|
||||
FR17: The map covers the configured coverage radius
|
||||
FR18: The home location is marked as a distinct point on the map
|
||||
FR19: Airspace circular boundaries are rendered as outlines on the map (OpenAIP data)
|
||||
FR20: The device fetches live aircraft data from the dump1090 JSON feed
|
||||
FR21: Each aircraft is rendered at its current position with a heading arrow aligned to direction of travel
|
||||
FR22: Each aircraft displays its callsign and altitude as a label
|
||||
FR23: Each aircraft is colour-coded by altitude band
|
||||
FR24: Each aircraft is rendered with a type-specific icon determined from ADS-B category data or callsign pattern matching (GA/light, commercial/large, helicopter, private jet)
|
||||
FR24a: When aircraft type cannot be determined, icon is assigned by altitude — GA below 10,000ft, private jet 10,000–30,000ft, airliner above 30,000ft
|
||||
FR25: Each aircraft displays a trail of up to 5 previous positions as dots, oldest dot smallest
|
||||
FR26: Aircraft transmitted via MLAT are visually distinguished from directly received aircraft
|
||||
FR27: The device detects when the dump1090 feed has not produced a fresh decode
|
||||
FR28: Aircraft from the last successful decode are retained on display and visually marked as stale
|
||||
FR29: Aircraft positions are restored to normal display state when fresh decode data is received
|
||||
FR30: The display refreshes on a 60-second cycle
|
||||
FR31: The device continues the refresh loop indefinitely without manual intervention
|
||||
FR32: The device resumes the refresh loop automatically after power cycling
|
||||
FR33: The device displays a defined startup screen during boot, before the first radar render is complete
|
||||
|
||||
### NonFunctional Requirements
|
||||
|
||||
NFR1: Full radar render must complete within 45 seconds on Pi Zero 2W hardware
|
||||
NFR2: Base map tile layer is pre-composited and cached in memory between refresh cycles — only the aircraft overlay is re-rendered each cycle
|
||||
NFR3: dump1090 JSON fetch must complete within 5 seconds; timeout triggers stale data path
|
||||
NFR4: E-ink SPI transfer initiates only after render pipeline is complete
|
||||
NFR5: Refresh loop must sustain 72+ hours of continuous operation without restart or intervention
|
||||
NFR6: Device must recover to operational state within 5 minutes of unclean power loss, without manual intervention
|
||||
NFR7: dump1090 decode failure must not crash the refresh loop
|
||||
NFR8: OSM tile cache must not exceed 2GB for any supported coverage radius (16GB SD card)
|
||||
NFR9: Cache size validated during provisioning before WiFi radio is killed
|
||||
NFR10: dump1090 JSON feed at http://localhost:8080/data/aircraft.json — local, no authentication
|
||||
NFR11: Nominatim geocoding API called once during provisioning only; internet required at that point only
|
||||
NFR12: OurAirports database bundled with software, no runtime dependency
|
||||
NFR13: OpenAIP airspace data fetched and cached during provisioning alongside OSM tiles
|
||||
NFR14: WiFi radio off in operational state — network attack surface is zero
|
||||
NFR15: No external network calls in operational state
|
||||
NFR16: Config stored plaintext on SD card — acceptable for personal single-user device
|
||||
|
||||
### Additional Requirements
|
||||
|
||||
- **Project scaffold (Architecture):** `src/` layout, `pyproject.toml` with two entry points (`planemapper-radar`, `planemapper-provision`) and `planemapper` package data config, `requirements.txt`, `requirements-dev.txt`, empty module stubs, `pip install -e .` verified working
|
||||
- **Two process entry points (Architecture):** `planemapper-provision` and `planemapper-radar` are separate processes and systemd units — they must never share a runtime context; `main.py` must not import from `planemapper.provisioning.*`
|
||||
- **Python 3.11 (Architecture):** Raspberry Pi OS Bookworm default; pure Python, no compilation step; deployment via git pull + `pip install .`
|
||||
- **Pinned runtime deps (Architecture):** Pillow 12.2.0, gpiozero 2.0.1, Flask 3.1.3, requests 2.33.1; ruff 0.15.11 for linting/formatting
|
||||
- **Config file (Architecture):** JSON at `/etc/planemapper/config.json` — home lat/lon, coverage radius (nm), WiFi SSID/password, provisioning state flag; single module (`provisioning/config.py`) reads/writes/wipes it
|
||||
- **Background map (Architecture):** Pre-composited `background.png` (800×480) generated at provisioning; loaded once into Renderer memory at radar startup — eliminates all tile I/O from the operational render loop
|
||||
- **Airspace cache (Architecture):** GeoJSON at `/etc/planemapper/airspace.geojson`, downloaded during provisioning; no runtime network dependency
|
||||
- **Stale data visual (Architecture):** Stale aircraft rendered as outlines only (no fill); threshold = 1 missed fetch cycle; recovery on next successful fetch restores normal rendering automatically
|
||||
- **Systemd units (Architecture):** `planemapper-provision.service` (Type=oneshot, runs at first boot/post-reset) and `planemapper-radar.service` (Restart=always, After=planemapper-provision)
|
||||
- **Logging (Architecture):** stdout → systemd journal; stdlib `logging` module; levels: DEBUG (per-aircraft), INFO (cycle start/complete with phase timings), WARNING (render >40s, stale state change), ERROR (fetch failure, SPI failure, required file not found)
|
||||
- **Render pipeline instrumentation (Architecture):** Phase timings logged each cycle (tile load, aircraft overlay, SPI transfer); warn threshold 40s total; alert threshold 50s; stale path triggered if render exceeds 60s boundary
|
||||
- **Aircraft dataclass (Architecture):** `@dataclass Aircraft` with typed optional fields defaulting to safe sentinels; `is_stale` carried on dataclass; nothing beyond `fetcher.py` touches raw JSON
|
||||
- **Coordinate convention (Architecture):** `(lat, lon)` throughout all internal code; GeoJSON parsed with explicit reversal at parse boundary only; single projection function in `renderer/projection.py`
|
||||
- **Units convention (Architecture):** Altitude in feet throughout; thresholds in `constants.py`; no metres conversion anywhere
|
||||
- **Interface protocols (Architecture):** `DisplayInterface` and `FetcherInterface` as `typing.Protocol`; all production code typed against Protocol, never concrete class
|
||||
- **Constants (Architecture):** Single `src/planemapper/constants.py` for all project-wide values — colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing; no inline literals anywhere
|
||||
- **Error handling (Architecture):** Single try/except at render loop boundary; internal functions raise normally; no bare `except:` except at top-level loop
|
||||
- **Reset flow (Architecture):** `config.wipe()` → `display.show(setup_screen)` → `os.execvp('planemapper-provision', ...)` — no IPC required; systemd handles restart sequencing
|
||||
- **OurAirports data (Architecture):** `airports.csv` bundled in `src/planemapper/data/airports.csv`; accessed via `importlib.resources`; configured in `pyproject.toml` package-data
|
||||
- **GPIO non-blocking (Architecture):** `ButtonHoldDetector.check() -> bool` is non-blocking, polled once per cycle alongside render loop
|
||||
- **Test infrastructure (Architecture):** pytest; gpiozero MockFactory for GPIO boundary tests; `NullDisplay` + `FileFixtureFetcher` for hardware-free testing; `conftest.py` patches `CONFIG_PATH` to `tmp_path` — no `/etc/` dependency in CI
|
||||
|
||||
### FR Coverage Map
|
||||
|
||||
```
|
||||
FR1: Epic 1 — WiFi hotspot broadcast on first boot / post-reset
|
||||
FR2: Epic 1 — Captive portal served to connecting user
|
||||
FR3: Epic 1 — Location entry: ICAO code or address/postcode
|
||||
FR4: Epic 1 — ICAO code → coordinates (bundled OurAirports DB)
|
||||
FR5: Epic 1 — Address/postcode → coordinates (Nominatim)
|
||||
FR6: Epic 1 — Resolved location displayed for user confirmation
|
||||
FR7: Epic 1 — Coverage radius selection
|
||||
FR8: Epic 1 — Home WiFi credential entry
|
||||
FR9: Epic 1 — Tile download and caching for configured area
|
||||
FR9a: Epic 1 — Cache completeness/size validation before WiFi kill; retry on failure
|
||||
FR10: Epic 1 — WiFi radio killed (rfkill) after successful provisioning
|
||||
FR11: Epic 1 — Portal confirms provisioning success before hotspot dropped
|
||||
FR12: Epic 4 — Reset button 3-second hold detection
|
||||
FR13: Epic 4 — Immediate LED feedback on reset hold
|
||||
FR14: Epic 4 — Config wipe + return to provisioning state
|
||||
FR15: Epic 4 — Setup screen shown on e-ink after reset
|
||||
FR16: Epic 2 — OSM base map rendered, centred on home location
|
||||
FR17: Epic 2 — Map covers configured coverage radius
|
||||
FR18: Epic 2 — Home location marked on map
|
||||
FR19: Epic 2 — Airspace circular boundaries rendered as outlines (OpenAIP)
|
||||
FR20: Epic 2 — Live aircraft data fetched from dump1090 JSON feed
|
||||
FR21: Epic 2 — Per-aircraft heading arrow aligned to direction of travel
|
||||
FR22: Epic 2 — Per-aircraft callsign + altitude label
|
||||
FR23: Epic 2 — Per-aircraft colour coding by altitude band
|
||||
FR24: Epic 2 — Per-aircraft type icon (GA, commercial, helicopter, private jet)
|
||||
FR24a: Epic 2 — Altitude-based icon fallback when type unknown
|
||||
FR25: Epic 2 — 5-dot position trail, oldest dot smallest
|
||||
FR26: Epic 2 — MLAT positions visually distinguished from direct positions
|
||||
FR27: Epic 3 — Stale data detection (missed dump1090 decode)
|
||||
FR28: Epic 3 — Stale aircraft retained on display, visually marked (outline-only)
|
||||
FR29: Epic 3 — Normal display restored on next fresh decode
|
||||
FR30: Epic 2 — 60-second refresh cycle
|
||||
FR31: Epic 2 — Refresh loop runs indefinitely without intervention
|
||||
FR32: Epic 2 — Refresh loop resumes automatically after power cycling
|
||||
FR33: Epic 2 — Startup screen shown during boot before first radar render
|
||||
```
|
||||
|
||||
## Epic List
|
||||
|
||||
### Epic 1: Device Setup & Provisioning
|
||||
A user can take a freshly flashed SD card, power on the device, connect via their phone, enter their location and home WiFi credentials, and have the device provision itself fully — downloading and validating map tiles, killing the WiFi radio — and confirm success on the portal.
|
||||
**FRs covered:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR9a, FR10, FR11
|
||||
|
||||
### Epic 2: Live Radar Display
|
||||
A user can glance at the e-ink display and see live aircraft positions with heading arrows, callsigns, altitude labels, colour-coded altitude bands, type icons, and position trails — refreshing automatically every 60 seconds indefinitely, including after power cycling.
|
||||
**FRs covered:** FR16, FR17, FR18, FR19, FR20, FR21, FR22, FR23, FR24, FR24a, FR25, FR26, FR30, FR31, FR32, FR33
|
||||
|
||||
### Epic 3: Stale Data Resilience
|
||||
When dump1090 decoding fails or times out, the device continues displaying the last known aircraft positions with a visual stale indicator and recovers automatically when decoding resumes — no crash, no blank screen, no intervention needed.
|
||||
**FRs covered:** FR27, FR28, FR29
|
||||
|
||||
### Epic 4: Reset & Reconfiguration
|
||||
A user can hold the reset button for 3 seconds, receive immediate LED confirmation, and have the device wipe its configuration and return to provisioning state — enabling full re-setup from any location.
|
||||
**FRs covered:** FR12, FR13, FR14, FR15
|
||||
|
||||
---
|
||||
|
||||
## Epic 1: Device Setup & Provisioning
|
||||
|
||||
A user can take a freshly flashed SD card, power on the device, connect via their phone, enter their location and home WiFi credentials, and have the device provision itself fully — downloading and validating map tiles, killing the WiFi radio — and confirm success on the portal.
|
||||
|
||||
### Story 1.1: Project Scaffold & Verified Entry Points
|
||||
|
||||
As a developer,
|
||||
I want a verified project scaffold with the `src/planemapper/` layout, both console entry points installable, all module stubs in place, systemd unit files, and `pytest` running without error,
|
||||
So that every subsequent story has a consistent, working foundation to build on.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the repository is cloned on a Pi Zero 2W running Raspberry Pi OS Bookworm
|
||||
**When** `pip install -e .` is run
|
||||
**Then** it completes without errors and both `planemapper-provision` and `planemapper-radar` commands are available on PATH
|
||||
**And** running either command logs "not implemented" and exits with code 0
|
||||
|
||||
**Given** the project is installed
|
||||
**When** `pytest` is run
|
||||
**Then** the test suite discovers tests and exits with 0 failures (empty stubs acceptable)
|
||||
|
||||
**Given** the project structure
|
||||
**When** a developer inspects the repository
|
||||
**Then** all files from the Architecture directory structure exist: `src/planemapper/` with `__init__.py`, `constants.py`, `models.py`, `main.py`, `provision.py`, `fetcher.py`, `gpio_ctrl.py`, `display.py`, `provisioning/` (7 modules), `renderer/` (8 modules), `data/airports.csv`; `systemd/` with both `.service` files; `pyproject.toml`, `requirements.txt`, `requirements-dev.txt`
|
||||
**And** `src/planemapper/data/airports.csv` is accessible via `importlib.resources`
|
||||
**And** `ruff check .` passes with zero violations
|
||||
|
||||
### Story 1.2: Configuration Read/Write/Wipe
|
||||
|
||||
As a provisioning system,
|
||||
I want a single config module that reads, writes, and wipes `/etc/planemapper/config.json`,
|
||||
So that all components share one reliable config boundary with no direct filesystem access elsewhere.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** no config file exists at `CONFIG_PATH`
|
||||
**When** `config.read()` is called
|
||||
**Then** it raises `FileNotFoundError`
|
||||
|
||||
**Given** a valid config dict with home lat/lon, coverage radius, WiFi SSID/password, and `provisioned` flag
|
||||
**When** `config.write(data)` is called
|
||||
**Then** the file is created at `CONFIG_PATH` with correct JSON content and all expected keys present
|
||||
|
||||
**Given** an existing config file
|
||||
**When** `config.wipe()` is called
|
||||
**Then** the config file is deleted and a subsequent `config.read()` raises `FileNotFoundError`
|
||||
|
||||
**Given** a test using `conftest.py`
|
||||
**When** `CONFIG_PATH` is patched to `tmp_path`
|
||||
**Then** all config operations work without touching `/etc/planemapper/`
|
||||
|
||||
### Story 1.3: WiFi Hotspot & Captive Portal Form
|
||||
|
||||
As a user setting up the device for the first time,
|
||||
I want to connect my phone to the `planeMapper-setup` hotspot and be automatically redirected to a setup page where I can enter my location, coverage radius, and home WiFi credentials,
|
||||
So that I can configure the device without a keyboard or monitor.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the device boots with no config file present
|
||||
**When** `planemapper-provision` starts
|
||||
**Then** `hostapd` and `dnsmasq` are started and the `planeMapper-setup` SSID is broadcast
|
||||
**And** any DNS query from a connected client resolves to the Pi's IP (triggering captive portal detection)
|
||||
|
||||
**Given** a phone connected to `planeMapper-setup`
|
||||
**When** the phone attempts to load any URL
|
||||
**Then** the Flask portal page is served (captive portal detection triggers automatically)
|
||||
|
||||
**Given** the portal page is displayed
|
||||
**When** the user views the form
|
||||
**Then** the form contains: location field (ICAO code or address/postcode), coverage radius field (default 100nm), WiFi SSID field, WiFi password field, and a "Find location" button separate from the final submit
|
||||
|
||||
**Given** `wifi.start_ap()` fails (e.g. hostapd not installed or subprocess returns non-zero)
|
||||
**When** the failure occurs
|
||||
**Then** a `ProvisioningError` is raised, an ERROR is logged, and the provisioning loop resets to portal state
|
||||
|
||||
### Story 1.4: Location Resolution (ICAO & Address)
|
||||
|
||||
As a user setting up the device,
|
||||
I want to type my home airfield ICAO code or my home address/postcode and have the device resolve it to coordinates and show the result for confirmation,
|
||||
So that I can verify the device is centred on the correct location before committing.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the user enters a valid ICAO code (e.g. `EGLL`)
|
||||
**When** "Find location" is pressed
|
||||
**Then** the bundled `airports.csv` is queried via `importlib.resources` and the matching lat/lon is returned
|
||||
**And** the resolved location name and coordinates are displayed on the portal for confirmation
|
||||
|
||||
**Given** the user enters an address or postcode (e.g. `OX1 1AA`)
|
||||
**When** "Find location" is pressed
|
||||
**Then** the Nominatim API is called once with the input and the resolved lat/lon is displayed for confirmation
|
||||
|
||||
**Given** the user enters an ICAO code not present in `airports.csv`
|
||||
**When** "Find location" is pressed
|
||||
**Then** the portal displays: "ICAO code not found — try an address instead"
|
||||
|
||||
**Given** Nominatim returns no results
|
||||
**When** "Find location" is pressed
|
||||
**Then** the portal displays: "Location not found — try a different search term"
|
||||
|
||||
**Given** tests run in CI
|
||||
**When** location tests execute
|
||||
**Then** Nominatim calls are mocked — no real network calls required in the test suite
|
||||
|
||||
### Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill
|
||||
|
||||
As a user who has confirmed their location and entered WiFi credentials,
|
||||
I want the device to automatically join my home WiFi, download all map tiles and airspace data, validate the cache, confirm success on screen, and kill the WiFi radio without further interaction,
|
||||
So that the device is fully provisioned and permanently offline from that point.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the user submits the portal form with valid location, radius, and WiFi credentials
|
||||
**When** the form is submitted
|
||||
**Then** the portal updates to show: "Downloading map data — this may take a few minutes. Do not power off."
|
||||
**And** the device joins the user's home WiFi network
|
||||
|
||||
**Given** the device has joined home WiFi
|
||||
**When** tile download runs
|
||||
**Then** all OSM tiles for the configured area and zoom level are downloaded and composited into `background.png` (800×480) saved at `/etc/planemapper/background.png`
|
||||
**And** OpenAIP airspace GeoJSON is downloaded and saved to `/etc/planemapper/airspace.geojson`
|
||||
|
||||
**Given** tile download is complete
|
||||
**When** cache validation runs
|
||||
**Then** `background.png` is confirmed non-zero size and readable as a valid PNG
|
||||
**And** total tile data is confirmed within 2GB (NFR8, NFR9)
|
||||
**And** if validation fails, the device remains in provisioning state and the portal displays a retry prompt
|
||||
|
||||
**Given** cache validation passes
|
||||
**When** provisioning completes
|
||||
**Then** `config.write()` saves home lat/lon, coverage radius, WiFi credentials, and `provisioned: true`
|
||||
**And** `rfkill block wifi` is called and returns exit code 0
|
||||
**And** the portal displays: "Setup complete. The device will now start displaying radar."
|
||||
**And** if `rfkill` fails, a `ProvisioningError` is raised and the provisioning loop resets
|
||||
|
||||
---
|
||||
|
||||
## Epic 2: Live Radar Display
|
||||
|
||||
A user can glance at the e-ink display and see live aircraft positions with heading arrows, callsigns, altitude labels, colour-coded altitude bands, type icons, and position trails — refreshing automatically every 60 seconds indefinitely, including after power cycling.
|
||||
|
||||
### Story 2.1: Aircraft Data Model & Fetcher
|
||||
|
||||
As the radar system,
|
||||
I want an `Aircraft` dataclass with safe-default optional fields and a `FetcherInterface` with both an `HttpFetcher` (live dump1090) and a `FileFixtureFetcher` (for testing),
|
||||
So that all downstream rendering code works with typed `Aircraft` objects and the fetch boundary is cleanly isolated from raw JSON.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a valid dump1090 JSON response with all fields present
|
||||
**When** `HttpFetcher.fetch()` is called
|
||||
**Then** it returns a `list[Aircraft]` with all fields populated correctly
|
||||
|
||||
**Given** the dump1090 response contains aircraft with missing `callsign`, `altitude`, or `category`
|
||||
**When** `HttpFetcher.fetch()` is called
|
||||
**Then** the corresponding fields use safe defaults (`callsign=""`, `altitude_ft=0`, `category=""`) and no exception is raised
|
||||
|
||||
**Given** the dump1090 HTTP request exceeds `FETCH_TIMEOUT_S` (5 seconds)
|
||||
**When** `HttpFetcher.fetch()` is called
|
||||
**Then** a `requests.Timeout` is raised (not caught here — the loop boundary handles it)
|
||||
|
||||
**Given** an aircraft entry has the MLAT flag set in the JSON
|
||||
**When** `HttpFetcher.fetch()` is called
|
||||
**Then** the resulting `Aircraft` has `is_mlat=True`
|
||||
|
||||
**Given** a `FileFixtureFetcher` pointed at `tests/fixtures/aircraft_sample.json`
|
||||
**When** `.fetch()` is called
|
||||
**Then** it returns the equivalent `list[Aircraft]` with no network call made
|
||||
|
||||
### Story 2.2: Coordinate Projection & Base Map Loading
|
||||
|
||||
As the renderer,
|
||||
I want a `MapBounds` dataclass and a `project()` function converting `(lat, lon)` to pixel `(x, y)`, and a basemap module that loads `background.png` into memory once,
|
||||
So that all rendering uses consistent coordinates and the base map is always available without disk I/O in the loop.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a `MapBounds` from home lat/lon and coverage radius
|
||||
**When** `project(lat, lon, bounds)` is called with the home location
|
||||
**Then** it returns pixel coordinates at the centre of the 800×480 display (±2px)
|
||||
|
||||
**Given** `project()` is called with a position outside the map bounds
|
||||
**When** the result is used
|
||||
**Then** the returned pixel coordinate is outside display dimensions — no clamping, callers handle clipping
|
||||
|
||||
**Given** `background.png` exists at `BACKGROUND_PATH`
|
||||
**When** `basemap.load()` is called
|
||||
**Then** it returns a `PIL.Image` (800×480) loaded into memory
|
||||
|
||||
**Given** `background.png` does not exist at `BACKGROUND_PATH`
|
||||
**When** `basemap.load()` is called
|
||||
**Then** it raises `FileNotFoundError` (logged as ERROR by the caller)
|
||||
|
||||
### Story 2.3: Home Marker & Airspace Outlines
|
||||
|
||||
As a user glancing at the display,
|
||||
I want to see my home location marked on the map and published airspace boundaries shown as outlines,
|
||||
So that I have immediate spatial context for all aircraft positions.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a loaded base map image and home lat/lon from config
|
||||
**When** the home marker is drawn
|
||||
**Then** a distinct `COLOUR_HOME_MARKER` (red) marker is drawn at the projected pixel position of the home location
|
||||
|
||||
**Given** a valid `airspace.geojson` at `AIRSPACE_PATH`
|
||||
**When** airspace outlines are drawn
|
||||
**Then** each circular boundary in the GeoJSON is drawn as an outline in `COLOUR_AIRSPACE` on the image
|
||||
**And** GeoJSON `[lon, lat]` coordinates are reversed to `(lat, lon)` at the parse boundary before any projection
|
||||
|
||||
**Given** `airspace.geojson` does not exist at `AIRSPACE_PATH`
|
||||
**When** airspace draw is called
|
||||
**Then** no exception is raised — the map renders without airspace outlines and a WARNING is logged
|
||||
|
||||
### Story 2.4: Altitude Colour Bands & Aircraft Type Icons
|
||||
|
||||
As the renderer,
|
||||
I want pure functions mapping an aircraft's altitude to a display colour and its ADS-B category/callsign to an icon type,
|
||||
So that every aircraft is consistently colour-coded and type-classified with all logic centralised.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** `altitude_ft` values at the exact boundaries in `ALTITUDE_BANDS_FT`
|
||||
**When** `altitude_to_colour(altitude_ft)` is called
|
||||
**Then** the correct `ALTITUDE_COLOURS` entry is returned for each boundary and above/below it
|
||||
**And** all 6 Waveshare Spectra 6 palette colours are reachable
|
||||
|
||||
**Given** an `Aircraft` with `category="A1"` (light aircraft)
|
||||
**When** `classify_aircraft_type(aircraft)` is called
|
||||
**Then** it returns the GA/light icon type
|
||||
|
||||
**Given** an `Aircraft` with a BA callsign pattern and no category
|
||||
**When** `classify_aircraft_type(aircraft)` is called
|
||||
**Then** it returns the commercial/large icon type
|
||||
|
||||
**Given** an `Aircraft` with `category="A7"` (helicopter)
|
||||
**When** `classify_aircraft_type(aircraft)` is called
|
||||
**Then** it returns the helicopter icon type
|
||||
|
||||
**Given** an `Aircraft` with no category, no recognised callsign, at `altitude_ft=5000`
|
||||
**When** `classify_aircraft_type(aircraft)` is called
|
||||
**Then** it returns GA/light (altitude <10,000ft — FR24a fallback)
|
||||
|
||||
**Given** an `Aircraft` with no category, at `altitude_ft=18000`
|
||||
**When** `classify_aircraft_type(aircraft)` is called
|
||||
**Then** it returns private jet (10,000–30,000ft — FR24a)
|
||||
|
||||
**Given** an `Aircraft` with no category, at `altitude_ft=38000`
|
||||
**When** `classify_aircraft_type(aircraft)` is called
|
||||
**Then** it returns airliner (>30,000ft — FR24a)
|
||||
|
||||
### Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT)
|
||||
|
||||
As a user looking at the display,
|
||||
I want each aircraft drawn with a heading arrow, callsign/altitude label, a 5-dot position trail with the oldest dot smallest, and MLAT aircraft visually distinct,
|
||||
So that I can read direction, identity, altitude, recent path, and data confidence at a glance.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** an `Aircraft` with `heading=90.0` (due east)
|
||||
**When** the heading arrow is drawn
|
||||
**Then** the arrow points east on the display, correctly rotated from north-up reference
|
||||
|
||||
**Given** an `Aircraft` with `callsign="BAW1"` and `altitude_ft=28000`
|
||||
**When** the label is drawn
|
||||
**Then** callsign and altitude are rendered near the aircraft position
|
||||
**And** the label colour matches the aircraft's altitude colour band
|
||||
|
||||
**Given** a trail `deque` with 3 entries
|
||||
**When** the trail is drawn
|
||||
**Then** 3 dots are rendered with decreasing size from most-recent to oldest (interpolated between `TRAIL_DOT_SIZE_MAX` and `TRAIL_DOT_SIZE_MIN`)
|
||||
**And** dot colour is `COLOUR_TRAIL`
|
||||
|
||||
**Given** an `Aircraft` with `is_mlat=True`
|
||||
**When** the aircraft is drawn
|
||||
**Then** it is rendered in a visually distinct style from directly-received aircraft
|
||||
|
||||
**Given** an `Aircraft` with `callsign=""`
|
||||
**When** the label is drawn
|
||||
**Then** altitude only is rendered with no blank callsign prefix, and no exception is raised
|
||||
|
||||
### Story 2.6: Stateful Renderer & Display Interface
|
||||
|
||||
As the radar loop,
|
||||
I want a stateful `Renderer` owning the in-memory tile composite and per-aircraft trail history, and a `DisplayInterface` protocol with `WaveshareDisplay` (SPI) and `NullDisplay` (tests),
|
||||
So that the render pipeline is fully isolated, testable without hardware, and trail history persists across cycles.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a `Renderer` initialised with a loaded base map
|
||||
**When** `renderer.render(aircraft_list)` is called
|
||||
**Then** it returns a `PIL.Image` (800×480) with base map, airspace outlines, home marker, and all aircraft drawn
|
||||
|
||||
**Given** an aircraft appears in two consecutive calls to `renderer.render()`
|
||||
**When** the second call is made
|
||||
**Then** its previous position appears as a trail dot in the output
|
||||
**And** trail length never exceeds `TRAIL_MAX_DOTS` (5)
|
||||
|
||||
**Given** an aircraft was present last cycle but is absent from the current list
|
||||
**When** `renderer.render()` is called
|
||||
**Then** the aircraft does not appear on the display
|
||||
**And** its trail history is retained in `dict[str, deque]` for when it reappears
|
||||
|
||||
**Given** a `NullDisplay`
|
||||
**When** `display.show(image)` is called
|
||||
**Then** it logs image dimensions at DEBUG level and returns without error — no SPI call made
|
||||
|
||||
**Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Renderer → NullDisplay`)
|
||||
**When** one full cycle runs
|
||||
**Then** it completes without exception and the returned image is 800×480
|
||||
|
||||
### Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
|
||||
|
||||
As a device operator,
|
||||
I want the device to show a startup screen during boot, then enter a 60-second radar refresh loop that runs indefinitely and resumes automatically after power cycling,
|
||||
So that the display is always current with zero manual intervention.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the device boots with a valid config file
|
||||
**When** `planemapper-radar` starts
|
||||
**Then** a startup screen is displayed on the e-ink before the first radar render begins (FR33)
|
||||
**And** once the first radar render completes, the live display replaces the startup screen
|
||||
|
||||
**Given** the radar loop is running
|
||||
**When** each 60-second cycle completes
|
||||
**Then** `fetcher.fetch()` → `renderer.render()` → `display.show()` executes in sequence
|
||||
**And** render phase timings (tile load, overlay, SPI) are logged at INFO level each cycle
|
||||
|
||||
**Given** total render time exceeds 40 seconds
|
||||
**When** the cycle completes
|
||||
**Then** a WARNING is logged with the total render time
|
||||
|
||||
**Given** `planemapper-radar.service`
|
||||
**When** the service file is inspected
|
||||
**Then** it has `Restart=always` and `After=planemapper-provision.service`
|
||||
|
||||
**Given** the device loses mains power and is restored
|
||||
**When** the Pi reboots
|
||||
**Then** `planemapper-provision.service` detects `provisioned: true` in config and exits immediately
|
||||
**And** `planemapper-radar.service` starts and resumes the loop within 5 minutes (NFR6, FR32)
|
||||
|
||||
---
|
||||
|
||||
## Epic 3: Stale Data Resilience
|
||||
|
||||
When dump1090 decoding fails or times out, the device continues displaying the last known aircraft positions with a visual stale indicator and recovers automatically when decoding resumes — no crash, no blank screen, no intervention needed.
|
||||
|
||||
### Story 3.1: Stale State Detection & Dimmed Display
|
||||
|
||||
As a user whose RTL-SDR has temporarily lost signal,
|
||||
I want the display to retain the last known aircraft positions shown as outlines when dump1090 stops delivering fresh data,
|
||||
So that I know the display is stale without a crash or blank screen.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the radar loop is running with a previous successful fetch
|
||||
**When** `HttpFetcher.fetch()` raises `requests.Timeout` (>5s)
|
||||
**Then** the exception propagates to the loop boundary, which catches it and marks all retained aircraft as `is_stale=True`
|
||||
|
||||
**Given** the dump1090 response returns an empty aircraft list when the previous cycle had aircraft
|
||||
**When** the fetcher processes the response
|
||||
**Then** the previous aircraft list is retained with `is_stale=True` on each entry (not replaced with an empty list)
|
||||
|
||||
**Given** aircraft with `is_stale=True` are passed to the renderer
|
||||
**When** `renderer.render()` is called
|
||||
**Then** each stale aircraft is drawn as an outline only (no fill) using `COLOUR_STALE_OUTLINE`
|
||||
**And** heading arrow, label, and trail are still rendered at their last known positions
|
||||
|
||||
**Given** a stale render cycle
|
||||
**When** the render loop timing is measured
|
||||
**Then** the loop does not crash and completes within normal bounds — stale path is not a crash path (NFR7)
|
||||
|
||||
### Story 3.2: Automatic Recovery on Fresh Decode
|
||||
|
||||
As a user whose RTL-SDR has recovered,
|
||||
I want the display to automatically return to normal filled aircraft rendering on the next successful fetch,
|
||||
So that recovery requires no manual intervention.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the display is in stale state (aircraft rendered as outlines)
|
||||
**When** `HttpFetcher.fetch()` returns a non-empty aircraft list successfully
|
||||
**Then** all newly fetched aircraft have `is_stale=False`
|
||||
**And** the renderer draws them with normal filled icons in their altitude colour band
|
||||
|
||||
**Given** the display has recovered from stale state
|
||||
**When** the next render cycle runs
|
||||
**Then** no stale outline rendering occurs for the recovered aircraft
|
||||
|
||||
**Given** a stale-then-recovery sequence in `test_pipeline.py`
|
||||
**When** `FileFixtureFetcher` returns an empty list followed by a populated list
|
||||
**Then** the first cycle produces outline-only aircraft and the second produces normal filled aircraft
|
||||
|
||||
---
|
||||
|
||||
## Epic 4: Reset & Reconfiguration
|
||||
|
||||
A user can hold the reset button for 3 seconds, receive immediate LED confirmation, and have the device wipe its configuration and return to provisioning state — enabling full re-setup from any location.
|
||||
|
||||
### Story 4.1: GPIO Button Hold Detection & LED Feedback
|
||||
|
||||
As a user wanting to reconfigure the device,
|
||||
I want to hold the reset button for 3 seconds and receive immediate LED confirmation,
|
||||
So that I know the reset was registered before anything else changes.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** the reset button GPIO is configured via gpiozero
|
||||
**When** the button is held for `RESET_HOLD_S` (3 seconds)
|
||||
**Then** `ButtonHoldDetector.check()` returns `True`
|
||||
|
||||
**Given** the button is held for less than 3 seconds
|
||||
**When** `ButtonHoldDetector.check()` is called
|
||||
**Then** it returns `False` — no reset triggered
|
||||
|
||||
**Given** `ButtonHoldDetector.check()` returns `True`
|
||||
**When** the main loop processes the result
|
||||
**Then** `LEDController.on()` is called immediately (FR13 — immediate feedback before any config change)
|
||||
|
||||
**Given** gpiozero `MockFactory` is active in tests
|
||||
**When** button hold and LED tests run
|
||||
**Then** they pass without physical GPIO hardware
|
||||
|
||||
**Given** `ButtonHoldDetector.check()` is called once per render cycle
|
||||
**When** the render loop runs
|
||||
**Then** the call is non-blocking and adds no perceptible delay to the render pipeline
|
||||
|
||||
### Story 4.2: Config Wipe, Setup Screen & Return to Provisioning
|
||||
|
||||
As a user who has triggered a reset,
|
||||
I want the device to wipe its configuration, show a setup screen on the e-ink display, and restart into the provisioning flow,
|
||||
So that I can re-configure the device from scratch for a new location or home network.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** `ButtonHoldDetector.check()` returns `True` in the main loop
|
||||
**When** the reset handler runs
|
||||
**Then** `config.wipe()` is called and the config file is deleted (FR14)
|
||||
|
||||
**Given** the config has been wiped
|
||||
**When** the reset handler continues
|
||||
**Then** `display.show(setup_screen_image)` is called, displaying the setup screen on the e-ink (FR15)
|
||||
|
||||
**Given** the setup screen is shown
|
||||
**When** the reset handler completes
|
||||
**Then** `os.execvp('planemapper-provision', ['planemapper-provision'])` is called, replacing the current process
|
||||
**And** systemd restarts `planemapper-radar` → detects no config → runs provisioning flow from scratch
|
||||
|
||||
**Given** `config.wipe()` raises an unexpected error
|
||||
**When** the reset handler encounters it
|
||||
**Then** an ERROR is logged and `os.execvp` is not called — no partial reset leaves the device in an inconsistent state
|
||||
@@ -0,0 +1,44 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "planemapper"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Pillow==12.2.0",
|
||||
"gpiozero==2.0.1",
|
||||
"Flask==3.1.3",
|
||||
"requests==2.33.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
planemapper-radar = "planemapper.main:main"
|
||||
planemapper-provision = "planemapper.provision:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"planemapper" = ["data/airports.csv"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
exclude = [".claude", "_bmad", "_bmad-output"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "TID", "UP"]
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
||||
"planemapper.provisioning".msg = "main.py must not import from planemapper.provisioning.*"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# All non-main modules may import from provisioning freely
|
||||
"src/planemapper/main.py" = ["TID251"]
|
||||
"src/planemapper/provision.py" = ["TID251"]
|
||||
"src/planemapper/provisioning/*.py" = ["TID251"]
|
||||
# Tests may import from provisioning to test its public API
|
||||
"tests/provisioning/*.py" = ["TID251"]
|
||||
"tests/conftest.py" = ["TID251"]
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest==9.0.3
|
||||
ruff==0.15.11
|
||||
gpiozero[mock]
|
||||
@@ -0,0 +1,4 @@
|
||||
Pillow==12.2.0
|
||||
gpiozero==2.0.1
|
||||
Flask==3.1.3
|
||||
requests==2.33.1
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
DUMP1090_URL = "http://localhost:8080/data/aircraft.json"
|
||||
|
||||
DISPLAY_WIDTH = 800
|
||||
DISPLAY_HEIGHT = 480
|
||||
|
||||
REFRESH_INTERVAL_S = 60
|
||||
FETCH_TIMEOUT_S = 5
|
||||
RENDER_WARN_S = 40
|
||||
RENDER_ALERT_S = 50
|
||||
STALE_CYCLES = 1
|
||||
RESET_HOLD_S = 3
|
||||
|
||||
ALTITUDE_BANDS_FT = [1500, 5000, 10000, 20000, 35000, 99999]
|
||||
|
||||
COLOUR_BLACK = (0, 0, 0)
|
||||
COLOUR_WHITE = (255, 255, 255)
|
||||
COLOUR_RED = (255, 0, 0)
|
||||
COLOUR_YELLOW = (255, 255, 0)
|
||||
COLOUR_BLUE = (0, 0, 255)
|
||||
COLOUR_GREEN = (0, 255, 0)
|
||||
|
||||
ALTITUDE_COLOURS = [
|
||||
COLOUR_GREEN,
|
||||
COLOUR_BLUE,
|
||||
COLOUR_YELLOW,
|
||||
COLOUR_RED,
|
||||
COLOUR_BLACK,
|
||||
COLOUR_WHITE,
|
||||
]
|
||||
|
||||
COLOUR_STALE_OUTLINE = COLOUR_BLACK
|
||||
COLOUR_HOME_MARKER = COLOUR_RED
|
||||
COLOUR_AIRSPACE = COLOUR_BLUE
|
||||
COLOUR_TRAIL = COLOUR_BLACK
|
||||
|
||||
TRAIL_MAX_DOTS = 5
|
||||
TRAIL_DOT_SIZE_MAX = 6
|
||||
TRAIL_DOT_SIZE_MIN = 2
|
||||
|
||||
CONFIG_PATH = Path("/etc/planemapper/config.json")
|
||||
AIRSPACE_PATH = Path("/etc/planemapper/airspace.geojson")
|
||||
BACKGROUND_PATH = Path("/etc/planemapper/background.png")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from PIL import Image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DisplayInterface(Protocol):
|
||||
def show(self, image: Image.Image) -> None: ...
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
def show(self, image: Image.Image) -> None:
|
||||
log.debug("NullDisplay.show: %dx%d", image.width, image.height)
|
||||
|
||||
|
||||
class WaveshareDisplay:
|
||||
def show(self, image: Image.Image) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import requests
|
||||
|
||||
from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S
|
||||
from planemapper.models import Aircraft
|
||||
|
||||
|
||||
class FetcherInterface(Protocol):
|
||||
def fetch(self) -> list[Aircraft]: ...
|
||||
|
||||
|
||||
def _parse_aircraft(entry: dict) -> Aircraft:
|
||||
raw_alt = entry.get("altitude", 0)
|
||||
altitude_ft = int(raw_alt) if isinstance(raw_alt, int) else 0
|
||||
return Aircraft(
|
||||
icao=entry["hex"],
|
||||
lat=float(entry["lat"]),
|
||||
lon=float(entry["lon"]),
|
||||
heading=float(entry.get("track", 0.0)),
|
||||
altitude_ft=altitude_ft,
|
||||
callsign=entry.get("flight", "").strip(),
|
||||
category=entry.get("category", ""),
|
||||
is_mlat=bool(entry.get("mlat")),
|
||||
is_stale=False,
|
||||
)
|
||||
|
||||
|
||||
class HttpFetcher:
|
||||
def fetch(self) -> list[Aircraft]:
|
||||
resp = requests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [_parse_aircraft(e) for e in data.get("aircraft", []) if "lat" in e and "lon" in e]
|
||||
|
||||
|
||||
class FileFixtureFetcher:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
|
||||
def fetch(self) -> list[Aircraft]:
|
||||
with self._path.open() as f:
|
||||
data = json.load(f)
|
||||
return [_parse_aircraft(e) for e in data.get("aircraft", []) if "lat" in e and "lon" in e]
|
||||
@@ -0,0 +1,29 @@
|
||||
import logging
|
||||
|
||||
from gpiozero import LED, Button
|
||||
|
||||
from planemapper.constants import RESET_HOLD_S
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_GPIO_PIN = 22 # BCM 17 is used by Waveshare e-ink HAT (RST_PIN)
|
||||
LED_GPIO_PIN = 27
|
||||
|
||||
|
||||
class ButtonHoldDetector:
|
||||
def __init__(self, pin: int = BUTTON_GPIO_PIN) -> None:
|
||||
self._button = Button(pin, hold_time=RESET_HOLD_S)
|
||||
|
||||
def check(self) -> bool:
|
||||
return self._button.is_held
|
||||
|
||||
|
||||
class LEDController:
|
||||
def __init__(self, pin: int = LED_GPIO_PIN) -> None:
|
||||
self._led = LED(pin)
|
||||
|
||||
def on(self) -> None:
|
||||
self._led.on()
|
||||
|
||||
def off(self) -> None:
|
||||
self._led.off()
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from planemapper.constants import (
|
||||
DISPLAY_HEIGHT,
|
||||
DISPLAY_WIDTH,
|
||||
REFRESH_INTERVAL_S,
|
||||
RENDER_WARN_S,
|
||||
)
|
||||
from planemapper.display import DisplayInterface, WaveshareDisplay
|
||||
from planemapper.fetcher import HttpFetcher
|
||||
from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.provisioning.config import read as read_config
|
||||
from planemapper.provisioning.config import wipe as wipe_config
|
||||
from planemapper.renderer.basemap import load as load_basemap
|
||||
from planemapper.renderer.projection import MapBounds
|
||||
from planemapper.renderer.renderer import Renderer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_startup_screen() -> Image.Image:
|
||||
image = Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
draw.text(
|
||||
(DISPLAY_WIDTH // 2 - 60, DISPLAY_HEIGHT // 2),
|
||||
"planeMapper starting...",
|
||||
fill=(0, 0, 0),
|
||||
font=font,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def _make_setup_screen() -> Image.Image:
|
||||
image = Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
draw.text(
|
||||
(DISPLAY_WIDTH // 2 - 60, DISPLAY_HEIGHT // 2),
|
||||
"Resetting...",
|
||||
fill=(0, 0, 0),
|
||||
font=font,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def _handle_reset(display: DisplayInterface, led: LEDController) -> None:
|
||||
led.on()
|
||||
try:
|
||||
wipe_config()
|
||||
except Exception:
|
||||
log.error("config wipe failed — aborting reset", exc_info=True)
|
||||
led.off()
|
||||
return
|
||||
setup_screen = _make_setup_screen()
|
||||
display.show(setup_screen)
|
||||
os.execvp("planemapper-provision", ["planemapper-provision"])
|
||||
|
||||
|
||||
def _run_one_cycle(
|
||||
renderer: Renderer,
|
||||
fetcher: HttpFetcher,
|
||||
display: DisplayInterface,
|
||||
last_aircraft: list[Aircraft],
|
||||
) -> list[Aircraft]:
|
||||
t0 = time.monotonic()
|
||||
stale_needed = False
|
||||
try:
|
||||
fresh = fetcher.fetch()
|
||||
except requests.Timeout:
|
||||
log.warning("fetch timeout — using stale data")
|
||||
fresh = []
|
||||
stale_needed = True
|
||||
else:
|
||||
stale_needed = len(fresh) == 0 and len(last_aircraft) > 0
|
||||
|
||||
if stale_needed:
|
||||
aircraft_list = [dataclasses.replace(a, is_stale=True) for a in last_aircraft]
|
||||
else:
|
||||
aircraft_list = fresh
|
||||
|
||||
t1 = time.monotonic()
|
||||
image = renderer.render(aircraft_list)
|
||||
t2 = time.monotonic()
|
||||
display.show(image)
|
||||
t3 = time.monotonic()
|
||||
total = t3 - t0
|
||||
log.info(
|
||||
"cycle: fetch=%.1fs render=%.1fs spi=%.1fs total=%.1fs",
|
||||
t1 - t0,
|
||||
t2 - t1,
|
||||
t3 - t2,
|
||||
total,
|
||||
)
|
||||
if total > RENDER_WARN_S:
|
||||
log.warning("render slow: %.1fs > %ds threshold", total, RENDER_WARN_S)
|
||||
return aircraft_list
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
cfg = read_config()
|
||||
bounds = MapBounds(
|
||||
home_lat=cfg["home_lat"],
|
||||
home_lon=cfg["home_lon"],
|
||||
radius_nm=cfg["coverage_radius_nm"],
|
||||
)
|
||||
base_map = load_basemap()
|
||||
fetcher = HttpFetcher()
|
||||
renderer = Renderer(base_map, bounds)
|
||||
display = WaveshareDisplay()
|
||||
button = ButtonHoldDetector()
|
||||
led = LEDController()
|
||||
startup = _make_startup_screen()
|
||||
display.show(startup)
|
||||
last: list[Aircraft] = []
|
||||
while True:
|
||||
if button.check():
|
||||
_handle_reset(display, led)
|
||||
last = _run_one_cycle(renderer, fetcher, display, last)
|
||||
time.sleep(REFRESH_INTERVAL_S)
|
||||
@@ -0,0 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Aircraft:
|
||||
icao: str
|
||||
lat: float
|
||||
lon: float
|
||||
heading: float = 0.0
|
||||
altitude_ft: int = 0
|
||||
callsign: str = ""
|
||||
category: str = ""
|
||||
is_mlat: bool = False
|
||||
is_stale: bool = False
|
||||
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
|
||||
from planemapper.provisioning import ProvisioningError, config, wifi
|
||||
from planemapper.provisioning.portal import app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reset_to_portal_state() -> None:
|
||||
log.info("Resetting to portal state")
|
||||
try:
|
||||
wifi.stop_ap()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
try:
|
||||
cfg = config.read()
|
||||
if cfg.get("provisioned"):
|
||||
log.info("already provisioned — exiting")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass # no config — proceed to portal
|
||||
provisioned = False
|
||||
while not provisioned:
|
||||
try:
|
||||
wifi.start_ap()
|
||||
log.info("Portal starting on 0.0.0.0:80")
|
||||
app.run(host="0.0.0.0", port=80)
|
||||
provisioned = True
|
||||
except ProvisioningError as e:
|
||||
log.error("Provisioning failed: %s", e)
|
||||
_reset_to_portal_state()
|
||||
@@ -0,0 +1,2 @@
|
||||
class ProvisioningError(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
from planemapper.constants import AIRSPACE_PATH
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_EMPTY_GEOJSON = '{"type": "FeatureCollection", "features": []}'
|
||||
_OPENAIP_URL = "https://api.openaip.net/api/airspaces"
|
||||
|
||||
|
||||
def download(lat: float, lon: float, radius_nm: float) -> None:
|
||||
api_key = os.environ.get("OPENAIP_API_KEY")
|
||||
AIRSPACE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not api_key:
|
||||
log.warning(
|
||||
"OPENAIP_API_KEY not set — writing empty airspace cache; "
|
||||
"airspace outlines will not be shown"
|
||||
)
|
||||
AIRSPACE_PATH.write_text(_EMPTY_GEOJSON)
|
||||
return
|
||||
|
||||
radius_deg = radius_nm / 60.0
|
||||
bbox = [lon - radius_deg, lat - radius_deg, lon + radius_deg, lat + radius_deg]
|
||||
resp = requests.get(
|
||||
_OPENAIP_URL,
|
||||
params={"bbox": ",".join(f"{v:.6f}" for v in bbox)},
|
||||
headers={"x-openaip-api-key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise ProvisioningError(f"OpenAIP API failed: {resp.status_code}")
|
||||
AIRSPACE_PATH.write_text(resp.text)
|
||||
log.info("airspace data saved to %s", AIRSPACE_PATH)
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from planemapper.constants import CONFIG_PATH as _CONFIG_PATH
|
||||
|
||||
# Module-level reference so tests can monkeypatch this name
|
||||
CONFIG_PATH: Path = _CONFIG_PATH
|
||||
|
||||
|
||||
def read() -> dict:
|
||||
with CONFIG_PATH.open() as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def write(data: dict) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with CONFIG_PATH.open("w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def wipe() -> None:
|
||||
CONFIG_PATH.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
import csv
|
||||
import importlib.resources
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
_USER_AGENT = "planemapper/0.1 (https://github.com/football2801/planeMapper)"
|
||||
|
||||
|
||||
def _lookup_icao(code: str) -> tuple[float, float, str] | None:
|
||||
traversable = importlib.resources.files("planemapper.data").joinpath("airports.csv")
|
||||
with traversable.open("r", encoding="utf-8") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
for row in reader:
|
||||
if row["ident"] == code:
|
||||
return float(row["latitude_deg"]), float(row["longitude_deg"]), row["name"]
|
||||
return None
|
||||
|
||||
|
||||
def _geocode(query: str) -> tuple[float, float, str] | None:
|
||||
resp = requests.get(
|
||||
NOMINATIM_URL,
|
||||
params={"q": query, "format": "json", "limit": 1},
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
results = resp.json()
|
||||
if not results:
|
||||
return None
|
||||
r = results[0]
|
||||
return float(r["lat"]), float(r["lon"]), r["display_name"]
|
||||
|
||||
|
||||
def resolve(query: str) -> tuple[float, float, str]:
|
||||
query = query.strip().upper()
|
||||
if len(query) == 4 and query.isalpha():
|
||||
result = _lookup_icao(query)
|
||||
if result is None:
|
||||
raise ValueError("ICAO code not found — try an address instead")
|
||||
log.info("ICAO %s resolved to %s", query, result[2])
|
||||
return result
|
||||
result = _geocode(query)
|
||||
if result is None:
|
||||
raise ValueError("Location not found — try a different search term")
|
||||
log.info("Address '%s' resolved to %s", query, result[2])
|
||||
return result
|
||||
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
|
||||
from flask import Flask, redirect, request, url_for
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from planemapper.provisioning import ProvisioningError, airspace, config, location, tiles, wifi
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _build_form_html(
|
||||
error: str = "",
|
||||
resolved_name: str = "",
|
||||
resolved_lat: str = "",
|
||||
resolved_lon: str = "",
|
||||
radius: str = "100",
|
||||
) -> str:
|
||||
error_html = f'<p style="color:red">{error}</p>' if error else ""
|
||||
confirmed_html = ""
|
||||
hidden_fields = ""
|
||||
if resolved_name:
|
||||
confirmed_html = f"""
|
||||
<div style="background:#f0f0f0;padding:8px;margin:8px 0">
|
||||
<strong>Confirmed:</strong> {resolved_name}<br>
|
||||
Lat: {resolved_lat}, Lon: {resolved_lon}
|
||||
</div>"""
|
||||
hidden_fields = f"""
|
||||
<input type="hidden" name="confirmed_lat" value="{resolved_lat}">
|
||||
<input type="hidden" name="confirmed_lon" value="{resolved_lon}">
|
||||
<input type="hidden" name="confirmed_name" value="{resolved_name}">"""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>planeMapper Setup</title></head>
|
||||
<body>
|
||||
<h1>planeMapper Setup</h1>
|
||||
{error_html}
|
||||
<form method="POST" action="/find-location">
|
||||
<label>Location (ICAO code or address/postcode):<br>
|
||||
<input type="text" name="location" required>
|
||||
</label><br><br>
|
||||
<label>Coverage radius (nm):<br>
|
||||
<input type="number" name="radius" value="{radius}" min="10" max="500">
|
||||
</label><br><br>
|
||||
<button type="submit">Find location</button>
|
||||
</form>
|
||||
{confirmed_html}
|
||||
<hr>
|
||||
<form method="POST" action="/submit">
|
||||
{hidden_fields}
|
||||
<label>Home WiFi SSID:<br>
|
||||
<input type="text" name="wifi_ssid" required>
|
||||
</label><br><br>
|
||||
<label>Home WiFi password:<br>
|
||||
<input type="password" name="wifi_password" required>
|
||||
</label><br><br>
|
||||
<button type="submit">Set up device</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index() -> str:
|
||||
return _build_form_html()
|
||||
|
||||
|
||||
@app.route("/find-location", methods=["POST"])
|
||||
def find_location() -> str:
|
||||
query = request.form.get("location", "").strip()
|
||||
radius = request.form.get("radius", "100")
|
||||
error_msg = ""
|
||||
resolved_name = ""
|
||||
resolved_lat = ""
|
||||
resolved_lon = ""
|
||||
if query:
|
||||
try:
|
||||
lat, lon, name = location.resolve(query)
|
||||
resolved_lat = str(lat)
|
||||
resolved_lon = str(lon)
|
||||
resolved_name = name
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
return _build_form_html(
|
||||
error=error_msg,
|
||||
resolved_name=resolved_name,
|
||||
resolved_lat=resolved_lat,
|
||||
resolved_lon=resolved_lon,
|
||||
radius=radius,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/generate_204")
|
||||
@app.route("/hotspot-detect.html")
|
||||
@app.route("/ncsi.txt")
|
||||
def captive_redirect() -> Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def catch_all(e: Exception) -> Response:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
def _run_provisioning(
|
||||
confirmed_lat: float,
|
||||
confirmed_lon: float,
|
||||
radius: float,
|
||||
wifi_ssid: str,
|
||||
wifi_password: str,
|
||||
) -> str:
|
||||
wifi.join_home_wifi(wifi_ssid, wifi_password)
|
||||
tiles.download_and_composite(confirmed_lat, confirmed_lon, radius)
|
||||
airspace.download(confirmed_lat, confirmed_lon, radius)
|
||||
try:
|
||||
tiles.validate_cache()
|
||||
except ProvisioningError as e:
|
||||
log.error("cache validation failed: %s", e)
|
||||
return (
|
||||
f"<html><body><p>Cache validation failed: {e}. "
|
||||
f"<a href='/'>Try again</a></p></body></html>"
|
||||
)
|
||||
config.write(
|
||||
{
|
||||
"home_lat": confirmed_lat,
|
||||
"home_lon": confirmed_lon,
|
||||
"coverage_radius_nm": int(radius),
|
||||
"wifi_ssid": wifi_ssid,
|
||||
"wifi_password": wifi_password,
|
||||
"provisioned": True,
|
||||
}
|
||||
)
|
||||
wifi.kill_wifi()
|
||||
return (
|
||||
"<html><body>"
|
||||
"<h1>Setup complete. The device will now start displaying radar.</h1>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
@app.route("/submit", methods=["POST"])
|
||||
def submit() -> str:
|
||||
try:
|
||||
confirmed_lat = float(request.form["confirmed_lat"])
|
||||
confirmed_lon = float(request.form["confirmed_lon"])
|
||||
confirmed_name = request.form.get("confirmed_name", "")
|
||||
radius = float(request.form.get("radius", "100"))
|
||||
wifi_ssid = request.form["wifi_ssid"]
|
||||
wifi_password = request.form["wifi_password"]
|
||||
except (KeyError, ValueError) as e:
|
||||
return f"<html><body><p>Invalid form data: {e}. <a href='/'>Try again</a></p></body></html>"
|
||||
|
||||
log.info(
|
||||
"provisioning started for %s (%.4f, %.4f) r=%.0fnm",
|
||||
confirmed_name,
|
||||
confirmed_lat,
|
||||
confirmed_lon,
|
||||
radius,
|
||||
)
|
||||
result = _run_provisioning(confirmed_lat, confirmed_lon, radius, wifi_ssid, wifi_password)
|
||||
return result
|
||||
@@ -0,0 +1,93 @@
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.constants import BACKGROUND_PATH, COLOUR_WHITE, DISPLAY_HEIGHT, DISPLAY_WIDTH
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
_USER_AGENT = "planemapper/0.1 (https://github.com/football2801/planeMapper)"
|
||||
|
||||
|
||||
def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
|
||||
x = int((lon + 180.0) / 360.0 * (1 << zoom))
|
||||
lat_r = math.radians(lat)
|
||||
y = int((1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) / 2.0 * (1 << zoom))
|
||||
return x, y
|
||||
|
||||
|
||||
def _zoom_for_radius(radius_nm: float) -> int:
|
||||
if radius_nm > 200:
|
||||
return 8
|
||||
if radius_nm > 100:
|
||||
return 9
|
||||
if radius_nm > 50:
|
||||
return 10
|
||||
return 11
|
||||
|
||||
|
||||
def download_and_composite(lat: float, lon: float, radius_nm: float) -> None:
|
||||
zoom = _zoom_for_radius(radius_nm)
|
||||
radius_deg = radius_nm / 60.0
|
||||
|
||||
# Tile bounds
|
||||
x_min, y_max = lat_lon_to_tile(lat - radius_deg, lon - radius_deg, zoom)
|
||||
x_max, y_min = lat_lon_to_tile(lat + radius_deg, lon + radius_deg, zoom)
|
||||
|
||||
canvas_w = (x_max - x_min + 1) * 256
|
||||
canvas_h = (y_max - y_min + 1) * 256
|
||||
|
||||
# Guard: ensure canvas is at least display size
|
||||
canvas_w = max(canvas_w, DISPLAY_WIDTH)
|
||||
canvas_h = max(canvas_h, DISPLAY_HEIGHT)
|
||||
|
||||
canvas = Image.new("RGB", (canvas_w, canvas_h), COLOUR_WHITE)
|
||||
|
||||
tile_count = 0
|
||||
for tx in range(x_min, x_max + 1):
|
||||
for ty in range(y_min, y_max + 1):
|
||||
url = _TILE_URL.format(z=zoom, x=tx, y=ty)
|
||||
resp = requests.get(url, headers={"User-Agent": _USER_AGENT}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
tile_img = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
px = (tx - x_min) * 256
|
||||
py = (ty - y_min) * 256
|
||||
canvas.paste(tile_img, (px, py))
|
||||
tile_count += 1
|
||||
|
||||
# Crop to display size centred on home location
|
||||
home_tx, home_ty = lat_lon_to_tile(lat, lon, zoom)
|
||||
home_px = (home_tx - x_min) * 256 + 128
|
||||
home_py = (home_ty - y_min) * 256 + 128
|
||||
left = max(0, home_px - DISPLAY_WIDTH // 2)
|
||||
top = max(0, home_py - DISPLAY_HEIGHT // 2)
|
||||
left = min(left, canvas_w - DISPLAY_WIDTH)
|
||||
top = min(top, canvas_h - DISPLAY_HEIGHT)
|
||||
left = max(0, left)
|
||||
top = max(0, top)
|
||||
cropped = canvas.crop((left, top, left + DISPLAY_WIDTH, top + DISPLAY_HEIGHT))
|
||||
|
||||
BACKGROUND_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
cropped.save(str(BACKGROUND_PATH))
|
||||
log.info("composited %d tiles → %s", tile_count, BACKGROUND_PATH)
|
||||
|
||||
|
||||
def validate_cache() -> None:
|
||||
if not BACKGROUND_PATH.exists():
|
||||
raise ProvisioningError("background.png not found")
|
||||
size = BACKGROUND_PATH.stat().st_size
|
||||
if size == 0:
|
||||
raise ProvisioningError("background.png is empty")
|
||||
try:
|
||||
with Image.open(BACKGROUND_PATH) as img:
|
||||
img.verify()
|
||||
except Exception as e:
|
||||
raise ProvisioningError(f"background.png is not a valid PNG: {e}") from e
|
||||
if size >= 2 * 1024**3:
|
||||
raise ProvisioningError("background.png exceeds 2GB limit")
|
||||
log.info("cache validation passed: background.png %.1f KB", size / 1024)
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_HOSTAPD_CONF_PATH = Path("/etc/hostapd/hostapd.conf")
|
||||
_HOSTAPD_CONF = """\
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
ssid=planeMapper-setup
|
||||
hw_mode=g
|
||||
channel=6
|
||||
wmm_enabled=0
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
"""
|
||||
|
||||
|
||||
def _write_hostapd_conf() -> None:
|
||||
_HOSTAPD_CONF_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_HOSTAPD_CONF_PATH.write_text(_HOSTAPD_CONF)
|
||||
|
||||
|
||||
def start_ap() -> None:
|
||||
_write_hostapd_conf()
|
||||
result = subprocess.run(["hostapd", str(_HOSTAPD_CONF_PATH)], check=False)
|
||||
if result.returncode != 0:
|
||||
log.error("hostapd failed with return code %d", result.returncode)
|
||||
raise ProvisioningError(f"hostapd failed: returncode={result.returncode}")
|
||||
result = subprocess.run(
|
||||
["dnsmasq", "--no-daemon", "--address=/#/192.168.4.1"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("dnsmasq failed with return code %d", result.returncode)
|
||||
raise ProvisioningError(f"dnsmasq failed: returncode={result.returncode}")
|
||||
log.info("AP started: SSID=planeMapper-setup")
|
||||
|
||||
|
||||
def stop_ap() -> None:
|
||||
subprocess.run(["pkill", "-f", "hostapd"], check=False)
|
||||
subprocess.run(["pkill", "-f", "dnsmasq"], check=False)
|
||||
log.info("AP stopped")
|
||||
|
||||
|
||||
def kill_wifi() -> None:
|
||||
result = subprocess.run(["rfkill", "block", "wifi"], check=False)
|
||||
if result.returncode != 0:
|
||||
log.error("rfkill failed with return code %d", result.returncode)
|
||||
raise ProvisioningError(f"rfkill failed: returncode={result.returncode}")
|
||||
log.info("WiFi radio killed")
|
||||
|
||||
|
||||
def join_home_wifi(ssid: str, password: str) -> None:
|
||||
result = subprocess.run(
|
||||
["nmcli", "device", "wifi", "connect", ssid, "password", password],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("nmcli failed (rc=%d): %s", result.returncode, result.stderr.decode())
|
||||
raise ProvisioningError(f"nmcli failed (rc={result.returncode}): {result.stderr.decode()}")
|
||||
log.info("joined home WiFi: %s", ssid)
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import math
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from planemapper.constants import (
|
||||
COLOUR_STALE_OUTLINE,
|
||||
COLOUR_TRAIL,
|
||||
TRAIL_DOT_SIZE_MAX,
|
||||
TRAIL_DOT_SIZE_MIN,
|
||||
)
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.renderer.colours import altitude_to_colour
|
||||
|
||||
|
||||
def _rotate_point(x: float, y: float, angle_deg: float) -> tuple[float, float]:
|
||||
r = math.radians(angle_deg)
|
||||
return (x * math.cos(r) - y * math.sin(r), x * math.sin(r) + y * math.cos(r))
|
||||
|
||||
|
||||
def _draw_arrow(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int,
|
||||
cy: int,
|
||||
heading: float,
|
||||
colour: tuple[int, int, int],
|
||||
is_mlat: bool,
|
||||
) -> None:
|
||||
tip = _rotate_point(0, -12, heading)
|
||||
left = _rotate_point(-6, 8, heading)
|
||||
right = _rotate_point(6, 8, heading)
|
||||
pts = [
|
||||
(cx + tip[0], cy + tip[1]),
|
||||
(cx + left[0], cy + left[1]),
|
||||
(cx + right[0], cy + right[1]),
|
||||
]
|
||||
if is_mlat:
|
||||
draw.polygon(pts, fill=None, outline=colour)
|
||||
else:
|
||||
draw.polygon(pts, fill=colour)
|
||||
|
||||
|
||||
def _draw_label(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int,
|
||||
cy: int,
|
||||
aircraft: Aircraft,
|
||||
colour: tuple[int, int, int],
|
||||
) -> None:
|
||||
font = ImageFont.load_default()
|
||||
if aircraft.callsign:
|
||||
text = f"{aircraft.callsign}\n{aircraft.altitude_ft}ft"
|
||||
else:
|
||||
text = f"{aircraft.altitude_ft}ft"
|
||||
draw.text((cx + 12, cy - 8), text, fill=colour, font=font)
|
||||
|
||||
|
||||
def _draw_trail(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
trail: collections.deque[tuple[int, int]],
|
||||
) -> None:
|
||||
n = len(trail)
|
||||
if n == 0:
|
||||
return
|
||||
for i, (tx, ty) in enumerate(trail):
|
||||
if n > 1:
|
||||
size = int(TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / (n - 1))
|
||||
else:
|
||||
size = TRAIL_DOT_SIZE_MAX
|
||||
r = max(size // 2, 1)
|
||||
draw.ellipse((tx - r, ty - r, tx + r, ty + r), fill=COLOUR_TRAIL)
|
||||
|
||||
|
||||
def draw_aircraft(
|
||||
image: Image.Image,
|
||||
aircraft: Aircraft,
|
||||
pos: tuple[int, int],
|
||||
trail: collections.deque[tuple[int, int]],
|
||||
) -> None:
|
||||
cx, cy = pos
|
||||
colour = altitude_to_colour(aircraft.altitude_ft)
|
||||
draw = ImageDraw.Draw(image)
|
||||
_draw_trail(draw, trail)
|
||||
if aircraft.is_stale:
|
||||
_draw_arrow(draw, cx, cy, aircraft.heading or 0.0, COLOUR_STALE_OUTLINE, is_mlat=True)
|
||||
else:
|
||||
_draw_arrow(draw, cx, cy, aircraft.heading or 0.0, colour, aircraft.is_mlat)
|
||||
_draw_label(draw, cx, cy, aircraft, colour)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE
|
||||
from planemapper.renderer.projection import MapBounds, project
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def draw_airspace(image: Image.Image, bounds: MapBounds) -> None:
|
||||
try:
|
||||
data = json.loads(AIRSPACE_PATH.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
log.warning("airspace.geojson not found at %s — skipping airspace overlay", AIRSPACE_PATH)
|
||||
return
|
||||
draw = ImageDraw.Draw(image)
|
||||
for feature in data.get("features", []):
|
||||
geom = feature.get("geometry", {})
|
||||
if geom.get("type") != "Polygon":
|
||||
continue
|
||||
coords = geom.get("coordinates", [[]])[0]
|
||||
if len(coords) < 2:
|
||||
continue
|
||||
# GeoJSON is [lon, lat] — reverse at parse boundary
|
||||
points = [project(lat, lon, bounds) for lon, lat in coords]
|
||||
draw.line(points, fill=COLOUR_AIRSPACE, width=2)
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.constants import BACKGROUND_PATH
|
||||
|
||||
|
||||
def load() -> Image.Image:
|
||||
return Image.open(BACKGROUND_PATH).copy()
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from planemapper.constants import ALTITUDE_BANDS_FT, ALTITUDE_COLOURS
|
||||
|
||||
|
||||
def altitude_to_colour(altitude_ft: int) -> tuple[int, int, int]:
|
||||
for i, band in enumerate(ALTITUDE_BANDS_FT):
|
||||
if altitude_ft <= band:
|
||||
return ALTITUDE_COLOURS[i]
|
||||
return ALTITUDE_COLOURS[-1]
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from planemapper.models import Aircraft
|
||||
|
||||
|
||||
class AircraftType(Enum):
|
||||
GA_LIGHT = "ga_light"
|
||||
COMMERCIAL = "commercial"
|
||||
PRIVATE_JET = "private_jet"
|
||||
AIRLINER = "airliner"
|
||||
HELICOPTER = "helicopter"
|
||||
MILITARY = "military"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
_AIRLINE_PREFIXES: frozenset[str] = frozenset(
|
||||
{
|
||||
"BAW",
|
||||
"EIN",
|
||||
"RYR",
|
||||
"EZY",
|
||||
"THY",
|
||||
"DLH",
|
||||
"AFR",
|
||||
"IBE",
|
||||
"KLM",
|
||||
"UAE",
|
||||
"SWR",
|
||||
"AAL",
|
||||
"UAL",
|
||||
"DAL",
|
||||
"SAS",
|
||||
"TAP",
|
||||
"VLG",
|
||||
"NOS",
|
||||
"WZZ",
|
||||
"AEA",
|
||||
"NAX",
|
||||
"FIN",
|
||||
"CSN",
|
||||
"CCA",
|
||||
}
|
||||
)
|
||||
|
||||
_CATEGORY_MAP: dict[str, AircraftType] = {
|
||||
"A1": AircraftType.GA_LIGHT,
|
||||
"A2": AircraftType.GA_LIGHT,
|
||||
"A3": AircraftType.COMMERCIAL,
|
||||
"A4": AircraftType.COMMERCIAL,
|
||||
"A5": AircraftType.COMMERCIAL,
|
||||
"A7": AircraftType.HELICOPTER,
|
||||
"B1": AircraftType.MILITARY,
|
||||
"B2": AircraftType.MILITARY,
|
||||
"B3": AircraftType.MILITARY,
|
||||
"B4": AircraftType.MILITARY,
|
||||
}
|
||||
|
||||
|
||||
def classify_aircraft_type(aircraft: Aircraft) -> AircraftType:
|
||||
if aircraft.category and aircraft.category in _CATEGORY_MAP:
|
||||
return _CATEGORY_MAP[aircraft.category]
|
||||
callsign = aircraft.callsign.strip()
|
||||
if len(callsign) >= 3 and callsign[:3].upper() in _AIRLINE_PREFIXES:
|
||||
return AircraftType.COMMERCIAL
|
||||
if aircraft.altitude_ft < 10000:
|
||||
return AircraftType.GA_LIGHT
|
||||
if aircraft.altitude_ft < 30000:
|
||||
return AircraftType.PRIVATE_JET
|
||||
return AircraftType.AIRLINER
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from planemapper.constants import COLOUR_HOME_MARKER
|
||||
from planemapper.renderer.projection import MapBounds, project
|
||||
|
||||
|
||||
def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:
|
||||
cx, cy = project(bounds.home_lat, bounds.home_lon, bounds)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.line([(cx - 10, cy), (cx + 10, cy)], fill=COLOUR_HOME_MARKER, width=3)
|
||||
draw.line([(cx, cy - 10), (cx, cy + 10)], fill=COLOUR_HOME_MARKER, width=3)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from planemapper.constants import DISPLAY_HEIGHT, DISPLAY_WIDTH
|
||||
|
||||
|
||||
@dataclass
|
||||
class MapBounds:
|
||||
home_lat: float
|
||||
home_lon: float
|
||||
radius_nm: float
|
||||
width: int = field(default=DISPLAY_WIDTH)
|
||||
height: int = field(default=DISPLAY_HEIGHT)
|
||||
|
||||
|
||||
def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]:
|
||||
deg_per_nm_lat = 1 / 60
|
||||
deg_per_nm_lon = 1 / (60 * math.cos(math.radians(bounds.home_lat)))
|
||||
px_per_nm_x = (bounds.width / 2) / bounds.radius_nm
|
||||
px_per_nm_y = (bounds.height / 2) / bounds.radius_nm
|
||||
x = bounds.width // 2 + int((lon - bounds.home_lon) / deg_per_nm_lon * px_per_nm_x)
|
||||
y = bounds.height // 2 - int((lat - bounds.home_lat) / deg_per_nm_lat * px_per_nm_y)
|
||||
return (x, y)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.constants import TRAIL_MAX_DOTS
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.renderer.aircraft import draw_aircraft
|
||||
from planemapper.renderer.airspace import draw_airspace
|
||||
from planemapper.renderer.overlay import draw_home_marker
|
||||
from planemapper.renderer.projection import MapBounds, project
|
||||
|
||||
|
||||
class Renderer:
|
||||
def __init__(self, base_map: Image.Image, bounds: MapBounds) -> None:
|
||||
self._base_map = base_map
|
||||
self._bounds = bounds
|
||||
self._trails: dict[str, collections.deque[tuple[int, int]]] = {}
|
||||
|
||||
def render(self, aircraft_list: list[Aircraft]) -> Image.Image:
|
||||
image = self._base_map.copy()
|
||||
draw_airspace(image, self._bounds)
|
||||
draw_home_marker(image, self._bounds)
|
||||
for aircraft in aircraft_list:
|
||||
pos = project(aircraft.lat, aircraft.lon, self._bounds)
|
||||
trail = self._trails.get(aircraft.icao, collections.deque())
|
||||
draw_aircraft(image, aircraft, pos, trail)
|
||||
trail.appendleft(pos)
|
||||
while len(trail) > TRAIL_MAX_DOTS:
|
||||
trail.pop()
|
||||
self._trails[aircraft.icao] = trail
|
||||
return image
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=planeMapper Provisioning
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/planemapper-provision
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=planeMapper Radar Display
|
||||
After=planemapper-provision.service
|
||||
Requires=planemapper-provision.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/planemapper-radar
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import planemapper.provisioning.config as config_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config_module, "CONFIG_PATH", tmp_path / "config.json")
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "4ca7f2",
|
||||
"lat": 53.3498,
|
||||
"lon": -6.2603,
|
||||
"flight": "EIN123 ",
|
||||
"altitude": 12000,
|
||||
"category": "A3",
|
||||
"track": 270.0,
|
||||
"mlat": []
|
||||
},
|
||||
{
|
||||
"hex": "4001a1",
|
||||
"lat": 53.42,
|
||||
"lon": -6.11,
|
||||
"altitude": 5000,
|
||||
"category": "A1",
|
||||
"mlat": []
|
||||
},
|
||||
{
|
||||
"hex": "4002b2",
|
||||
"lat": 53.28,
|
||||
"lon": -6.4,
|
||||
"flight": "RYR456 ",
|
||||
"category": "A3",
|
||||
"mlat": []
|
||||
},
|
||||
{
|
||||
"hex": "4003c3",
|
||||
"lat": 53.5,
|
||||
"lon": -5.9,
|
||||
"flight": "MIL001 ",
|
||||
"altitude": 1500,
|
||||
"category": "B1",
|
||||
"mlat": ["lat", "lon"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-6.5, 53.3],
|
||||
[-5.5, 53.3],
|
||||
[-5.5, 53.7],
|
||||
[-6.5, 53.7],
|
||||
[-6.5, 53.3]
|
||||
]
|
||||
]
|
||||
},
|
||||
"properties": {"name": "Test Airspace"}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from planemapper.provisioning.airspace import download
|
||||
|
||||
|
||||
def test_download_no_api_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
airspace_path = tmp_path / "airspace.geojson"
|
||||
monkeypatch.delenv("OPENAIP_API_KEY", raising=False)
|
||||
with patch("planemapper.provisioning.airspace.AIRSPACE_PATH", airspace_path):
|
||||
download(51.5, -0.1, 100)
|
||||
data = json.loads(airspace_path.read_text())
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert data["features"] == []
|
||||
|
||||
|
||||
def test_download_with_api_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
airspace_path = tmp_path / "airspace.geojson"
|
||||
monkeypatch.setenv("OPENAIP_API_KEY", "testkey123")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.ok = True
|
||||
mock_resp.text = '{"type":"FeatureCollection","features":[]}'
|
||||
with (
|
||||
patch("planemapper.provisioning.airspace.requests.get", return_value=mock_resp),
|
||||
patch("planemapper.provisioning.airspace.AIRSPACE_PATH", airspace_path),
|
||||
):
|
||||
download(51.5, -0.1, 100)
|
||||
assert airspace_path.exists()
|
||||
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import planemapper.provisioning.config as config_module
|
||||
|
||||
SAMPLE_CONFIG = {
|
||||
"home_lat": 51.5074,
|
||||
"home_lon": -0.1278,
|
||||
"coverage_radius_nm": 100,
|
||||
"wifi_ssid": "HomeNetwork",
|
||||
"wifi_password": "s3cr3t",
|
||||
"provisioned": False,
|
||||
}
|
||||
|
||||
|
||||
def test_read_raises_when_no_file_exists() -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
config_module.read()
|
||||
|
||||
|
||||
def test_write_creates_file_with_correct_content(tmp_path: Path) -> None:
|
||||
config_module.write(SAMPLE_CONFIG)
|
||||
assert config_module.CONFIG_PATH.exists()
|
||||
with config_module.CONFIG_PATH.open() as f:
|
||||
data = json.load(f)
|
||||
for key in (
|
||||
"home_lat",
|
||||
"home_lon",
|
||||
"coverage_radius_nm",
|
||||
"wifi_ssid",
|
||||
"wifi_password",
|
||||
"provisioned",
|
||||
):
|
||||
assert key in data
|
||||
assert data["home_lat"] == 51.5074
|
||||
assert data["wifi_ssid"] == "HomeNetwork"
|
||||
assert data["provisioned"] is False
|
||||
|
||||
|
||||
def test_write_then_read_round_trips() -> None:
|
||||
config_module.write(SAMPLE_CONFIG)
|
||||
result = config_module.read()
|
||||
assert result == SAMPLE_CONFIG
|
||||
|
||||
|
||||
def test_wipe_deletes_file() -> None:
|
||||
config_module.write(SAMPLE_CONFIG)
|
||||
assert config_module.CONFIG_PATH.exists()
|
||||
config_module.wipe()
|
||||
assert not config_module.CONFIG_PATH.exists()
|
||||
|
||||
|
||||
def test_wipe_then_read_raises() -> None:
|
||||
config_module.write(SAMPLE_CONFIG)
|
||||
config_module.wipe()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
config_module.read()
|
||||
|
||||
|
||||
def test_wipe_is_idempotent() -> None:
|
||||
# wipe on a non-existent file should not raise
|
||||
config_module.wipe()
|
||||
config_module.wipe()
|
||||
|
||||
|
||||
def test_config_path_is_in_tmp_path(tmp_path: Path) -> None:
|
||||
assert str(config_module.CONFIG_PATH).startswith(str(tmp_path))
|
||||
@@ -0,0 +1,55 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from planemapper.provisioning.location import resolve
|
||||
|
||||
|
||||
def test_icao_lookup_hit_egll() -> None:
|
||||
lat, lon, name = resolve("EGLL")
|
||||
assert isinstance(lat, float)
|
||||
assert isinstance(lon, float)
|
||||
assert isinstance(name, str)
|
||||
assert 50.0 < lat < 52.0 # Heathrow is ~51.47°N
|
||||
assert -1.0 < lon < 0.0 # and ~0.46°W
|
||||
|
||||
|
||||
def test_icao_lookup_case_insensitive() -> None:
|
||||
lat, lon, name = resolve("egll")
|
||||
assert lat != 0.0
|
||||
|
||||
|
||||
def test_icao_lookup_miss_raises_value_error() -> None:
|
||||
with pytest.raises(ValueError, match="ICAO code not found"):
|
||||
resolve("ZZZZ")
|
||||
|
||||
|
||||
def test_nominatim_success() -> None:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = [
|
||||
{"lat": "51.5", "lon": "-0.1", "display_name": "London, England"}
|
||||
]
|
||||
with patch("planemapper.provisioning.location.requests.get", return_value=mock_resp):
|
||||
lat, lon, name = resolve("OX1 1AA")
|
||||
assert lat == 51.5
|
||||
assert lon == -0.1
|
||||
assert name == "London, England"
|
||||
|
||||
|
||||
def test_nominatim_empty_raises_value_error() -> None:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = []
|
||||
with patch("planemapper.provisioning.location.requests.get", return_value=mock_resp):
|
||||
with pytest.raises(ValueError, match="Location not found"):
|
||||
resolve("nonsense query that returns nothing")
|
||||
|
||||
|
||||
def test_nominatim_called_with_user_agent() -> None:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = [{"lat": "51.5", "lon": "-0.1", "display_name": "London"}]
|
||||
with patch(
|
||||
"planemapper.provisioning.location.requests.get", return_value=mock_resp
|
||||
) as mock_get:
|
||||
resolve("London")
|
||||
call_kwargs = mock_get.call_args
|
||||
assert "User-Agent" in call_kwargs.kwargs.get("headers", {})
|
||||
@@ -0,0 +1,123 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
from planemapper.provisioning.portal import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app.config["TESTING"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_index_returns_200(client) -> None:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_index_contains_form_fields(client) -> None:
|
||||
resp = client.get("/")
|
||||
data = resp.data.decode()
|
||||
assert 'name="location"' in data
|
||||
assert 'name="radius"' in data
|
||||
assert 'name="wifi_ssid"' in data
|
||||
assert 'name="wifi_password"' in data
|
||||
assert "Find location" in data
|
||||
assert "Set up device" in data
|
||||
|
||||
|
||||
def test_generate_204_redirects_to_index(client) -> None:
|
||||
resp = client.get("/generate_204")
|
||||
assert resp.status_code in (301, 302)
|
||||
assert resp.headers["Location"].endswith("/")
|
||||
|
||||
|
||||
def test_hotspot_detect_redirects_to_index(client) -> None:
|
||||
resp = client.get("/hotspot-detect.html")
|
||||
assert resp.status_code in (301, 302)
|
||||
|
||||
|
||||
def test_ncsi_redirects_to_index(client) -> None:
|
||||
resp = client.get("/ncsi.txt")
|
||||
assert resp.status_code in (301, 302)
|
||||
|
||||
|
||||
def test_unknown_route_redirects_to_index(client) -> None:
|
||||
resp = client.get("/some/random/path")
|
||||
assert resp.status_code in (301, 302)
|
||||
|
||||
|
||||
def test_find_location_success(client) -> None:
|
||||
mock_resolve = patch(
|
||||
"planemapper.provisioning.portal.location.resolve", return_value=(51.5, -0.1, "London")
|
||||
)
|
||||
with mock_resolve:
|
||||
resp = client.post("/find-location", data={"location": "EGLL", "radius": "100"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.data.decode()
|
||||
assert "London" in data
|
||||
assert "51.5" in data
|
||||
|
||||
|
||||
def test_find_location_error(client) -> None:
|
||||
with patch(
|
||||
"planemapper.provisioning.portal.location.resolve",
|
||||
side_effect=ValueError("ICAO code not found — try an address instead"),
|
||||
):
|
||||
resp = client.post("/find-location", data={"location": "ZZZZ", "radius": "100"})
|
||||
assert resp.status_code == 200
|
||||
assert "ICAO code not found" in resp.data.decode()
|
||||
|
||||
|
||||
def test_submit_success(client) -> None:
|
||||
with (
|
||||
patch("planemapper.provisioning.portal.wifi.join_home_wifi"),
|
||||
patch("planemapper.provisioning.portal.tiles.download_and_composite"),
|
||||
patch("planemapper.provisioning.portal.airspace.download"),
|
||||
patch("planemapper.provisioning.portal.tiles.validate_cache"),
|
||||
patch("planemapper.provisioning.portal.config.write"),
|
||||
patch("planemapper.provisioning.portal.wifi.kill_wifi"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/submit",
|
||||
data={
|
||||
"confirmed_lat": "51.5",
|
||||
"confirmed_lon": "-0.1",
|
||||
"confirmed_name": "London",
|
||||
"radius": "100",
|
||||
"wifi_ssid": "HomeNet",
|
||||
"wifi_password": "secret",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"Setup complete" in resp.data
|
||||
|
||||
|
||||
def test_submit_validation_failure(client) -> None:
|
||||
with (
|
||||
patch("planemapper.provisioning.portal.wifi.join_home_wifi"),
|
||||
patch("planemapper.provisioning.portal.tiles.download_and_composite"),
|
||||
patch("planemapper.provisioning.portal.airspace.download"),
|
||||
patch(
|
||||
"planemapper.provisioning.portal.tiles.validate_cache",
|
||||
side_effect=ProvisioningError("bad png"),
|
||||
),
|
||||
patch("planemapper.provisioning.portal.config.write"),
|
||||
patch("planemapper.provisioning.portal.wifi.kill_wifi"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/submit",
|
||||
data={
|
||||
"confirmed_lat": "51.5",
|
||||
"confirmed_lon": "-0.1",
|
||||
"confirmed_name": "London",
|
||||
"radius": "100",
|
||||
"wifi_ssid": "HomeNet",
|
||||
"wifi_password": "secret",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"Try again" in resp.data
|
||||
@@ -0,0 +1,54 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from planemapper.provisioning import ProvisioningError, wifi
|
||||
|
||||
|
||||
def test_provisioning_error_is_exception() -> None:
|
||||
assert issubclass(ProvisioningError, Exception)
|
||||
|
||||
|
||||
def test_provisioning_error_can_be_raised_and_caught() -> None:
|
||||
try:
|
||||
raise ProvisioningError("test error")
|
||||
except ProvisioningError as e:
|
||||
assert str(e) == "test error"
|
||||
|
||||
|
||||
def test_start_ap_raises_on_hostapd_failure() -> None:
|
||||
mock_fail = MagicMock()
|
||||
mock_fail.returncode = 1
|
||||
with patch("planemapper.provisioning.wifi._write_hostapd_conf"):
|
||||
with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_fail):
|
||||
with pytest.raises(ProvisioningError, match="hostapd failed"):
|
||||
wifi.start_ap()
|
||||
|
||||
|
||||
def test_start_ap_raises_on_dnsmasq_failure() -> None:
|
||||
mock_ok = MagicMock()
|
||||
mock_ok.returncode = 0
|
||||
mock_fail = MagicMock()
|
||||
mock_fail.returncode = 1
|
||||
with patch("planemapper.provisioning.wifi._write_hostapd_conf"):
|
||||
with patch(
|
||||
"planemapper.provisioning.wifi.subprocess.run",
|
||||
side_effect=[mock_ok, mock_fail], # hostapd OK, dnsmasq fails
|
||||
):
|
||||
with pytest.raises(ProvisioningError, match="dnsmasq failed"):
|
||||
wifi.start_ap()
|
||||
|
||||
|
||||
def test_kill_wifi_raises_on_rfkill_failure() -> None:
|
||||
mock_fail = MagicMock()
|
||||
mock_fail.returncode = 1
|
||||
with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_fail):
|
||||
with pytest.raises(ProvisioningError, match="rfkill failed"):
|
||||
wifi.kill_wifi()
|
||||
|
||||
|
||||
def test_stop_ap_does_not_raise() -> None:
|
||||
mock_ok = MagicMock()
|
||||
mock_ok.returncode = 0
|
||||
with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_ok):
|
||||
wifi.stop_ap() # should not raise
|
||||
@@ -0,0 +1,71 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
from planemapper.provisioning.tiles import (
|
||||
download_and_composite,
|
||||
lat_lon_to_tile,
|
||||
validate_cache,
|
||||
)
|
||||
|
||||
|
||||
def _make_png_bytes() -> bytes:
|
||||
img = Image.new("RGB", (256, 256), (200, 200, 200))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_lat_lon_to_tile_london() -> None:
|
||||
x, y = lat_lon_to_tile(51.5, -0.1, 10)
|
||||
assert x == 511
|
||||
assert y == 340
|
||||
|
||||
|
||||
def test_download_and_composite(tmp_path: Path) -> None:
|
||||
bg_path = tmp_path / "background.png"
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = _make_png_bytes()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch("planemapper.provisioning.tiles.requests.get", return_value=mock_resp),
|
||||
patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path),
|
||||
):
|
||||
download_and_composite(51.5, -0.1, 100)
|
||||
assert bg_path.exists()
|
||||
with Image.open(bg_path) as img:
|
||||
assert img.size == (800, 480)
|
||||
|
||||
|
||||
def test_validate_cache_passes(tmp_path: Path) -> None:
|
||||
bg_path = tmp_path / "background.png"
|
||||
Image.new("RGB", (800, 480), (255, 255, 255)).save(str(bg_path))
|
||||
with patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path):
|
||||
validate_cache()
|
||||
|
||||
|
||||
def test_validate_cache_missing(tmp_path: Path) -> None:
|
||||
bg_path = tmp_path / "nonexistent.png"
|
||||
with patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path):
|
||||
with pytest.raises(ProvisioningError, match="not found"):
|
||||
validate_cache()
|
||||
|
||||
|
||||
def test_validate_cache_empty(tmp_path: Path) -> None:
|
||||
bg_path = tmp_path / "background.png"
|
||||
bg_path.write_bytes(b"")
|
||||
with patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path):
|
||||
with pytest.raises(ProvisioningError, match="empty"):
|
||||
validate_cache()
|
||||
|
||||
|
||||
def test_validate_cache_corrupt(tmp_path: Path) -> None:
|
||||
bg_path = tmp_path / "background.png"
|
||||
bg_path.write_bytes(b"not a png at all")
|
||||
with patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path):
|
||||
with pytest.raises(ProvisioningError, match="not a valid PNG"):
|
||||
validate_cache()
|
||||
@@ -0,0 +1,22 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from planemapper.provisioning import ProvisioningError
|
||||
from planemapper.provisioning.wifi import join_home_wifi
|
||||
|
||||
|
||||
def test_join_home_wifi_success() -> None:
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_result):
|
||||
join_home_wifi("MySSID", "MyPass")
|
||||
|
||||
|
||||
def test_join_home_wifi_failure() -> None:
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stderr = b"Error: connection failed"
|
||||
with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(ProvisioningError, match="nmcli failed"):
|
||||
join_home_wifi("MySSID", "wrongpassword")
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.renderer.aircraft import draw_aircraft
|
||||
|
||||
|
||||
def _white_image() -> Image.Image:
|
||||
return Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
|
||||
|
||||
def _aircraft(**kwargs) -> Aircraft:
|
||||
defaults = {"icao": "ABC123", "lat": 53.0, "lon": -6.0}
|
||||
defaults.update(kwargs)
|
||||
return Aircraft(**defaults)
|
||||
|
||||
|
||||
def test_heading_east_changes_pixels() -> None:
|
||||
img = _white_image()
|
||||
ac = _aircraft(heading=90.0, altitude_ft=10000)
|
||||
draw_aircraft(img, ac, (400, 240), collections.deque())
|
||||
# Arrow should have painted at least one non-white pixel near centre
|
||||
changed = any(
|
||||
img.getpixel((x, y)) != (255, 255, 255) for x in range(388, 413) for y in range(228, 253)
|
||||
)
|
||||
assert changed
|
||||
|
||||
|
||||
def test_label_with_callsign_no_exception() -> None:
|
||||
img = _white_image()
|
||||
ac = _aircraft(callsign="BAW1", altitude_ft=28000)
|
||||
result = draw_aircraft(img, ac, (400, 240), collections.deque())
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_trail_drawn_no_exception() -> None:
|
||||
img = _white_image()
|
||||
ac = _aircraft(altitude_ft=5000)
|
||||
trail: collections.deque[tuple[int, int]] = collections.deque(
|
||||
[(390, 230), (380, 220), (370, 210)]
|
||||
)
|
||||
result = draw_aircraft(img, ac, (400, 240), trail)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_mlat_aircraft_no_exception() -> None:
|
||||
img = _white_image()
|
||||
ac = _aircraft(is_mlat=True, altitude_ft=8000)
|
||||
result = draw_aircraft(img, ac, (400, 240), collections.deque())
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_empty_callsign_no_exception() -> None:
|
||||
img = _white_image()
|
||||
ac = _aircraft(callsign="", altitude_ft=15000)
|
||||
result = draw_aircraft(img, ac, (400, 240), collections.deque())
|
||||
assert result is None
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.renderer.airspace import draw_airspace
|
||||
from planemapper.renderer.overlay import draw_home_marker
|
||||
from planemapper.renderer.projection import MapBounds
|
||||
|
||||
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def white_image() -> Image.Image:
|
||||
return Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bounds() -> MapBounds:
|
||||
return MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
|
||||
|
||||
def test_home_marker_draws_red_cross(white_image: Image.Image, bounds: MapBounds) -> None:
|
||||
draw_home_marker(white_image, bounds)
|
||||
# Centre pixel should be red (COLOUR_HOME_MARKER)
|
||||
assert white_image.getpixel((400, 240)) == (255, 0, 0)
|
||||
|
||||
|
||||
def test_airspace_drawn_without_exception(
|
||||
white_image: Image.Image,
|
||||
bounds: MapBounds,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||
FIXTURE_DIR / "airspace_sample.geojson",
|
||||
)
|
||||
draw_airspace(white_image, bounds) # should not raise
|
||||
|
||||
|
||||
def test_airspace_missing_file_no_exception(
|
||||
white_image: Image.Image,
|
||||
bounds: MapBounds,
|
||||
tmp_path: pathlib.Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||
tmp_path / "nonexistent.geojson",
|
||||
)
|
||||
draw_airspace(white_image, bounds) # should not raise — logs WARNING instead
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.renderer import basemap
|
||||
|
||||
|
||||
def test_load_returns_image(tmp_path, monkeypatch):
|
||||
img_path = tmp_path / "background.png"
|
||||
img = Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
img.save(img_path)
|
||||
monkeypatch.setattr("planemapper.renderer.basemap.BACKGROUND_PATH", img_path)
|
||||
result = basemap.load()
|
||||
assert result.size == (800, 480)
|
||||
|
||||
|
||||
def test_load_raises_if_missing(tmp_path, monkeypatch):
|
||||
missing = tmp_path / "nonexistent.png"
|
||||
monkeypatch.setattr("planemapper.renderer.basemap.BACKGROUND_PATH", missing)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
basemap.load()
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from planemapper.constants import ALTITUDE_COLOURS
|
||||
from planemapper.renderer.colours import altitude_to_colour
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"altitude_ft,expected",
|
||||
[
|
||||
(0, ALTITUDE_COLOURS[0]), # surface → GREEN
|
||||
(1500, ALTITUDE_COLOURS[0]), # boundary inclusive → GREEN
|
||||
(1501, ALTITUDE_COLOURS[1]), # just above → BLUE
|
||||
(5000, ALTITUDE_COLOURS[1]), # boundary inclusive → BLUE
|
||||
(5001, ALTITUDE_COLOURS[2]), # just above → YELLOW
|
||||
(10000, ALTITUDE_COLOURS[2]), # boundary inclusive → YELLOW
|
||||
(10001, ALTITUDE_COLOURS[3]), # just above → RED
|
||||
(20000, ALTITUDE_COLOURS[3]), # boundary inclusive → RED
|
||||
(20001, ALTITUDE_COLOURS[4]), # just above → BLACK
|
||||
(35000, ALTITUDE_COLOURS[4]), # boundary inclusive → BLACK
|
||||
(35001, ALTITUDE_COLOURS[5]), # just above → WHITE
|
||||
(99999, ALTITUDE_COLOURS[5]), # max band → WHITE
|
||||
(100000, ALTITUDE_COLOURS[5]), # beyond max → WHITE (fallback)
|
||||
],
|
||||
)
|
||||
def test_altitude_to_colour(altitude_ft: int, expected: tuple[int, int, int]) -> None:
|
||||
assert altitude_to_colour(altitude_ft) == expected
|
||||
|
||||
|
||||
def test_all_six_colours_reachable() -> None:
|
||||
results = {altitude_to_colour(alt) for alt in [0, 2000, 7500, 15000, 25000, 40000]}
|
||||
assert results == set(ALTITUDE_COLOURS)
|
||||
@@ -0,0 +1,124 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from planemapper.fetcher import FileFixtureFetcher, HttpFetcher
|
||||
|
||||
_FIXTURES = Path(__file__).parent / "fixtures" / "aircraft_sample.json"
|
||||
|
||||
_FULL_RESPONSE = {
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "4ca7f2",
|
||||
"lat": 53.3498,
|
||||
"lon": -6.2603,
|
||||
"flight": "EIN123 ",
|
||||
"altitude": 12000,
|
||||
"category": "A3",
|
||||
"track": 270.0,
|
||||
"mlat": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
_DEFAULTS_RESPONSE = {
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "aabbcc",
|
||||
"lat": 51.0,
|
||||
"lon": -1.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
_MLAT_RESPONSE = {
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "dddddd",
|
||||
"lat": 52.0,
|
||||
"lon": -2.0,
|
||||
"mlat": ["lat", "lon"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _mock_get(payload: dict) -> MagicMock:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = payload
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
|
||||
def test_http_fetcher_full_response() -> None:
|
||||
with patch("planemapper.fetcher.requests.get", return_value=_mock_get(_FULL_RESPONSE)):
|
||||
aircraft = HttpFetcher().fetch()
|
||||
assert len(aircraft) == 1
|
||||
a = aircraft[0]
|
||||
assert a.icao == "4ca7f2"
|
||||
assert a.lat == 53.3498
|
||||
assert a.lon == -6.2603
|
||||
assert a.callsign == "EIN123"
|
||||
assert a.altitude_ft == 12000
|
||||
assert a.category == "A3"
|
||||
assert a.heading == 270.0
|
||||
assert a.is_mlat is False
|
||||
assert a.is_stale is False
|
||||
|
||||
|
||||
def test_http_fetcher_missing_fields_use_defaults() -> None:
|
||||
with patch("planemapper.fetcher.requests.get", return_value=_mock_get(_DEFAULTS_RESPONSE)):
|
||||
aircraft = HttpFetcher().fetch()
|
||||
assert len(aircraft) == 1
|
||||
a = aircraft[0]
|
||||
assert a.callsign == ""
|
||||
assert a.altitude_ft == 0
|
||||
assert a.category == ""
|
||||
assert a.is_mlat is False
|
||||
|
||||
|
||||
def test_http_fetcher_altitude_ground_string() -> None:
|
||||
payload = {"aircraft": [{"hex": "aaa", "lat": 51.0, "lon": -1.0, "altitude": "ground"}]}
|
||||
with patch("planemapper.fetcher.requests.get", return_value=_mock_get(payload)):
|
||||
aircraft = HttpFetcher().fetch()
|
||||
assert aircraft[0].altitude_ft == 0
|
||||
|
||||
|
||||
def test_http_fetcher_skips_entries_without_position() -> None:
|
||||
payload = {"aircraft": [{"hex": "nopos"}, {"hex": "haspos", "lat": 51.0, "lon": -1.0}]}
|
||||
with patch("planemapper.fetcher.requests.get", return_value=_mock_get(payload)):
|
||||
aircraft = HttpFetcher().fetch()
|
||||
assert len(aircraft) == 1
|
||||
assert aircraft[0].icao == "haspos"
|
||||
|
||||
|
||||
def test_http_fetcher_propagates_timeout() -> None:
|
||||
with patch("planemapper.fetcher.requests.get", side_effect=requests.Timeout):
|
||||
with pytest.raises(requests.Timeout):
|
||||
HttpFetcher().fetch()
|
||||
|
||||
|
||||
def test_http_fetcher_mlat_flag() -> None:
|
||||
with patch("planemapper.fetcher.requests.get", return_value=_mock_get(_MLAT_RESPONSE)):
|
||||
aircraft = HttpFetcher().fetch()
|
||||
assert aircraft[0].is_mlat is True
|
||||
|
||||
|
||||
def test_file_fixture_fetcher_returns_aircraft() -> None:
|
||||
aircraft = FileFixtureFetcher(_FIXTURES).fetch()
|
||||
assert len(aircraft) == 4
|
||||
|
||||
|
||||
def test_file_fixture_fetcher_no_network_call() -> None:
|
||||
with patch("planemapper.fetcher.requests.get") as mock_get:
|
||||
FileFixtureFetcher(_FIXTURES).fetch()
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
def test_file_fixture_fetcher_mlat_aircraft() -> None:
|
||||
aircraft = FileFixtureFetcher(_FIXTURES).fetch()
|
||||
mlat_aircraft = [a for a in aircraft if a.is_mlat]
|
||||
assert len(mlat_aircraft) == 1
|
||||
assert mlat_aircraft[0].icao == "4003c3"
|
||||
@@ -0,0 +1,27 @@
|
||||
import pytest
|
||||
from gpiozero import Device
|
||||
from gpiozero.pins.mock import MockFactory
|
||||
|
||||
from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_gpio() -> None:
|
||||
Device.pin_factory = MockFactory()
|
||||
|
||||
|
||||
def test_button_hold_detector_returns_bool() -> None:
|
||||
detector = ButtonHoldDetector()
|
||||
result = detector.check()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
def test_button_hold_detector_not_held_by_default() -> None:
|
||||
detector = ButtonHoldDetector()
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
def test_led_controller_on_off_no_exception() -> None:
|
||||
led = LEDController()
|
||||
led.on()
|
||||
led.off()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.renderer.icons import AircraftType, classify_aircraft_type
|
||||
|
||||
|
||||
def _aircraft(**kwargs) -> Aircraft:
|
||||
defaults = {"icao": "ABC123", "lat": 53.0, "lon": -6.0}
|
||||
defaults.update(kwargs)
|
||||
return Aircraft(**defaults)
|
||||
|
||||
|
||||
def test_category_a1_returns_ga_light() -> None:
|
||||
ac = _aircraft(category="A1")
|
||||
assert classify_aircraft_type(ac) == AircraftType.GA_LIGHT
|
||||
|
||||
|
||||
def test_category_a7_returns_helicopter() -> None:
|
||||
ac = _aircraft(category="A7")
|
||||
assert classify_aircraft_type(ac) == AircraftType.HELICOPTER
|
||||
|
||||
|
||||
def test_ba_callsign_returns_commercial() -> None:
|
||||
ac = _aircraft(callsign="BAW123")
|
||||
assert classify_aircraft_type(ac) == AircraftType.COMMERCIAL
|
||||
|
||||
|
||||
def test_altitude_below_10000_returns_ga_light() -> None:
|
||||
ac = _aircraft(altitude_ft=5000)
|
||||
assert classify_aircraft_type(ac) == AircraftType.GA_LIGHT
|
||||
|
||||
|
||||
def test_altitude_18000_returns_private_jet() -> None:
|
||||
ac = _aircraft(altitude_ft=18000)
|
||||
assert classify_aircraft_type(ac) == AircraftType.PRIVATE_JET
|
||||
|
||||
|
||||
def test_altitude_38000_returns_airliner() -> None:
|
||||
ac = _aircraft(altitude_ft=38000)
|
||||
assert classify_aircraft_type(ac) == AircraftType.AIRLINER
|
||||
|
||||
|
||||
def test_category_a3_returns_commercial() -> None:
|
||||
ac = _aircraft(category="A3")
|
||||
assert classify_aircraft_type(ac) == AircraftType.COMMERCIAL
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.display import NullDisplay
|
||||
from planemapper.fetcher import FileFixtureFetcher
|
||||
from planemapper.main import _run_one_cycle
|
||||
from planemapper.renderer.projection import MapBounds
|
||||
from planemapper.renderer.renderer import Renderer
|
||||
|
||||
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def renderer(monkeypatch: pytest.MonkeyPatch) -> Renderer:
|
||||
monkeypatch.setattr(
|
||||
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||
FIXTURE_DIR / "airspace_sample.geojson",
|
||||
)
|
||||
base_map = Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
return Renderer(base_map, bounds)
|
||||
|
||||
|
||||
def test_run_one_cycle_calls_display_show(renderer: Renderer) -> None:
|
||||
fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json")
|
||||
display = NullDisplay()
|
||||
display_mock = MagicMock(wraps=display)
|
||||
_run_one_cycle(renderer, fetcher, display_mock, [])
|
||||
display_mock.show.assert_called_once()
|
||||
|
||||
|
||||
def test_run_one_cycle_logs_warning_when_slow(
|
||||
renderer: Renderer, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json")
|
||||
display = NullDisplay()
|
||||
# Simulate 43s total: t0=0, t1=1, t2=2, t3=43
|
||||
with patch("planemapper.main.time.monotonic", side_effect=[0.0, 1.0, 2.0, 43.0]):
|
||||
with caplog.at_level(logging.WARNING, logger="planemapper.main"):
|
||||
_run_one_cycle(renderer, fetcher, display, [])
|
||||
assert any("render slow" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_provision_main_exits_if_already_provisioned() -> None:
|
||||
with patch("planemapper.provisioning.config.read", return_value={"provisioned": True}):
|
||||
with patch("planemapper.provisioning.wifi.start_ap") as mock_ap:
|
||||
from planemapper.provision import main as provision_main
|
||||
|
||||
provision_main()
|
||||
mock_ap.assert_not_called()
|
||||
@@ -0,0 +1,28 @@
|
||||
from planemapper.models import Aircraft
|
||||
|
||||
|
||||
def test_aircraft_defaults() -> None:
|
||||
a = Aircraft(icao="ABC123", lat=51.5, lon=-0.1)
|
||||
assert a.heading == 0.0
|
||||
assert a.altitude_ft == 0
|
||||
assert a.callsign == ""
|
||||
assert a.category == ""
|
||||
assert a.is_mlat is False
|
||||
assert a.is_stale is False
|
||||
|
||||
|
||||
def test_aircraft_full() -> None:
|
||||
a = Aircraft(
|
||||
icao="ABC123",
|
||||
lat=51.5,
|
||||
lon=-0.1,
|
||||
heading=90.0,
|
||||
altitude_ft=5000,
|
||||
callsign="BAW1",
|
||||
category="A3",
|
||||
is_mlat=True,
|
||||
is_stale=False,
|
||||
)
|
||||
assert a.heading == 90.0
|
||||
assert a.callsign == "BAW1"
|
||||
assert a.is_mlat is True
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.display import NullDisplay
|
||||
from planemapper.fetcher import FileFixtureFetcher
|
||||
from planemapper.renderer.projection import MapBounds
|
||||
from planemapper.renderer.renderer import Renderer
|
||||
|
||||
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def test_full_pipeline_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||
FIXTURE_DIR / "airspace_sample.geojson",
|
||||
)
|
||||
base_map = Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json")
|
||||
renderer = Renderer(base_map, bounds)
|
||||
display = NullDisplay()
|
||||
|
||||
aircraft_list = fetcher.fetch()
|
||||
result = renderer.render(aircraft_list)
|
||||
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.size == (800, 480)
|
||||
display.show(result) # should not raise
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from planemapper.renderer.projection import MapBounds, project
|
||||
|
||||
|
||||
def test_home_projects_to_centre() -> None:
|
||||
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
x, y = project(53.0, -6.0, bounds)
|
||||
assert abs(x - 400) <= 2
|
||||
assert abs(y - 240) <= 2
|
||||
|
||||
|
||||
def test_out_of_bounds_not_clamped() -> None:
|
||||
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
# 10 degrees of lat north is far outside any 100nm bounds
|
||||
x, y = project(63.0, -6.0, bounds)
|
||||
# y should be well above display top (y < 0)
|
||||
assert y < 0 or y > 480 or x < 0 or x > 800
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.renderer.projection import MapBounds
|
||||
from planemapper.renderer.renderer import Renderer
|
||||
|
||||
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def white_base_map() -> Image.Image:
|
||||
return Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bounds() -> MapBounds:
|
||||
return MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def renderer(
|
||||
white_base_map: Image.Image, bounds: MapBounds, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Renderer:
|
||||
monkeypatch.setattr(
|
||||
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||
FIXTURE_DIR / "airspace_sample.geojson",
|
||||
)
|
||||
return Renderer(white_base_map, bounds)
|
||||
|
||||
|
||||
def _aircraft(icao: str = "ABC123", **kwargs) -> Aircraft:
|
||||
defaults = {"lat": 53.1, "lon": -6.1}
|
||||
defaults.update(kwargs)
|
||||
return Aircraft(icao=icao, **defaults)
|
||||
|
||||
|
||||
def test_render_returns_800x480(renderer: Renderer) -> None:
|
||||
result = renderer.render([])
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.size == (800, 480)
|
||||
|
||||
|
||||
def test_trail_accumulated_across_renders(renderer: Renderer) -> None:
|
||||
ac = _aircraft(icao="ABC123")
|
||||
renderer.render([ac])
|
||||
renderer.render([ac])
|
||||
assert "ABC123" in renderer._trails
|
||||
assert len(renderer._trails["ABC123"]) == 2
|
||||
|
||||
|
||||
def test_absent_aircraft_trail_retained(renderer: Renderer) -> None:
|
||||
ac = _aircraft(icao="ABC123")
|
||||
renderer.render([ac])
|
||||
renderer.render([]) # aircraft absent
|
||||
assert "ABC123" in renderer._trails
|
||||
assert len(renderer._trails["ABC123"]) == 1
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from gpiozero import Device
|
||||
from gpiozero.pins.mock import MockFactory
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.display import NullDisplay
|
||||
from planemapper.gpio_ctrl import LEDController
|
||||
from planemapper.main import _handle_reset
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_gpio():
|
||||
Device.pin_factory = MockFactory()
|
||||
|
||||
|
||||
def test_reset_wipes_config_and_execvp():
|
||||
display = NullDisplay()
|
||||
led = LEDController()
|
||||
with patch("planemapper.main.wipe_config") as mock_wipe:
|
||||
with patch("planemapper.main.os.execvp") as mock_exec:
|
||||
_handle_reset(display, led)
|
||||
mock_wipe.assert_called_once()
|
||||
mock_exec.assert_called_once_with("planemapper-provision", ["planemapper-provision"])
|
||||
|
||||
|
||||
def test_reset_shows_setup_screen():
|
||||
display = MagicMock()
|
||||
led = LEDController()
|
||||
with patch("planemapper.main.wipe_config"):
|
||||
with patch("planemapper.main.os.execvp"):
|
||||
_handle_reset(display, led)
|
||||
display.show.assert_called_once()
|
||||
img = display.show.call_args[0][0]
|
||||
assert isinstance(img, Image.Image)
|
||||
assert img.size == (800, 480)
|
||||
|
||||
|
||||
def test_reset_does_not_execvp_on_wipe_failure():
|
||||
display = NullDisplay()
|
||||
led = LEDController()
|
||||
with patch("planemapper.main.wipe_config", side_effect=PermissionError("no perms")):
|
||||
with patch("planemapper.main.os.execvp") as mock_exec:
|
||||
_handle_reset(display, led)
|
||||
mock_exec.assert_not_called()
|
||||
@@ -0,0 +1,85 @@
|
||||
import ast
|
||||
import importlib.resources
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def test_required_toplevel_modules_exist() -> None:
|
||||
base = REPO_ROOT / "src" / "planemapper"
|
||||
for name in [
|
||||
"__init__.py",
|
||||
"constants.py",
|
||||
"models.py",
|
||||
"main.py",
|
||||
"provision.py",
|
||||
"fetcher.py",
|
||||
"gpio_ctrl.py",
|
||||
"display.py",
|
||||
]:
|
||||
assert (base / name).exists(), f"Missing: {name}"
|
||||
|
||||
|
||||
def test_provisioning_subpackage_exists() -> None:
|
||||
base = REPO_ROOT / "src" / "planemapper" / "provisioning"
|
||||
for name in [
|
||||
"__init__.py",
|
||||
"portal.py",
|
||||
"location.py",
|
||||
"tiles.py",
|
||||
"airspace.py",
|
||||
"wifi.py",
|
||||
"config.py",
|
||||
]:
|
||||
assert (base / name).exists(), f"Missing provisioning/{name}"
|
||||
|
||||
|
||||
def test_renderer_subpackage_exists() -> None:
|
||||
base = REPO_ROOT / "src" / "planemapper" / "renderer"
|
||||
for name in [
|
||||
"__init__.py",
|
||||
"renderer.py",
|
||||
"projection.py",
|
||||
"basemap.py",
|
||||
"aircraft.py",
|
||||
"airspace.py",
|
||||
"colours.py",
|
||||
"icons.py",
|
||||
]:
|
||||
assert (base / name).exists(), f"Missing renderer/{name}"
|
||||
|
||||
|
||||
def test_systemd_units_exist() -> None:
|
||||
systemd = REPO_ROOT / "systemd"
|
||||
assert (systemd / "planemapper-provision.service").exists()
|
||||
assert (systemd / "planemapper-radar.service").exists()
|
||||
|
||||
|
||||
def test_airports_csv_via_importlib_resources() -> None:
|
||||
ref = importlib.resources.files("planemapper.data").joinpath("airports.csv")
|
||||
content = ref.read_text(encoding="utf-8")
|
||||
assert len(content) > 0
|
||||
assert "icao_code" in content or "ident" in content # OurAirports CSV header
|
||||
|
||||
|
||||
def test_constants_colours_complete() -> None:
|
||||
from planemapper import constants
|
||||
|
||||
assert len(constants.ALTITUDE_COLOURS) == 6
|
||||
assert len(constants.ALTITUDE_BANDS_FT) == 6
|
||||
assert constants.DISPLAY_WIDTH == 800
|
||||
assert constants.DISPLAY_HEIGHT == 480
|
||||
assert constants.REFRESH_INTERVAL_S == 60
|
||||
assert constants.FETCH_TIMEOUT_S == 5
|
||||
|
||||
|
||||
def test_main_only_imports_config_from_provisioning() -> None:
|
||||
"""main.py may import planemapper.provisioning.config (to read stored config)
|
||||
but must not import other provisioning sub-modules (portal, wifi, tiles, etc.)."""
|
||||
allowed = {"planemapper.provisioning.config"}
|
||||
main_path = REPO_ROOT / "src" / "planemapper" / "main.py"
|
||||
tree = ast.parse(main_path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("planemapper.provisioning"):
|
||||
assert node.module in allowed, f"main.py must not import from {node.module}"
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import pathlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from planemapper.display import NullDisplay
|
||||
from planemapper.main import _run_one_cycle
|
||||
from planemapper.models import Aircraft
|
||||
from planemapper.renderer.projection import MapBounds
|
||||
from planemapper.renderer.renderer import Renderer
|
||||
|
||||
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def renderer(monkeypatch: pytest.MonkeyPatch) -> Renderer:
|
||||
monkeypatch.setattr(
|
||||
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||
FIXTURE_DIR / "airspace_sample.geojson",
|
||||
)
|
||||
base_map = Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||
return Renderer(base_map, bounds)
|
||||
|
||||
|
||||
def _make_aircraft(icao: str = "ABC123") -> Aircraft:
|
||||
return Aircraft(icao=icao, lat=53.1, lon=-6.1, altitude_ft=10000, callsign="TST1")
|
||||
|
||||
|
||||
def test_timeout_returns_stale_last_aircraft(renderer: Renderer) -> None:
|
||||
display = NullDisplay()
|
||||
last = [_make_aircraft()]
|
||||
mock_fetcher = MagicMock()
|
||||
mock_fetcher.fetch.side_effect = requests.Timeout
|
||||
result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||
assert len(result) == 1
|
||||
assert result[0].is_stale is True
|
||||
|
||||
|
||||
def test_empty_fetch_returns_stale_when_had_previous(renderer: Renderer) -> None:
|
||||
display = NullDisplay()
|
||||
last = [_make_aircraft()]
|
||||
mock_fetcher = MagicMock()
|
||||
mock_fetcher.fetch.return_value = []
|
||||
result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||
assert len(result) == 1
|
||||
assert result[0].is_stale is True
|
||||
|
||||
|
||||
def test_stale_aircraft_rendered_without_exception(renderer: Renderer) -> None:
|
||||
display = NullDisplay()
|
||||
stale_aircraft = dataclasses.replace(_make_aircraft(), is_stale=True)
|
||||
mock_fetcher = MagicMock()
|
||||
mock_fetcher.fetch.side_effect = requests.Timeout
|
||||
result = _run_one_cycle(renderer, mock_fetcher, display, [stale_aircraft])
|
||||
assert result[0].is_stale is True
|
||||
|
||||
|
||||
def test_stale_cycle_completes_without_crash(renderer: Renderer) -> None:
|
||||
display = NullDisplay()
|
||||
last = [_make_aircraft()]
|
||||
mock_fetcher = MagicMock()
|
||||
mock_fetcher.fetch.side_effect = requests.Timeout
|
||||
# Should complete without any exception
|
||||
result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_recovery_after_timeout_returns_fresh(renderer: Renderer) -> None:
|
||||
display = NullDisplay()
|
||||
last = [_make_aircraft()]
|
||||
mock_fetcher = MagicMock()
|
||||
# Cycle 1: timeout → stale
|
||||
mock_fetcher.fetch.side_effect = requests.Timeout
|
||||
stale_result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||
assert stale_result[0].is_stale is True
|
||||
# Cycle 2: fresh fetch → recovery
|
||||
fresh_aircraft = [_make_aircraft(icao="DEF456")]
|
||||
mock_fetcher.fetch.side_effect = None
|
||||
mock_fetcher.fetch.return_value = fresh_aircraft
|
||||
recovered = _run_one_cycle(renderer, mock_fetcher, display, stale_result)
|
||||
assert len(recovered) == 1
|
||||
assert recovered[0].is_stale is False
|
||||
assert recovered[0].icao == "DEF456"
|
||||
|
||||
|
||||
def test_recovery_after_empty_returns_fresh(renderer: Renderer) -> None:
|
||||
display = NullDisplay()
|
||||
last = [_make_aircraft()]
|
||||
mock_fetcher = MagicMock()
|
||||
# Cycle 1: empty → stale
|
||||
mock_fetcher.fetch.return_value = []
|
||||
stale_result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||
assert stale_result[0].is_stale is True
|
||||
# Cycle 2: non-empty → recovery
|
||||
mock_fetcher.fetch.return_value = [_make_aircraft(icao="GHI789")]
|
||||
recovered = _run_one_cycle(renderer, mock_fetcher, display, stale_result)
|
||||
assert recovered[0].is_stale is False
|
||||
Reference in New Issue
Block a user