When stream.aisstream.io is unreachable (cert outage, server down — see
2026-05-20 and 2026-05-23 events) the ships layer goes empty. This adds
a slow REST fallback to data.aishub.net so the layer stays populated in
degraded mode.
Behavior:
* Opt-in via AISHUB_USERNAME (free registration at aishub.net/api).
Without the env var the fetcher is a no-op.
* Default poll cadence 20 min — well inside their free-tier limits, gives
ships time to move enough to look "alive". Configurable via
AISHUB_POLL_INTERVAL_MINUTES, clamped to [1, 360].
* Internal gate: skips the poll entirely when the WebSocket primary is
currently connected. Stomping fresh live data with 20-min-old REST
data would be worse than leaving it alone.
* Vessels merge into the shared _vessels dict with source="aishub" so
the existing UI / health tooling can attribute the provider.
* Live data wins races: if a WebSocket update for the same MMSI lands in
the last 1s, we don't overwrite with the slower REST record.
Scheduler job runs every AISHUB_POLL_INTERVAL_MINUTES minutes alongside
the existing ais_prune job in data_fetcher.py.
24 tests cover gating (no-username, primary-connected), response parsing
(success / error / empty / malformed / unexpected shape), record
normalization (sentinels, missing fields, range checks, AIS @ padding),
poll interval clamping, and end-to-end merge with live-data-wins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>