Skip to content
joergsflow edited this page Jun 11, 2026 · 6 revisions

🛰 Sun-Moon Transit Predictor wiki — Home · Setup · Usage · Advanced · Reference · ↩ README

🛰 Usage — alerts, web UI, display

Contents


Web UI

http://<host>:8081/ ships a single-page UI with two panels:

  • Sky now — current Sun/Moon Az/El with the observability flag.
  • Tracking — the unified lifecycle list (see Candidate lifecycle above). One row per (icao, body) or (flight, body), sorted by status urgency then ETA. Status pill on the left with the icon (📅 📡 ✈️ 🎯 ❌); whole-row tint for imminent / candidate so urgent rows draw the eye. Polls /api/state every 2 s — rows transition status in real time as the tracker sees them appear, converge, and (sometimes) drop.
  • History — paginated list backed by /api/history, showing every persisted notification (radio + candidate + imminent stages) with Transit time, callsign, IATA flight, origin / destination, body, minimum separation, altitude and speed.

FOV preview pane (v0.7.1+)

Top right of the page, beside Sky now, sits a permanent FOV preview pane (originally a click-to-open modal, v0.6.0). It auto-shows the most recently spotted live candidate whose minimum angular separation is under — i.e. visually close enough to actually intersect or graze the body — and refreshes on every 2 s state poll. Clicking a row in Tracking or History pins that entry into the pane (an orange bar marks the pinned row); the pin is released as soon as a newer qualifying live candidate (sep < 1°) arrives. Press Escape at any time to drop the pin and resume auto-tracking.

The sketch itself shows:

  • FOV rectangle sized to the optical setup configured in Settings (default 500 mm + ZWO ASI174MM → FOV ≈ 1.30° × 0.82°); changes take effect on the next poll, no reload needed.
  • Sun / Moon disc centred at the body's apparent diameter (Sun 0.53°, Moon 0.52°).
  • Aircraft silhouette scaled by line-of-sight distance using a generic ~36 m airliner footprint — at 10 km this works out to ~0.2°, roughly a third of the Sun's diameter.
  • Apparent transit line (dashed) connecting five samples of the aircraft–body relative position at ±60, ±30 and 0 s around closest approach, with an arrowhead in the direction of motion. Body drift is subtracted per sample, so the line shows the path as seen through a tracking mount keeping the disc centred.

The sketch is built client-side from a small transitPath array that the tracker attaches to every TransitCandidate. History rows written before v0.6.0 can still be pinned, but without the motion line (the disc + aircraft anchor point are derived from the existing payload_json).

Settings panel (v0.7.0+)

The header now exposes a ⚙ Settings button that opens an in-browser form for the three configuration areas you actually touch in the field:

  • Observer — name, latitude, longitude, elevation, plus optional temperature and pressure for the refraction model.
  • Pushover — app token, user key, device, master enable + minimum stage. Token and user key are stored on the Pi but never echoed back in plaintextGET /api/config returns them as ••••<last4> so a page reload (or a forgotten browser tab) cannot leak the secret. Leaving the masked value untouched on save keeps the existing credentials.
  • Telescope & sensor — focal length, sensor width/height in mm, pixel count, and a free-text sensor name. The FOV preview pane picks the new optics up on the next state poll, no reload needed.
  • TrackerhorizonS, thresholdDeg, looseThresholdDeg, minAltitudeM, minBodyElevationDeg (v0.30.37+). All hot-reload.
  • AirNav Radar API — bearer token (masked after save).
  • SharpCap capture trigger — toggle, host/port for the main rig, per-rig list (targets[]) with body / pre-buffer / post-buffer / maxSepDeg / minElevationDeg overrides. Hot-reload + Test trigger button per rig.

(The dump1090-status link in the header is hardcoded to http://<host>:8080/ since v0.15.2 — the configurable "External links" field that used to be here was removed in M46.)

Saved changes hot-reload the running service in place and are written back to config/observer.json + config/service.json so the next restart (including the nightly auto-update timer) keeps the new values.

Upgrading from a pre-v0.7.0 install: older stp.service units only listed data/ under ReadWritePaths, so saving from the Settings panel fails with EROFS: read-only file system. Refresh the systemd unit on the Pi with one of:

# Option A — re-run the installer (preserves existing configs):
bash scripts/install-pi5.sh --non-interactive

# Option B — drop-in override, no reinstall needed:
sudo systemctl edit stp.service        # opens an empty override
# Paste these three lines, save and exit:
#   [Service]
#   ReadWritePaths=
#   ReadWritePaths=/home/<user>/sun-moon-transit-predictor/data \
#                  /home/<user>/sun-moon-transit-predictor/config
sudo systemctl daemon-reload
sudo systemctl restart stp.service

Hot-reload of the in-memory state still works even when the disk write fails — the Settings panel just shows the actionable hint as a warning so you see exactly what to fix.

Tracking-list persistence across restarts (v0.7.0+)

The unified tracking panel is snapshotted to data/lifecycle.json every 30 s and on SIGTERM. On startup the file is read back so the panel does not appear empty after the auto-update timer restarts the service overnight. Entries whose predicted closest-approach time is already more than 10 minutes in the past are dropped on load to keep the panel meaningful; restored live entries are marked stale until the next tick reaffirms them.

What is persisted, what is not. The History panel reads from <repo>/data/history.db (SQLite, see src/store.js), which is written server-side every time the notifier dispatches a stage — both early and precise rows. Closing the browser does not lose anything; the next load (even days later) re-reads the same DB file. What is not written is the live "candidate" stream (/api/state.candidates) — those rows are recomputed in memory each tick and only graduate to the DB if they trip a notification. If you want every detected near-miss persisted, you would need to call store.recordEvent from the tracker tick rather than only from the notifier — happy to add that as a config switch if useful.

E-paper display (optional, v0.31.0)

A standalone client that drives a Waveshare 4.2" B/W SPI e-paper panel (400×300) on the Pi 5 for a browserless at-a-glance readout — no browser, no monitor needed:

A fixed three-paragraph layout with large, legible body text:

  • Header (two lines) — big bold clock + date on line 1; place + GPS on line 2; a compact Sky-now (Sun/Moon elevation) in the top-right
  • Nearest plane — the nearest tracked plane in detail, with ETA and SEP as the big bold headline figures and route/bearing/distance/altitude/ speed small underneath, plus a large FOV preview on the right
  • Recent + aircraftRECENT learned transits on the left (flight, how long ago, achieved SEP) and the live tracked aircraft with big SEP / ETA per plane (right); the heading carries a (candidates / total live) counter

Planes come from the unified live-tracking list, so the panel keeps showing nearby traffic even when nothing is a Real candidate.

The client lives in display/ and carries no logic of its own — it polls the predictor's /api/state, so it can render data from this Pi or a remote Pi on the LAN.

Configured entirely from the browser

Open ⚙ Settings → E-paper display and the client picks the changes up live (within a few seconds, no restart):

Setting Meaning
Enabled Master on/off. Off → the panel clears once and idles.
Data source URL Blank = this Pi (localhost). Set a LAN URL like http://192.168.1.50:8081 to drive a local panel from a remote ADS-B/Node host.
Quick refresh (s) Partial-refresh cadence — fast, flash-free text update. Default 2, floor 1.
Long refresh (s) Full-refresh cadence — the periodic brief flash that clears e-paper ghosting. Default 60, must be ≥ Quick.

The on-panel layout itself is fixed (the three paragraphs above) — there are no list-length or compact toggles.

Audio buzzer (optional)

A piezo buzzer wired between a GPIO pin (default GPIO13) and GND gives an audible transit countdown — driven by display/buzzer.py in the same client (no extra service), configured from ⚙ Settings → Audio / buzzer. The client always PWM-drives the pin, so it works for both passive (needs a frequency) and active (beeps on power) buzzers — no need to know which you have. Confirm yours and find the loudest tone with cd display && python3 epaper_client.py --test-buzzer (2000 Hz is the default). A Test signals button in Settings plays the whole sequence once on the Pi. Defaults: a rising 3-tone chord (3 × 0.1 s, +200 Hz/step) when a new Real candidate comes within 2 min, a falling chord (3 × 0.1 s, −200 Hz/step from 500 Hz) when one is lost/passes, an accelerating countdown for candidates within 0.3°2 × 0.05 s every 10 s from 60 s, 5 s from 15 s, 2 s from 8 s — then a rising chord at the transit itself. Beep length (down to 20 ms), count, frequency, frequency step, fade-out, intervals and windows are all adjustable in Settings. See display/README.md.

E-paper isn't a video display. A full refresh flashes for a couple of seconds; a partial refresh is quick (~0.3–0.5 s) but builds up ghosting. The two-cadence design (quick partial + periodic full clear) gives a lively yet clean panel; ~1–2 s is the practical floor for the quick refresh.

Quick install (Pi 5)

SPI, not I2C — the 4.2" panel plugs onto the 40-pin header and talks SPI. The Pi 5's new GPIO needs Waveshare's gpiozero/lgpio driver (the old RPi.GPIO does not work on the Pi 5).

Easiest — let the installer do everything (enables SPI, installs the Python panel libraries, adds the spi/gpio groups, installs + enables the service):

# fresh box, with the panel, in one line:
curl -fsSL https://raw.githubusercontent.com/joergs-git/sun-moon-transit-predictor/main/scripts/bootstrap-pi5.sh | bash -s -- --with-display

# already have the repo:
bash scripts/install-pi5.sh --with-display

Then reboot once (so SPI + group membership take effect) and turn the panel on in the web UI: ⚙ Settings → E-paper display → Enabled → Save.

Manual install (if you'd rather not use the flag)
# 1. Enable SPI, then reboot.
sudo raspi-config nonint do_spi 0 && sudo reboot

# 2. Python deps + group access for the panel.
pip3 install -r display/requirements.txt
sudo usermod -aG spi,gpio "$USER"

# 3. Install + start the service (fills in install dir + user).
sudo cp systemd/stp-display.service /etc/systemd/system/
sudo sed -i "s|__INSTALL_DIR__|$(pwd)|g; s|__USER__|$USER|g" /etc/systemd/system/stp-display.service
sudo systemctl daemon-reload
sudo systemctl enable --now stp-display.service

# 4. Turn it on in the web UI: ⚙ Settings → E-paper display → Enabled → Save
journalctl -u stp-display -f

Full wiring table, the two-Pi (remote source) setup, the --dry-run hardware-free preview and troubleshooting are in display/README.md.

Pushover setup & test push

A fresh checkout has no config/service.json — only config/service.example.json. Without a service config the Pushover client runs in disabled mode (enabled: false) and silently no-ops every send. That's safe for first-boot but means nothing will alert until you provide credentials.

1. Provide credentials

scripts/install-pi5.sh prompts for your Pushover application token and user key the first time it runs and writes them into config/service.json. To re-do it later:

bash scripts/install-pi5.sh --overwrite

Or edit config/service.json directly:

"pushover": {
  "token":   "azGD…<your app token>",
  "user":    "uQiR…<your user/group key>",
  "device":  "",
  "enabled": true,
  "minStage": "radio",
  "radioThresholdDeg": 1.0
}

device is optional — leave empty to fan out to every device on the account. minStage controls which stages dispatch at all (radio = all, imminent = only the ±30 s alert). radioThresholdDeg adds a tighter Pushover-only filter on top: the tracker still surfaces matches inside tracker.looseThresholdDeg (2° by default) to the tracking panel, but the phone only buzzes when the projected minimum separation is at or below this value (default — i.e. only flights likely to actually graze the body). Restart the service (sudo systemctl restart stp.service) after editing, or use the Settings panel in the web UI for hot-reload.

Alert learning

The UI surfaces a rolling 14-day stats panel showing how well the early-warning radio stage predicts the tight transits that actually matter. Each history row gets one of three outcome tags:

  • graduated — radio alert paid off: the flight later reached candidate or imminent.
  • faded — radio alert never tightened up: false positive of the early stage.
  • surprise — candidate or imminent fired with no prior radio warning. Useful to spot under-detected geometries.

Headline numbers in the panel:

  • hit rate = radioGraduated / radioFired — how often a radio alert was worth paying attention to.
  • surprise rate = surprises / (graduated + surprises) — how often we missed an early heads-up for a transit that actually fired.

Same data is available raw via GET /api/learning?windowDays=….

2. Send a test push

A small helper ships in scripts/test-push.js. It loads the live config/service.json and sends a single low-priority message via the same PushoverClient the notifier uses, so it verifies token, user key, network, and TLS in one shot.

node scripts/test-push.js
node scripts/test-push.js "custom message"      # optional payload

Expected output: pushover: sent (status=1, request=…). The push should land on every Pushover-equipped device within a couple of seconds. If the config is disabled or missing keys, the script prints pushover: disabled and exits 1 without contacting the API.

3. Verify in production

To confirm the live service can actually reach Pushover (not just the helper), tail the journal while temporarily lowering thresholdDeg in config/service.json to a wide value (e.g. 30) and restarting — the next overhead aircraft will then trip both an early and a precise notification. Restore the threshold afterwards:

sudo systemctl restart stp.service
journalctl -u stp.service -f | grep -iE 'push|notif'

ISS transits (v0.9.0)

The International Space Station is predicted alongside aircraft and shown in LIVE-TRACKING-SIGNALS, History and the FOV preview, in front of both the Sun and the Moon, with its own cyan highlight + 🛰 badge (and a small station glyph instead of an aircraft silhouette in the sketch).

  • Offline, dependency-free. Position comes from an embedded SGP4 propagator (src/sgp4.js, validated against the official Spacetrack #3 / Vallado 88888 verification vectors) applied to a local TLE file — the running service never touches the network for this.

  • The TLE. The feature stays inactive ("no ISS info" in Sky-now) until data/iss.tle exists. Since v0.10.3 scripts/install-pi5.sh does an initial fetch and installs a daily stp-tle.timer (05:40 ± 20 min, Persistent) — so on a normal Pi install ISS info just appears and stays fresh. Re-run install-pi5.sh once if you upgraded from < v0.10.3.

    # see / force a refresh:
    systemctl list-timers | grep stp-tle
    node scripts/refresh-tle.js          # → data/iss.tle (Celestrak, CATNR 25544)
    systemctl start stp-tle.service      # same, via the timer's unit

    An ISS TLE older than ~3 days noticeably degrades transit timing; the daily timer keeps it current. No network at install time? The timer retries — or run the command above once you're online.

  • Tuning (config/service.json → iss): horizonMs (how far ahead to scan for the next Sun/Moon transit, default 14 days — these are weeks apart at a fixed site; raising it costs more CPU per recompute since the scan is O(horizon)), visibleHorizonMs (next-visible-pass cap, default 30 days; cheap — the scan returns at the first pass found), recomputeMs (scan cadence, default 10 min), thresholdDeg / looseThresholdDeg. Set "enabled": false to switch it off entirely.

  • An ISS transit is written to History like any transit and feeds the Disc xing column (its angular rate is huge, so the full-disc crossing time is well under a second).

  • Pushover (v0.10.0). ISS transits ride the same notifier path as aircraft, so you get a heads-up the moment a Sun/Moon transit is predicted (and again ±30 s before). Titles read 🛰 ISS Sun transit predicted …. Disable per the usual pushover settings if unwanted.

  • Next visible pass (v0.10.0). The Sky-now panel shows the next naked-eye ISS pass for the site — station above 20°, sky dark (Sun below −6°, "after dusk") and the ISS sunlit (offline cylindrical Earth-shadow test). It is a visibility line, independent of any disc transit.

  • Alert-learning hit/surprise/graze rates are an ADS-B-traffic quality signal and therefore exclude ISS rows (a deliberately-hunted orbital event would otherwise skew them); the ISS still appears in the History table itself.

Good to know — ISS transit prediction is only reliable a few days out

SGP4 propagated from a TLE drifts roughly 1–3 km/day cross-track (more after a reboost). The ISS transit centre line is only a few km wide and the Sun/Moon disc is 0.5°, so a transit predicted > ~3 days ahead is essentially noise: it appears, then vanishes after the next daily TLE refresh (and a different phantom may appear). This is physics, not a bug.

Consequences in this tool (v0.10.9+):

  • A transit only fires Pushover and gets a History row once it is within iss.notifyWithinMs (default 3 days / 72 h) — close enough that SGP4+TLE is trustworthy. This stops phantom-transit alert spam and the "⚡ surprise" pollution it caused in the learning stats.
  • The Sky-now "Next ISS Sun/Moon transit" line still previews the soonest predicted transit even weeks out, but anything beyond the notify window is shown flagged "tentative — refines with each daily TLE". So Sky-now saying "none in the next N days" while an old, now-stale row sits in History is expected — they reflect different TLEs at different times, each correct for its own.
  • Visible passes (the other Sky-now line) are unaffected — they recur ~daily and the next one is near, so it stays accurate.
  • Want it sooner/later anyway? Tune iss.notifyWithinMs in config/service.json. Reliable horizon for sub-disc accuracy is roughly ≤ 48–72 h; keep the TLE fresh (the daily stp-tle.timer).

Good to know — observer coordinates & elevation

  • latitudeDeg / longitudeDeg are decimal degrees, WGS84 (e.g. 52.2870, 7.4223). There is no aviation-vs-astronomy datum difference — ADS-B, AirNav and this tool all use WGS84. The same point just has several notations; mixing them up is the usual confusion:
    • 52°17'13.7"N = degrees-minutes-seconds52 + 17/60 + 13.7/3600 = 52.2871° decimal.
    • 52.1714 is not decimal degrees — it is the packed aviation/NMEA "degrees + decimal-minutes" form (52°17.14') ≈ 52.2857°. Putting that into latitudeDeg lands you ~13 km off. Use the decimal form (your phone GPS / Google-Maps right-click at the antenna gives it directly), and use the same value for the rbfeeder /AirNav station so the feed and the predictor agree.
  • elevationM is the WGS84 ellipsoidal height of your site — in practice your local height above sea level is fine (the geometry is robust to a few tens of metres of observer height). It is not "height above ground" and not the antenna's height over the roof — just the site elevation (Rheine ≈ 40–50 m), never 0.
  • geoidUndulationM is a separate field: EGM2008 N at the site (Rheine ≈ +46 m). It only corrects aircraft barometric altitude (≈ MSL) to ellipsoidal before the geometric comparison — it is not applied to your own elevation. Set it (~46 for Rheine) for the best aircraft- altitude accuracy; 0 is tolerable.

Good to know — how far can you actually see an aircraft, and the elevation rule

There is no single hard distance. How far a plane stays usable depends on its size, how the Sun lights it (a sunlit fuselage or a contrail carries far further than a shaded belly), the ground visibility (aerosol / humidity haze) and the atmospheric seeing on the day.

Rule of thumb for clear North-German air, for good visual recognition of the airframe (wings, engines, type) — not mere detection:

  • 8–10× binoculars: the airframe shape is clearly recognizable out to roughly 30–40 km slant distance, and the type is still guessable to ~20–25 km. Mere detection of a jet or its contrail reaches much further — 60–80 km+.
  • Small telescope at ~40–80×: more geometric detail (~30–50 km), but it is turbulence-limited — at low elevation the unsteady air smears the image before haze ever does.

Why elevation dominates everything. Both the slant distance to a cruising airliner (~11 km altitude) and the amount of hazy, turbulent air you look through scale with 1 / sin(elevation). The slant range is simply R = h / sin(el). Below ~20° the line of sight grazes the worst — the haziest, most turbulent low air — and clouds often sit right on the horizon. ≥ 30° is the practical sweet spot; ≥ 45° is best.

This is why the predictor (v0.15.0):

  • shows a 3-state visibility traffic-light per row — red below 30°, amber 30–45°, green ≥ 45° (aircraft elevation at closest approach);
  • by default only sends Pushover notifications when the target is ≥ 30° elevation (configurable via pushover.minElevationDeg; set 0 to disable the gate). The ISS is exempt — it has its own 15° visibility gate. History and all statistics still record everything, regardless of the notify gate.
Elevation Slant range @ ~11 km Rel. air mass Visual usability
20° ~32 km ~2.9 usable but shimmery, weak contrast
30° ~22 km ~2.0 practical entry point — much steadier
45° ~15.5 km ~1.4 very good
60–90° 11–13 km ~1.0–1.15 optimal (also best for transit photos)

Clone this wiki locally