When fetch times out or returns empty after prior data, retain last aircraft with is_stale=True and render them as black outlines so the display stays informative rather than blank or crashing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.1 KiB
Story 3.1: Stale State Detection & Dimmed Display
Status: review
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
-
Task 1: Update
_run_one_cycle()andmain()insrc/planemapper/main.py(AC: #1, #2, #4)- 1.1 Add
last_aircraft: list[Aircraft]parameter to_run_one_cycle; change return type tolist[Aircraft] - 1.2 Wrap
fetcher.fetch()intry/except requests.Timeout - 1.3 Implement stale logic: use
last_aircraftwithis_stale=Truewhen timeout or empty+had-previous - 1.4 Update
main()to initialiselast: list[Aircraft] = []and track return value of_run_one_cycle - 1.5 Add
import dataclassesandimport requeststomain.py
- 1.1 Add
-
Task 2: Update
draw_aircraft()insrc/planemapper/renderer/aircraft.py(AC: #3)- 2.1 Import
COLOUR_STALE_OUTLINEfromplanemapper.constants - 2.2 Check
aircraft.is_stalebefore calling_draw_arrow: if stale, useCOLOUR_STALE_OUTLINEwith forced outline mode
- 2.1 Import
-
Task 3: Write tests in
tests/test_stale.py(AC: #1, #2, #3, #4)- 3.1 Test AC1: mock
fetcher.fetch()to raiserequests.Timeout; call_run_one_cyclewithlast_aircraft=[some_aircraft]; assert returned list hasis_stale=True - 3.2 Test AC2: mock
fetcher.fetch()to return[]; call with non-emptylast_aircraft; assert returned list hasis_stale=True - 3.3 Test AC3: render stale aircraft; assert pixel at aircraft position is not the altitude colour (stale colour is
COLOUR_STALE_OUTLINE= black) - 3.4 Test AC4: full stale cycle with renderer completes without exception
- 3.1 Test AC1: mock
-
Task 4: Run quality gates
- 4.1
python -m pytest tests/— all tests pass - 4.2
python -m ruff check .— zero violations - 4.3
python -m ruff format --check .— no formatting issues
- 4.1
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
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
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
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:
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— fordataclasses.replace()import requests— to namerequests.Timeoutin theexceptclause
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 |