Compare commits

..

123 Commits

Author SHA1 Message Date
James Smith 205f396942 Fix TSCM sweep module variable scoping and remove stale progress bar call
- Access module-level _sweep_running, _current_sweep_id, and tscm_queue
  via explicit package import to avoid UnboundLocalError from closure
  variable shadowing in route handlers
- Remove orphaned tscmProgressBar.style.width assignment in index.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:52:29 +00:00
James Smith 89c7c2fb07 Fix 5GHz WiFi scanning failures in deep scan and band detection
- Fix deep scan with 'All bands' never scanning 5GHz: band='all' now
  correctly passes --band abg to airodump-ng (previously no flag was
  added, causing airodump-ng to default to 2.4GHz-only)
- Fix APs first seen without channel info permanently stuck at
  band='unknown': _update_access_point now backfills channel, frequency,
  and band when a subsequent observation resolves the channel
- Fix legacy /wifi/scan/start combining mutually exclusive --band and -c
  flags: --band is now only added when no explicit channel list is given,
  and the interface is always placed as the last argument

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:50:47 +00:00
James Smith b20b9838d0 Fix ADS-B remote mode incorrectly stopping other SDR services
When using a remote SBS feed, no local SDR is needed. The pre-flight
device conflict check was running regardless and stopping whichever
mode had the selected SDR device claimed — even though ADS-B remote
mode never touches a local SDR. Skip the conflict check when remoteConfig is set.
2026-03-20 14:04:04 +00:00
James Smith 2d65c4efbf Fix radiosonde false 'missing' report at end of setup
check_tools() was using cmd_exists on 'auto_rx.py' which fails because
it's never in PATH — installed to /opt/radiosonde_auto_rx/. Now uses
the same file-based check as tool_is_installed(), consistent with
health check and status view.
2026-03-20 13:53:50 +00:00
James Smith 34e1d25069 Fix ruff lint errors to unblock CI (import sorting, unused imports, style) 2026-03-20 13:51:30 +00:00
James Smith 90d39f12c1 Add multi-arch support (amd64 + arm64) to Docker CI workflow 2026-03-20 13:47:27 +00:00
Smittix bca7888077 Merge pull request #199 from jangrewe/main
Add Github Action to build Docker image
2026-03-20 13:38:32 +00:00
Jan Grewe cbc6275307 Add Github Action to build Docker image 2026-03-20 13:58:02 +01:00
James Smith b26ce4f56f Balance column heights to eliminate sky view gap
Precise calculation showed mission-drawer (~1100px) was 213px taller
than command-rail content (~887px), leaving 213px of empty background
at the bottom of the right column.

Three targeted reductions to mission-drawer height (~234px total):
- drawer-actions: stacked 1-column → 3-column row (-76px)
- drawer-list: max-height 240px → 180px (-40px)
- drawer-info-grid: 1-column → 2-column for Quick Info (-118px)

Mission-drawer drops to ~866px, command-rail (~887px) now drives the
primary-layout row height — gap closes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:08:07 +00:00
James Smith 44428c2517 Fix sky view gap: don't stretch polar panel beyond its content
The command-rail's 1fr last row caused the sky view panel to fill all
remaining column height (driven by the tall mission-drawer), showing a
large empty bordered space below the pass data strip.

Switch to all-auto rows with align-content: start so each panel is
exactly as tall as its content — the open background below the column
looks intentional rather than a panel with dead space inside it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:00:47 +00:00
James Smith a670103325 Reduce map and primary-layout min-height 460px → 400px to close sky view gap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:56:19 +00:00
James Smith a2bd0e27f9 Fix weather sat handoff: remove defunct METEOR-M2, fire change event
- Remove 'METEOR-M2' (NORAD 40069) from WEATHER_SAT_KEYS — it has no
  entry in WEATHER_SATELLITES and no dropdown option, so the Capture
  button was silently scheduling against the wrong satellite
- Dispatch a 'change' event after setting satSelect.value in preSelect()
  and startPass() so any UI listeners (frequency display, mode info)
  update correctly when the satellite is set programmatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:52:14 +00:00
James Smith 7ca018fd7b Reduce map min-height 520px → 460px to close gap below sky view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:45:41 +00:00
James Smith 607a2f28fa Cap polar container height to prevent oversized sky view on narrow screens
1fr in the grid row caused the panel to fill the entire remaining page
height on mobile (~1000px+), leaving large gaps around the centred content.
max-height: min(55vh, 520px) keeps it proportionate on any screen size.
Also switch to justify-content: flex-start so the canvas+strip pack at
the top rather than floating in the middle of a large void.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:43:04 +00:00
James Smith a42ea35d8b Add AOS/TCA/LOS pass data strip below polar plot
Fills the empty space below the sky view circle with a compact
three-column AOS / TCA / LOS readout (time + azimuth/elevation)
and a duration + max elevation footer line.
Populated by drawPolarPlot() when a pass is selected; shows a
placeholder prompt otherwise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:40:38 +00:00
James Smith 123d38d295 Fix oval polar plot — remove height:100% that overrode aspect-ratio
Setting both width:100% and height:100% made CSS ignore aspect-ratio,
stretching the drawing buffer non-uniformly into the tall container.
Fixed by keeping only width:100% + max-height:100% so aspect-ratio:1/1
clamps the height and the element stays square.
Draw functions now use canvas.offsetWidth for the square buffer size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:35:34 +00:00
James Smith 35c874da52 Stretch sky view to fill right column height
- command-rail last row changed from minmax(260px, 340px) to 1fr so the
  polar plot expands to fill whatever vertical space remains after the
  Next Pass and Live Telemetry panels
- polar-container made flex-column so panel-content can grow with flex: 1
- #polarPlot width/height 100% with aspect-ratio 1/1 — canvas fills the
  available square area and stays proportional
- Remove align-items: start from the 1320px breakpoint primary-layout so
  the command-rail stretches to match map height in the two-column layout
- Fix matching 2-column command-rail rows at 1320px breakpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:33:17 +00:00
James Smith ad4a4db160 Fix squashed sky view polar plot and eliminate wasted space
- Remove align-self: start from .polar-container so the grid row's
  minmax(260px, 340px) height is actually respected
- Switch #polarPlot to aspect-ratio: 1/1 so the canvas is always square
- Fix both draw functions to size canvas from getBoundingClientRect on
  the canvas itself (not parent) using min(width, height) for a square plot
- Remove min-height from .dashboard to prevent empty space below content
  on narrow/mobile screens where stacked panels are shorter than 720px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:17:35 +00:00
James Smith 72d4fab25e Fix pass calculation race condition and 1Hz distance updates
- Move _passAbortController = null to after response.json() so the retry
  scheduler cannot see a false idle state mid-parse, increment
  _passRequestId, and discard the in-flight response — this was causing
  non-ISS satellites to show no passes intermittently
- Add _computeSlantRange() helper using 3D ECEF geometry
- Update applyTelemetryPosition to compute slant range from SSE lat/lon/
  altitude, giving distance updates at 1Hz instead of 5s HTTP poll rate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:55:56 +00:00
James Smith 7c4342e560 Fix satellite tracker TLE key mismatch and empty pass caching
Two root-cause bugs causing the reported issues:

1. Tracker never sent ISS positions: _start_satellite_tracker fell back
   to sat_name.replace(' ', '-').upper() as the TLE cache key when the
   DB entry had null TLE lines. For 'ISS (ZARYA)' this produced
   'ISS-(ZARYA)' which has no matching entry in _tle_cache (keyed as
   'ISS'). ISS was silently skipped every loop tick, so no SSE positions
   were ever emitted and the map marker never moved.

   Fix: try _BUILTIN_NORAD_TO_KEY.get(norad_int) first before the
   name-derived fallback so the NORAD-to-key mapping is always used.

2. Stale TLE pass prediction results were cached: if startup TLEs were
   too old for Skyfield to find events in the 48h window, the empty
   passes list was cached for 300s. A page refresh within that window
   re-served the empty result, showing 'NO PASSES FOUND' persistently.

   Fix: only cache non-empty pass results so the next request
   recomputes once the TLE auto-refresh has populated fresh data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:21:31 +00:00
James Smith 33959403f4 fix(satellite): show 'NO UPCOMING PASSES' when all passes are in the past
updateCountdown fell back to passes[0] even when it was in the past,
showing 00:00:00:00 with a stale satellite name indefinitely. Now
displays a clear 'NO UPCOMING PASSES' state with '--' for all fields
when no future pass exists in the current prediction window.
2026-03-19 21:55:25 +00:00
James Smith f549957c0b perf(satellite): compute ground tracks in thread pool, not inline
Ground track computation (90 Skyfield points per satellite) was blocking
the 1Hz tracker loop on every cache miss. On cold start with multiple
tracked satellites this could stall the SSE stream for several seconds.

Tracks are now computed in a 2-worker ThreadPoolExecutor. The tracker
loop emits position without groundTrack on cache miss; clients retain
the previous track via SSE merge until the new one is ready.
2026-03-19 21:55:00 +00:00
James Smith e5abeba11c refactor(satellite): simplify telemetry abort controller management
Consolidated to a single active-request guard with cleanup in finally.
The previous pattern had redundant null-checks across try and catch, and
an always-false check on a controller that was already null. Cancel-on-
new-request is now explicit before creating the new controller.
2026-03-19 21:49:45 +00:00
James Smith 8cf1b05042 fix(satellite): add METEOR-M2 to weather satellite handoff keys
METEOR-M2 (NORAD 40069) is a weather satellite with LRPT downlink but
was missing from WEATHER_SAT_KEYS, so no capture button appeared in
the pass list. Adds it alongside M2-3 and M2-4.
2026-03-19 21:49:09 +00:00
James Smith cfcdc8e85e fix(satellite): use wgs84 subpoint elevation for altitude in /position
Replace geocentric.distance().km - 6371 (fixed spherical radius) with
wgs84.subpoint(geocentric).elevation.km in the /position endpoint.
The SSE tracker was already fixed in the Task 1 commit.
2026-03-19 21:48:55 +00:00
James Smith d240ae06e3 fix(satellite): populate currentPos with full telemetry in pass predictions
Previously currentPos only had lat/lon, so the updateTelemetry fallback
(used before first live position arrives) always showed '---' for
altitude/elevation/azimuth/distance. currentPos now includes all fields
computed from the request observer location. updateTelemetry simplified
to delegate to applyTelemetryPosition.
2026-03-19 21:48:33 +00:00
James Smith d84237dbb4 feat(satellite): add 24-hour periodic TLE auto-refresh
TLE data was only refreshed once at startup. After each refresh, a new
24-hour timer is now scheduled in a finally block so it fires even on
refresh failure. threading moved to module-level import.
2026-03-19 21:47:38 +00:00
James Smith 7194422c0e fix(satellite): SSE path only updates orbit position, not observer data
Adds a 'source' param to handleLivePositions. The SSE path ('sse') only
applies lat/lon/altitude/groundTrack since the server-side tracker has
no per-client location. The HTTP poll path ('poll') owns all observer-
relative data and the visible-count badge.
2026-03-19 21:46:36 +00:00
James Smith d20808fb35 fix(satellite): strip observer-relative fields from SSE tracker
SSE runs server-wide with DEFAULT_LAT/LON defaults of 0,0. Emitting
elevation/azimuth/distance/visible from the tracker produced wrong
values (always visible:False) that overwrote correct data from the
per-client HTTP poll every second.

The HTTP poll (/satellite/position) owns all observer-relative data.
SSE now only emits lat/lon/altitude/groundTrack. Also removes the
unused DEFAULT_LATITUDE/DEFAULT_LONGITUDE import.
2026-03-19 21:45:48 +00:00
James Smith 51b332f4cf Stabilize satellite live telemetry state 2026-03-19 21:09:03 +00:00
James Smith a8f73f9a73 Tear down satellite dashboard cleanly 2026-03-19 20:34:32 +00:00
James Smith 4798652ad5 Preserve satellite panes during refresh 2026-03-19 20:30:52 +00:00
James Smith 080464de98 Guard satellite target refreshes 2026-03-19 19:12:51 +00:00
James Smith 8caec74c5c Stabilize satellite target switching 2026-03-19 17:44:02 +00:00
James Smith 511cecb311 Speed up satellite live telemetry updates 2026-03-19 17:18:39 +00:00
James Smith 0992d6578c Batch satellite pass predictions 2026-03-19 17:07:23 +00:00
James Smith 3f1564817c Stabilize satellite pass target switching 2026-03-19 16:41:55 +00:00
James Smith b62b97ab57 Wire satellite capture handoff 2026-03-19 15:59:58 +00:00
James Smith 2eeea3b74d Harden satellite target switching 2026-03-19 15:33:20 +00:00
James Smith f05a5197cd Fix satellite target switching regression 2026-03-19 14:55:48 +00:00
James Smith 016d05f082 Stabilize satellite dashboard refreshes 2026-03-19 14:26:08 +00:00
James Smith 302a362885 Tighten satellite polar plot sizing 2026-03-19 14:16:44 +00:00
James Smith 81c05859fc Fix satellite dashboard startup helpers 2026-03-19 13:49:20 +00:00
James Smith f1881fdf52 Stabilize satellite dashboard startup 2026-03-19 13:23:52 +00:00
James Smith d0731120f9 Restore satellite mission controls 2026-03-19 13:06:26 +00:00
James Smith 7677b12f74 Move satellite packets into map console 2026-03-19 12:17:28 +00:00
James Smith ddaf5aa64e Rework satellite dashboard mission layout 2026-03-19 12:01:59 +00:00
James Smith 2418ae2d8b Fix satellite pass prediction route 2026-03-19 11:39:22 +00:00
James Smith 0916b62bfe Cache satellite pass predictions 2026-03-19 11:27:38 +00:00
James Smith 0b22393395 Vendor flask-socketio fallback for radiosonde 2026-03-19 11:16:42 +00:00
James Smith 9fa492e20c Vendor semver fallback for radiosonde 2026-03-19 11:09:54 +00:00
James Smith fa46483dd9 Probe more radiosonde Python environments 2026-03-19 11:02:42 +00:00
James Smith 18b442eb21 Fix dashboard startup regressions and mode utilities 2026-03-19 10:37:21 +00:00
James Smith 5f34d20287 Delay welcome page GPS and voice streams 2026-03-19 09:34:33 +00:00
James Smith 5905aa6415 Defer hidden dashboard startup work 2026-03-19 09:19:36 +00:00
James Smith aaed831420 Lazy-load satellite iframe on main dashboard 2026-03-19 09:05:48 +00:00
James Smith 007a8d50c6 Revert "Retry ADS-B map bootstrap safely"
This reverts commit 02ce4d5bb6.
2026-03-19 08:54:59 +00:00
James Smith 02ce4d5bb6 Retry ADS-B map bootstrap safely 2026-03-19 08:52:08 +00:00
James Smith 613258c3a2 Retry slow SDR detection in ADS-B 2026-03-19 08:43:20 +00:00
James Smith 4410aa2433 Harden ADS-B dashboard bootstrap 2026-03-19 08:35:43 +00:00
James Smith 54ad3b9362 Revert "Keep ADS-B on local startup tiles"
This reverts commit 2cf2c6af2a.
2026-03-19 08:32:10 +00:00
James Smith 2cf2c6af2a Keep ADS-B on local startup tiles 2026-03-19 08:28:13 +00:00
James Smith f5f3e766ad Keep ADS-B fallback grid until tiles load 2026-03-19 08:18:00 +00:00
James Smith fb8b6a01e8 Shorten agent health checks on load 2026-03-19 08:09:07 +00:00
James Smith db0a26cd64 Ignore aborted satellite pass requests 2026-03-19 08:04:36 +00:00
James Smith 8b1ca5ab96 Defer noncritical ADS-B startup work 2026-03-19 08:01:33 +00:00
James Smith cb0fb4f3be Reduce repeated ADS-B device probes 2026-03-19 07:55:19 +00:00
James Smith 334146b799 Skip pre-stop on dashboard navigation 2026-03-19 07:47:46 +00:00
James Smith 63237b9534 Stop clearing browser caches on load 2026-03-19 07:43:32 +00:00
James Smith 595a2003d5 Revert "Reduce ADS-B map layout shift"
This reverts commit 3afaa6e1ee.
2026-03-19 00:17:22 +00:00
James Smith 3afaa6e1ee Reduce ADS-B map layout shift 2026-03-19 00:11:29 +00:00
James Smith 5731631ebc Harden APRS mode teardown and map fallback 2026-03-19 00:06:47 +00:00
James Smith ac445184b6 Disable stale dashboard service worker cache 2026-03-19 00:01:47 +00:00
James Smith 981b103b90 Revert "Stage dashboard startup requests"
This reverts commit af7b29b6b0.
2026-03-18 23:57:37 +00:00
James Smith af7b29b6b0 Stage dashboard startup requests 2026-03-18 23:53:54 +00:00
James Smith 0ff0df632b Open satellite dashboard in new tab 2026-03-18 23:41:29 +00:00
James Smith 73e17e8509 Use direct satellite dashboard links 2026-03-18 23:35:24 +00:00
James Smith 317e0d7108 Fix satellite mode redirect endpoint 2026-03-18 23:32:38 +00:00
James Smith dd37a0b5a7 Unify satellite navigation to dashboard 2026-03-18 23:29:09 +00:00
James Smith 28f172a643 Lock satellite sidebar panel heights 2026-03-18 23:20:53 +00:00
James Smith 96146a2e2c Stabilize satellite dashboard sidebar panels 2026-03-18 23:17:02 +00:00
James Smith e32942fb35 Refresh embedded satellite dashboard state 2026-03-18 23:09:07 +00:00
James Smith a61d4331f0 Harden embedded satellite dashboard loading 2026-03-18 23:00:21 +00:00
James Smith 62ee2252a3 Fix satellite dashboard refresh flows 2026-03-18 22:53:36 +00:00
James Smith 6fd5098b89 Clear stale telemetry and add transmitter fallbacks 2026-03-18 22:43:40 +00:00
James Smith 6941e704cd Include NORAD IDs in satellite positions 2026-03-18 22:40:00 +00:00
James Smith 985c8a155a Harden satellite dashboard telemetry loading 2026-03-18 22:35:31 +00:00
James Smith d0402f4746 Refresh weather decoder docs for Meteor flow 2026-03-18 22:30:54 +00:00
James Smith 6dc0936d6d Align Meteor tracking defaults 2026-03-18 22:28:04 +00:00
James Smith 38a10cb0de Improve persisted Meteor status in dashboard 2026-03-18 22:25:20 +00:00
James Smith badf587be6 Reset persisted Meteor state on satellite switch 2026-03-18 22:24:12 +00:00
James Smith a995fceb8c Clarify persisted Meteor decode failures 2026-03-18 22:23:09 +00:00
James Smith 2a9c98a83d Show persisted Meteor decode state in weather mode 2026-03-18 22:22:07 +00:00
James Smith 4cf394f92e Persist Meteor decode job state 2026-03-18 22:20:24 +00:00
James Smith e388baa464 Add Meteor LRPT ground station pipeline 2026-03-18 22:01:52 +00:00
James Smith 5cae753e0d Harden dashboard loading against network stalls 2026-03-18 21:33:32 +00:00
James Smith 86625cf3ec Fix mode switch re-entry regressions 2026-03-18 21:26:01 +00:00
James Smith 98bb6ce10b Fix flask-sock install failure on RPi by skipping --only-binary for pure-Python packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:59:00 +00:00
James Smith cbe7f591e3 Fix weather sat test failures: login auth, timer refs, and SDR device mocking
- Add authenticated client fixture to test_weather_sat_routes.py so
  require_login() before_request doesn't redirect test clients to /login
- Save timer mock references before disable()/skip_pass() clear _timer = None
- Patch app.claim_sdr_device to return None in execute_capture and
  scheduling cycle tests to avoid real USB hardware probing in CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:56:01 +00:00
James Smith 0078d539de Fix flask-sock ImportError caused by simple-websocket missing from venv
When pip installs flask-sock into the venv, it finds simple-websocket
already satisfied in ~/.local (user site-packages from a prior install)
and skips installing it into the venv. The venv Python cannot import from
~/.local (user site-packages are disabled in venvs), so flask_sock's
top-level "from simple_websocket import Server" raises ImportError, and
all WebSocket features are silently disabled.

Fix: explicitly list simple-websocket>=0.5.1 as an install target in
setup.sh and requirements.txt so pip installs it into the venv
regardless of what is already present in user or system site-packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:44:52 +00:00
James Smith e1b532d48a Fix SSE first-byte delay in ADS-B and controller streams; harden WebSocket init
- Add immediate keepalive to /adsb/stream generator so the Werkzeug dev
  server flushes response headers immediately on tracking start, preventing
  the 30-second delay before the aircraft map begins receiving data
- Same fix for /controller/stream/all used by the ADSB dashboard in agent mode
- Widen WebSocket init exception guards in app.py from ImportError to
  Exception so any startup failure (e.g. RuntimeError from flask-sock on
  an unsupported WSGI server) is caught instead of propagating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:40:15 +00:00
James Smith f043baed9f Fix satellite dashboard page never loading by sending immediate SSE keepalive
Werkzeug's dev server buffers SSE response headers until the first body byte
is written. With keepalive_interval=30s, opening two SSE connections on
DOMContentLoaded (satellite stream + new ground station stream) meant the
browser waited 30 seconds before receiving any response bytes from either
connection. Browsers keep their loading indicator active while connections are
pending, causing the satellite dashboard to appear stuck loading.

Fix: yield an immediate keepalive at the start of sse_stream_fanout so every
SSE endpoint flushes headers + first data to the browser instantly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:03:07 +00:00
James Smith 8d8ee57cec Add observation profile management UI to Ground Station panel
- OBSERVATION PROFILES section with list of configured satellites
- + ADD button opens inline form pre-filled from currently selected satellite
  and SatNOGS transmitter data (frequency, decoder type auto-detected)
- EDIT / ✕ buttons per profile row
- Form fields: frequency, decoder (FM/AFSK/GMSK/BPSK/IQ-only), min elevation,
  gain, record IQ checkbox
- UPCOMING PASSES section below profiles with friendlier empty-state message
- gsOnSatelliteChange hook updates form when satellite dropdown changes
- CSS for .gs-form-row, .gs-profile-item, .gs-form-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:52:25 +00:00
James Smith 4607c358ed Add ground station automation with 6-phase implementation
Phase 1 - Automated observation engine:
- utils/ground_station/scheduler.py: GroundStationScheduler fires at AOS/LOS,
  claims SDR, manages IQBus lifecycle, emits SSE events
- utils/ground_station/observation_profile.py: ObservationProfile dataclass + DB CRUD
- routes/ground_station.py: REST API for profiles, scheduler, observations, recordings,
  rotator; SSE stream; /ws/satellite_waterfall WebSocket
- DB tables: observation_profiles, ground_station_observations, ground_station_events,
  sigmf_recordings (added to utils/database.py init_db)
- app.py: ground_station_queue, WebSocket init, scheduler startup in _deferred_init
- routes/__init__.py: register ground_station_bp

Phase 2 - Doppler correction:
- utils/doppler.py: generalized DopplerTracker extracted from sstv_decoder.py;
  accepts satellite name or raw TLE tuple; thread-safe; update_tle() method
- utils/sstv/sstv_decoder.py: replace inline DopplerTracker with import from utils.doppler
- Scheduler runs 5s retune loop; calls rotator.point_to() if enabled

Phase 3 - IQ recording (SigMF):
- utils/sigmf.py: SigMFWriter writes .sigmf-data + .sigmf-meta; disk-free guard (500MB)
- utils/ground_station/consumers/sigmf_writer.py: SigMFConsumer wraps SigMFWriter

Phase 4 - Multi-decoder IQ broadcast pipeline:
- utils/ground_station/iq_bus.py: IQBus single-producer fan-out; IQConsumer Protocol
- utils/ground_station/consumers/waterfall.py: CU8→FFT→binary frames
- utils/ground_station/consumers/fm_demod.py: CU8→FM demod (numpy)→decoder subprocess
- utils/ground_station/consumers/gr_satellites.py: CU8→cf32→gr_satellites (optional)

Phase 5 - Live spectrum waterfall:
- static/js/modes/ground_station_waterfall.js: /ws/satellite_waterfall canvas renderer
- Waterfall panel in satellite dashboard sidebar, auto-shown on iq_bus_started SSE event

Phase 6 - Antenna rotator control (optional):
- utils/rotator.py: RotatorController TCP client for rotctld (Hamlib line protocol)
- Rotator panel in satellite dashboard; silently disabled if rotctld unreachable

Also fixes pre-existing test_weather_sat_predict.py breakage:
- utils/weather_sat_predict.py: rewritten with self-contained skyfield implementation
  using find_discrete (matching what committed tests expected); adds _format_utc_iso
- tests/test_weather_sat_predict.py: add _MOCK_WEATHER_SATS and @patch decorators
  for tests that assumed NOAA-18 active (decommissioned Jun 2025, now active=False)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:36:55 +00:00
James Smith ed1461626b Prefetch SatNOGS transmitter cache at startup to fix loading delay 2026-03-18 15:00:03 +00:00
James Smith ee9bd9bbb2 Fix transmitters and decoded packets panels hidden in satellite dashboard sidebar 2026-03-18 14:50:30 +00:00
James Smith 75da95b38a Speed up bulk satellite import by using executemany in a single transaction 2026-03-18 14:43:21 +00:00
James Smith 5896ebd5b7 Fix setup.sh crashing on Python < 3.13 version check due to ERR trap 2026-03-18 14:34:42 +00:00
James Smith 9e7dfbda5a Fix satellite dashboard TARGET dropdown not reflecting enabled satellites
Add auto-refresh on window focus so the dropdown updates automatically when
switching back from the sidebar, plus a manual ↺ refresh button next to the
dropdown. Also preserves the current selection across refreshes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:05:22 +00:00
James Smith dc84e933c1 Fix setup.sh hanging on Python 3.14/macOS and add satellite enhancements
- Add --no-cache-dir and --timeout 120 to all pip calls to prevent hanging
  on corrupt/stale pip HTTP cache (cachecontrol .pyc issue)
- Replace silent python -c import verification with pip show to avoid
  import-time side effects hanging the installer
- Switch optional packages to --only-binary :all: to skip source compilation
  on Python versions without pre-built wheels (prevents gevent/numpy hangs)
- Warn early when Python 3.13+ is detected that some packages may be skipped
- Add ground track caching with 30-minute TTL to satellite route
- Add live satellite position tracker background thread via SSE fanout
- Add satellite_predict, satellite_telemetry, and satnogs utilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:09:00 +00:00
Smittix 3140f54419 Add apt lock wait to prevent setup.sh hanging on fresh Ubuntu VMs
On first boot, unattended-upgrades or apt-daily often holds the dpkg
lock, causing silent hangs with no user feedback. Added wait_for_apt_lock()
that polls for up to 120s with status messages, called before apt-get
update and inside apt_try_install_any.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:34:42 +00:00
Smittix e9fdadbbd8 Fix APRS observer location not updating after settings change
Dispatch observer-location-changed event from settings manager and
listen for it in APRS mode so manual location saves propagate to
the map and distance calculations. Also refresh ObserverLocation in
initAprsMap() to catch changes between page load and first map use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:00:35 +00:00
Smittix 8d537a61ed Change default ADS-B range rings from 200nm to 50nm
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:06:29 +00:00
Smittix ddf23377c3 Fix flask-compress not installing into venv
The core pip install was suppressing errors with 2>/dev/null, and the
verification check was finding packages in ~/.local/site-packages
instead of the venv. When run with sudo, ~/.local isn't visible,
causing the flask-compress warning.

- Remove --quiet and stderr suppression from core package install
- Use python -s flag in verification to ignore user site-packages
- Update health check to also verify flask-compress and flask-wtf

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:20:17 +00:00
Smittix c0138ed849 Add flask-compress and flask-wtf to setup.sh core installs
These packages were in requirements.txt but missing from the explicit
pip install commands in setup.sh, causing warnings on fresh installs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:10:57 +00:00
Smittix b5115d4aa1 Fix observer location persistence and APRS defaults 2026-03-15 17:49:46 +00:00
Smittix 6b9c4ebebd v2.26.12: fix AIS/ADS-B dashboards ignoring configured observer position
Pass DEFAULT_LATITUDE/DEFAULT_LONGITUDE from config to both standalone
dashboard templates so observer-location.js uses .env values instead of
falling back to hardcoded London coordinates on first visit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:11:46 +00:00
Smittix 7ed039564b v2.26.11: fix APRS map ignoring configured observer position (#193)
The APRS map initialisation only checked for a live GPS fix, falling
back to the centre of the US (39.8N, 98.6W) when none was available.
It never read the observer position configured in .env via
INTERCEPT_DEFAULT_LAT / INTERCEPT_DEFAULT_LON.

Seed aprsUserLocation from ObserverLocation.getShared() (or the
Jinja-injected defaults) on page load so the map centres on the
user's configured position and distance calculations work without GPS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:17:50 +00:00
Smittix 8adfb3a40a v2.26.10: fix APRS stop timeout and inverted SDR device status (#194)
The APRS stop endpoint terminated two processes sequentially (up to 4s
with PROCESS_TERMINATE_TIMEOUT=2s each) while the frontend fetch timed
out at 2.2s. This caused console errors and left the SDR device claimed
in the registry until termination finished, making the status panel show
the device as active after the user clicked stop.

Fix: release the SDR device from the registry immediately inside the
lock, clear process references, then terminate processes in a background
thread so the HTTP response returns instantly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:14:21 +00:00
Smittix 9a9b1e9856 v2.26.9: add rtl_biast fallback for ADS-B bias-t on Blog V4 (#195)
When dump1090 lacks native --enable-biast support, the system now falls
back to rtl_biast (RTL-SDR Blog drivers) to enable bias-t power before
starting dump1090. The Blog V4's built-in LNA requires bias-t to
receive ADS-B signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:05:15 +00:00
Smittix 8aeb52380e v2.26.8: fix acarsdec build failure on macOS (#187)
HOST_NAME_MAX is Linux-specific and undefined on macOS, causing 3
compile errors in acarsdec.c. Now patched with #define HOST_NAME_MAX 255
before building. Also fixed deprecated -Ofast flag on all macOS archs
(was only patched for arm64).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:03:46 +00:00
Smittix 05141b9a1b v2.26.7: fix health check SDR detection on macOS (#188)
timeout (GNU coreutils) is not available on macOS, causing rtl_test to
silently fail and report no SDR device found. Now tries timeout, then
gtimeout (Homebrew coreutils), then falls back to background process
with manual kill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:01:55 +00:00
Smittix dc0850d339 v2.26.6: fix oversized branded 'i' logo on dashboard pages (#189)
.logo span { display: inline } in dashboard CSS had specificity (0,1,1),
overriding .brand-i { display: inline-block } at (0,1,0). Inline elements
ignore width/height, so the SVG rendered at intrinsic size (~80px tall).
Added .logo .brand-i selector at (0,2,0) to retain inline-block display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:57:25 +00:00
76 changed files with 12723 additions and 2108 deletions
+69
View File
@@ -0,0 +1,69 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Set permissions for GITHUB_TOKEN
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
# Step 1: Check out the repository code
- name: Checkout
uses: actions/checkout@v4
# Step 2: Set up QEMU for multi-arch builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Step 3: Set up Docker Buildx for advanced features
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Step 4: Log in to GitHub Container Registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Step 5: Generate tags and labels from Git metadata
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with PR number
type=ref,event=pr
# Tag with semver from git tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Tag with short SHA
type=sha,prefix=
# Step 6: Build and push the Docker image
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
# Only push on main branch and tags, not PRs
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Enable build cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max
+42
View File
@@ -2,6 +2,48 @@
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [2.26.11] - 2026-03-14
### Fixed
- **APRS map ignores configured observer position** — The APRS map always fell back to the centre of the US (39.8°N, 98.6°W) when no live GPS fix was available, ignoring the observer position configured in `.env` (`INTERCEPT_DEFAULT_LAT` / `INTERCEPT_DEFAULT_LON`). Now seeds the APRS user location from the shared observer location on page load, so the map centres correctly and distance calculations work. (#193)
---
## [2.26.10] - 2026-03-14
### Fixed
- **APRS stop timeout and inverted SDR device status** — The APRS stop endpoint terminated two processes sequentially (up to 4s) while the frontend timed out at 2.2s, causing console errors and the SDR status panel to show stale state (active after stop, idle during use). Now releases the SDR device immediately and terminates processes in a background thread so the response returns instantly. (#194)
---
## [2.26.9] - 2026-03-14
### Fixed
- **ADS-B bias-t support for RTL-SDR Blog V4** — When dump1090 lacks native `--enable-biast` support, the system now falls back to `rtl_biast` (from RTL-SDR Blog drivers) to enable bias-t power before starting dump1090. The Blog V4's built-in LNA requires bias-t to receive ADS-B signals. (#195)
---
## [2.26.8] - 2026-03-14
### Fixed
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
---
## [2.26.7] - 2026-03-14
### Fixed
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
---
## [2.26.6] - 2026-03-14
### Fixed
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
---
## [2.26.5] - 2026-03-14 ## [2.26.5] - 2026-03-14
### Fixed ### Fixed
+41 -4
View File
@@ -274,6 +274,9 @@ dsc_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock() tscm_lock = threading.Lock()
# Ground Station automation
ground_station_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
# SubGHz Transceiver (HackRF) # SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock() subghz_lock = threading.Lock()
@@ -477,6 +480,9 @@ def login():
@app.route('/') @app.route('/')
def index() -> str: def index() -> str:
if request.args.get('mode') == 'satellite':
return redirect(url_for('satellite.satellite_dashboard'))
tools = { tools = {
'rtl_fm': check_tool('rtl_fm'), 'rtl_fm': check_tool('rtl_fm'),
'multimon': check_tool('multimon-ng'), 'multimon': check_tool('multimon-ng'),
@@ -1125,28 +1131,35 @@ def _init_app() -> None:
try: try:
from routes.audio_websocket import init_audio_websocket from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app) init_audio_websocket(app)
except ImportError: except Exception:
pass pass
# Initialize KiwiSDR WebSocket audio proxy # Initialize KiwiSDR WebSocket audio proxy
try: try:
from routes.websdr import init_websdr_audio from routes.websdr import init_websdr_audio
init_websdr_audio(app) init_websdr_audio(app)
except ImportError: except Exception:
pass pass
# Initialize WebSocket for waterfall streaming # Initialize WebSocket for waterfall streaming
try: try:
from routes.waterfall_websocket import init_waterfall_websocket from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app) init_waterfall_websocket(app)
except ImportError: except Exception:
pass pass
# Initialize WebSocket for meteor scatter monitoring # Initialize WebSocket for meteor scatter monitoring
try: try:
from routes.meteor_websocket import init_meteor_websocket from routes.meteor_websocket import init_meteor_websocket
init_meteor_websocket(app) init_meteor_websocket(app)
except ImportError: except Exception:
pass
# Initialize WebSocket for ground station live waterfall
try:
from routes.ground_station import init_ground_station_websocket
init_ground_station_websocket(app)
except Exception:
pass pass
# Defer heavy/network operations so the worker can serve requests immediately # Defer heavy/network operations so the worker can serve requests immediately
@@ -1188,6 +1201,30 @@ def _init_app() -> None:
except Exception as e: except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}") logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
# Pre-warm SatNOGS transmitter cache so first dashboard load is instant
try:
if not os.environ.get('TESTING'):
from utils.satnogs import prefetch_transmitters
prefetch_transmitters()
except Exception as e:
logger.warning(f"SatNOGS prefetch failed: {e}")
# Wire ground station scheduler event → SSE queue
try:
import app as _self
from utils.ground_station.scheduler import get_ground_station_scheduler
gs_scheduler = get_ground_station_scheduler()
def _gs_event_to_sse(event: dict) -> None:
try:
_self.ground_station_queue.put_nowait(event)
except Exception:
pass
gs_scheduler.set_event_callback(_gs_event_to_sse)
except Exception as e:
logger.warning(f"Ground station scheduler init failed: {e}")
threading.Thread(target=_deferred_init, daemon=True).start() threading.Thread(target=_deferred_init, daemon=True).start()
+36 -1
View File
@@ -7,10 +7,45 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.26.5" VERSION = "2.26.12"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.26.12",
"date": "March 2026",
"highlights": [
"AIS and ADS-B dashboards now use configured observer position from .env",
]
},
{
"version": "2.26.11",
"date": "March 2026",
"highlights": [
"APRS map now centres on configured observer position from .env",
]
},
{
"version": "2.26.8",
"date": "March 2026",
"highlights": [
"Fix acarsdec build failure on macOS (HOST_NAME_MAX undefined)",
]
},
{
"version": "2.26.7",
"date": "March 2026",
"highlights": [
"Fix health check SDR detection on macOS (timeout command not available)",
]
},
{
"version": "2.26.6",
"date": "March 2026",
"highlights": [
"Fix oversized branded 'i' logo on Aircraft & Vessel dashboards",
]
},
{ {
"version": "2.26.5", "version": "2.26.5",
"date": "March 2026", "date": "March 2026",
+43
View File
@@ -0,0 +1,43 @@
"""Minimal Flask-SocketIO compatibility shim.
This is only intended to satisfy radiosonde_auto_rx's optional web UI
dependency in environments where ``flask_socketio`` is not installed.
It provides the small subset of the API that auto_rx imports.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
class SocketIO:
"""Very small subset of Flask-SocketIO's SocketIO interface."""
def __init__(self, app, async_mode: str | None = None, *args, **kwargs):
self.app = app
self.async_mode = async_mode or "threading"
self._handlers: dict[tuple[str, str | None], Callable[..., Any]] = {}
def on(self, event: str, namespace: str | None = None):
"""Register an event handler decorator."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
self._handlers[(event, namespace)] = func
return func
return decorator
def emit(self, event: str, data: Any = None, namespace: str | None = None, *args, **kwargs) -> None:
"""No-op emit used when the real Socket.IO server is unavailable."""
return None
def run(self, app=None, host: str = "127.0.0.1", port: int = 5000, *args, **kwargs) -> None:
"""Fallback to Flask's built-in development server."""
flask_app = app or self.app
flask_app.run(
host=host,
port=port,
threaded=True,
use_reloader=False,
)
+1 -1
View File
@@ -3502,7 +3502,7 @@ class ModeManager:
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle' stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
satellites = load.tle_file(stations_url) satellites = load.tle_file(stations_url)
ts = load.timescale() ts = load.timescale(builtin=True)
observer = Topos(latitude_degrees=lat, longitude_degrees=lon) observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded") logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.26.5" version = "2.26.11"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+1
View File
@@ -45,6 +45,7 @@ cryptography>=41.0.0
# mypy>=1.0.0 # mypy>=1.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post) # WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock flask-sock
simple-websocket>=0.5.1
websocket-client>=1.6.0 websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable) # System health monitoring (optional - graceful fallback if unavailable)
+2
View File
@@ -20,6 +20,7 @@ def register_blueprints(app):
from .correlation import correlation_bp from .correlation import correlation_bp
from .dsc import dsc_bp from .dsc import dsc_bp
from .gps import gps_bp from .gps import gps_bp
from .ground_station import ground_station_bp
from .listening_post import receiver_bp from .listening_post import receiver_bp
from .meshtastic import meshtastic_bp from .meshtastic import meshtastic_bp
from .meteor_websocket import meteor_bp from .meteor_websocket import meteor_bp
@@ -89,6 +90,7 @@ def register_blueprints(app):
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
app.register_blueprint(system_bp) # System health monitoring app.register_blueprint(system_bp) # System health monitoring
app.register_blueprint(ook_bp) # Generic OOK signal decoder app.register_blueprint(ook_bp) # Generic OOK signal decoder
app.register_blueprint(ground_station_bp) # Ground station automation
# Exempt all API blueprints from CSRF (they use JSON, not form tokens) # Exempt all API blueprints from CSRF (they use JSON, not form tokens)
if _csrf: if _csrf:
+11 -13
View File
@@ -40,6 +40,8 @@ from config import (
ADSB_DB_PORT, ADSB_DB_PORT,
ADSB_DB_USER, ADSB_DB_USER,
ADSB_HISTORY_ENABLED, ADSB_HISTORY_ENABLED,
DEFAULT_LATITUDE,
DEFAULT_LONGITUDE,
SHARED_OBSERVER_LOCATION_ENABLED, SHARED_OBSERVER_LOCATION_ENABLED,
) )
from utils import aircraft_db from utils import aircraft_db
@@ -765,23 +767,14 @@ def check_adsb_tools():
has_readsb = shutil.which('readsb') is not None has_readsb = shutil.which('readsb') is not None
has_rtl_adsb = shutil.which('rtl_adsb') is not None has_rtl_adsb = shutil.which('rtl_adsb') is not None
# Check what SDR hardware is detected
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
# Determine if readsb is needed but missing
needs_readsb = has_soapy_sdr and not has_readsb
return jsonify({ return jsonify({
'dump1090': has_dump1090, 'dump1090': has_dump1090,
'readsb': has_readsb, 'readsb': has_readsb,
'rtl_adsb': has_rtl_adsb, 'rtl_adsb': has_rtl_adsb,
'has_rtlsdr': has_rtlsdr, 'has_rtlsdr': None,
'has_soapy_sdr': has_soapy_sdr, 'has_soapy_sdr': None,
'soapy_types': soapy_types, 'soapy_types': [],
'needs_readsb': needs_readsb 'needs_readsb': False
}) })
@@ -1165,6 +1158,9 @@ def stream_adsb():
def generate(): def generate():
last_keepalive = time.time() last_keepalive = time.time()
# Send immediate keepalive so Werkzeug dev server flushes response
# headers right away (it buffers until first body byte is written).
yield format_sse({'type': 'keepalive'})
try: try:
while True: while True:
@@ -1197,6 +1193,8 @@ def adsb_dashboard():
'adsb_dashboard.html', 'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START, adsb_auto_start=ADSB_AUTO_START,
default_latitude=DEFAULT_LATITUDE,
default_longitude=DEFAULT_LONGITUDE,
embedded=embedded, embedded=embedded,
) )
+3 -1
View File
@@ -15,7 +15,7 @@ import time
from flask import Blueprint, Response, jsonify, render_template, request from flask import Blueprint, Response, jsonify, render_template, request
import app as app_module import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED
from utils.constants import ( from utils.constants import (
AIS_RECONNECT_DELAY, AIS_RECONNECT_DELAY,
AIS_SOCKET_TIMEOUT, AIS_SOCKET_TIMEOUT,
@@ -542,5 +542,7 @@ def ais_dashboard():
return render_template( return render_template(
'ais_dashboard.html', 'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
default_latitude=DEFAULT_LATITUDE,
default_longitude=DEFAULT_LONGITUDE,
embedded=embedded, embedded=embedded,
) )
+30 -16
View File
@@ -1924,7 +1924,13 @@ def start_aprs() -> Response:
@aprs_bp.route('/stop', methods=['POST']) @aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response: def stop_aprs() -> Response:
"""Stop APRS decoder.""" """Stop APRS decoder.
Releases the SDR device immediately so the status panel updates
without waiting for process termination. Process cleanup runs in a
background thread to avoid blocking the HTTP response (which caused
frontend timeout errors when two processes each took up to 2s to die).
"""
global aprs_active_device, aprs_active_sdr_type global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock: with app_module.aprs_lock:
@@ -1939,6 +1945,28 @@ def stop_aprs() -> Response:
if not processes_to_stop: if not processes_to_stop:
return api_error('APRS decoder not running', 400) return api_error('APRS decoder not running', 400)
# Release SDR device immediately so status panel reflects the
# change without waiting for process termination.
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None
aprs_active_sdr_type = None
# Capture refs to clear before releasing the lock
master_fd = getattr(app_module, 'aprs_master_fd', None)
app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
app_module.aprs_master_fd = None
# Terminate processes in background so the response returns fast.
# Each proc.wait() can block up to PROCESS_TERMINATE_TIMEOUT (2s),
# which previously caused the frontend 2200ms fetch to abort.
def _cleanup():
# Close PTY master fd first — this unblocks the stream thread
if master_fd is not None:
with contextlib.suppress(OSError):
os.close(master_fd)
for proc in processes_to_stop: for proc in processes_to_stop:
try: try:
proc.terminate() proc.terminate()
@@ -1948,21 +1976,7 @@ def stop_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Error stopping APRS process: {e}") logger.error(f"Error stopping APRS process: {e}")
# Close PTY master fd threading.Thread(target=_cleanup, daemon=True).start()
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
with contextlib.suppress(OSError):
os.close(app_module.aprs_master_fd)
app_module.aprs_master_fd = None
app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+22 -4
View File
@@ -43,6 +43,8 @@ from utils.trilateration import (
logger = logging.getLogger('intercept.controller') logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller') controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
AGENT_HEALTH_TIMEOUT_SECONDS = 2.0
AGENT_STATUS_TIMEOUT_SECONDS = 2.5
# Multi-agent SSE fanout state (per-client queues). # Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set() _agent_stream_subscribers: set[queue.Queue] = set()
@@ -81,7 +83,11 @@ def get_agents():
if refresh: if refresh:
for agent in agents: for agent in agents:
try: try:
client = create_client_from_agent(agent) client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
agent['healthy'] = client.health_check() agent['healthy'] = client.health_check()
except Exception: except Exception:
agent['healthy'] = False agent['healthy'] = False
@@ -328,7 +334,11 @@ def check_all_agents_health():
} }
try: try:
client = create_client_from_agent(agent) client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
# Time the health check # Time the health check
start_time = time.time() start_time = time.time()
@@ -344,7 +354,12 @@ def check_all_agents_health():
# Also fetch running modes # Also fetch running modes
try: try:
status = client.get_status() status_client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_STATUS_TIMEOUT_SECONDS,
)
status = status_client.get_status()
result['running_modes'] = status.get('running_modes', []) result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {}) result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception: except Exception:
@@ -673,6 +688,7 @@ def stream_all_agents():
def generate() -> Generator[str, None, None]: def generate() -> Generator[str, None, None]:
last_keepalive = time.time() last_keepalive = time.time()
keepalive_interval = 30.0 keepalive_interval = 30.0
yield format_sse({'type': 'keepalive'})
try: try:
while True: while True:
@@ -713,7 +729,9 @@ def agent_management_page():
def network_monitor_page(): def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view.""" """Render the network monitor page for multi-agent aggregated view."""
from flask import render_template from flask import render_template
return render_template('network_monitor.html')
from config import VERSION
return render_template('network_monitor.html', version=VERSION)
# ============================================================================= # =============================================================================
+567
View File
@@ -0,0 +1,567 @@
"""Ground Station REST API + SSE + WebSocket endpoints.
Phases implemented here:
1 Profile CRUD, scheduler control, observation history, SSE stream
3 SigMF recording browser (list / download / delete)
5 /ws/satellite_waterfall WebSocket
6 Rotator config / status / point / park endpoints
"""
from __future__ import annotations
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.ground_station.routes')
ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_scheduler():
from utils.ground_station.scheduler import get_ground_station_scheduler
return get_ground_station_scheduler()
def _get_queue():
import app as _app
return getattr(_app, 'ground_station_queue', None) or queue.Queue()
# ---------------------------------------------------------------------------
# Phase 1 — Observation Profiles
# ---------------------------------------------------------------------------
@ground_station_bp.route('/profiles', methods=['GET'])
def list_profiles():
from utils.ground_station.observation_profile import list_profiles as _list
return jsonify([p.to_dict() for p in _list()])
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['GET'])
def get_profile(norad_id: int):
from utils.ground_station.observation_profile import get_profile as _get
p = _get(norad_id)
if not p:
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
return jsonify(p.to_dict())
@ground_station_bp.route('/profiles', methods=['POST'])
def create_profile():
data = request.get_json(force=True) or {}
try:
_validate_profile(data)
except ValueError as e:
return jsonify({'error': str(e)}), 400
from utils.ground_station.observation_profile import (
ObservationProfile,
legacy_decoder_to_tasks,
normalize_tasks,
save_profile,
tasks_to_legacy_decoder,
)
tasks = normalize_tasks(data.get('tasks'))
if not tasks:
tasks = legacy_decoder_to_tasks(
str(data.get('decoder_type', 'fm')),
bool(data.get('record_iq', False)),
)
profile = ObservationProfile(
norad_id=int(data['norad_id']),
name=str(data['name']),
frequency_mhz=float(data['frequency_mhz']),
decoder_type=tasks_to_legacy_decoder(tasks),
gain=float(data.get('gain', 40.0)),
bandwidth_hz=int(data.get('bandwidth_hz', 200_000)),
min_elevation=float(data.get('min_elevation', 10.0)),
enabled=bool(data.get('enabled', True)),
record_iq=bool(data.get('record_iq', False)) or ('record_iq' in tasks),
iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)),
tasks=tasks,
)
saved = save_profile(profile)
return jsonify(saved.to_dict()), 201
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['PUT'])
def update_profile(norad_id: int):
data = request.get_json(force=True) or {}
from utils.ground_station.observation_profile import (
get_profile as _get,
)
from utils.ground_station.observation_profile import (
legacy_decoder_to_tasks,
normalize_tasks,
save_profile,
tasks_to_legacy_decoder,
)
existing = _get(norad_id)
if not existing:
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
# Apply updates
for field, cast in [
('name', str), ('frequency_mhz', float), ('decoder_type', str),
('gain', float), ('bandwidth_hz', int), ('min_elevation', float),
]:
if field in data:
setattr(existing, field, cast(data[field]))
for field in ('enabled', 'record_iq'):
if field in data:
setattr(existing, field, bool(data[field]))
if 'iq_sample_rate' in data:
existing.iq_sample_rate = int(data['iq_sample_rate'])
if 'tasks' in data:
existing.tasks = normalize_tasks(data['tasks'])
elif 'decoder_type' in data:
existing.tasks = legacy_decoder_to_tasks(
str(data.get('decoder_type', existing.decoder_type)),
bool(data.get('record_iq', existing.record_iq)),
)
existing.decoder_type = tasks_to_legacy_decoder(existing.tasks)
existing.record_iq = bool(existing.record_iq) or ('record_iq' in existing.tasks)
saved = save_profile(existing)
return jsonify(saved.to_dict())
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['DELETE'])
def delete_profile(norad_id: int):
from utils.ground_station.observation_profile import delete_profile as _del
ok = _del(norad_id)
if not ok:
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
return jsonify({'status': 'deleted', 'norad_id': norad_id})
# ---------------------------------------------------------------------------
# Phase 1 — Scheduler control
# ---------------------------------------------------------------------------
@ground_station_bp.route('/scheduler/status', methods=['GET'])
def scheduler_status():
return jsonify(_get_scheduler().get_status())
@ground_station_bp.route('/scheduler/enable', methods=['POST'])
def scheduler_enable():
data = request.get_json(force=True) or {}
try:
lat = float(data.get('lat', 0.0))
lon = float(data.get('lon', 0.0))
device = int(data.get('device', 0))
sdr_type = str(data.get('sdr_type', 'rtlsdr'))
except (TypeError, ValueError) as e:
return jsonify({'error': str(e)}), 400
status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type)
return jsonify(status)
@ground_station_bp.route('/scheduler/disable', methods=['POST'])
def scheduler_disable():
return jsonify(_get_scheduler().disable())
@ground_station_bp.route('/scheduler/observations', methods=['GET'])
def get_observations():
return jsonify(_get_scheduler().get_scheduled_observations())
@ground_station_bp.route('/scheduler/trigger/<int:norad_id>', methods=['POST'])
def trigger_manual(norad_id: int):
ok, msg = _get_scheduler().trigger_manual(norad_id)
if not ok:
return jsonify({'error': msg}), 400
return jsonify({'status': 'started', 'message': msg})
@ground_station_bp.route('/scheduler/stop', methods=['POST'])
def stop_active():
return jsonify(_get_scheduler().stop_active())
# ---------------------------------------------------------------------------
# Phase 1 — Observation history (from DB)
# ---------------------------------------------------------------------------
@ground_station_bp.route('/observations', methods=['GET'])
def observation_history():
limit = min(int(request.args.get('limit', 50)), 200)
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''SELECT * FROM ground_station_observations
ORDER BY created_at DESC LIMIT ?''',
(limit,),
).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
logger.error(f"Failed to fetch observation history: {e}")
return jsonify([])
# ---------------------------------------------------------------------------
# Phase 1 — SSE stream
# ---------------------------------------------------------------------------
@ground_station_bp.route('/stream')
def sse_stream():
gs_queue = _get_queue()
return Response(
sse_stream_fanout(gs_queue, 'ground_station'),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
)
# ---------------------------------------------------------------------------
# Phase 3 — SigMF recording browser
# ---------------------------------------------------------------------------
@ground_station_bp.route('/recordings', methods=['GET'])
def list_recordings():
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100'
).fetchall()
return jsonify([dict(r) for r in rows])
except Exception as e:
logger.error(f"Failed to fetch recordings: {e}")
return jsonify([])
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['GET'])
def get_recording(rec_id: int):
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,)
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
return jsonify(dict(row))
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['DELETE'])
def delete_recording(rec_id: int):
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
(rec_id,),
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
# Remove files
for path_col in ('sigmf_data_path', 'sigmf_meta_path'):
p = Path(row[path_col])
if p.exists():
p.unlink(missing_ok=True)
conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,))
return jsonify({'status': 'deleted', 'id': rec_id})
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/recordings/<int:rec_id>/download/<file_type>')
def download_recording(rec_id: int, file_type: str):
if file_type not in ('data', 'meta'):
return jsonify({'error': 'file_type must be data or meta'}), 400
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
(rec_id,),
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path'
p = Path(row[col])
if not p.exists():
return jsonify({'error': 'File not found on disk'}), 404
mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json'
return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/outputs', methods=['GET'])
def list_outputs():
try:
query = '''
SELECT * FROM ground_station_outputs
WHERE (? IS NULL OR norad_id = ?)
AND (? IS NULL OR observation_id = ?)
AND (? IS NULL OR output_type = ?)
ORDER BY created_at DESC
LIMIT 200
'''
norad_id = request.args.get('norad_id', type=int)
observation_id = request.args.get('observation_id', type=int)
output_type = request.args.get('type')
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
query,
(
norad_id, norad_id,
observation_id, observation_id,
output_type, output_type,
),
).fetchall()
results = []
for row in rows:
item = dict(row)
metadata_raw = item.get('metadata_json')
if metadata_raw:
try:
item['metadata'] = json.loads(metadata_raw)
except json.JSONDecodeError:
item['metadata'] = {}
else:
item['metadata'] = {}
item.pop('metadata_json', None)
results.append(item)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/outputs/<int:output_id>/download', methods=['GET'])
def download_output(output_id: int):
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT file_path FROM ground_station_outputs WHERE id=?',
(output_id,),
).fetchone()
if not row:
return jsonify({'error': 'Not found'}), 404
p = Path(row['file_path'])
if not p.exists():
return jsonify({'error': 'File not found on disk'}), 404
return send_file(p, as_attachment=True, download_name=p.name)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ground_station_bp.route('/decode-jobs', methods=['GET'])
def list_decode_jobs():
try:
query = '''
SELECT * FROM ground_station_decode_jobs
WHERE (? IS NULL OR norad_id = ?)
AND (? IS NULL OR observation_id = ?)
AND (? IS NULL OR backend = ?)
ORDER BY created_at DESC
LIMIT ?
'''
norad_id = request.args.get('norad_id', type=int)
observation_id = request.args.get('observation_id', type=int)
backend = request.args.get('backend')
limit = min(request.args.get('limit', 20, type=int) or 20, 200)
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
query,
(
norad_id, norad_id,
observation_id, observation_id,
backend, backend,
limit,
),
).fetchall()
results = []
for row in rows:
item = dict(row)
details_raw = item.get('details_json')
if details_raw:
try:
item['details'] = json.loads(details_raw)
except json.JSONDecodeError:
item['details'] = {}
else:
item['details'] = {}
item.pop('details_json', None)
results.append(item)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ---------------------------------------------------------------------------
# Phase 5 — Live waterfall WebSocket
# ---------------------------------------------------------------------------
def init_ground_station_websocket(app) -> None:
"""Register the /ws/satellite_waterfall WebSocket endpoint."""
try:
from flask_sock import Sock
except ImportError:
logger.warning("flask-sock not installed — satellite waterfall WebSocket disabled")
return
sock = Sock(app)
@sock.route('/ws/satellite_waterfall')
def satellite_waterfall_ws(ws):
"""Stream binary waterfall frames from the active ground station IQ bus."""
scheduler = _get_scheduler()
wf_queue = scheduler.waterfall_queue
from utils.sse import subscribe_fanout_queue
sub_queue, unsubscribe = subscribe_fanout_queue(
source_queue=wf_queue,
channel_key='gs_waterfall',
subscriber_queue_size=120,
)
try:
while True:
try:
frame = sub_queue.get(timeout=1.0)
try:
ws.send(frame)
except Exception:
break
except queue.Empty:
if not ws.connected:
break
finally:
unsubscribe()
# ---------------------------------------------------------------------------
# Phase 6 — Rotator
# ---------------------------------------------------------------------------
@ground_station_bp.route('/rotator/status', methods=['GET'])
def rotator_status():
from utils.rotator import get_rotator
return jsonify(get_rotator().get_status())
@ground_station_bp.route('/rotator/config', methods=['POST'])
def rotator_config():
data = request.get_json(force=True) or {}
host = str(data.get('host', '127.0.0.1'))
port = int(data.get('port', 4533))
from utils.rotator import get_rotator
ok = get_rotator().connect(host, port)
if not ok:
return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503
return jsonify(get_rotator().get_status())
@ground_station_bp.route('/rotator/point', methods=['POST'])
def rotator_point():
data = request.get_json(force=True) or {}
try:
az = float(data['az'])
el = float(data['el'])
except (KeyError, TypeError, ValueError) as e:
return jsonify({'error': f'az and el required: {e}'}), 400
from utils.rotator import get_rotator
ok = get_rotator().point_to(az, el)
if not ok:
return jsonify({'error': 'Rotator command failed'}), 503
return jsonify({'status': 'ok', 'az': az, 'el': el})
@ground_station_bp.route('/rotator/park', methods=['POST'])
def rotator_park():
from utils.rotator import get_rotator
ok = get_rotator().park()
if not ok:
return jsonify({'error': 'Rotator park failed'}), 503
return jsonify({'status': 'parked'})
@ground_station_bp.route('/rotator/disconnect', methods=['POST'])
def rotator_disconnect():
from utils.rotator import get_rotator
get_rotator().disconnect()
return jsonify({'status': 'disconnected'})
# ---------------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------------
def _validate_profile(data: dict) -> None:
if 'norad_id' not in data:
raise ValueError("norad_id is required")
if 'name' not in data:
raise ValueError("name is required")
if 'frequency_mhz' not in data:
raise ValueError("frequency_mhz is required")
try:
norad_id = int(data['norad_id'])
if norad_id <= 0:
raise ValueError("norad_id must be positive")
except (TypeError, ValueError):
raise ValueError("norad_id must be a positive integer")
try:
freq = float(data['frequency_mhz'])
if not (0.1 <= freq <= 3000.0):
raise ValueError("frequency_mhz must be between 0.1 and 3000")
except (TypeError, ValueError):
raise ValueError("frequency_mhz must be a number between 0.1 and 3000")
from utils.ground_station.observation_profile import VALID_TASK_TYPES
valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'}
if 'tasks' in data:
if not isinstance(data['tasks'], list):
raise ValueError("tasks must be a list")
invalid = [
str(task) for task in data['tasks']
if str(task).strip().lower() not in VALID_TASK_TYPES
]
if invalid:
raise ValueError(
f"tasks contains unsupported values: {', '.join(invalid)}"
)
else:
dt = str(data.get('decoder_type', 'fm'))
if dt not in valid_decoders:
raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}")
+134 -19
View File
@@ -11,6 +11,7 @@ import contextlib
import json import json
import os import os
import queue import queue
import shlex
import shutil import shutil
import socket import socket
import subprocess import subprocess
@@ -45,6 +46,7 @@ from utils.validation import (
logger = get_logger('intercept.radiosonde') logger = get_logger('intercept.radiosonde')
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde') radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Track radiosonde state # Track radiosonde state
radiosonde_running = False radiosonde_running = False
@@ -83,6 +85,119 @@ def find_auto_rx() -> str | None:
return None return None
def _resolve_shebang_interpreter(script_path: str) -> str | None:
"""Resolve a Python interpreter from a script shebang if possible."""
try:
with open(script_path, encoding='utf-8', errors='ignore') as handle:
first_line = handle.readline().strip()
except OSError:
return None
if not first_line.startswith('#!'):
return None
parts = shlex.split(first_line[2:].strip())
if not parts:
return None
if os.path.basename(parts[0]) == 'env' and len(parts) > 1:
return shutil.which(parts[1])
return parts[0]
def _resolve_pip_python(pip_bin: str | None) -> str | None:
"""Resolve the Python interpreter used by a pip executable."""
if not pip_bin:
return None
return _resolve_shebang_interpreter(pip_bin)
def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]:
"""Build environment for radiosonde_auto_rx with compatibility shims."""
env = os.environ.copy()
python_path_entries = [PROJECT_ROOT, auto_rx_dir]
existing_pythonpath = env.get('PYTHONPATH', '')
if existing_pythonpath:
python_path_entries.append(existing_pythonpath)
env['PYTHONPATH'] = os.pathsep.join(entry for entry in python_path_entries if entry)
return env
def _iter_auto_rx_python_candidates(auto_rx_path: str):
"""Yield plausible Python interpreters for radiosonde_auto_rx."""
auto_rx_abs = os.path.abspath(auto_rx_path)
auto_rx_dir = os.path.dirname(auto_rx_abs)
install_root = os.path.dirname(auto_rx_dir)
install_parent = os.path.dirname(install_root)
candidates = [
_resolve_shebang_interpreter(auto_rx_abs),
sys.executable,
os.path.join(install_root, 'venv', 'bin', 'python'),
os.path.join(install_root, 'venv', 'bin', 'python3'),
os.path.join(install_root, '.venv', 'bin', 'python'),
os.path.join(install_root, '.venv', 'bin', 'python3'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python3'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python3'),
os.path.join(install_parent, 'venv', 'bin', 'python'),
os.path.join(install_parent, 'venv', 'bin', 'python3'),
os.path.join(install_parent, '.venv', 'bin', 'python'),
os.path.join(install_parent, '.venv', 'bin', 'python3'),
_resolve_pip_python(shutil.which('pip3')),
_resolve_pip_python(shutil.which('pip')),
shutil.which('python3'),
shutil.which('python'),
'/usr/local/bin/python3',
'/usr/local/bin/python',
'/usr/bin/python3',
]
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
candidate_abs = os.path.abspath(candidate)
if candidate_abs in seen:
continue
seen.add(candidate_abs)
if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK):
yield candidate_abs
def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]:
"""Pick a Python interpreter that can import autorx.scan successfully."""
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
checked: list[str] = []
last_error = 'No usable Python interpreter found'
for python_bin in _iter_auto_rx_python_candidates(auto_rx_path):
checked.append(python_bin)
try:
dep_check = subprocess.run(
[python_bin, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
env=auto_rx_env,
capture_output=True,
timeout=10,
)
except Exception as exc:
last_error = str(exc)
continue
if dep_check.returncode == 0:
return python_bin, '', checked
stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip()
stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip()
last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}'
return None, last_error, checked
def generate_station_cfg( def generate_station_cfg(
freq_min: float = 400.0, freq_min: float = 400.0,
freq_max: float = 406.0, freq_max: float = 406.0,
@@ -547,30 +662,29 @@ def start_radiosonde():
# Build command - auto_rx -c expects the path to station.cfg # Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path) cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'): if auto_rx_path.endswith('.py'):
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs] selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path)
if not selected_python:
logger.error(
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
checked_interpreters,
dep_error,
)
app_module.release_sdr_device(device_int, sdr_type_str)
checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
'Install or repair its Python environment (missing packages such as semver). '
f'Checked interpreters: {checked_msg}. '
f'Last error: {dep_error[:500]}',
500,
)
cmd = [selected_python, auto_rx_path, '-c', cfg_abs]
else: else:
cmd = [auto_rx_path, '-c', cfg_abs] cmd = [auto_rx_path, '-c', cfg_abs]
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
# Quick dependency check before launching the full process
if auto_rx_path.endswith('.py'):
dep_check = subprocess.run(
[sys.executable, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
capture_output=True,
timeout=10,
)
if dep_check.returncode != 0:
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
app_module.release_sdr_device(device_int, sdr_type_str)
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
500,
)
try: try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
@@ -580,6 +694,7 @@ def start_radiosonde():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, start_new_session=True,
cwd=auto_rx_dir, cwd=auto_rx_dir,
env=auto_rx_env,
) )
# Wait briefly for process to start # Wait briefly for process to start
+452 -152
View File
@@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
import math import math
import threading
import time
import urllib.request import urllib.request
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests import requests
from flask import Blueprint, jsonify, render_template, request from flask import Blueprint, Response, jsonify, make_response, render_template, request
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
@@ -20,6 +22,7 @@ from utils.database import (
) )
from utils.logging import satellite_logger as logger from utils.logging import satellite_logger as logger
from utils.responses import api_error from utils.responses import api_error
from utils.sse import sse_stream_fanout
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite') satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
@@ -32,7 +35,8 @@ def _get_timescale():
global _cached_timescale global _cached_timescale
if _cached_timescale is None: if _cached_timescale is None:
from skyfield.api import load from skyfield.api import load
_cached_timescale = load.timescale() # Use bundled timescale data so the first request does not block on network I/O.
_cached_timescale = load.timescale(builtin=True)
return _cached_timescale return _cached_timescale
# Maximum response size for external requests (1MB) # Maximum response size for external requests (1MB)
@@ -44,6 +48,26 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
# Local TLE cache (can be updated via API) # Local TLE cache (can be updated via API)
_tle_cache = dict(TLE_SATELLITES) _tle_cache = dict(TLE_SATELLITES)
# Ground track cache: key=(sat_name, tle_line1[:20]) -> (track_data, computed_at_timestamp)
# TTL is 1800 seconds (30 minutes)
_track_cache: dict = {}
_TRACK_CACHE_TTL = 1800
# Thread pool for background ground-track computation (non-blocking from 1Hz tracker loop)
from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor
_track_executor = _ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track')
_track_in_progress: set = set() # cache keys currently being computed
_pass_cache: dict = {}
_PASS_CACHE_TTL = 300
_BUILTIN_NORAD_TO_KEY = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3',
59051: 'METEOR-M2-4',
}
def _load_db_satellites_into_cache(): def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache.""" """Load user-tracked satellites from DB into the TLE cache."""
@@ -64,9 +88,251 @@ def _load_db_satellites_into_cache():
logger.warning(f"Failed to load DB satellites into TLE cache: {e}") logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def _normalize_satellite_name(value: object) -> str:
"""Normalize satellite identifiers for loose name matching."""
return str(value or '').strip().replace(' ', '-').upper()
def _get_tracked_satellite_maps() -> tuple[dict[int, dict], dict[str, dict]]:
"""Return tracked satellites indexed by NORAD ID and normalized name."""
by_norad: dict[int, dict] = {}
by_name: dict[str, dict] = {}
try:
for sat in get_tracked_satellites():
try:
norad_id = int(sat['norad_id'])
except (TypeError, ValueError):
continue
by_norad[norad_id] = sat
by_name[_normalize_satellite_name(sat.get('name'))] = sat
except Exception as e:
logger.warning(f"Failed to read tracked satellites for lookup: {e}")
return by_norad, by_name
def _resolve_satellite_request(sat: object, tracked_by_norad: dict[int, dict], tracked_by_name: dict[str, dict]) -> tuple[str, int | None, tuple[str, str, str] | None]:
"""Resolve a satellite request to display name, NORAD ID, and TLE data."""
norad_id: int | None = None
sat_key: str | None = None
tracked: dict | None = None
if isinstance(sat, int):
norad_id = sat
elif isinstance(sat, str):
stripped = sat.strip()
if stripped.isdigit():
norad_id = int(stripped)
else:
sat_key = stripped
if norad_id is not None:
tracked = tracked_by_norad.get(norad_id)
sat_key = _BUILTIN_NORAD_TO_KEY.get(norad_id) or (tracked.get('name') if tracked else str(norad_id))
else:
normalized = _normalize_satellite_name(sat_key)
tracked = tracked_by_name.get(normalized)
if tracked:
try:
norad_id = int(tracked['norad_id'])
except (TypeError, ValueError):
norad_id = None
sat_key = tracked.get('name') or sat_key
tle_data = None
candidate_keys: list[str] = []
if sat_key:
candidate_keys.extend([
sat_key,
_normalize_satellite_name(sat_key),
])
if tracked and tracked.get('name'):
candidate_keys.extend([
tracked['name'],
_normalize_satellite_name(tracked['name']),
])
seen: set[str] = set()
for key in candidate_keys:
norm = _normalize_satellite_name(key)
if norm in seen:
continue
seen.add(norm)
if key in _tle_cache:
tle_data = _tle_cache[key]
break
if norm in _tle_cache:
tle_data = _tle_cache[norm]
break
if tle_data is None and tracked and tracked.get('tle_line1') and tracked.get('tle_line2'):
display_name = tracked.get('name') or sat_key or str(norad_id or 'UNKNOWN')
tle_data = (display_name, tracked['tle_line1'], tracked['tle_line2'])
_tle_cache[_normalize_satellite_name(display_name)] = tle_data
if tle_data is None and sat_key:
normalized = _normalize_satellite_name(sat_key)
for key, value in _tle_cache.items():
if key == normalized or _normalize_satellite_name(value[0]) == normalized:
tle_data = value
break
display_name = _BUILTIN_NORAD_TO_KEY.get(norad_id or -1)
if not display_name:
display_name = (tracked.get('name') if tracked else None) or (tle_data[0] if tle_data else None) or (sat_key if sat_key else str(norad_id or 'UNKNOWN'))
return display_name, norad_id, tle_data
def _make_pass_cache_key(
lat: float,
lon: float,
hours: int,
min_el: float,
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]],
) -> tuple:
"""Build a stable cache key for predicted passes."""
return (
round(lat, 4),
round(lon, 4),
int(hours),
round(float(min_el), 1),
tuple(
(
sat_name,
norad_id,
tle_data[1][:32],
tle_data[2][:32],
)
for sat_name, norad_id, tle_data in resolved_satellites
),
)
def _start_satellite_tracker():
"""Background thread: push live satellite positions to satellite_queue every second."""
import app as app_module
try:
from skyfield.api import EarthSatellite, wgs84
except ImportError:
logger.warning("skyfield not installed; satellite tracker thread will not run")
return
ts = _get_timescale()
logger.info("Satellite tracker thread started")
while True:
try:
now = ts.now()
now_dt = now.utc_datetime()
tracked = get_tracked_satellites(enabled_only=True)
positions = []
for sat_rec in tracked:
sat_name = sat_rec['name']
norad_id = sat_rec.get('norad_id', 0)
tle1 = sat_rec.get('tle_line1')
tle2 = sat_rec.get('tle_line2')
if not tle1 or not tle2:
# Fall back to TLE cache. Try the builtin NORAD-ID key first
# (e.g. 'ISS'), then the name-derived key as a last resort.
try:
norad_int = int(norad_id)
except (TypeError, ValueError):
norad_int = 0
builtin_key = _BUILTIN_NORAD_TO_KEY.get(norad_int)
cache_key = builtin_key if (builtin_key and builtin_key in _tle_cache) else sat_name.replace(' ', '-').upper()
if cache_key not in _tle_cache:
continue
tle_entry = _tle_cache[cache_key]
tle1 = tle_entry[1]
tle2 = tle_entry[2]
try:
satellite = EarthSatellite(tle1, tle2, sat_name, ts)
geocentric = satellite.at(now)
subpoint = wgs84.subpoint(geocentric)
# SSE stream is server-wide and cannot know per-client observer
# location. Observer-relative fields (elevation, azimuth, distance,
# visible) are intentionally omitted here — the per-client HTTP poll
# at /satellite/position owns those using the client's actual location.
pos = {
'satellite': sat_name,
'norad_id': norad_id,
'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees),
'altitude': float(subpoint.elevation.km),
}
# Ground track with caching (90 points, TTL 1800s).
# If the cache is stale, kick off background computation so the
# 1Hz tracker loop is not blocked. The client retains the previous
# track via SSE merge until the new one arrives next tick.
cache_key_track = (sat_name, tle1[:20])
cached = _track_cache.get(cache_key_track)
if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL:
pos['groundTrack'] = cached[0]
elif cache_key_track not in _track_in_progress:
_track_in_progress.add(cache_key_track)
_sat_ref = satellite
_ts_ref = ts
_now_dt_ref = now_dt
def _compute_track(_sat=_sat_ref, _ts=_ts_ref, _now_dt=_now_dt_ref, _key=cache_key_track):
try:
track = []
for minutes_offset in range(-45, 46, 1):
t_point = _ts.utc(_now_dt + timedelta(minutes=minutes_offset))
try:
geo = _sat.at(t_point)
sp = wgs84.subpoint(geo)
track.append({
'lat': float(sp.latitude.degrees),
'lon': float(sp.longitude.degrees),
'past': minutes_offset < 0,
})
except Exception:
continue
_track_cache[_key] = (track, time.time())
except Exception:
pass
finally:
_track_in_progress.discard(_key)
_track_executor.submit(_compute_track)
# groundTrack omitted this tick; frontend retains prior value
positions.append(pos)
except Exception:
continue
if positions:
msg = {
'type': 'positions',
'positions': positions,
'timestamp': datetime.utcnow().isoformat(),
}
try:
app_module.satellite_queue.put_nowait(msg)
except Exception:
pass
except Exception as e:
logger.debug(f"Satellite tracker error: {e}")
time.sleep(1)
_TLE_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 # 24 hours
def init_tle_auto_refresh(): def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization.""" """Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading def _schedule_next_tle_refresh(delay: float = _TLE_REFRESH_INTERVAL_SECONDS) -> None:
t = threading.Timer(delay, _auto_refresh_tle)
t.daemon = True
t.start()
def _auto_refresh_tle(): def _auto_refresh_tle():
try: try:
@@ -76,10 +342,22 @@ def init_tle_auto_refresh():
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}") logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e: except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}") logger.warning(f"Auto TLE refresh failed: {e}")
finally:
# Schedule next refresh regardless of success/failure
_schedule_next_tle_refresh()
# Start auto-refresh in background # First refresh 2 seconds after startup, then every 24 hours
threading.Timer(2.0, _auto_refresh_tle).start() threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled") logger.info("TLE auto-refresh scheduled (24h interval)")
# Start live position tracker thread
tracker_thread = threading.Thread(
target=_start_satellite_tracker,
daemon=True,
name='satellite-tracker',
)
tracker_thread.start()
logger.info("Satellite tracker thread launched")
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None: def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
@@ -123,6 +401,7 @@ def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float |
result = { result = {
'satellite': 'ISS', 'satellite': 'ISS',
'norad_id': 25544,
'lat': iss_lat, 'lat': iss_lat,
'lon': iss_lon, 'lon': iss_lon,
'altitude': iss_alt, 'altitude': iss_alt,
@@ -174,18 +453,21 @@ def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float |
def satellite_dashboard(): def satellite_dashboard():
"""Popout satellite tracking dashboard.""" """Popout satellite tracking dashboard."""
embedded = request.args.get('embedded', 'false') == 'true' embedded = request.args.get('embedded', 'false') == 'true'
return render_template( response = make_response(render_template(
'satellite_dashboard.html', 'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
embedded=embedded, embedded=embedded,
) ))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@satellite_bp.route('/predict', methods=['POST']) @satellite_bp.route('/predict', methods=['POST'])
def predict_passes(): def predict_passes():
"""Calculate satellite passes using skyfield.""" """Calculate satellite passes using skyfield."""
try: try:
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84 from skyfield.api import EarthSatellite, wgs84
except ImportError: except ImportError:
return jsonify({ return jsonify({
@@ -193,10 +475,12 @@ def predict_passes():
'message': 'skyfield library not installed. Run: pip install skyfield' 'message': 'skyfield library not installed. Run: pip install skyfield'
}), 503 }), 503
from utils.satellite_predict import predict_passes as _predict_passes
data = request.json or {} data = request.json or {}
# Validate inputs
try: try:
# Validate inputs
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074))) lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278))) lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
hours = validate_hours(data.get('hours', 24)) hours = validate_hours(data.get('hours', 24))
@@ -204,135 +488,108 @@ def predict_passes():
except ValueError as e: except ValueError as e:
return api_error(str(e), 400) return api_error(str(e), 400)
norad_to_name = { try:
25544: 'ISS', sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4'])
40069: 'METEOR-M2', passes = []
57166: 'METEOR-M2-3' colors = {
} 'ISS': '#00ffff',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff',
'METEOR-M2-4': '#00ff88',
}
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3']) resolved_satellites: list[tuple[str, int, tuple[str, str, str]]] = []
satellites = [] for sat in sat_input:
for sat in sat_input: sat_name, norad_id, tle_data = _resolve_satellite_request(
if isinstance(sat, int) and sat in norad_to_name: sat,
satellites.append(norad_to_name[sat]) tracked_by_norad,
else: tracked_by_name,
satellites.append(sat) )
if not tle_data:
continue
resolved_satellites.append((sat_name, norad_id or 0, tle_data))
passes = [] if not resolved_satellites:
colors = { return jsonify({
'ISS': '#00ffff', 'status': 'success',
'METEOR-M2': '#9370DB', 'passes': [],
'METEOR-M2-3': '#ff00ff' 'cached': False,
} })
name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = _get_timescale() cache_key = _make_pass_cache_key(lat, lon, hours, min_el, resolved_satellites)
observer = wgs84.latlon(lat, lon) cached = _pass_cache.get(cache_key)
now_ts = time.time()
if cached and (now_ts - cached[1]) < _PASS_CACHE_TTL:
return jsonify({
'status': 'success',
'passes': cached[0],
'cached': True,
})
t0 = ts.now() ts = _get_timescale()
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours)) observer = wgs84.latlon(lat, lon)
t0 = ts.now()
for sat_name in satellites: t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
if sat_name not in _tle_cache:
continue
tle_data = _tle_cache[sat_name]
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
except Exception:
continue
def above_horizon(t):
diff = satellite - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1/720
try:
times, events = find_discrete(t0, t1, above_horizon)
except Exception:
continue
i = 0
while i < len(times):
if i < len(events) and events[i]:
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]:
set_time = times[j]
i = j
break
if set_time is None:
i += 1
continue
trajectory = []
max_elevation = 0
num_points = 30
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
for k in range(num_points):
frac = k / (num_points - 1)
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
for sat_name, norad_id, tle_data in resolved_satellites:
current_pos = None
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
geo = satellite.at(t0)
sp = wgs84.subpoint(geo)
current_pos = {
'lat': float(sp.latitude.degrees),
'lon': float(sp.longitude.degrees),
'altitude': float(sp.elevation.km),
}
# Add observer-relative data using the request's observer location
try:
diff = satellite - observer diff = satellite - observer
topocentric = diff.at(t_point) topo = diff.at(t0)
alt, az, _ = topocentric.altaz() alt_deg, az_deg, dist_km = topo.altaz()
current_pos['elevation'] = round(float(alt_deg.degrees), 1)
current_pos['azimuth'] = round(float(az_deg.degrees), 1)
current_pos['distance'] = round(float(dist_km.km), 1)
current_pos['visible'] = bool(alt_deg.degrees > 0)
except Exception:
pass
except Exception:
pass
el = alt.degrees sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el)
azimuth = az.degrees for p in sat_passes:
p['satellite'] = sat_name
p['norad'] = norad_id
p['color'] = colors.get(sat_name, '#00ff00')
if current_pos:
p['currentPos'] = current_pos
passes.extend(sat_passes)
if el > max_elevation: passes.sort(key=lambda p: p['startTimeISO'])
max_elevation = el # Only cache non-empty results to avoid serving a stale empty response
# on the next request (which could happen if TLEs were too old to produce
# any events — the auto-refresh will update them shortly after startup).
if passes:
_pass_cache[cache_key] = (passes, now_ts)
trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)}) return jsonify({
'status': 'success',
if max_elevation >= min_el: 'passes': passes,
duration_minutes = int(duration_seconds / 60) 'cached': False,
})
ground_track = [] except Exception as exc:
for k in range(60): logger.exception('Satellite pass calculation failed')
frac = k / 59 if 'cache_key' in locals():
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac)) stale_cached = _pass_cache.get(cache_key)
geocentric = satellite.at(t_point) if stale_cached and stale_cached[0]:
subpoint = wgs84.subpoint(geocentric) return jsonify({
ground_track.append({ 'status': 'success',
'lat': float(subpoint.latitude.degrees), 'passes': stale_cached[0],
'lon': float(subpoint.longitude.degrees) 'cached': True,
}) 'stale': True,
})
current_geo = satellite.at(ts.now()) return api_error(f'Failed to calculate passes: {exc}', 500)
current_subpoint = wgs84.subpoint(current_geo)
passes.append({
'satellite': sat_name,
'norad': name_to_norad.get(sat_name, 0),
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'maxEl': float(round(max_elevation, 1)),
'duration': int(duration_minutes),
'trajectory': trajectory,
'groundTrack': ground_track,
'currentPos': {
'lat': float(current_subpoint.latitude.degrees),
'lon': float(current_subpoint.longitude.degrees)
},
'color': colors.get(sat_name, '#00ff00')
})
i += 1
passes.sort(key=lambda p: p['startTime'])
return jsonify({
'status': 'success',
'passes': passes
})
@satellite_bp.route('/position', methods=['POST']) @satellite_bp.route('/position', methods=['POST'])
@@ -354,35 +611,30 @@ def get_satellite_position():
sat_input = data.get('satellites', []) sat_input = data.get('satellites', [])
include_track = bool(data.get('includeTrack', True)) include_track = bool(data.get('includeTrack', True))
prefer_realtime_api = bool(data.get('preferRealtimeApi', False))
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
satellites.append(norad_to_name[sat])
else:
satellites.append(sat)
ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
now = ts.now() ts = None
now_dt = now.utc_datetime() now = None
now_dt = None
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
positions = [] positions = []
for sat_name in satellites: for sat in sat_input:
# Special handling for ISS - use real-time API for accurate position sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
if sat_name == 'ISS': # Optional special handling for ISS. The dashboard does not enable this
# because external API latency can make live updates stall.
if prefer_realtime_api and (norad_id == 25544 or sat_name == 'ISS'):
iss_data = _fetch_iss_realtime(lat, lon) iss_data = _fetch_iss_realtime(lat, lon)
if iss_data: if iss_data:
# Add orbit track if requested (using TLE for track prediction) # Add orbit track if requested (using TLE for track prediction)
if include_track and 'ISS' in _tle_cache: if include_track and 'ISS' in _tle_cache:
try: try:
if ts is None:
ts = _get_timescale()
now = ts.now()
now_dt = now.utc_datetime()
tle_data = _tle_cache['ISS'] tle_data = _tle_cache['ISS']
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
orbit_track = [] orbit_track = []
@@ -402,14 +654,17 @@ def get_satellite_position():
except Exception: except Exception:
pass pass
positions.append(iss_data) positions.append(iss_data)
continue continue
# Other satellites - use TLE data # Other satellites - use TLE data
if sat_name not in _tle_cache: if not tle_data:
continue continue
tle_data = _tle_cache[sat_name]
try: try:
if ts is None:
ts = _get_timescale()
now = ts.now()
now_dt = now.utc_datetime()
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
geocentric = satellite.at(now) geocentric = satellite.at(now)
@@ -421,9 +676,10 @@ def get_satellite_position():
pos_data = { pos_data = {
'satellite': sat_name, 'satellite': sat_name,
'norad_id': norad_id,
'lat': float(subpoint.latitude.degrees), 'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees), 'lon': float(subpoint.longitude.degrees),
'altitude': float(geocentric.distance().km - 6371), 'altitude': float(subpoint.elevation.km),
'elevation': float(alt.degrees), 'elevation': float(alt.degrees),
'azimuth': float(az.degrees), 'azimuth': float(az.degrees),
'distance': float(distance.km), 'distance': float(distance.km),
@@ -446,6 +702,7 @@ def get_satellite_position():
continue continue
pos_data['track'] = orbit_track pos_data['track'] = orbit_track
pos_data['groundTrack'] = orbit_track
positions.append(pos_data) positions.append(pos_data)
except Exception: except Exception:
@@ -458,6 +715,49 @@ def get_satellite_position():
}) })
@satellite_bp.route('/transmitters/<int:norad_id>')
def get_transmitters_endpoint(norad_id: int):
"""Return SatNOGS transmitter data for a satellite by NORAD ID."""
from utils.satnogs import get_transmitters
transmitters = get_transmitters(norad_id)
return jsonify({'status': 'success', 'norad_id': norad_id, 'transmitters': transmitters})
@satellite_bp.route('/parse-packet', methods=['POST'])
def parse_packet():
"""Parse a raw satellite telemetry packet (base64-encoded)."""
import base64
from utils.satellite_telemetry import auto_parse
data = request.json or {}
try:
raw_bytes = base64.b64decode(data.get('data', ''))
except Exception:
return api_error('Invalid base64 data', 400)
result = auto_parse(raw_bytes)
return jsonify({'status': 'success', 'parsed': result})
@satellite_bp.route('/stream_satellite')
def stream_satellite() -> Response:
"""SSE endpoint streaming live satellite positions from the background tracker."""
import app as app_module
response = Response(
sse_stream_fanout(
source_queue=app_module.satellite_queue,
channel_key='satellite',
timeout=1.0,
keepalive_interval=30.0,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
def refresh_tle_data() -> list: def refresh_tle_data() -> list:
""" """
Refresh TLE data from CelesTrak. Refresh TLE data from CelesTrak.
+103
View File
@@ -2,9 +2,13 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import os import os
import re
import subprocess import subprocess
import sys import sys
import threading
from pathlib import Path
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
@@ -17,10 +21,81 @@ from utils.database import (
) )
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error, api_success from utils.responses import api_error, api_success
from utils.validation import validate_latitude, validate_longitude
logger = get_logger('intercept.settings') logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings') settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
_env_lock = threading.Lock()
def _get_env_file_path() -> Path:
"""Return the project .env path."""
return Path(__file__).resolve().parent.parent / '.env'
def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None:
"""Create or update a single key in the project .env file."""
path = env_path or _get_env_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
with _env_lock:
lines = path.read_text().splitlines() if path.exists() else [
'# INTERCEPT environment configuration',
'',
]
pattern = re.compile(rf'^\s*{re.escape(key)}=')
updated = False
new_lines: list[str] = []
for line in lines:
if pattern.match(line):
if not updated:
new_lines.append(f'{key}={value}')
updated = True
continue
new_lines.append(line)
if not updated:
if new_lines and new_lines[-1] != '':
new_lines.append('')
new_lines.append(f'{key}={value}')
path.write_text('\n'.join(new_lines).rstrip('\n') + '\n')
sudo_uid = os.environ.get('INTERCEPT_SUDO_UID')
sudo_gid = os.environ.get('INTERCEPT_SUDO_GID')
if os.geteuid() == 0 and sudo_uid and sudo_gid:
with contextlib.suppress(OSError, ValueError):
os.chown(path, int(sudo_uid), int(sudo_gid))
def _apply_runtime_observer_defaults(lat: float, lon: float) -> None:
"""Update in-process defaults so refreshed pages use the saved location."""
lat_str = str(lat)
lon_str = str(lon)
os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str
os.environ['INTERCEPT_DEFAULT_LON'] = lon_str
import config
config.DEFAULT_LATITUDE = lat
config.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
import app as app_module
app_module.DEFAULT_LATITUDE = lat
app_module.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import adsb as adsb_routes
adsb_routes.DEFAULT_LATITUDE = lat
adsb_routes.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import ais as ais_routes
ais_routes.DEFAULT_LATITUDE = lat
ais_routes.DEFAULT_LONGITUDE = lon
@settings_bp.route('', methods=['GET']) @settings_bp.route('', methods=['GET'])
@@ -109,6 +184,34 @@ def delete_single_setting(key: str) -> Response:
return api_error(str(e), 500) return api_error(str(e), 500)
@settings_bp.route('/observer-location', methods=['POST'])
def save_observer_location() -> Response:
"""Persist observer location to .env and refresh in-process defaults."""
data = request.json or {}
try:
lat = validate_latitude(data.get('lat'))
lon = validate_longitude(data.get('lon'))
except ValueError as exc:
return api_error(str(exc), 400)
try:
_write_env_value('INTERCEPT_DEFAULT_LAT', str(lat))
_write_env_value('INTERCEPT_DEFAULT_LON', str(lon))
_apply_runtime_observer_defaults(lat, lon)
return api_success(
data={
'lat': lat,
'lon': lon,
'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'],
},
message='Observer location saved to .env',
)
except Exception as exc:
logger.error(f'Error saving observer location to .env: {exc}')
return api_error(str(exc), 500)
# ============================================================================= # =============================================================================
# Device Correlation Endpoints # Device Correlation Endpoints
# ============================================================================= # =============================================================================
+1 -1
View File
@@ -478,7 +478,7 @@ def _get_timescale():
with _timescale_lock: with _timescale_lock:
if _timescale is None: if _timescale is None:
from skyfield.api import load from skyfield.api import load
_timescale = load.timescale() _timescale = load.timescale(builtin=True)
return _timescale return _timescale
+10 -6
View File
@@ -36,7 +36,8 @@ logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/status') @tscm_bp.route('/status')
def tscm_status(): def tscm_status():
"""Check if any TSCM operation is currently running.""" """Check if any TSCM operation is currently running."""
return jsonify({'running': _sweep_running}) import routes.tscm as _tscm_pkg
return jsonify({'running': _tscm_pkg._sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST']) @tscm_bp.route('/sweep/start', methods=['POST'])
@@ -95,14 +96,15 @@ def stop_sweep():
@tscm_bp.route('/sweep/status') @tscm_bp.route('/sweep/status')
def sweep_status(): def sweep_status():
"""Get current sweep status.""" """Get current sweep status."""
import routes.tscm as _tscm_pkg
status = { status = {
'running': _sweep_running, 'running': _tscm_pkg._sweep_running,
'sweep_id': _current_sweep_id, 'sweep_id': _tscm_pkg._current_sweep_id,
} }
if _current_sweep_id: if _tscm_pkg._current_sweep_id:
sweep = get_tscm_sweep(_current_sweep_id) sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id)
if sweep: if sweep:
status['sweep'] = sweep status['sweep'] = sweep
@@ -113,12 +115,14 @@ def sweep_status():
def sweep_stream(): def sweep_stream():
"""SSE stream for real-time sweep updates.""" """SSE stream for real-time sweep updates."""
import routes.tscm as _tscm_pkg
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('tscm', msg, msg.get('type')) process_event('tscm', msg, msg.get('type'))
return Response( return Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=tscm_queue, source_queue=_tscm_pkg.tscm_queue,
channel_key='tscm', channel_key='tscm',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
+122 -12
View File
@@ -1,12 +1,15 @@
"""Weather Satellite decoder routes. """Weather Satellite decoder routes.
Provides endpoints for capturing and decoding weather satellite images Provides endpoints for capturing and decoding Meteor LRPT weather
from NOAA (APT) and Meteor (LRPT) satellites using SatDump. imagery, including shared results produced by the ground-station
observation pipeline.
""" """
from __future__ import annotations from __future__ import annotations
import json
import queue import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
@@ -37,6 +40,15 @@ weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming # Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) _weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
def _progress_callback(progress: CaptureProgress) -> None: def _progress_callback(progress: CaptureProgress) -> None:
"""Callback to queue progress updates for SSE stream.""" """Callback to queue progress updates for SSE stream."""
@@ -120,7 +132,7 @@ def start_capture():
JSON body: JSON body:
{ {
"satellite": "NOAA-18", // Required: satellite key "satellite": "METEOR-M2-3", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0) "device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40) "gain": 40.0, // SDR gain in dB (default: 40)
"bias_t": false // Enable bias-T for LNA (default: false) "bias_t": false // Enable bias-T for LNA (default: false)
@@ -248,7 +260,7 @@ def test_decode():
JSON body: JSON body:
{ {
"satellite": "NOAA-18", // Required: satellite key "satellite": "METEOR-M2-3", // Required: satellite key
"input_file": "/path/to/file", // Required: server-side file path "input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000) "sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
} }
@@ -292,14 +304,13 @@ def test_decode():
from pathlib import Path from pathlib import Path
input_path = Path(input_file) input_path = Path(input_file)
# Security: restrict to data directory (anchored to app root, not CWD) # Restrict test-decode to application-owned sample and recording paths.
allowed_base = Path(__file__).resolve().parent.parent / 'data'
try: try:
resolved = input_path.resolve() resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base): if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'input_file must be under the data/ directory' 'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403 }), 403
except (OSError, ValueError): except (OSError, ValueError):
return jsonify({ return jsonify({
@@ -389,21 +400,34 @@ def list_images():
JSON with list of decoded images. JSON with list of decoded images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
images = decoder.get_images() images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified # Filter by satellite if specified
satellite_filter = request.args.get('satellite') satellite_filter = request.args.get('satellite')
if satellite_filter: if satellite_filter:
images = [img for img in images if img.satellite == satellite_filter] images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit # Apply limit
limit = request.args.get('limit', type=int) limit = request.args.get('limit', type=int)
if limit and limit > 0: if limit and limit > 0:
images = images[-limit:] images = images[:limit]
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'images': [img.to_dict() for img in images], 'images': images,
'count': len(images), 'count': len(images),
}) })
@@ -436,6 +460,36 @@ def get_image(filename: str):
return send_file(image_path, mimetype=mimetype) return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<int:output_id>')
def get_shared_image(output_id: int):
"""Serve a Meteor image stored in ground-station outputs."""
try:
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'''
SELECT file_path FROM ground_station_outputs
WHERE id=? AND output_type='image'
''',
(output_id,),
).fetchone()
except Exception as e:
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
return api_error('Image not found', 404)
if not row:
return api_error('Image not found', 404)
image_path = Path(row['file_path'])
if not image_path.exists():
return api_error('Image not found', 404)
suffix = image_path.suffix.lower()
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/<filename>', methods=['DELETE']) @weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str): def delete_image(filename: str):
"""Delete a decoded image. """Delete a decoded image.
@@ -469,6 +523,62 @@ def delete_all_images():
return jsonify({'status': 'ok', 'deleted': count}) return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
@weather_sat_bp.route('/stream') @weather_sat_bp.route('/stream')
def stream_progress(): def stream_progress():
"""SSE stream of capture/decode progress. """SSE stream of capture/decode progress.
+12 -7
View File
@@ -673,13 +673,6 @@ def start_wifi_scan():
os.remove(f) os.remove(f)
airodump_path = get_tool_path('airodump-ng') airodump_path = get_tool_path('airodump-ng')
cmd = [
airodump_path,
'-w', csv_path,
'--output-format', 'csv,pcap',
'--band', band,
interface
]
channel_list = None channel_list = None
if channels: if channels:
@@ -688,10 +681,22 @@ def start_wifi_scan():
except ValueError as e: except ValueError as e:
return api_error(str(e), 400) return api_error(str(e), 400)
cmd = [
airodump_path,
'-w', csv_path,
'--output-format', 'csv,pcap',
]
# --band and -c are mutually exclusive: only add --band when not
# locking to specific channels, and always place the interface last.
if channel_list: if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel: elif channel:
cmd.extend(['-c', str(channel)]) cmd.extend(['-c', str(channel)])
else:
cmd.extend(['--band', band])
cmd.append(interface)
logger.info(f"Running: {' '.join(cmd)}") logger.info(f"Running: {' '.join(cmd)}")
+162
View File
@@ -0,0 +1,162 @@
"""Minimal semver compatibility shim.
This project vendors a tiny subset of the ``semver`` package API so
integrations like radiosonde_auto_rx can run even when the external
dependency is missing from the target Python environment.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, replace
from typing import Iterable
_SEMVER_RE = re.compile(
r"^\s*"
r"(?P<major>0|[1-9]\d*)"
r"(?:\.(?P<minor>0|[1-9]\d*))?"
r"(?:\.(?P<patch>0|[1-9]\d*))?"
r"(?:-(?P<prerelease>[0-9A-Za-z.-]+))?"
r"(?:\+(?P<build>[0-9A-Za-z.-]+))?"
r"\s*$"
)
def _split_prerelease(value: str | None) -> list[int | str]:
if not value:
return []
parts: list[int | str] = []
for token in value.split("."):
parts.append(int(token) if token.isdigit() else token)
return parts
def _compare_identifiers(left: Iterable[int | str], right: Iterable[int | str]) -> int:
left_parts = list(left)
right_parts = list(right)
for l_part, r_part in zip(left_parts, right_parts):
if l_part == r_part:
continue
if isinstance(l_part, int) and isinstance(r_part, str):
return -1
if isinstance(l_part, str) and isinstance(r_part, int):
return 1
return -1 if l_part < r_part else 1
if len(left_parts) == len(right_parts):
return 0
return -1 if len(left_parts) < len(right_parts) else 1
@dataclass(frozen=True)
class VersionInfo:
major: int
minor: int = 0
patch: int = 0
prerelease: str | None = None
build: str | None = None
@classmethod
def parse(cls, version: str) -> VersionInfo:
match = _SEMVER_RE.match(str(version))
if not match:
raise ValueError(f"{version!r} is not valid SemVer")
groups = match.groupdict()
return cls(
major=int(groups["major"]),
minor=int(groups["minor"] or 0),
patch=int(groups["patch"] or 0),
prerelease=groups["prerelease"],
build=groups["build"],
)
@classmethod
def isvalid(cls, version: str) -> bool:
return _SEMVER_RE.match(str(version)) is not None
@classmethod
def is_valid(cls, version: str) -> bool:
return cls.isvalid(version)
def compare(self, other: str | VersionInfo) -> int:
return compare(self, other)
def match(self, expr: str) -> bool:
return match(str(self), expr)
def bump_major(self) -> VersionInfo:
return VersionInfo(self.major + 1, 0, 0)
def bump_minor(self) -> VersionInfo:
return VersionInfo(self.major, self.minor + 1, 0)
def bump_patch(self) -> VersionInfo:
return VersionInfo(self.major, self.minor, self.patch + 1)
def finalize_version(self) -> VersionInfo:
return VersionInfo(self.major, self.minor, self.patch)
def replace(self, **changes) -> VersionInfo:
return replace(self, **changes)
def __str__(self) -> str:
value = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease:
value += f"-{self.prerelease}"
if self.build:
value += f"+{self.build}"
return value
def parse(version: str) -> VersionInfo:
return VersionInfo.parse(version)
def compare(left: str | VersionInfo, right: str | VersionInfo) -> int:
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
left_core = (left_ver.major, left_ver.minor, left_ver.patch)
right_core = (right_ver.major, right_ver.minor, right_ver.patch)
if left_core != right_core:
return -1 if left_core < right_core else 1
if left_ver.prerelease == right_ver.prerelease:
return 0
if left_ver.prerelease is None:
return 1
if right_ver.prerelease is None:
return -1
return _compare_identifiers(
_split_prerelease(left_ver.prerelease),
_split_prerelease(right_ver.prerelease),
)
def match(version: str | VersionInfo, expr: str) -> bool:
version_info = version if isinstance(version, VersionInfo) else parse(str(version))
expression = str(expr).strip()
for operator in ("<=", ">=", "==", "!=", "<", ">"):
if expression.startswith(operator):
other = parse(expression[len(operator):].strip())
result = compare(version_info, other)
return {
"<": result < 0,
"<=": result <= 0,
">": result > 0,
">=": result >= 0,
"==": result == 0,
"!=": result != 0,
}[operator]
return compare(version_info, parse(expression)) == 0
def max_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
return left_ver if compare(left_ver, right_ver) >= 0 else right_ver
def min_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
return left_ver if compare(left_ver, right_ver) <= 0 else right_ver
+99 -20
View File
@@ -438,7 +438,11 @@ check_tools() {
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2 check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py if [[ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]] && [[ -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]]; then
ok "auto_rx.py - Radiosonde weather balloon decoder"
else
warn "auto_rx.py - Radiosonde weather balloon decoder (missing, optional)"
fi
echo echo
info "GPS:" info "GPS:"
check_required "gpsd" "GPS daemon" gpsd check_required "gpsd" "GPS daemon" gpsd
@@ -487,6 +491,16 @@ import sys
raise SystemExit(0 if sys.version_info >= (3,9) else 1) raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY PY
ok "Python version OK (>= 3.9)" ok "Python version OK (>= 3.9)"
# Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have
# pre-built wheels yet and will be skipped to avoid hanging on compilation.
if python3 - <<'PY'
import sys
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
PY
then
warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)."
fi
} }
install_python_deps() { install_python_deps() {
@@ -520,8 +534,11 @@ install_python_deps() {
source venv/bin/activate source venv/bin/activate
local PIP="venv/bin/python -m pip" local PIP="venv/bin/python -m pip"
local PY="venv/bin/python" local PY="venv/bin/python"
# --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue)
# --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections
local PIP_OPTS="--no-cache-dir --timeout 120"
if ! $PIP install --upgrade pip setuptools wheel; then if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions" warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
else else
ok "Upgraded pip tooling" ok "Upgraded pip tooling"
@@ -530,24 +547,39 @@ install_python_deps() {
progress "Installing Python dependencies" progress "Installing Python dependencies"
info "Installing core packages..." info "Installing core packages..."
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \ $PIP install $PIP_OPTS "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \
"Werkzeug>=3.1.5" "pyserial>=3.5" 2>/dev/null || true "flask-limiter>=2.5.4" "requests>=2.28.0" \
"Werkzeug>=3.1.5" "pyserial>=3.5" || true
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || { # Verify core packages are installed by checking pip's reported list (avoids hanging imports)
fail "Critical Python packages (flask, requests, flask-limiter) not installed" for core_pkg in flask requests flask-limiter flask-compress flask-wtf; do
echo "Try: venv/bin/pip install flask requests flask-limiter" if ! $PIP show "$core_pkg" >/dev/null 2>&1; then
exit 1 fail "Critical Python package not installed: ${core_pkg}"
} echo "Try: venv/bin/pip install ${core_pkg}"
exit 1
fi
done
ok "Core Python packages installed" ok "Core Python packages installed"
info "Installing optional packages..." info "Installing optional packages..."
for pkg in "flask-sock" "websocket-client>=1.6.0" "numpy>=1.24.0" "scipy>=1.10.0" \ # Pure-Python packages: install without --only-binary so they always succeed regardless of platform
"Pillow>=9.0.0" "skyfield>=1.45" "bleak>=0.21.0" "psycopg2-binary>=2.9.9" \ for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
"meshtastic>=2.0.0" "scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \ "skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do "qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
pkg_name="${pkg%%>=*}" pkg_name="${pkg%%[><=]*}"
info " Installing ${pkg_name}..." info " Installing ${pkg_name}..."
if ! $PIP install "$pkg"; then if ! $PIP install $PIP_OPTS "$pkg"; then
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
fi
done
# Compiled packages: use --only-binary :all: to skip slow source compilation on RPi
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" \
"psycopg2-binary>=2.9.9" "scapy>=2.4.5" "cryptography>=41.0.0" \
"gevent>=23.9.0"; do
pkg_name="${pkg%%[><=]*}"
info " Installing ${pkg_name}..."
# --only-binary :all: prevents source compilation hangs for heavy packages
if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then
warn "${pkg_name} failed to install (optional - related features may be unavailable)" warn "${pkg_name} failed to install (optional - related features may be unavailable)"
fi fi
done done
@@ -603,7 +635,25 @@ apt_install() {
fi fi
} }
wait_for_apt_lock() {
local max_wait=120
local waited=0
while fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; do
if [[ $waited -eq 0 ]]; then
info "Waiting for apt lock (another package manager is running)..."
fi
sleep 5
waited=$((waited + 5))
if [[ $waited -ge $max_wait ]]; then
warn "apt lock held for over ${max_wait}s. Continuing anyway (may fail)."
return 1
fi
done
return 0
}
apt_try_install_any() { apt_try_install_any() {
wait_for_apt_lock
local p local p
for p in "$@"; do for p in "$@"; do
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
@@ -751,9 +801,26 @@ install_acarsdec_from_source_macos() {
cd "$tmp_dir/acarsdec" cd "$tmp_dir/acarsdec"
# Replace deprecated -Ofast (all macOS, not just arm64)
if grep -q '\-Ofast' CMakeLists.txt 2>/dev/null; then
sed -i '' 's/-Ofast/-O3 -ffast-math/g' CMakeLists.txt
info "Patched deprecated -Ofast flag"
fi
# macOS doesn't have -march=native on arm64
if [[ "$(uname -m)" == "arm64" ]]; then if [[ "$(uname -m)" == "arm64" ]]; then
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt sed -i '' 's/ -march=native//g' CMakeLists.txt
info "Patched compiler flags for Apple Silicon (arm64)" info "Removed -march=native for Apple Silicon"
fi
# HOST_NAME_MAX is Linux-specific; macOS uses _POSIX_HOST_NAME_MAX
if grep -q 'HOST_NAME_MAX' acarsdec.c 2>/dev/null; then
sed -i '' '1i\
#ifndef HOST_NAME_MAX\
#define HOST_NAME_MAX 255\
#endif
' acarsdec.c
info "Patched HOST_NAME_MAX for macOS compatibility"
fi fi
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
@@ -1703,6 +1770,7 @@ install_profiles() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
wait_for_apt_lock
info "Updating APT package lists..." info "Updating APT package lists..."
if ! $SUDO apt-get update -y >/dev/null 2>&1; then if ! $SUDO apt-get update -y >/dev/null 2>&1; then
warn "apt-get update reported errors. Continuing anyway." warn "apt-get update reported errors. Continuing anyway."
@@ -1924,7 +1992,18 @@ do_health_check() {
info "SDR device detection..." info "SDR device detection..."
if cmd_exists rtl_test; then if cmd_exists rtl_test; then
local rtl_output local rtl_output
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true) if cmd_exists timeout; then
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
elif cmd_exists gtimeout; then
rtl_output=$(gtimeout 3 rtl_test -d 0 2>&1 || true)
else
# No timeout command (common on macOS) — run with background kill
rtl_test -d 0 > /tmp/.rtl_test_out 2>&1 & local rtl_pid=$!
sleep 2
kill "$rtl_pid" 2>/dev/null; wait "$rtl_pid" 2>/dev/null
rtl_output=$(cat /tmp/.rtl_test_out 2>/dev/null || true)
rm -f /tmp/.rtl_test_out
fi
if echo "$rtl_output" | grep -q "Found\|Using device"; then if echo "$rtl_output" | grep -q "Found\|Using device"; then
ok "RTL-SDR device detected" ok "RTL-SDR device detected"
((pass++)) || true ((pass++)) || true
@@ -1984,8 +2063,8 @@ do_health_check() {
ok "Python venv exists" ok "Python venv exists"
((pass++)) || true ((pass++)) || true
if venv/bin/python -c "import flask; import requests" 2>/dev/null; then if venv/bin/python -s -c "import flask; import requests; import flask_compress; import flask_wtf" 2>/dev/null; then
ok "Critical Python packages (flask, requests) — OK" ok "Critical Python packages (flask, requests, flask-compress, flask-wtf) — OK"
((pass++)) || true ((pass++)) || true
else else
fail "Critical Python packages missing in venv" fail "Critical Python packages missing in venv"
+5 -2
View File
@@ -88,8 +88,11 @@
} }
/* Branded "i" inline SVG that matches the logo icon. /* Branded "i" inline SVG that matches the logo icon.
Sized to 0.9em so it sits naturally alongside text at any font-size. */ Sized to 0.9em so it sits naturally alongside text at any font-size.
.brand-i { Uses .logo .brand-i (0,2,0) to beat .logo span (0,1,1) in dashboard CSS
which otherwise forces display:inline and breaks width/height. */
.brand-i,
.logo .brand-i {
display: inline-block; display: inline-block;
width: 0.55em; width: 0.55em;
height: 0.9em; height: 0.9em;
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -14,6 +14,7 @@ let agentRunningModes = []; // Track agent's running modes for conflict detecti
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents) let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
let healthCheckInterval = null; // Health monitoring interval let healthCheckInterval = null; // Health monitoring interval
let agentHealthStatus = {}; // Cache of health status per agent ID let agentHealthStatus = {}; // Cache of health status per agent ID
let healthCheckKickoffTimer = null;
// ============== AGENT HEALTH MONITORING ============== // ============== AGENT HEALTH MONITORING ==============
@@ -25,8 +26,15 @@ function startHealthMonitoring() {
// Don't start if already running // Don't start if already running
if (healthCheckInterval) return; if (healthCheckInterval) return;
// Initial check // Defer the first probe so heavy dashboards can finish initial render
checkAllAgentsHealth(); // before we start contacting remote agents.
if (healthCheckKickoffTimer) {
clearTimeout(healthCheckKickoffTimer);
}
healthCheckKickoffTimer = setTimeout(() => {
healthCheckKickoffTimer = null;
checkAllAgentsHealth();
}, 5000);
// Start periodic checks every 30 seconds // Start periodic checks every 30 seconds
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000); healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
@@ -37,6 +45,10 @@ function startHealthMonitoring() {
* Stop health monitoring. * Stop health monitoring.
*/ */
function stopHealthMonitoring() { function stopHealthMonitoring() {
if (healthCheckKickoffTimer) {
clearTimeout(healthCheckKickoffTimer);
healthCheckKickoffTimer = null;
}
if (healthCheckInterval) { if (healthCheckInterval) {
clearInterval(healthCheckInterval); clearInterval(healthCheckInterval);
healthCheckInterval = null; healthCheckInterval = null;
+103 -33
View File
@@ -8,16 +8,41 @@ const AlertCenter = (function() {
let eventSource = null; let eventSource = null;
let reconnectTimer = null; let reconnectTimer = null;
let lastConnectionWarningAt = 0; let lastConnectionWarningAt = 0;
let rulesLoaded = false;
let rulesPromise = null;
let bootTimer = null;
let feedLoaded = false;
function init() { function init(options = {}) {
loadRules(); const connectFeed = options.connectFeed !== false;
loadFeed(); const refreshRules = options.refreshRules === true;
connect();
if (bootTimer) {
clearTimeout(bootTimer);
bootTimer = null;
}
loadRules(refreshRules);
if (connectFeed) {
if (!feedLoaded) {
loadFeed();
}
connect();
}
}
function scheduleInit(delayMs = 15000) {
if (bootTimer || eventSource) return;
bootTimer = window.setTimeout(() => {
bootTimer = null;
init();
}, delayMs);
} }
function connect() { function connect() {
if (eventSource) { if (eventSource) {
eventSource.close(); return;
} }
eventSource = new EventSource('/alerts/stream'); eventSource = new EventSource('/alerts/stream');
@@ -40,6 +65,10 @@ const AlertCenter = (function() {
lastConnectionWarningAt = now; lastConnectionWarningAt = now;
console.warn('[Alerts] SSE connection error; retrying'); console.warn('[Alerts] SSE connection error; retrying');
} }
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 2500); reconnectTimer = setTimeout(connect, 2500);
}; };
@@ -133,6 +162,7 @@ const AlertCenter = (function() {
} }
function loadFeed() { function loadFeed() {
feedLoaded = true;
fetch('/alerts/events?limit=30') fetch('/alerts/events?limit=30')
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
@@ -144,21 +174,37 @@ const AlertCenter = (function() {
.catch((err) => console.error('[Alerts] Load feed failed', err)); .catch((err) => console.error('[Alerts] Load feed failed', err));
} }
function loadRules() { function loadRules(force = false) {
return fetch('/alerts/rules?all=1') if (!force && rulesLoaded) {
renderRulesUI();
return Promise.resolve(rules);
}
if (!force && rulesPromise) {
return rulesPromise;
}
rulesPromise = fetch('/alerts/rules?all=1')
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data.status === 'success') { if (data.status === 'success') {
rules = data.rules || []; rules = data.rules || [];
rulesLoaded = true;
renderRulesUI(); renderRulesUI();
} }
return rules;
}) })
.catch((err) => { .catch((err) => {
console.error('[Alerts] Load rules failed', err); console.error('[Alerts] Load rules failed', err);
if (typeof reportActionableError === 'function') { if (typeof reportActionableError === 'function') {
reportActionableError('Alert Rules', err, { onRetry: loadRules }); reportActionableError('Alert Rules', err, { onRetry: loadRules });
} }
throw err;
})
.finally(() => {
rulesPromise = null;
}); });
return rulesPromise;
} }
function saveRule() { function saveRule() {
@@ -260,7 +306,7 @@ const AlertCenter = (function() {
if (data.status !== 'success') { if (data.status !== 'success') {
throw new Error(data.message || 'Failed to update rule'); throw new Error(data.message || 'Failed to update rule');
} }
return loadRules(); return loadRules(true);
}) })
.catch((err) => { .catch((err) => {
if (typeof reportActionableError === 'function') { if (typeof reportActionableError === 'function') {
@@ -287,7 +333,7 @@ const AlertCenter = (function() {
if (Number(getEditingRuleId()) === Number(ruleId)) { if (Number(getEditingRuleId()) === Number(ruleId)) {
clearRuleForm(); clearRuleForm();
} }
return loadRules(); return loadRules(true);
}) })
.catch((err) => { .catch((err) => {
if (typeof reportActionableError === 'function') { if (typeof reportActionableError === 'function') {
@@ -325,7 +371,7 @@ const AlertCenter = (function() {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}).then(() => loadRules()); }).then(() => loadRules(true));
} }
if (enabled) { if (enabled) {
@@ -341,7 +387,7 @@ const AlertCenter = (function() {
enabled: true, enabled: true,
notify: { webhook: true }, notify: { webhook: true },
}), }),
}).then(() => loadRules()); }).then(() => loadRules(true));
} }
return null; return null;
}); });
@@ -349,41 +395,63 @@ const AlertCenter = (function() {
function addBluetoothWatchlist(address, name) { function addBluetoothWatchlist(address, name) {
if (!address) return; if (!address) return;
const upper = String(address).toUpperCase(); loadRules().then(() => {
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper); const upper = String(address).toUpperCase();
if (existing) return; const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
if (existing) return;
fetch('/alerts/rules', { return fetch('/alerts/rules', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`, name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
mode: 'bluetooth', mode: 'bluetooth',
event_type: 'device_update', event_type: 'device_update',
match: { address: upper }, match: { address: upper },
severity: 'medium', severity: 'medium',
enabled: true, enabled: true,
notify: { webhook: true }, notify: { webhook: true },
}), }),
}).then(() => loadRules()); }).then(() => loadRules(true));
});
} }
function removeBluetoothWatchlist(address) { function removeBluetoothWatchlist(address) {
if (!address) return; if (!address) return;
const upper = String(address).toUpperCase(); loadRules().then(() => {
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper); const upper = String(address).toUpperCase();
if (!existing) return; const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
if (!existing) return;
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' }) return fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
.then(() => loadRules()); .then(() => loadRules(true));
});
} }
function isWatchlisted(address) { function isWatchlisted(address) {
if (!address) return false; if (!address) return false;
if (!rulesLoaded && !rulesPromise) {
loadRules();
}
const upper = String(address).toUpperCase(); const upper = String(address).toUpperCase();
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled); return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
} }
function destroy() {
if (bootTimer) {
clearTimeout(bootTimer);
bootTimer = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return String(str) return String(str)
@@ -396,6 +464,7 @@ const AlertCenter = (function() {
return { return {
init, init,
scheduleInit,
loadFeed, loadFeed,
loadRules, loadRules,
saveRule, saveRule,
@@ -408,11 +477,12 @@ const AlertCenter = (function() {
addBluetoothWatchlist, addBluetoothWatchlist,
removeBluetoothWatchlist, removeBluetoothWatchlist,
isWatchlisted, isWatchlisted,
destroy,
}; };
})(); })();
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
if (typeof AlertCenter !== 'undefined') { if (typeof AlertCenter !== 'undefined') {
AlertCenter.init(); AlertCenter.scheduleInit();
} }
}); });
+1
View File
@@ -19,6 +19,7 @@ const CheatSheets = (function () {
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] }, sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] }, gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] }, spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
controller_monitor: { title: 'Controller Monitor', icon: '🖧', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] }, spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] }, websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
+5
View File
@@ -330,6 +330,11 @@ const CommandPalette = (function() {
} }
function goToMode(mode) { function goToMode(mode) {
if (mode === 'satellite') {
window.open('/satellite/dashboard', '_blank', 'noopener');
return;
}
const welcome = document.getElementById('welcomePage'); const welcome = document.getElementById('welcomePage');
if (welcome && getComputedStyle(welcome).display !== 'none') { if (welcome && getComputedStyle(welcome).display !== 'none') {
welcome.style.display = 'none'; welcome.style.display = 'none';
+15 -13
View File
@@ -1,9 +1,6 @@
// Shared observer location helper for map-based modules. // Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config. // Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() { window.ObserverLocation = (function() {
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
: { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation'; const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation'; const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat'; const LEGACY_LAT_KEY = 'observerLat';
@@ -21,6 +18,9 @@ window.ObserverLocation = (function() {
return { lat: latNum, lon: lonNum }; return { lat: latNum, lon: lonNum };
} }
const DEFAULT_LOCATION = normalize(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON)
|| { lat: 51.5074, lon: -0.1278 };
function parseLocation(raw) { function parseLocation(raw) {
if (!raw) return null; if (!raw) return null;
try { try {
@@ -39,7 +39,7 @@ window.ObserverLocation = (function() {
function readLegacyLatLon() { function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY); const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY); const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null; if (lat === null || lon === null) return null;
return normalize(lat, lon); return normalize(lat, lon);
} }
@@ -60,11 +60,12 @@ window.ObserverLocation = (function() {
} }
function setShared(location, options = {}) { function setShared(location, options = {}) {
if (!location) return; const normalized = location ? normalize(location.lat, location.lon) : null;
localStorage.setItem(SHARED_KEY, JSON.stringify(location)); if (!normalized) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(normalized));
if (options.updateLegacy !== false) { if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString()); localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString()); localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
} }
} }
@@ -84,16 +85,17 @@ window.ObserverLocation = (function() {
} }
function setForModule(moduleKey, location, options = {}) { function setForModule(moduleKey, location, options = {}) {
if (!location) return; const normalized = location ? normalize(location.lat, location.lon) : null;
if (!normalized) return;
if (isSharedEnabled()) { if (isSharedEnabled()) {
setShared(location, options); setShared(normalized, options);
return; return;
} }
if (moduleKey) { if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location)); localStorage.setItem(moduleKey, JSON.stringify(normalized));
} else if (options.fallbackToLatLon) { } else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString()); localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString()); localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
} }
} }
-6
View File
@@ -137,9 +137,3 @@ const RecordingUI = (function() {
openReplay, openReplay,
}; };
})(); })();
document.addEventListener('DOMContentLoaded', () => {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.init();
}
});
+45 -13
View File
@@ -896,23 +896,26 @@ function loadObserverLocation() {
lon = shared.lon.toString(); lon = shared.lon.toString();
} }
const hasLat = lat !== undefined && lat !== null && lat !== '';
const hasLon = lon !== undefined && lon !== null && lon !== '';
const latInput = document.getElementById('observerLatInput'); const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput'); const lonInput = document.getElementById('observerLonInput');
const currentLatDisplay = document.getElementById('currentLatDisplay'); const currentLatDisplay = document.getElementById('currentLatDisplay');
const currentLonDisplay = document.getElementById('currentLonDisplay'); const currentLonDisplay = document.getElementById('currentLonDisplay');
if (latInput && lat) latInput.value = lat; if (latInput && hasLat) latInput.value = lat;
if (lonInput && lon) lonInput.value = lon; if (lonInput && hasLon) lonInput.value = lon;
if (currentLatDisplay) { if (currentLatDisplay) {
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set'; currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
} }
if (currentLonDisplay) { if (currentLonDisplay) {
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; currentLonDisplay.textContent = hasLon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
} }
// Sync dashboard-specific location keys for backward compatibility // Sync dashboard-specific location keys for backward compatibility
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') { if (hasLat && hasLon) {
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) }); const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
if (!localStorage.getItem('observerLocation')) { if (!localStorage.getItem('observerLocation')) {
localStorage.setItem('observerLocation', locationObj); localStorage.setItem('observerLocation', locationObj);
@@ -1011,9 +1014,9 @@ function detectLocationGPS(btn) {
} }
/** /**
* Save observer location to localStorage * Save observer location to localStorage and persist defaults to .env
*/ */
function saveObserverLocation() { async function saveObserverLocation() {
const latInput = document.getElementById('observerLatInput'); const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput'); const lonInput = document.getElementById('observerLonInput');
@@ -1056,19 +1059,48 @@ function saveObserverLocation() {
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°'; if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°'; if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
if (typeof showNotification === 'function') {
showNotification('Location', 'Observer location saved');
}
if (window.observerLocation) { if (window.observerLocation) {
window.observerLocation.lat = lat; window.observerLocation.lat = lat;
window.observerLocation.lon = lon; window.observerLocation.lon = lon;
} }
let notificationMessage = 'Observer location saved';
try {
const response = await fetch('/settings/observer-location', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ lat, lon }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.status === 'error') {
throw new Error(data.message || 'Failed to save observer location to .env');
}
window.INTERCEPT_DEFAULT_LAT = lat;
window.INTERCEPT_DEFAULT_LON = lon;
notificationMessage = 'Observer location saved to settings and .env';
} catch (error) {
notificationMessage = `Observer location saved for this browser, but .env update failed: ${error.message}`;
}
// Refresh SSTV ISS schedule if available // Refresh SSTV ISS schedule if available
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') { if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
SSTV.loadIssSchedule(); SSTV.loadIssSchedule();
} }
// Update APRS user location if function is available
if (typeof updateAprsUserLocation === 'function') {
updateAprsUserLocation({ latitude: lat, longitude: lon });
}
// Notify all listeners (any mode can subscribe)
window.dispatchEvent(new CustomEvent('observer-location-changed', { detail: { lat, lon } }));
if (typeof showNotification === 'function') {
showNotification('Location', notificationMessage);
}
} }
// ============================================================================= // =============================================================================
@@ -1260,11 +1292,11 @@ function switchSettingsTab(tabName) {
} else if (tabName === 'alerts') { } else if (tabName === 'alerts') {
loadVoiceAlertConfig(); loadVoiceAlertConfig();
if (typeof AlertCenter !== 'undefined') { if (typeof AlertCenter !== 'undefined') {
AlertCenter.loadFeed(); AlertCenter.init();
} }
} else if (tabName === 'recording') { } else if (tabName === 'recording') {
if (typeof RecordingUI !== 'undefined') { if (typeof RecordingUI !== 'undefined') {
RecordingUI.refresh(); RecordingUI.init();
} }
} else if (tabName === 'apikeys') { } else if (tabName === 'apikeys') {
loadApiKeyStatus(); loadApiKeyStatus();
+23 -5
View File
@@ -5,6 +5,7 @@
const Updater = { const Updater = {
// State // State
_checkInterval: null, _checkInterval: null,
_startupCheckTimer: null,
_toastElement: null, _toastElement: null,
_modalElement: null, _modalElement: null,
_updateData: null, _updateData: null,
@@ -19,13 +20,26 @@ const Updater = {
// Create toast container if it doesn't exist // Create toast container if it doesn't exist
this._ensureToastContainer(); this._ensureToastContainer();
// Check for updates on page load const enabled = localStorage.getItem('intercept_update_check_enabled') !== 'false';
this.checkForUpdates(); if (!enabled) {
this.destroy();
return;
}
// Defer the first check so the active dashboard can finish loading first.
if (!this._startupCheckTimer) {
this._startupCheckTimer = setTimeout(() => {
this._startupCheckTimer = null;
this.checkForUpdates();
}, 15000);
}
// Set up periodic checks // Set up periodic checks
this._checkInterval = setInterval(() => { if (!this._checkInterval) {
this.checkForUpdates(); this._checkInterval = setInterval(() => {
}, this.CHECK_INTERVAL_MS); this.checkForUpdates();
}, this.CHECK_INTERVAL_MS);
}
}, },
/** /**
@@ -506,6 +520,10 @@ const Updater = {
* Clean up on page unload * Clean up on page unload
*/ */
destroy() { destroy() {
if (this._startupCheckTimer) {
clearTimeout(this._startupCheckTimer);
this._startupCheckTimer = null;
}
if (this._checkInterval) { if (this._checkInterval) {
clearInterval(this._checkInterval); clearInterval(this._checkInterval);
this._checkInterval = null; this._checkInterval = null;
+24 -3
View File
@@ -8,6 +8,7 @@ const VoiceAlerts = (function () {
let _queue = []; let _queue = [];
let _speaking = false; let _speaking = false;
let _sources = {}; let _sources = {};
let _streamStartTimer = null;
const STORAGE_KEY = 'intercept-voice-muted'; const STORAGE_KEY = 'intercept-voice-muted';
const CONFIG_KEY = 'intercept-voice-config'; const CONFIG_KEY = 'intercept-voice-config';
const RATE_MIN = 0.5; const RATE_MIN = 0.5;
@@ -132,7 +133,12 @@ const VoiceAlerts = (function () {
} }
function _startStreams() { function _startStreams() {
if (_streamStartTimer) {
clearTimeout(_streamStartTimer);
_streamStartTimer = null;
}
if (!_enabled) return; if (!_enabled) return;
if (Object.keys(_sources).length > 0) return;
// Pager stream // Pager stream
if (_config.streams.pager) { if (_config.streams.pager) {
@@ -173,17 +179,32 @@ const VoiceAlerts = (function () {
} }
function _stopStreams() { function _stopStreams() {
if (_streamStartTimer) {
clearTimeout(_streamStartTimer);
_streamStartTimer = null;
}
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} }); Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
_sources = {}; _sources = {};
} }
function init() { function init(options) {
const opts = options || {};
_loadConfig(); _loadConfig();
if (_isSpeechSupported()) { if (_isSpeechSupported()) {
// Prime voices list early so user-triggered test calls are less likely to be silent. // Prime voices list early so user-triggered test calls are less likely to be silent.
speechSynthesis.getVoices(); speechSynthesis.getVoices();
} }
_startStreams(); if (opts.startStreams !== false) {
_startStreams();
}
}
function scheduleStreamStart(delayMs) {
if (_streamStartTimer || Object.keys(_sources).length > 0 || !_enabled) return;
_streamStartTimer = window.setTimeout(() => {
_streamStartTimer = null;
_startStreams();
}, Number(delayMs) > 0 ? Number(delayMs) : 20000);
} }
function setEnabled(val) { function setEnabled(val) {
@@ -255,7 +276,7 @@ const VoiceAlerts = (function () {
}, 1200); }, 1200);
} }
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY }; return { init, scheduleStreamStart, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
})(); })();
window.VoiceAlerts = VoiceAlerts; window.VoiceAlerts = VoiceAlerts;
+233
View File
@@ -0,0 +1,233 @@
/**
* Ground Station Live Waterfall Phase 5
*
* Subscribes to /ws/satellite_waterfall, receives binary frames in the same
* wire format as the main listening-post waterfall, and renders them onto the
* <canvas id="gs-waterfall"> element in satellite_dashboard.html.
*
* Wire frame format (matches utils/waterfall_fft.build_binary_frame):
* [uint8 msg_type=0x01]
* [float32 start_freq_mhz]
* [float32 end_freq_mhz]
* [uint16 bin_count]
* [uint8[] bins] 0=noise floor, 255=strongest signal
*/
(function () {
'use strict';
const CANVAS_ID = 'gs-waterfall';
const ROW_HEIGHT = 2; // px per waterfall row
const SCROLL_STEP = ROW_HEIGHT;
let _ws = null;
let _canvas = null;
let _ctx = null;
let _offscreen = null; // offscreen ImageData buffer
let _reconnectTimer = null;
let _centerMhz = 0;
let _spanMhz = 0;
let _connected = false;
// -----------------------------------------------------------------------
// Colour palette — 256-entry RGB array (matches listening-post waterfall)
// -----------------------------------------------------------------------
const _palette = _buildPalette();
function _buildPalette() {
const p = new Uint8Array(256 * 3);
for (let i = 0; i < 256; i++) {
let r, g, b;
if (i < 64) {
// black → dark blue
r = 0; g = 0; b = Math.round(i * 2);
} else if (i < 128) {
// dark blue → cyan
const t = (i - 64) / 64;
r = 0; g = Math.round(t * 200); b = Math.round(128 + t * 127);
} else if (i < 192) {
// cyan → yellow
const t = (i - 128) / 64;
r = Math.round(t * 255); g = 200; b = Math.round(255 - t * 255);
} else {
// yellow → white
const t = (i - 192) / 64;
r = 255; g = 200; b = Math.round(t * 255);
}
p[i * 3] = r; p[i * 3 + 1] = g; p[i * 3 + 2] = b;
}
return p;
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
window.GroundStationWaterfall = {
init,
connect,
disconnect,
isConnected: () => _connected,
setCenterFreq: (mhz, span) => { _centerMhz = mhz; _spanMhz = span; },
};
function init() {
_canvas = document.getElementById(CANVAS_ID);
if (!_canvas) return;
_ctx = _canvas.getContext('2d');
_resizeCanvas();
window.addEventListener('resize', _resizeCanvas);
_drawPlaceholder();
}
function connect() {
if (_ws && (_ws.readyState === WebSocket.CONNECTING || _ws.readyState === WebSocket.OPEN)) {
return;
}
if (_reconnectTimer) {
clearTimeout(_reconnectTimer);
_reconnectTimer = null;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws/satellite_waterfall`;
try {
_ws = new WebSocket(url);
_ws.binaryType = 'arraybuffer';
_ws.onopen = () => {
_connected = true;
_updateStatus('LIVE');
console.log('[GS Waterfall] WebSocket connected');
};
_ws.onmessage = (evt) => {
if (evt.data instanceof ArrayBuffer) {
_handleFrame(evt.data);
}
};
_ws.onclose = () => {
_connected = false;
_updateStatus('DISCONNECTED');
_scheduleReconnect();
};
_ws.onerror = (e) => {
console.warn('[GS Waterfall] WebSocket error', e);
};
} catch (e) {
console.error('[GS Waterfall] Failed to create WebSocket', e);
_scheduleReconnect();
}
}
function disconnect() {
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
if (_ws) { _ws.close(); _ws = null; }
_connected = false;
_updateStatus('STOPPED');
_drawPlaceholder();
}
// -----------------------------------------------------------------------
// Frame rendering
// -----------------------------------------------------------------------
function _handleFrame(buf) {
const view = new DataView(buf);
if (buf.byteLength < 11) return;
const msgType = view.getUint8(0);
if (msgType !== 0x01) return;
// const startFreq = view.getFloat32(1, true); // little-endian
// const endFreq = view.getFloat32(5, true);
const binCount = view.getUint16(9, true);
if (buf.byteLength < 11 + binCount) return;
const bins = new Uint8Array(buf, 11, binCount);
if (!_canvas || !_ctx) return;
const W = _canvas.width;
const H = _canvas.height;
// Scroll existing image up by ROW_HEIGHT pixels
if (!_offscreen || _offscreen.width !== W || _offscreen.height !== H) {
_offscreen = _ctx.getImageData(0, 0, W, H);
} else {
_offscreen = _ctx.getImageData(0, 0, W, H);
}
// Shift rows up by ROW_HEIGHT
const data = _offscreen.data;
const rowBytes = W * 4;
data.copyWithin(0, SCROLL_STEP * rowBytes);
// Write new row(s) at the bottom
const bottom = H - ROW_HEIGHT;
for (let row = 0; row < ROW_HEIGHT; row++) {
const rowStart = (bottom + row) * rowBytes;
for (let x = 0; x < W; x++) {
const binIdx = Math.floor((x / W) * binCount);
const val = bins[Math.min(binIdx, binCount - 1)];
const pi = val * 3;
const di = rowStart + x * 4;
data[di] = _palette[pi];
data[di + 1] = _palette[pi + 1];
data[di + 2] = _palette[pi + 2];
data[di + 3] = 255;
}
}
_ctx.putImageData(_offscreen, 0, 0);
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
function _resizeCanvas() {
if (!_canvas) return;
const container = _canvas.parentElement;
if (container) {
_canvas.width = container.clientWidth || 400;
_canvas.height = container.clientHeight || 200;
}
_offscreen = null;
_drawPlaceholder();
}
function _drawPlaceholder() {
if (!_ctx || !_canvas) return;
_ctx.fillStyle = '#000a14';
_ctx.fillRect(0, 0, _canvas.width, _canvas.height);
_ctx.fillStyle = 'rgba(0,212,255,0.3)';
_ctx.font = '12px monospace';
_ctx.textAlign = 'center';
_ctx.fillText('AWAITING SATELLITE PASS', _canvas.width / 2, _canvas.height / 2);
_ctx.textAlign = 'left';
}
function _updateStatus(text) {
const el = document.getElementById('gsWaterfallStatus');
if (el) el.textContent = text;
}
function _scheduleReconnect(delayMs = 5000) {
if (_reconnectTimer) return;
_reconnectTimer = setTimeout(() => {
_reconnectTimer = null;
connect();
}, delayMs);
}
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+4
View File
@@ -18,6 +18,7 @@ const Meshtastic = (function() {
let meshMap = null; let meshMap = null;
let meshMarkers = {}; // nodeId -> marker let meshMarkers = {}; // nodeId -> marker
let localNodeId = null; let localNodeId = null;
let clickDelegationAttached = false;
/** /**
* Initialize the Meshtastic mode * Initialize the Meshtastic mode
@@ -33,6 +34,9 @@ const Meshtastic = (function() {
* Setup event delegation for dynamically created elements * Setup event delegation for dynamically created elements
*/ */
function setupEventDelegation() { function setupEventDelegation() {
if (clickDelegationAttached) return;
clickDelegationAttached = true;
// Handle button clicks in Leaflet popups and elsewhere // Handle button clicks in Leaflet popups and elsewhere
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn'); const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
+49 -10
View File
@@ -18,6 +18,11 @@ const SpaceWeather = (function () {
// Current image selections // Current image selections
let _solarImageKey = 'sdo_193'; let _solarImageKey = 'sdo_193';
let _drapFreq = 'drap_global'; let _drapFreq = 'drap_global';
const SOLAR_IMAGE_FALLBACKS = {
sdo_193: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
sdo_304: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
sdo_magnetogram: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
};
/** Stable cache-bust key that rotates every 5 minutes (matches backend max-age). */ /** Stable cache-bust key that rotates every 5 minutes (matches backend max-age). */
function _cacheBust() { function _cacheBust() {
@@ -54,11 +59,12 @@ const SpaceWeather = (function () {
const frame = document.getElementById('swSolarImageFrame'); const frame = document.getElementById('swSolarImageFrame');
if (frame) { if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>'; frame.innerHTML = '<div class="sw-loading">Loading</div>';
const img = new Image(); _loadImageWithFallback(
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); }; frame,
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; }; ['/space-weather/image/' + key + '?' + _cacheBust(), _directImageUrlForKey(key)],
img.src = '/space-weather/image/' + key + '?' + _cacheBust(); key,
img.alt = key; '<div class="sw-empty">NASA SDO image is temporarily unavailable</div>'
);
} }
} }
@@ -68,11 +74,12 @@ const SpaceWeather = (function () {
const frame = document.getElementById('swDrapImageFrame'); const frame = document.getElementById('swDrapImageFrame');
if (frame) { if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>'; frame.innerHTML = '<div class="sw-loading">Loading</div>';
const img = new Image(); _loadImageWithFallback(
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); }; frame,
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; }; ['/space-weather/image/' + key + '?' + _cacheBust()],
img.src = '/space-weather/image/' + key + '?' + _cacheBust(); key,
img.alt = key; '<div class="sw-empty">Failed to load image</div>'
);
} }
} }
@@ -98,6 +105,38 @@ const SpaceWeather = (function () {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
} }
function _directImageUrlForKey(key) {
const base = SOLAR_IMAGE_FALLBACKS[key];
if (!base) return null;
return base + '?' + _cacheBust();
}
function _loadImageWithFallback(frame, urls, alt, failureHtml) {
const candidates = (urls || []).filter(Boolean);
if (!frame || candidates.length === 0) {
if (frame) frame.innerHTML = failureHtml;
return;
}
let index = 0;
const img = new Image();
img.alt = alt;
img.referrerPolicy = 'no-referrer';
img.onload = function () {
frame.innerHTML = '';
frame.appendChild(img);
};
img.onerror = function () {
index += 1;
if (index < candidates.length) {
img.src = candidates[index];
return;
}
frame.innerHTML = failureHtml;
};
img.src = candidates[index];
}
function _fetchData() { function _fetchData() {
fetch('/space-weather/data') fetch('/space-weather/data')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
+6 -3
View File
@@ -18,6 +18,7 @@ const SSTV = (function() {
let countdownInterval = null; let countdownInterval = null;
let nextPassData = null; let nextPassData = null;
let pendingMapInvalidate = false; let pendingMapInvalidate = false;
let locationListenersAttached = false;
// ISS frequency // ISS frequency
const ISS_FREQ = 145.800; const ISS_FREQ = 145.800;
@@ -92,9 +93,11 @@ const SSTV = (function() {
if (latInput && storedLat) latInput.value = storedLat; if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon; if (lonInput && storedLon) lonInput.value = storedLon;
// Add change handlers to save and refresh if (!locationListenersAttached) {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs); if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
locationListenersAttached = true;
}
} }
/** /**
+211 -21
View File
@@ -1,10 +1,15 @@
/** /**
* Weather Satellite Mode * Weather Satellite Mode
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler, * Meteor LRPT decoder interface with auto-scheduler,
* polar plot, styled real-world map, countdown, and timeline. * polar plot, styled real-world map, countdown, and timeline.
*/ */
const WeatherSat = (function() { const WeatherSat = (function() {
const METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
};
// State // State
let isRunning = false; let isRunning = false;
let eventSource = null; let eventSource = null;
@@ -27,11 +32,28 @@ const WeatherSat = (function() {
let consoleAutoHideTimer = null; let consoleAutoHideTimer = null;
let currentModalFilename = null; let currentModalFilename = null;
let locationListenersAttached = false; let locationListenersAttached = false;
let initialized = false;
let imageRefreshInterval = null;
let lastDecodeJobSignature = null;
let lastDecodeSatellite = null;
/** /**
* Initialize the Weather Satellite mode * Initialize the Weather Satellite mode
*/ */
function init() { function init() {
if (initialized) {
checkStatus();
loadImages();
loadLocationInputs();
loadPasses();
startCountdownTimer();
checkSchedulerStatus();
initGroundMap();
loadLatestDecodeJob();
return;
}
initialized = true;
checkStatus(); checkStatus();
loadImages(); loadImages();
loadLocationInputs(); loadLocationInputs();
@@ -39,14 +61,8 @@ const WeatherSat = (function() {
startCountdownTimer(); startCountdownTimer();
checkSchedulerStatus(); checkSchedulerStatus();
initGroundMap(); initGroundMap();
ensureImageRefresh();
// Re-filter passes when satellite selection changes loadLatestDecodeJob();
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) {
satSelect.addEventListener('change', () => {
applyPassFilter();
});
}
} }
/** /**
@@ -132,7 +148,14 @@ const WeatherSat = (function() {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs); if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
const satSelect = document.getElementById('weatherSatSelect'); const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) satSelect.addEventListener('change', applyPassFilter); if (satSelect) {
satSelect.addEventListener('change', () => {
resetDecodeJobDisplay();
applyPassFilter();
loadImages();
loadLatestDecodeJob();
});
}
locationListenersAttached = true; locationListenersAttached = true;
} }
} }
@@ -302,6 +325,19 @@ const WeatherSat = (function() {
} }
} }
/**
* Pre-select a satellite without starting capture.
* Used by the satellite dashboard handoff so the user can review
* settings before hitting Start.
*/
function preSelect(satellite) {
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) {
satSelect.value = satellite;
satSelect.dispatchEvent(new Event('change'));
}
}
/** /**
* Start capture for a specific pass * Start capture for a specific pass
*/ */
@@ -309,6 +345,7 @@ const WeatherSat = (function() {
const satSelect = document.getElementById('weatherSatSelect'); const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) { if (satSelect) {
satSelect.value = satellite; satSelect.value = satellite;
satSelect.dispatchEvent(new Event('change'));
} }
start(); start();
} }
@@ -521,6 +558,7 @@ const WeatherSat = (function() {
updatePhaseIndicator('error'); updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
loadImages();
} }
} }
@@ -1534,7 +1572,12 @@ const WeatherSat = (function() {
*/ */
async function loadImages() { async function loadImages() {
try { try {
const response = await fetch('/weather-sat/images'); const satSelect = document.getElementById('weatherSatSelect');
const selectedSatellite = satSelect?.value || '';
const url = selectedSatellite
? `/weather-sat/images?satellite=${encodeURIComponent(selectedSatellite)}`
: '/weather-sat/images';
const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (data.status === 'ok') { if (data.status === 'ok') {
@@ -1599,6 +1642,14 @@ const WeatherSat = (function() {
html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`; html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
html += imgs.map(img => { html += imgs.map(img => {
const fn = escapeHtml(img.filename || img.url.split('/').pop()); const fn = escapeHtml(img.filename || img.url.split('/').pop());
const deleteButton = img.deletable === false ? '' : `
<div class="wxsat-image-actions">
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>`;
return ` return `
<div class="wxsat-image-card"> <div class="wxsat-image-card">
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')"> <div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')">
@@ -1609,13 +1660,7 @@ const WeatherSat = (function() {
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div> <div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div> </div>
</div> </div>
<div class="wxsat-image-actions"> ${deleteButton}
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -1707,9 +1752,14 @@ const WeatherSat = (function() {
*/ */
async function deleteAllImages() { async function deleteAllImages() {
if (images.length === 0) return; if (images.length === 0) return;
const deletableCount = images.filter(img => img.deletable !== false).length;
if (deletableCount === 0) {
showNotification('Weather Sat', 'Only shared ground-station imagery is available here');
return;
}
const confirmed = await AppFeedback.confirmAction({ const confirmed = await AppFeedback.confirmAction({
title: 'Delete All Images', title: 'Delete All Images',
message: `Delete all ${images.length} decoded images? This cannot be undone.`, message: `Delete all ${deletableCount} local decoded images? Shared ground-station outputs will be kept.`,
confirmLabel: 'Delete All', confirmLabel: 'Delete All',
confirmClass: 'btn-danger' confirmClass: 'btn-danger'
}); });
@@ -1720,8 +1770,8 @@ const WeatherSat = (function() {
const data = await response.json(); const data = await response.json();
if (data.status === 'ok') { if (data.status === 'ok') {
images = []; images = images.filter(img => img.deletable === false);
updateImageCount(0); updateImageCount(images.length);
renderGallery(); renderGallery();
showNotification('Weather Sat', `Deleted ${data.deleted} images`); showNotification('Weather Sat', `Deleted ${data.deleted} images`);
} else { } else {
@@ -1745,6 +1795,145 @@ const WeatherSat = (function() {
} }
} }
function ensureImageRefresh() {
if (imageRefreshInterval) return;
imageRefreshInterval = setInterval(() => {
const mode = document.getElementById('weatherSatMode');
if (!mode || !mode.classList.contains('active')) return;
loadImages();
loadLatestDecodeJob();
}, 30000);
}
function getSelectedMeteorNorad() {
const satSelect = document.getElementById('weatherSatSelect');
const satellite = satSelect?.value || '';
return METEOR_NORAD_IDS[satellite] || null;
}
async function loadLatestDecodeJob() {
const norad = getSelectedMeteorNorad();
if (!norad) return;
const satSelect = document.getElementById('weatherSatSelect');
const satellite = satSelect?.value || null;
if (satellite !== lastDecodeSatellite) {
lastDecodeSatellite = satellite;
lastDecodeJobSignature = null;
}
try {
const response = await fetch(`/ground_station/decode-jobs?norad_id=${encodeURIComponent(norad)}&backend=meteor_lrpt&limit=1`);
const jobs = await response.json();
if (!Array.isArray(jobs) || !jobs.length) {
resetDecodeJobDisplay();
return;
}
const job = jobs[0];
const details = job.details || {};
const signature = `${job.id}:${job.status}:${job.error_message || ''}`;
const captureStatus = document.getElementById('wxsatCaptureStatus');
const captureMsg = document.getElementById('wxsatCaptureMsg');
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
const summary = formatDecodeJobSummary(job, details);
if (!isRunning) {
if (job.status === 'queued') {
updateStatusUI('idle', 'Decode queued');
if (captureMsg) captureMsg.textContent = summary;
if (captureElapsed) captureElapsed.textContent = '--';
if (captureStatus) captureStatus.classList.add('active');
} else if (job.status === 'decoding') {
updateStatusUI('decoding', 'Ground-station decode running');
if (captureMsg) captureMsg.textContent = summary;
if (captureStatus) captureStatus.classList.add('active');
} else if (job.status === 'failed') {
updateStatusUI('idle', 'Last decode failed');
if (captureMsg) captureMsg.textContent = summary;
if (captureElapsed) captureElapsed.textContent = formatDecodeJobMeta(details);
if (captureStatus) captureStatus.classList.remove('active');
if (signature !== lastDecodeJobSignature) {
showConsole(true);
addConsoleEntry(summary, 'error');
const context = formatDecodeJobContext(details);
if (context) addConsoleEntry(context, 'warning');
}
} else if (job.status === 'complete') {
const count = details.output_count;
updateStatusUI('idle', count ? `Last decode: ${count} image${count === 1 ? '' : 's'}` : 'Last decode complete');
if (captureMsg) captureMsg.textContent = summary;
if (captureElapsed) captureElapsed.textContent = formatDecodeJobMeta(details);
if (captureStatus) captureStatus.classList.remove('active');
if (signature !== lastDecodeJobSignature) {
addConsoleEntry(
count ? `Ground-station decode complete: ${count} image${count === 1 ? '' : 's'} produced`
: 'Ground-station decode complete',
'signal'
);
}
}
}
lastDecodeJobSignature = signature;
} catch (err) {
console.error('Failed to load latest decode job:', err);
}
}
function resetDecodeJobDisplay() {
if (isRunning) return;
const captureStatus = document.getElementById('wxsatCaptureStatus');
const captureMsg = document.getElementById('wxsatCaptureMsg');
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
if (captureStatus) captureStatus.classList.remove('active');
if (captureMsg) captureMsg.textContent = '--';
if (captureElapsed) captureElapsed.textContent = '--';
updateStatusUI('idle', 'Idle');
}
function formatDecodeJobSummary(job, details) {
if (job.status === 'queued') return 'Ground-station decode queued';
if (job.status === 'decoding') return details.message || 'Ground-station decode in progress';
if (job.status === 'complete') {
const count = details.output_count;
return count ? `Ground-station decode complete: ${count} image${count === 1 ? '' : 's'} produced`
: 'Ground-station decode complete';
}
if (job.status === 'failed') {
const reasonLabels = {
sample_rate_too_low: 'Sample rate too low for Meteor LRPT',
invalid_sample_rate: 'Sample rate rejected by decoder',
recording_too_small: 'Recording too small for useful decode',
satdump_failed: 'SatDump decode failed',
permission_error: 'Decoder could not access recording/output path',
input_missing: 'Input recording was not accessible',
missing_recording: 'Recording was missing when decode started',
no_imagery_produced: 'Decode produced no imagery',
};
return job.error_message || reasonLabels[details.reason] || details.message || 'Last decode failed';
}
return details.message || 'Decode status unavailable';
}
function formatDecodeJobMeta(details) {
const parts = [];
if (details.sample_rate) parts.push(`${Number(details.sample_rate).toLocaleString()} Hz`);
if (details.file_size_human) parts.push(details.file_size_human);
return parts.join(' / ') || '--';
}
function formatDecodeJobContext(details) {
const parts = [];
if (details.reason) parts.push(`Reason: ${String(details.reason).replace(/_/g, ' ')}`);
if (details.sample_rate) parts.push(`Sample rate ${Number(details.sample_rate).toLocaleString()} Hz`);
if (details.file_size_human) parts.push(`Recording ${details.file_size_human}`);
if (details.last_returncode !== undefined && details.last_returncode !== null) {
parts.push(`Exit code ${details.last_returncode}`);
}
return parts.join(' | ');
}
/** /**
* Escape HTML * Escape HTML
*/ */
@@ -1910,6 +2099,7 @@ const WeatherSat = (function() {
destroy, destroy,
start, start,
stop, stop,
preSelect,
startPass, startPass,
selectPass, selectPass,
testDecode, testDecode,
+8 -117
View File
@@ -1,122 +1,13 @@
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */ /* INTERCEPT Service Worker disabled to avoid stale cached static assets. */
const CACHE_NAME = 'intercept-v3'; self.addEventListener('install', () => {
const NETWORK_ONLY_PREFIXES = [
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
'/recordings/', '/controller/', '/ops/',
];
const STATIC_PREFIXES = [
'/static/css/',
'/static/js/',
'/static/icons/',
'/static/fonts/',
];
const CACHE_EXACT = ['/manifest.json'];
function isHttpRequest(req) {
const url = new URL(req.url);
return url.protocol === 'http:' || url.protocol === 'https:';
}
function isNetworkOnly(req) {
if (req.method !== 'GET') return true;
const accept = req.headers.get('Accept') || '';
if (accept.includes('text/event-stream')) return true;
const url = new URL(req.url);
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
}
function isStaticAsset(req) {
const url = new URL(req.url);
if (CACHE_EXACT.includes(url.pathname)) return true;
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
}
function fallbackResponse(req, status = 503) {
const accept = req.headers.get('Accept') || '';
if (accept.includes('application/json')) {
return new Response(
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
{
status,
headers: { 'Content-Type': 'application/json' },
}
);
}
if (accept.includes('text/event-stream')) {
return new Response('', {
status,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response('Offline', {
status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
self.addEventListener('install', (e) => {
self.skipWaiting(); self.skipWaiting();
}); });
self.addEventListener('activate', (e) => { self.addEventListener('activate', (event) => {
e.waitUntil( event.waitUntil(
caches.keys().then(keys => caches.keys()
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) .then((keys) => Promise.all(keys.filter((key) => key.startsWith('intercept-')).map((key) => caches.delete(key))))
).then(() => self.clients.claim()) .then(() => self.registration.unregister())
); .then(() => self.clients.claim())
});
self.addEventListener('fetch', (e) => {
const req = e.request;
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
if (!isHttpRequest(req)) {
return;
}
// Always bypass service worker for non-GET and streaming routes
if (isNetworkOnly(req)) {
e.respondWith(
fetch(req).catch(() => fallbackResponse(req, 503))
);
return;
}
// Cache-first for static assets
if (isStaticAsset(req)) {
e.respondWith(
caches.open(CACHE_NAME).then(cache =>
cache.match(req).then(cached => {
if (cached) {
// Revalidate in background
fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
}).catch(() => {});
return cached;
}
return fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
return res;
}).catch(() => fallbackResponse(req, 504));
})
)
);
return;
}
// Network-first for HTML pages
e.respondWith(
fetch(req).catch(() =>
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
)
); );
}); });
+344 -173
View File
@@ -4,27 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title> <title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
<!-- Preconnect hints --> <!-- Dedicated dashboards always use bundled assets so navigation is not
{% if offline_settings.assets_source != 'local' %} blocked by external CDN reachability. -->
<link rel="preconnect" href="https://unpkg.com" crossorigin>
{% endif %}
{% if offline_settings.fonts_source != 'local' %}
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet CSS -->
{% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
{% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endif %}
<!-- Core CSS --> <!-- Core CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
@@ -36,15 +19,13 @@
<script> <script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }}; window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }}; window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
</script> </script>
{% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script> <script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script> <script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body data-mode="adsb">
<div class="radar-bg"></div> <div class="radar-bg"></div>
<div class="scanline"></div> <div class="scanline"></div>
@@ -328,9 +309,9 @@
</select> </select>
<button class="watchlist-btn" onclick="showWatchlistModal()" title="Manage Watchlist"></button> <button class="watchlist-btn" onclick="showWatchlistModal()" title="Manage Watchlist"></button>
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance"> <select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
<option value="50">50nm</option> <option value="50" selected>50nm</option>
<option value="100">100nm</option> <option value="100">100nm</option>
<option value="200" selected>200nm</option> <option value="200">200nm</option>
<option value="300">300nm</option> <option value="300">300nm</option>
</select> </select>
</div> </div>
@@ -340,8 +321,8 @@
<div class="control-group"> <div class="control-group">
<span class="control-group-label">LOCATION</span> <span class="control-group-label">LOCATION</span>
<div class="control-group-items"> <div class="control-group-items">
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat"> <input type="text" id="obsLat" value="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon"> <input type="text" id="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span> <span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
</div> </div>
</div> </div>
@@ -441,6 +422,7 @@
let eventSource = null; let eventSource = null;
let agentPollTimer = null; // Polling fallback for agent mode let agentPollTimer = null; // Polling fallback for agent mode
let isTracking = false; let isTracking = false;
let isTrackingStarting = false;
let currentFilter = 'all'; let currentFilter = 'all';
// ICAO -> { emergency: bool, watchlist: bool, military: bool } // ICAO -> { emergency: bool, watchlist: bool, military: bool }
let alertedAircraft = {}; let alertedAircraft = {};
@@ -458,6 +440,13 @@
let panelSelectionFallbackTimer = null; let panelSelectionFallbackTimer = null;
let panelSelectionStageTimer = null; let panelSelectionStageTimer = null;
let mapCrosshairRequestId = 0; let mapCrosshairRequestId = 0;
let detectedDevicesPromise = null;
let deviceDetectionRetryTimer = null;
let clockInterval = null;
let cleanupInterval = null;
let delayedGpsInitTimer = null;
let delayedDriverCheckTimer = null;
let delayedAircraftDbTimer = null;
// Watchlist - persisted to localStorage // Watchlist - persisted to localStorage
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]'); let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
@@ -467,7 +456,7 @@
let showTrails = true; let showTrails = true;
const MAX_TRAIL_POINTS = 100; const MAX_TRAIL_POINTS = 100;
let maxRange = 200; // nautical miles let maxRange = 50; // nautical miles
// Statistics // Statistics
let stats = { let stats = {
@@ -643,7 +632,9 @@
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed; if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
} catch (e) {} } catch (e) {}
} }
return { lat: 51.5074, lon: -0.1278 }; const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
return { lat: defaultLat, lon: defaultLon };
})(); })();
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarker = null; let observerMarker = null;
@@ -1602,7 +1593,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
// ============================================ // ============================================
async function autoConnectGps() { async function autoConnectGps() {
try { try {
const response = await fetch('/gps/auto-connect', { method: 'POST' }); const response = await fetchJsonWithTimeout('/gps/auto-connect', { method: 'POST' }, 2000);
const data = await response.json(); const data = await response.json();
if (data.status === 'connected') { if (data.status === 'connected') {
@@ -1733,98 +1724,227 @@ ACARS: ${r.statistics.acarsMessages} messages`;
window.addEventListener('pagehide', function() { window.addEventListener('pagehide', function() {
if (eventSource) { eventSource.close(); eventSource = null; } if (eventSource) { eventSource.close(); eventSource = null; }
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; } if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
if (acarsEventSource) { acarsEventSource.close(); acarsEventSource = null; }
if (vdl2EventSource) { vdl2EventSource.close(); vdl2EventSource = null; }
if (allAgentsEventSource) { allAgentsEventSource.close(); allAgentsEventSource = null; }
if (agentPollTimer) { clearInterval(agentPollTimer); agentPollTimer = null; }
if (acarsPollTimer) { clearInterval(acarsPollTimer); acarsPollTimer = null; }
if (vdl2PollTimer) { clearInterval(vdl2PollTimer); vdl2PollTimer = null; }
if (clockInterval) { clearInterval(clockInterval); clockInterval = null; }
if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; }
if (delayedGpsInitTimer) { clearTimeout(delayedGpsInitTimer); delayedGpsInitTimer = null; }
if (delayedDriverCheckTimer) { clearTimeout(delayedDriverCheckTimer); delayedDriverCheckTimer = null; }
if (delayedAircraftDbTimer) { clearTimeout(delayedAircraftDbTimer); delayedAircraftDbTimer = null; }
if (deviceDetectionRetryTimer) { clearTimeout(deviceDetectionRetryTimer); deviceDetectionRetryTimer = null; }
}); });
function ensureAdsbMapBootstrapped() {
if (radarMap) return;
try {
initMap();
} catch (e) {
console.error('ADS-B map bootstrap failed:', e);
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize observer location input fields from saved location // Bring the map up first so a later startup error cannot leave the
const obsLatInput = document.getElementById('obsLat'); // dashboard in a half-rendered "shell only" state.
const obsLonInput = document.getElementById('obsLon'); ensureAdsbMapBootstrapped();
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Initialize detection sound toggle from localStorage try {
const detectionToggle = document.getElementById('detectionSoundToggle'); // Initialize observer location input fields from saved location
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled; const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Load Bias-T setting from localStorage // Initialize detection sound toggle from localStorage
loadAdsbBiasTSetting(); const detectionToggle = document.getElementById('detectionSoundToggle');
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
} catch (e) {
console.error('ADS-B UI bootstrap warning:', e);
}
initMap(); try {
initDeviceSelectors(); loadAdsbBiasTSetting();
updateClock(); } catch (e) {
setInterval(updateClock, 1000); console.error('ADS-B Bias-T bootstrap warning:', e);
setInterval(cleanupOldAircraft, 10000); }
checkAdsbTools();
checkAircraftDatabase();
checkDvbDriverConflict();
// Auto-connect to gpsd if available showDeviceDetectionPendingState();
autoConnectGps(); initDeviceSelectors()
.then((devices) => checkAdsbTools(devices))
.catch((e) => {
console.error('ADS-B device selector bootstrap warning:', e);
checkAdsbTools([]);
});
// Sync tracking state if ADS-B already running deviceDetectionRetryTimer = setTimeout(() => {
syncTrackingStatus(); deviceDetectionRetryTimer = null;
const adsbSelect = document.getElementById('adsbDeviceSelect');
const emptyText = adsbSelect?.options?.[0]?.textContent || '';
const stillWaitingForDevices = adsbSelect && adsbSelect.options.length === 1
&& /No SDR|Detecting SDR/i.test(emptyText);
if (!stillWaitingForDevices) return;
initDeviceSelectors(true, 20000)
.then((devices) => checkAdsbTools(devices))
.catch((e) => {
console.error('ADS-B device selector retry warning:', e);
});
}, 6000);
try {
updateClock();
clockInterval = setInterval(updateClock, 1000);
cleanupInterval = setInterval(cleanupOldAircraft, 10000);
} catch (e) {
console.error('ADS-B timer bootstrap warning:', e);
}
// Defer nonessential startup probes so the page can paint and
// return navigation remains snappy if the user leaves quickly.
delayedAircraftDbTimer = setTimeout(() => {
delayedAircraftDbTimer = null;
checkAircraftDatabase();
}, 1200);
delayedDriverCheckTimer = setTimeout(() => {
delayedDriverCheckTimer = null;
checkDvbDriverConflict();
}, 1800);
delayedGpsInitTimer = setTimeout(() => {
delayedGpsInitTimer = null;
autoConnectGps();
}, 2500);
syncTrackingStatus().catch((e) => {
console.error('ADS-B tracking status bootstrap warning:', e);
});
});
window.addEventListener('load', () => {
if (!radarMap) {
console.warn('ADS-B map was not initialized during DOMContentLoaded, retrying on window load');
ensureAdsbMapBootstrapped();
}
}); });
// Track which device is being used for ADS-B tracking // Track which device is being used for ADS-B tracking
let adsbActiveDevice = null; let adsbActiveDevice = null;
function initDeviceSelectors() { function fetchJsonWithTimeout(url, options = {}, timeoutMs = 4000) {
// Populate both ADS-B and airband device selectors const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
fetch('/devices') const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
.then(r => r.json()) return fetch(url, {
.then(devices => { ...options,
const adsbSelect = document.getElementById('adsbDeviceSelect'); ...(controller ? { signal: controller.signal } : {})
const airbandSelect = document.getElementById('airbandDeviceSelect'); }).finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});
}
// Clear loading state function populateCompositeDeviceSelect(select, devices, emptyLabel = 'No SDR detected') {
adsbSelect.innerHTML = ''; if (!select) return;
airbandSelect.innerHTML = ''; select.innerHTML = '';
if (!devices || devices.length === 0) {
select.innerHTML = `<option value="rtlsdr:0">${emptyLabel}</option>`;
return;
}
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
const sdrType = dev.sdr_type || 'rtlsdr';
const option = document.createElement('option');
option.value = `${sdrType}:${idx}`;
option.dataset.sdrType = sdrType;
option.dataset.index = idx;
option.textContent = `SDR ${idx}: ${dev.name || dev.type || 'SDR'}`;
select.appendChild(option);
});
}
function showDeviceDetectionPendingState() {
populateCompositeDeviceSelect(document.getElementById('adsbDeviceSelect'), [], 'Detecting SDRs...');
populateCompositeDeviceSelect(document.getElementById('airbandDeviceSelect'), [], 'Detecting SDRs...');
populateCompositeDeviceSelect(document.getElementById('acarsDeviceSelect'), [], 'Detecting SDRs...');
populateCompositeDeviceSelect(document.getElementById('vdl2DeviceSelect'), [], 'Detecting SDRs...');
}
function getDetectedDevices(force = false, timeoutMs = 12000) {
if (force) {
detectedDevicesPromise = null;
}
if (!force && detectedDevicesPromise) {
return detectedDevicesPromise;
}
detectedDevicesPromise = fetchJsonWithTimeout('/devices', {}, timeoutMs)
.then((r) => r.ok ? r.json() : [])
.then((devices) => {
if (!Array.isArray(devices)) {
detectedDevicesPromise = null;
return [];
}
if (devices.length === 0) { if (devices.length === 0) {
adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>'; detectedDevicesPromise = null;
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
airbandSelect.disabled = true;
} else {
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
const sdrType = dev.sdr_type || 'rtlsdr';
const compositeVal = `${sdrType}:${idx}`;
const displayName = `SDR ${idx}: ${dev.name}`;
// Add to ADS-B selector
const adsbOpt = document.createElement('option');
adsbOpt.value = compositeVal;
adsbOpt.dataset.sdrType = sdrType;
adsbOpt.dataset.index = idx;
adsbOpt.textContent = displayName;
adsbSelect.appendChild(adsbOpt);
// Add to Airband selector
const airbandOpt = document.createElement('option');
airbandOpt.value = compositeVal;
airbandOpt.dataset.sdrType = sdrType;
airbandOpt.dataset.index = idx;
airbandOpt.textContent = displayName;
airbandSelect.appendChild(airbandOpt);
});
// Default: ADS-B uses first device, Airband uses second (if available)
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
if (devices.length > 1) {
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
}
// Show warning if only one device
if (devices.length === 1) {
document.getElementById('airbandStatus').textContent = '1 SDR only';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
}
} }
return devices;
}) })
.catch(() => { .catch((err) => {
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>'; console.warn('[ADS-B] Device detection failed:', err?.message || err);
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>'; detectedDevicesPromise = null;
return [];
}); });
return detectedDevicesPromise;
}
function initDeviceSelectors(force = false, timeoutMs = 12000) {
return getDetectedDevices(force, timeoutMs).then((devices) => {
const adsbSelect = document.getElementById('adsbDeviceSelect');
const airbandSelect = document.getElementById('airbandDeviceSelect');
const acarsSelect = document.getElementById('acarsDeviceSelect');
const vdl2Select = document.getElementById('vdl2DeviceSelect');
populateCompositeDeviceSelect(adsbSelect, devices, 'No SDR found');
populateCompositeDeviceSelect(airbandSelect, devices, 'No SDR found');
populateCompositeDeviceSelect(acarsSelect, devices);
populateCompositeDeviceSelect(vdl2Select, devices);
if (!devices || devices.length === 0) {
if (airbandSelect) airbandSelect.disabled = true;
return devices;
}
if (airbandSelect) {
airbandSelect.disabled = false;
}
if (adsbSelect) {
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
}
if (airbandSelect && devices.length > 1) {
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
}
if (devices.length === 1) {
document.getElementById('airbandStatus').textContent = '1 SDR only';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
}
return devices;
}).catch(() => {
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
return [];
});
} }
function checkDvbDriverConflict() { function checkDvbDriverConflict() {
@@ -1928,12 +2048,15 @@ ACARS: ${r.statistics.acarsMessages} messages`;
if (warning) warning.remove(); if (warning) warning.remove();
} }
function checkAdsbTools() { function checkAdsbTools(devices = []) {
fetch('/adsb/tools') fetchJsonWithTimeout('/adsb/tools', {}, 3000)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.needs_readsb) { const soapyTypes = (devices || [])
showReadsbWarning(data.soapy_types); .filter((d) => ['hackrf', 'limesdr', 'airspy'].includes((d.sdr_type || '').toLowerCase()))
.map((d) => d.sdr_type);
if (!data.readsb && soapyTypes.length > 0) {
showReadsbWarning(soapyTypes);
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -1945,7 +2068,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
let aircraftDbStatus = { installed: false }; let aircraftDbStatus = { installed: false };
function checkAircraftDatabase() { function checkAircraftDatabase() {
fetch('/adsb/aircraft-db/status') fetchJsonWithTimeout('/adsb/aircraft-db/status', {}, 2000)
.then(r => r.json()) .then(r => r.json())
.then(status => { .then(status => {
aircraftDbStatus = status; aircraftDbStatus = status;
@@ -1953,7 +2076,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
showAircraftDbBanner('not_installed'); showAircraftDbBanner('not_installed');
} else { } else {
// Check for updates in background // Check for updates in background
fetch('/adsb/aircraft-db/check-updates') fetchJsonWithTimeout('/adsb/aircraft-db/check-updates', {}, 2000)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.update_available) { if (data.update_available) {
@@ -2096,6 +2219,77 @@ sudo make install</code>
now.toISOString().substring(11, 19) + ' UTC'; now.toISOString().substring(11, 19) + ' UTC';
} }
function createFallbackGridLayer() {
const layer = L.gridLayer({
tileSize: 256,
updateWhenIdle: true,
attribution: 'Local fallback grid'
});
layer.createTile = function(coords) {
const tile = document.createElement('canvas');
tile.width = 256;
tile.height = 256;
const ctx = tile.getContext('2d');
ctx.fillStyle = '#08121c';
ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(256, 0);
ctx.moveTo(0, 0);
ctx.lineTo(0, 256);
ctx.stroke();
ctx.strokeStyle = 'rgba(0, 212, 255, 0.08)';
ctx.beginPath();
ctx.moveTo(128, 0);
ctx.lineTo(128, 256);
ctx.moveTo(0, 128);
ctx.lineTo(256, 128);
ctx.stroke();
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
return tile;
};
return layer;
}
async function upgradeRadarTilesFromSettings(fallbackTiles) {
if (typeof Settings === 'undefined') return;
try {
await Settings.init();
if (!radarMap) return;
const configuredLayer = Settings.createTileLayer();
let tileLoaded = false;
configuredLayer.once('load', () => {
tileLoaded = true;
if (radarMap && fallbackTiles && radarMap.hasLayer(fallbackTiles)) {
radarMap.removeLayer(fallbackTiles);
}
});
configuredLayer.on('tileerror', () => {
if (!tileLoaded) {
console.warn('ADS-B tile layer failed to load, keeping fallback grid');
}
});
configuredLayer.addTo(radarMap);
Settings.registerMap(radarMap);
} catch (e) {
console.warn('ADS-B: Settings/tile upgrade failed, using fallback grid:', e);
}
}
async function initMap() { async function initMap() {
// Guard against double initialization (e.g. bfcache restore) // Guard against double initialization (e.g. bfcache restore)
const container = document.getElementById('radarMap'); const container = document.getElementById('radarMap');
@@ -2111,13 +2305,9 @@ sudo make install</code>
// Use settings manager for tile layer (allows runtime changes) // Use settings manager for tile layer (allows runtime changes)
window.radarMap = radarMap; window.radarMap = radarMap;
// Add fallback tiles immediately so the map is never blank // Use a zero-network fallback so dashboard navigation stays fast even
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { // when internet map providers are slow or unreachable.
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', const fallbackTiles = createFallbackGridLayer().addTo(radarMap);
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(radarMap);
// Draw range rings after map is ready // Draw range rings after map is ready
setTimeout(() => drawRangeRings(), 100); setTimeout(() => drawRangeRings(), 100);
@@ -2132,20 +2322,9 @@ sudo make install</code>
if (radarMap) radarMap.invalidateSize(); if (radarMap) radarMap.invalidateSize();
}, 500); }, 500);
// Upgrade tiles via Settings in the background (non-blocking) // Upgrade tiles via Settings in the background without tearing down
if (typeof Settings !== 'undefined') { // the local fallback grid until a real tile layer actually loads.
try { upgradeRadarTilesFromSettings(fallbackTiles);
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
radarMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(radarMap);
Settings.registerMap(radarMap);
} catch (e) {
console.warn('Settings init failed/timed out, using fallback tiles:', e);
}
}
} }
// Handle window resize for map (especially important on mobile) // Handle window resize for map (especially important on mobile)
@@ -2189,6 +2368,10 @@ sudo make install</code>
const btn = document.getElementById('startBtn'); const btn = document.getElementById('startBtn');
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local'; const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
if (isTrackingStarting) {
return;
}
if (!isTracking) { if (!isTracking) {
// Check for remote dump1090 config (only for local mode) // Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null; const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
@@ -2206,7 +2389,8 @@ sudo make install</code>
const adsbDevice = parseInt(adsbDeviceIdx) || 0; const adsbDevice = parseInt(adsbDeviceIdx) || 0;
// Pre-flight: check if another mode is using this device and auto-stop it // Pre-flight: check if another mode is using this device and auto-stop it
if (!useAgent) { // Skip when using a remote SBS feed — no local SDR is needed
if (!useAgent && !remoteConfig) {
try { try {
const devResp = await fetch('/devices/status'); const devResp = await fetch('/devices/status');
if (devResp.ok) { if (devResp.ok) {
@@ -2266,6 +2450,10 @@ sudo make install</code>
requestBody.remote_sbs_host = remoteConfig.host; requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port; requestBody.remote_sbs_port = remoteConfig.port;
} }
isTrackingStarting = true;
btn.disabled = true;
btn.textContent = 'STARTING...';
updateTrackingStatusDisplay();
try { try {
// Route through agent proxy if using remote agent // Route through agent proxy if using remote agent
const url = useAgent const url = useAgent
@@ -2292,10 +2480,12 @@ sudo make install</code>
drawRangeRings(); drawRangeRings();
startSessionTimer(); startSessionTimer();
isTracking = true; isTracking = true;
isTrackingStarting = false;
adsbActiveDevice = adsbDevice; // Track which device is being used adsbActiveDevice = adsbDevice; // Track which device is being used
adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking
btn.textContent = 'STOP'; btn.textContent = 'STOP';
btn.classList.add('active'); btn.classList.add('active');
btn.disabled = false;
document.getElementById('trackingDot').classList.remove('inactive'); document.getElementById('trackingDot').classList.remove('inactive');
updateTrackingStatusDisplay(); updateTrackingStatusDisplay();
// Disable ADS-B device selector while tracking // Disable ADS-B device selector while tracking
@@ -2315,6 +2505,14 @@ sudo make install</code>
} }
} catch (err) { } catch (err) {
alert('Error: ' + err.message); alert('Error: ' + err.message);
} finally {
if (!isTracking) {
isTrackingStarting = false;
btn.disabled = false;
btn.textContent = 'START';
btn.classList.remove('active');
updateTrackingStatusDisplay();
}
} }
} else { } else {
try { try {
@@ -4277,26 +4475,9 @@ sudo make install</code>
// Populate ACARS device selector // Populate ACARS device selector
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
fetch('/devices') getDetectedDevices().then((devices) => {
.then(r => r.json()) populateCompositeDeviceSelect(document.getElementById('acarsDeviceSelect'), devices);
.then(devices => { });
const select = document.getElementById('acarsDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
const sdrType = d.sdr_type || 'rtlsdr';
const idx = d.index !== undefined ? d.index : i;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
}
});
}); });
// ============================================ // ============================================
@@ -4826,26 +5007,9 @@ sudo make install</code>
// Populate VDL2 device selector and check running status // Populate VDL2 device selector and check running status
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
fetch('/devices') getDetectedDevices().then((devices) => {
.then(r => r.json()) populateCompositeDeviceSelect(document.getElementById('vdl2DeviceSelect'), devices);
.then(devices => { });
const select = document.getElementById('vdl2DeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
const sdrType = d.sdr_type || 'rtlsdr';
const idx = d.index !== undefined ? d.index : i;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
}
});
// Check if VDL2 is already running (e.g. after page reload) // Check if VDL2 is already running (e.g. after page reload)
fetch('/vdl2/status') fetch('/vdl2/status')
@@ -5553,10 +5717,14 @@ sudo make install</code>
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script> <script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script> <script>
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init(); if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
}); });
</script> </script>
@@ -5594,7 +5762,10 @@ sudo make install</code>
const statusEl = document.getElementById('trackingStatus'); const statusEl = document.getElementById('trackingStatus');
if (!statusEl) return; if (!statusEl) return;
if (!isTracking) { if (isTrackingStarting && !isTracking) {
statusEl.textContent = 'INITIALIZING';
statusEl.title = 'Starting ADS-B receiver';
} else if (!isTracking) {
statusEl.textContent = 'STANDBY'; statusEl.textContent = 'STANDBY';
statusEl.title = 'Select source and click START'; statusEl.title = 'Select source and click START';
} else { } else {
+67 -34
View File
@@ -4,27 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title> <title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
<!-- Preconnect hints --> <!-- Dedicated dashboards always use bundled assets so navigation is not
{% if offline_settings.assets_source != 'local' %} blocked by external CDN reachability. -->
<link rel="preconnect" href="https://unpkg.com" crossorigin>
{% endif %}
{% if offline_settings.fonts_source != 'local' %}
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet CSS -->
{% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
{% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endif %}
<!-- Core CSS --> <!-- Core CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
@@ -35,15 +18,13 @@
<!-- Deferred scripts --> <!-- Deferred scripts -->
<script> <script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }}; window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
</script> </script>
{% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script> <script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script> <script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body data-mode="ais">
<!-- Radar background effects --> <!-- Radar background effects -->
<div class="radar-bg"></div> <div class="radar-bg"></div>
<div class="scanline"></div> <div class="scanline"></div>
@@ -185,8 +166,8 @@
<div class="control-group"> <div class="control-group">
<span class="control-group-label">LOCATION</span> <span class="control-group-label">LOCATION</span>
<div class="control-group-items"> <div class="control-group-items">
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat"> <input type="text" id="obsLat" value="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon"> <input type="text" id="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
</div> </div>
</div> </div>
@@ -248,7 +229,9 @@
if (window.ObserverLocation && ObserverLocation.getForModule) { if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('ais_observerLocation'); return ObserverLocation.getForModule('ais_observerLocation');
} }
return { lat: 51.5074, lon: -0.1278 }; const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
return { lat: defaultLat, lon: defaultLon };
})(); })();
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarker = null; let observerMarker = null;
@@ -405,6 +388,47 @@
}; };
// Initialize map // Initialize map
function createFallbackGridLayer() {
const layer = L.gridLayer({
tileSize: 256,
updateWhenIdle: true,
attribution: 'Local fallback grid'
});
layer.createTile = function(coords) {
const tile = document.createElement('canvas');
tile.width = 256;
tile.height = 256;
const ctx = tile.getContext('2d');
ctx.fillStyle = '#07131c';
ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(256, 0);
ctx.moveTo(0, 0);
ctx.lineTo(0, 256);
ctx.stroke();
ctx.strokeStyle = 'rgba(34, 197, 94, 0.10)';
ctx.beginPath();
ctx.moveTo(128, 0);
ctx.lineTo(128, 256);
ctx.moveTo(0, 128);
ctx.lineTo(256, 128);
ctx.stroke();
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
return tile;
};
return layer;
}
async function initMap() { async function initMap() {
// Guard against double initialization (e.g. bfcache restore) // Guard against double initialization (e.g. bfcache restore)
const container = document.getElementById('vesselMap'); const container = document.getElementById('vesselMap');
@@ -424,13 +448,9 @@
// Use settings manager for tile layer (allows runtime changes) // Use settings manager for tile layer (allows runtime changes)
window.vesselMap = vesselMap; window.vesselMap = vesselMap;
// Add fallback tile layer immediately so the map is never blank // Use a zero-network fallback so dashboard navigation stays fast even
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { // when internet map providers are slow or unreachable.
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', const fallbackTiles = createFallbackGridLayer().addTo(vesselMap);
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(vesselMap);
// Then try to upgrade tiles via Settings (non-blocking) // Then try to upgrade tiles via Settings (non-blocking)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
@@ -1612,7 +1632,20 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') {
VoiceAlerts.init({ startStreams: false });
VoiceAlerts.scheduleStreamStart(20000);
}
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
<!-- Agent Manager --> <!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script> <script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
+339 -116
View File
@@ -20,7 +20,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %} {% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- Disclaimer gate - must accept before seeing welcome page --> <!-- Disclaimer gate - must accept before seeing welcome page -->
<script> <script>
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page // Check BEFORE page renders - if disclaimer not accepted, hide welcome page
@@ -162,28 +161,9 @@
if (!mode) return; if (!mode) return;
window.ensureModeStyles(mode).catch(() => {}); window.ensureModeStyles(mode).catch(() => {});
})(); })();
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC. // Do not warm every mode stylesheet on the welcome page. The eager
(function warmModeStylesInBackground() { // background fetch storm was adding substantial cross-mode load and
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {}; // delaying dedicated dashboards like ADS-B.
const queryMode = new URLSearchParams(window.location.search).get('mode');
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
if (!modes.length) return;
const warm = function () {
modes.forEach(function (mode, index) {
setTimeout(function () {
window.ensureModeStyles(mode).catch(() => {});
}, index * 40);
});
};
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(warm, { timeout: 2000 });
} else {
setTimeout(warm, 600);
}
})();
</script> </script>
<script> <script>
window.INTERCEPT_MODE_SCRIPT_MAP = { window.INTERCEPT_MODE_SCRIPT_MAP = {
@@ -394,10 +374,10 @@
<div class="mode-category"> <div class="mode-category">
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3> <h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3>
<div class="mode-grid mode-grid-compact"> <div class="mode-grid mode-grid-compact">
<button class="mode-card mode-card-sm" onclick="selectMode('satellite')"> <a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mode-card mode-card-sm" style="text-decoration: none;">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
<span class="mode-name">Satellite</span> <span class="mode-name">Satellite</span>
</button> </a>
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')"> <button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
<span class="mode-name">ISS SSTV</span> <span class="mode-name">ISS SSTV</span>
@@ -1416,8 +1396,9 @@
<!-- Satellite Dashboard (Embedded) --> <!-- Satellite Dashboard (Embedded) -->
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;"> <div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0" <iframe id="satelliteDashboardFrame" data-src="/satellite/dashboard?embedded=true&v={{ version }}" frameborder="0"
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;" style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
loading="lazy"
allowfullscreen> allowfullscreen>
</iframe> </iframe>
</div> </div>
@@ -3604,6 +3585,10 @@
// Mode selection from welcome page // Mode selection from welcome page
function selectMode(mode) { function selectMode(mode) {
if (mode === 'satellite') {
window.open('/satellite/dashboard', '_blank', 'noopener');
return;
}
selectedStartMode = mode; selectedStartMode = mode;
const welcome = document.getElementById('welcomePage'); const welcome = document.getElementById('welcomePage');
welcome.classList.add('fade-out'); welcome.classList.add('fade-out');
@@ -3664,6 +3649,10 @@
function applyModeFromQuery() { function applyModeFromQuery() {
const mode = getModeFromQuery(); const mode = getModeFromQuery();
if (!mode) return; if (!mode) return;
if (mode === 'satellite') {
window.location.replace('/satellite/dashboard');
return;
}
const accepted = localStorage.getItem('disclaimerAccepted') === 'true'; const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
if (accepted) { if (accepted) {
const welcome = document.getElementById('welcomePage'); const welcome = document.getElementById('welcomePage');
@@ -3890,7 +3879,11 @@
if (saved) { if (saved) {
try { try {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed; const lat = Number(parsed.lat);
const lon = Number(parsed.lon);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
return { lat, lon };
}
} catch (e) { } } catch (e) { }
} }
return { lat: 51.5074, lon: -0.1278 }; return { lat: 51.5074, lon: -0.1278 };
@@ -3899,6 +3892,8 @@
// GPS Dongle state // GPS Dongle state
let gpsConnected = false; let gpsConnected = false;
let gpsEventSource = null; let gpsEventSource = null;
let gpsAutoConnectTimer = null;
let gpsAutoConnectInFlight = null;
let gpsLastPosition = null; let gpsLastPosition = null;
// Satellite state // Satellite state
@@ -4097,8 +4092,8 @@
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4); if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4); if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Auto-connect to gpsd if available // Defer GPS auto-connect so it doesn't compete with initial dashboard navigation.
autoConnectGps(); scheduleGpsAutoConnect();
// Load pager message filters // Load pager message filters
loadPagerFilters(); loadPagerFilters();
@@ -4206,7 +4201,14 @@
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } }, acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } }, vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } }, radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
aprs: () => { if (aprsEventSource) { aprsEventSource.close(); aprsEventSource = null; } }, aprs: () => {
if (typeof destroyAprsMode === 'function') {
destroyAprsMode();
} else if (aprsEventSource) {
aprsEventSource.close();
aprsEventSource = null;
}
},
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } }, tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(), meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(), ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
@@ -4331,8 +4333,10 @@
activeScans: getActiveScanSummary(), activeScans: getActiveScanSummary(),
}); });
} }
// Let dedicated dashboards navigate immediately.
// Pre-navigation stop requests from active modes like Pager
// can stall same-tab navigation badly on some browsers.
destroyCurrentMode(); destroyCurrentMode();
stopActiveLocalScansForNavigation();
} catch (_) { } catch (_) {
// Ignore malformed hrefs. // Ignore malformed hrefs.
} }
@@ -4382,12 +4386,19 @@
} }
} }
let modeSwitchRequestId = 0;
// Mode switching // Mode switching
async function switchMode(mode, options = {}) { async function switchMode(mode, options = {}) {
const requestId = ++modeSwitchRequestId;
const { updateUrl = true } = options; const { updateUrl = true } = options;
const switchStartMs = performance.now(); const switchStartMs = performance.now();
const previousMode = currentMode; const previousMode = currentMode;
if (mode === 'listening') mode = 'waterfall'; if (mode === 'listening') mode = 'waterfall';
if (mode === 'satellite') {
window.open('/satellite/dashboard', '_blank', 'noopener');
return;
}
if (!validModes.has(mode)) mode = 'pager'; if (!validModes.has(mode)) mode = 'pager';
const styleReadyPromise = (typeof window.ensureModeStyles === 'function') const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => { ? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
@@ -4453,6 +4464,7 @@
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs); const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
await styleReadyPromise; await styleReadyPromise;
await scriptReadyPromise; await scriptReadyPromise;
if (requestId !== modeSwitchRequestId) return;
// Generic module cleanup — destroy previous mode's timers, SSE, etc. // Generic module cleanup — destroy previous mode's timers, SSE, etc.
if (previousMode && previousMode !== mode) { if (previousMode && previousMode !== mode) {
@@ -4461,6 +4473,7 @@
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); } try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
} }
} }
if (requestId !== modeSwitchRequestId) return;
currentMode = mode; currentMode = mode;
document.body.setAttribute('data-mode', mode); document.body.setAttribute('data-mode', mode);
@@ -4476,6 +4489,7 @@
// Sync with local status // Sync with local status
syncLocalModeStates(); syncLocalModeStates();
} }
if (requestId !== modeSwitchRequestId) return;
// Close dropdowns and update active state // Close dropdowns and update active state
closeAllDropdowns(); closeAllDropdowns();
@@ -4564,9 +4578,27 @@
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth'); if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none'; if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
const satFrame = document.getElementById('satelliteDashboardFrame'); const satFrame = document.getElementById('satelliteDashboardFrame');
if (satFrame && satFrame.contentWindow) { if (satFrame && mode === 'satellite') {
const baseSrc = satFrame.dataset.src || '/satellite/dashboard?embedded=true&v={{ version }}';
const currentSrc = satFrame.getAttribute('src') || '';
if (!currentSrc || currentSrc === 'about:blank') {
satFrame.src = `${baseSrc}&ts=${Date.now()}`;
}
} else if (satFrame) {
const currentSrc = satFrame.getAttribute('src') || '';
if (currentSrc && currentSrc !== 'about:blank') {
satFrame.src = 'about:blank';
}
}
if (satFrame && satFrame.contentWindow && satFrame.getAttribute('src') && satFrame.getAttribute('src') !== 'about:blank') {
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*'); satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
} }
// Weather-sat handoff: when switching away from satellite mode, clear any pending handoff banner
if (mode !== 'satellite' && mode !== 'weathersat') {
const existing = document.getElementById('weatherSatHandoffBanner');
if (existing) existing.remove();
}
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none'; if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none'; if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none'; if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
@@ -4789,6 +4821,7 @@
} else if (mode === 'ook') { } else if (mode === 'ook') {
OokMode.init(); OokMode.init();
} }
if (requestId !== modeSwitchRequestId) return;
// Waterfall destroy is now handled by moduleDestroyMap above. // Waterfall destroy is now handled by moduleDestroyMap above.
@@ -9847,10 +9880,39 @@
let aprsStationCount = 0; let aprsStationCount = 0;
let aprsMeterLastUpdate = 0; let aprsMeterLastUpdate = 0;
let aprsMeterCheckInterval = null; let aprsMeterCheckInterval = null;
let aprsClockInterval = null;
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
// APRS user location (from GPS) // APRS user location (from GPS or shared observer location)
let aprsUserLocation = { lat: null, lon: null }; let aprsUserLocation = { lat: null, lon: null };
// Seed from configured observer location so the map centres on the
// user's position even without a live GPS fix.
(function _seedAprsLocation() {
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
const shared = ObserverLocation.getShared();
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
aprsUserLocation.lat = shared.lat;
aprsUserLocation.lon = shared.lon;
return;
}
}
// Fallback: read the Jinja-injected defaults directly
const lat = Number(window.INTERCEPT_DEFAULT_LAT);
const lon = Number(window.INTERCEPT_DEFAULT_LON);
if (aprsHasValidCoordinates(lat, lon)) {
aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon;
}
})();
// Listen for observer location changes from settings or other sources
window.addEventListener('observer-location-changed', function(e) {
if (e.detail && aprsHasValidCoordinates(e.detail.lat, e.detail.lon)) {
updateAprsUserLocation({ latitude: e.detail.lat, longitude: e.detail.lon });
}
});
let aprsUserMarker = null; let aprsUserMarker = null;
// Calculate distance in miles using Haversine formula // Calculate distance in miles using Haversine formula
@@ -9962,12 +10024,65 @@
}); });
} }
function createAprsFallbackGridLayer() {
const layer = L.gridLayer({
tileSize: 256,
updateWhenIdle: true,
attribution: 'Local fallback grid'
});
layer.createTile = function(coords) {
const tile = document.createElement('canvas');
tile.width = 256;
tile.height = 256;
const ctx = tile.getContext('2d');
ctx.fillStyle = '#08121c';
ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(256, 0);
ctx.moveTo(0, 0);
ctx.lineTo(0, 256);
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.beginPath();
ctx.moveTo(128, 0);
ctx.lineTo(128, 256);
ctx.moveTo(0, 128);
ctx.lineTo(256, 128);
ctx.stroke();
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
return tile;
};
return layer;
}
async function initAprsMap() { async function initAprsMap() {
if (aprsMap) return; if (aprsMap) return;
const mapContainer = document.getElementById('aprsMap'); const mapContainer = document.getElementById('aprsMap');
if (!mapContainer) return; if (!mapContainer) return;
// Refresh from ObserverLocation in case it changed since page load
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon) ||
(aprsUserLocation.lat === 0 && aprsUserLocation.lon === 0)) {
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
const shared = ObserverLocation.getShared();
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
aprsUserLocation.lat = shared.lat;
aprsUserLocation.lon = shared.lon;
}
}
}
// Use GPS location if available, otherwise default to center of US // Use GPS location if available, otherwise default to center of US
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude); const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude); const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
@@ -9981,13 +10096,8 @@
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom); aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
window.aprsMap = aprsMap; window.aprsMap = aprsMap;
// Add fallback tiles immediately so the map is visible instantly // Zero-network fallback so mode switches never block on external tiles.
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { const fallbackTiles = createAprsFallbackGridLayer().addTo(aprsMap);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(aprsMap);
// Upgrade tiles in background via Settings (with timeout fallback) // Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') { if (typeof Settings !== 'undefined') {
@@ -10011,7 +10121,8 @@
} }
// Update time display (both map header and function bar) // Update time display (both map header and function bar)
setInterval(() => { if (aprsClockInterval) clearInterval(aprsClockInterval);
aprsClockInterval = setInterval(() => {
const now = new Date(); const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false }); const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
const utcStr = now.toUTCString().slice(17, 25) + ' UTC'; const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
@@ -10024,6 +10135,31 @@
}, 1000); }, 1000);
} }
function destroyAprsMode() {
stopAprsMeterCheck();
if (aprsEventSource) {
aprsEventSource.close();
aprsEventSource = null;
}
if (aprsPollTimer) {
clearInterval(aprsPollTimer);
aprsPollTimer = null;
}
if (aprsClockInterval) {
clearInterval(aprsClockInterval);
aprsClockInterval = null;
}
if (aprsMap) {
try {
aprsMap.remove();
} catch (_) {}
aprsMap = null;
window.aprsMap = null;
}
aprsMarkers = {};
aprsUserMarker = null;
}
function updateAprsStatus(state, freq) { function updateAprsStatus(state, freq) {
// Update function bar status // Update function bar status
const stripDot = document.getElementById('aprsStripDot'); const stripDot = document.getElementById('aprsStripDot');
@@ -10717,26 +10853,51 @@
// GPS FUNCTIONS (gpsd auto-connect) // GPS FUNCTIONS (gpsd auto-connect)
// ============================================ // ============================================
async function autoConnectGps() { function scheduleGpsAutoConnect(delayMs = 20000) {
// Automatically try to connect to gpsd on page load if (gpsConnected || gpsAutoConnectInFlight || gpsAutoConnectTimer) return;
try { gpsAutoConnectTimer = setTimeout(() => {
const response = await fetch('/gps/auto-connect', { method: 'POST' }); gpsAutoConnectTimer = null;
const data = await response.json(); autoConnectGps();
}, delayMs);
}
if (data.status === 'connected') { async function autoConnectGps() {
gpsConnected = true; if (gpsConnected) return true;
startGpsStream(); if (gpsAutoConnectTimer) {
showGpsIndicator(true); clearTimeout(gpsAutoConnectTimer);
console.log('GPS: Auto-connected to gpsd'); gpsAutoConnectTimer = null;
if (data.position) {
updateLocationFromGps(data.position);
}
} else {
console.log('GPS: gpsd not available -', data.message);
}
} catch (e) {
console.log('GPS: Auto-connect failed -', e.message);
} }
if (gpsAutoConnectInFlight) {
return gpsAutoConnectInFlight;
}
gpsAutoConnectInFlight = (async () => {
try {
const response = await fetch('/gps/auto-connect', { method: 'POST' });
const data = await response.json();
if (data.status === 'connected') {
gpsConnected = true;
startGpsStream();
showGpsIndicator(true);
console.log('GPS: Auto-connected to gpsd');
if (data.position) {
updateLocationFromGps(data.position);
}
return true;
}
console.log('GPS: gpsd not available -', data.message);
return false;
} catch (e) {
console.log('GPS: Auto-connect failed -', e.message);
return false;
} finally {
gpsAutoConnectInFlight = null;
}
})();
return gpsAutoConnectInFlight;
} }
let gpsReconnectTimeout = null; let gpsReconnectTimeout = null;
@@ -10804,25 +10965,26 @@
}); });
function updateLocationFromGps(position) { function updateLocationFromGps(position) {
if (!position || !position.latitude || !position.longitude) { const lat = Number(position && position.latitude);
const lon = Number(position && position.longitude);
const fixQuality = Number(position && position.fix_quality);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return; return;
} }
if (Number.isFinite(fixQuality) && fixQuality < 2) return;
// Update satellite observer location // Update satellite observer location
const satLatInput = document.getElementById('obsLat'); const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon'); const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = position.latitude.toFixed(4); if (satLatInput) satLatInput.value = lat.toFixed(4);
if (satLonInput) satLonInput.value = position.longitude.toFixed(4); if (satLonInput) satLonInput.value = lon.toFixed(4);
// Update observerLocation // Update observerLocation
observerLocation.lat = position.latitude; observerLocation.lat = lat;
observerLocation.lon = position.longitude; observerLocation.lon = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Update APRS user location // Keep live GPS separate from the configured shared observer location.
updateAprsUserLocation(position); updateAprsUserLocation({ latitude: lat, longitude: lon });
} }
function showGpsIndicator(show) { function showGpsIndicator(show) {
@@ -11496,10 +11658,13 @@
function fetchCelestrakCategory(category) { function fetchCelestrakCategory(category) {
const status = document.getElementById('celestrakStatus'); const status = document.getElementById('celestrakStatus');
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>'; status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
fetch('/satellite/celestrak/' + category) fetch('/satellite/celestrak/' + category, { signal: controller.signal })
.then(r => r.json()) .then(r => r.json())
.then(async data => { .then(async data => {
clearTimeout(timeout);
if (data.status === 'success' && data.satellites) { if (data.status === 'success' && data.satellites) {
const toAdd = data.satellites const toAdd = data.satellites
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad))) .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
@@ -11544,8 +11709,10 @@
} }
}) })
.catch((err) => { .catch((err) => {
clearTimeout(timeout);
const msg = err && err.message ? err.message : 'Network error'; const msg = err && err.message ? err.message : 'Network error';
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`; const label = err && err.name === 'AbortError' ? 'Request timed out' : msg;
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${label}</span>`;
}); });
} }
@@ -11555,7 +11722,7 @@
.then(data => { .then(data => {
if (data.status === 'success' && data.satellites) { if (data.status === 'success' && data.satellites) {
trackedSatellites = data.satellites.map(sat => ({ trackedSatellites = data.satellites.map(sat => ({
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(), id: String(sat.norad_id),
name: sat.name, name: sat.name,
norad: sat.norad_id, norad: sat.norad_id,
builtin: sat.builtin, builtin: sat.builtin,
@@ -11569,8 +11736,9 @@
// Fallback to hardcoded defaults if API fails // Fallback to hardcoded defaults if API fails
if (trackedSatellites.length === 0) { if (trackedSatellites.length === 0) {
trackedSatellites = [ trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true }, { id: '25544', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true } { id: '57166', name: 'Meteor-M2-3', norad: '57166', builtin: true, checked: true },
{ id: '59051', name: 'Meteor-M2-4', norad: '59051', builtin: true, checked: true }
]; ];
renderSatelliteList(); renderSatelliteList();
} }
@@ -14572,7 +14740,6 @@
document.getElementById('tscmProgress').style.display = 'none'; document.getElementById('tscmProgress').style.display = 'none';
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete'; document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
document.getElementById('tscmProgressPercent').textContent = '100%'; document.getElementById('tscmProgressPercent').textContent = '100%';
document.getElementById('tscmProgressBar').style.width = '100%';
// Final update of counts // Final update of counts
updateTscmThreatCounts(); updateTscmThreatCounts();
@@ -16100,40 +16267,6 @@
</div> </div>
<script> <script>
// Check dependencies on page load
document.addEventListener('DOMContentLoaded', function () {
// Check if user dismissed the startup check
const dismissed = localStorage.getItem('depsCheckDismissed');
// Quick check for missing dependencies
fetch('/dependencies')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
let missingModes = 0;
let missingTools = [];
for (const [modeKey, mode] of Object.entries(data.modes)) {
if (!mode.ready) {
missingModes++;
mode.missing_required.forEach(tool => {
if (!missingTools.includes(tool)) {
missingTools.push(tool);
}
});
}
}
// Show startup prompt if tools are missing and not dismissed
// Only show if disclaimer has been accepted
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
showStartupDepsPrompt(missingModes, missingTools.length);
}
}
});
});
function showStartupDepsPrompt(modeCount, toolCount) { function showStartupDepsPrompt(modeCount, toolCount) {
const notice = document.createElement('div'); const notice = document.createElement('div');
notice.id = 'startupDepsModal'; notice.id = 'startupDepsModal';
@@ -16257,18 +16390,108 @@
</div> </div>
</div> </div>
<!-- PWA Service Worker Registration -->
<script> <script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
});
}
// Initialize global core modules after page load // Initialize global core modules after page load
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init(); if (typeof VoiceAlerts !== 'undefined') {
VoiceAlerts.init({ startStreams: false });
VoiceAlerts.scheduleStreamStart(20000);
}
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init(); if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
}); });
// ── Weather-satellite handoff from the satellite dashboard iframe/tab ─────
function processWeatherSatHandoff(payload) {
if (!payload || payload.type !== 'weather-sat-handoff') return;
const { satellite, aosTime, tcaEl, duration } = payload;
if (!satellite) return;
// Determine how far away the pass is
const aosMs = aosTime ? (new Date(aosTime) - Date.now()) : Infinity;
const minsAway = aosMs / 60000;
// Switch to weather-satellite mode and pre-select the satellite
switchMode('weathersat', { updateUrl: true }).then(() => {
if (typeof WeatherSat !== 'undefined') {
if (minsAway <= 2) {
// Pass is imminent — start immediately
WeatherSat.startPass(satellite);
showNotification('Weather Sat', `Auto-starting capture: ${satellite}`);
} else {
// Pre-select so the user can review settings and hit Start
WeatherSat.preSelect(satellite);
showHandoffBanner(satellite, minsAway, tcaEl, duration);
}
}
});
}
function consumePendingWeatherSatHandoff() {
let raw = null;
try {
raw = window.sessionStorage?.getItem('intercept.pendingWeatherSatHandoff')
|| window.localStorage?.getItem('intercept.pendingWeatherSatHandoff');
} catch (_) {
raw = null;
}
if (!raw) return;
try {
window.sessionStorage?.removeItem('intercept.pendingWeatherSatHandoff');
window.localStorage?.removeItem('intercept.pendingWeatherSatHandoff');
} catch (_) {}
try {
processWeatherSatHandoff(JSON.parse(raw));
} catch (err) {
console.warn('Failed to consume weather-satellite handoff payload:', err);
}
}
window.addEventListener('message', (event) => {
processWeatherSatHandoff(event.data);
});
function showHandoffBanner(satellite, minsAway, tcaEl, duration) {
// Remove any existing banner
const existing = document.getElementById('weatherSatHandoffBanner');
if (existing) existing.remove();
const mins = Math.round(minsAway);
const elStr = tcaEl != null ? `${Number(tcaEl).toFixed(0)}°` : '?°';
const durStr = duration != null ? `${Math.round(duration)} min` : '';
const banner = document.createElement('div');
banner.id = 'weatherSatHandoffBanner';
banner.style.cssText = [
'position:fixed', 'top:60px', 'left:50%', 'transform:translateX(-50%)',
'background:rgba(0,20,30,0.95)', 'border:1px solid rgba(0,255,136,0.5)',
'color:#00ff88', 'font-family:var(--font-mono,monospace)', 'font-size:12px',
'padding:10px 18px', 'border-radius:6px', 'z-index:9999',
'display:flex', 'align-items:center', 'gap:12px',
'box-shadow:0 0 20px rgba(0,255,136,0.2)'
].join(';');
banner.innerHTML = `
<span>📡 <strong>${satellite}</strong> pass in <strong>${mins} min</strong> · max ${elStr}${durStr ? ' · ' + durStr : ''} — satellite pre-selected</span>
<button onclick="if(typeof WeatherSat!=='undefined')WeatherSat.start();this.closest('#weatherSatHandoffBanner').remove();"
style="background:rgba(0,255,136,0.2);border:1px solid rgba(0,255,136,0.5);color:#00ff88;padding:3px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;">
Start Now
</button>
<button onclick="this.closest('#weatherSatHandoffBanner').remove();"
style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 4px;">✕</button>
`;
document.body.appendChild(banner);
// Auto-dismiss after 2 minutes
setTimeout(() => { if (banner.parentNode) banner.remove(); }, 120000);
}
window.addEventListener('DOMContentLoaded', () => {
setTimeout(consumePendingWeatherSatHandoff, 250);
});
</script> </script>
</body> </body>
+14 -1
View File
@@ -518,7 +518,7 @@
} }
</style> </style>
</head> </head>
<body> <body data-mode="controller_monitor">
<header class="header"> <header class="header">
<div class="logo"> <div class="logo">
NETWORK MONITOR NETWORK MONITOR
@@ -1117,7 +1117,20 @@
<!-- Help Modal --> <!-- Help Modal -->
{% include 'partials/help-modal.html' %} {% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script> <script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') {
VoiceAlerts.init({ startStreams: false });
VoiceAlerts.scheduleStreamStart(20000);
}
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
</body> </body>
</html> </html>
+11 -23
View File
@@ -6,8 +6,8 @@
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div> </div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites. Receive and decode Meteor LRPT weather imagery.
Uses SatDump for live SDR capture and image processing. Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
</p> </p>
</div> </div>
@@ -18,9 +18,6 @@
<select id="weatherSatSelect" class="mode-select"> <select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option> <option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option> <option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -72,7 +69,7 @@
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li> <li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
</ul> </ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;"> <p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
Best starter antenna. Good enough for clear NOAA images with a direct overhead pass. Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead.
</p> </p>
</div> </div>
@@ -136,7 +133,7 @@
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li> <li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li> <li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end. <li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)</li> Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li> <li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
</ul> </ul>
</div> </div>
@@ -165,10 +162,6 @@
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td> <td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
</tr> </tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~40 kHz</td>
</tr>
<tr> <tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td> <td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
@@ -185,31 +178,26 @@
</h3> </h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;"> <div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Decode a pre-recorded IQ or WAV file without SDR hardware. Decode a pre-recorded Meteor IQ file without SDR hardware.
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files. Shared ground-station recordings are also accepted by the backend.
</p> </p>
<div class="form-group"> <div class="form-group">
<label>Satellite</label> <label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select"> <select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option> <option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option> <option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18">NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>File Path (server-side)</label> <label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;"> <input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Sample Rate</label> <label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select"> <select id="wxsatTestSampleRate" class="mode-select">
<option value="11025">11025 Hz (WAV audio APT)</option>
<option value="48000">48000 Hz (WAV audio APT)</option>
<option value="500000">500 kHz (IQ LRPT)</option> <option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000" selected>1 MHz (IQ default)</option> <option value="1000000">1 MHz (IQ narrow)</option>
<option value="2000000">2 MHz (IQ wideband)</option> <option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
</select> </select>
</div> </div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;"> <button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
@@ -241,8 +229,8 @@
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation SatDump Documentation
</a> </a>
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Reception Guide Meteor Reception Guide
</a> </a>
</div> </div>
</div> </div>
@@ -0,0 +1,26 @@
<!-- Cheat Sheet Modal -->
<div id="cheatSheetModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)CheatSheets.hide()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:480px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="CheatSheets.hide()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<div id="cheatSheetContent"></div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div id="kbShortcutsModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)KeyboardShortcuts.hideHelp()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:520px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="KeyboardShortcuts.hideHelp()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<h2 style="margin:0 0 16px; font-size:16px; color:var(--accent-cyan, #4aa3ff); font-family:var(--font-mono);">Keyboard Shortcuts</h2>
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
<tbody>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+W</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Waterfall</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+M</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle voice mute</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+C</td><td style="padding:6px 8px; color:var(--text-secondary);">Show cheat sheet for current mode</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+1..9</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Nth mode in current group</td></tr>
<tr><td style="padding:6px 8px; color:var(--accent-cyan);">Escape</td><td style="padding:6px 8px; color:var(--text-secondary);">Close modal</td></tr>
</tbody>
</table>
</div>
</div>
+11 -2
View File
@@ -16,7 +16,12 @@
{% macro mode_item(mode, label, icon_svg, href=None) -%} {% macro mode_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%} {%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %} {%- if mode == 'satellite' %}
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span>
</a>
{%- elif href %}
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode"> <a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="nav-icon icon">{{ icon_svg | safe }}</span> <span class="nav-icon icon">{{ icon_svg | safe }}</span>
<span class="nav-label">{{ label }}</span> <span class="nav-label">{{ label }}</span>
@@ -36,7 +41,11 @@
{% macro mobile_item(mode, label, icon_svg, href=None) -%} {% macro mobile_item(mode, label, icon_svg, href=None) -%}
{%- set is_active = 'active' if active_mode == mode else '' -%} {%- set is_active = 'active' if active_mode == mode else '' -%}
{%- if href %} {%- if mode == 'satellite' %}
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a>
{%- elif href %}
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode"> <a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }} <span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
</a> </a>
File diff suppressed because it is too large Load Diff
+51
View File
@@ -189,6 +189,57 @@ class TestSettingsEndpoints:
assert data['status'] == 'success' assert data['status'] == 'success'
assert data['deleted'] is True assert data['deleted'] is True
def test_save_observer_location_updates_env_and_runtime_defaults(self, client, monkeypatch, tmp_path):
"""Saving observer location should persist to .env and update in-memory defaults."""
import app as app_module
import config
from routes import adsb as adsb_routes
from routes import ais as ais_routes
from routes import settings as settings_routes
with client.session_transaction() as sess:
sess['logged_in'] = True
env_path = tmp_path / '.env'
monkeypatch.setattr(settings_routes, '_get_env_file_path', lambda: env_path)
response = client.post(
'/settings/observer-location',
data=json.dumps({'lat': 48.0, 'lon': 16.16}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['lat'] == 48.0
assert data['lon'] == 16.16
env_text = env_path.read_text()
assert 'INTERCEPT_DEFAULT_LAT=48.0' in env_text
assert 'INTERCEPT_DEFAULT_LON=16.16' in env_text
assert config.DEFAULT_LATITUDE == 48.0
assert config.DEFAULT_LONGITUDE == 16.16
assert app_module.DEFAULT_LATITUDE == 48.0
assert app_module.DEFAULT_LONGITUDE == 16.16
assert adsb_routes.DEFAULT_LATITUDE == 48.0
assert adsb_routes.DEFAULT_LONGITUDE == 16.16
assert ais_routes.DEFAULT_LATITUDE == 48.0
assert ais_routes.DEFAULT_LONGITUDE == 16.16
def test_save_observer_location_rejects_invalid_values(self, client):
"""Observer location save should validate coordinates."""
with client.session_transaction() as sess:
sess['logged_in'] = True
response = client.post(
'/settings/observer-location',
data=json.dumps({'lat': 200, 'lon': 16.16}),
content_type='application/json'
)
assert response.status_code == 400
class TestCorrelationEndpoints: class TestCorrelationEndpoints:
"""Tests for correlation API endpoints.""" """Tests for correlation API endpoints."""
+102
View File
@@ -1,3 +1,5 @@
import queue
import threading
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -70,6 +72,106 @@ def test_get_satellite_position_skyfield_error(mock_load, client):
assert response.status_code == 200 assert response.status_code == 200
assert response.json['positions'] == [] assert response.json['positions'] == []
def test_tracker_position_has_no_observer_fields():
"""SSE tracker positions must NOT include observer-relative fields.
The tracker runs server-side with a fixed (potentially wrong) observer
location. Only the per-request /satellite/position endpoint, which
receives the client's actual location, should emit elevation/azimuth/
distance/visible.
"""
from routes.satellite import _start_satellite_tracker
ISS_TLE = (
'ISS (ZARYA)',
'1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993',
'2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457',
)
sat_q = queue.Queue(maxsize=5)
mock_app = MagicMock()
mock_app.satellite_queue = sat_q
from skyfield.api import load as _real_load
real_ts = _real_load.timescale(builtin=True)
# Pre-populate track cache so the tracker loop doesn't block computing 90 points
tle_key = (ISS_TLE[0], ISS_TLE[1][:20])
stub_track = [{'lat': 0.0, 'lon': float(i), 'past': i < 45} for i in range(91)]
with patch('routes.satellite._tle_cache', {'ISS': ISS_TLE}), \
patch('routes.satellite.get_tracked_satellites') as mock_tracked, \
patch('routes.satellite._track_cache', {tle_key: (stub_track, 1e18)}), \
patch('routes.satellite._get_timescale', return_value=real_ts), \
patch.dict('sys.modules', {'app': mock_app}):
mock_tracked.return_value = [{
'name': 'ISS (ZARYA)', 'norad_id': 25544,
'tle_line1': ISS_TLE[1], 'tle_line2': ISS_TLE[2],
}]
t = threading.Thread(target=_start_satellite_tracker, daemon=True)
t.start()
msg = sat_q.get(timeout=10)
assert msg['type'] == 'positions'
pos = msg['positions'][0]
for forbidden in ('elevation', 'azimuth', 'distance', 'visible'):
assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'"
for required in ('lat', 'lon', 'altitude', 'satellite', 'norad_id'):
assert required in pos, f"SSE tracker must emit '{required}'"
def test_predict_passes_currentpos_has_full_fields(client):
"""currentPos in pass results must include altitude, elevation, azimuth, distance."""
payload = {
'latitude': 51.5074,
'longitude': -0.1278,
'hours': 48,
'minEl': 5,
'satellites': ['ISS'],
}
response = client.post('/satellite/predict', json=payload)
assert response.status_code == 200
data = response.json
assert data['status'] == 'success'
if data['passes']:
cp = data['passes'][0].get('currentPos', {})
for field in ('lat', 'lon', 'altitude', 'elevation', 'azimuth', 'distance'):
assert field in cp, f"currentPos missing field: {field}"
@patch('routes.satellite.refresh_tle_data', return_value=['ISS'])
@patch('routes.satellite._load_db_satellites_into_cache')
def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh):
"""After the first TLE refresh, a 24-hour follow-up timer must be scheduled."""
import threading as real_threading
scheduled_delays = []
class CapturingTimer:
def __init__(self, delay, fn, *a, **kw):
scheduled_delays.append(delay)
self._fn = fn
self._delay = delay
def start(self):
# Execute the startup timer inline so we can capture the follow-up
if self._delay <= 5:
self._fn()
with patch('routes.satellite.threading') as mock_threading:
mock_threading.Timer = CapturingTimer
mock_threading.Thread = real_threading.Thread
from routes.satellite import init_tle_auto_refresh
init_tle_auto_refresh()
# First timer: startup delay (≤5s); second timer: 24h repeat (≥86400s)
assert any(d <= 5 for d in scheduled_delays), \
f"Expected startup delay timer; got delays: {scheduled_delays}"
assert any(d >= 86400 for d in scheduled_delays), \
f"Expected ~24h repeat timer; got delays: {scheduled_delays}"
# Logic Integration Test (Simulating prediction) # Logic Integration Test (Simulating prediction)
def test_predict_passes_empty_cache(client): def test_predict_passes_empty_cache(client):
"""Verify that if the satellite is not in cache, no passes are returned.""" """Verify that if the satellite is not in cache, no passes are returned."""
+23
View File
@@ -13,6 +13,20 @@ import pytest
from utils.weather_sat_predict import _format_utc_iso, predict_passes from utils.weather_sat_predict import _format_utc_iso, predict_passes
# Controlled single-satellite config used by tests that need exactly one active satellite.
# NOAA-18 was decommissioned Jun 2025 and is inactive in the real WEATHER_SATELLITES,
# so tests that assert on satellite-specific fields patch the module-level name.
_MOCK_WEATHER_SATS = {
'NOAA-18': {
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-18',
'active': True,
}
}
class TestPredictPasses: class TestPredictPasses:
"""Tests for predict_passes() function.""" """Tests for predict_passes() function."""
@@ -31,6 +45,7 @@ class TestPredictPasses:
assert passes == [] assert passes == []
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -96,6 +111,7 @@ class TestPredictPasses:
assert 'duration' in pass_data assert 'duration' in pass_data
assert 'quality' in pass_data assert 'quality' in pass_data
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -150,6 +166,7 @@ class TestPredictPasses:
assert len(passes) == 0 assert len(passes) == 0
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -207,6 +224,7 @@ class TestPredictPasses:
assert 'trajectory' in passes[0] assert 'trajectory' in passes[0]
assert len(passes[0]['trajectory']) == 30 assert len(passes[0]['trajectory']) == 30
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -281,6 +299,7 @@ class TestPredictPasses:
assert 'groundTrack' in passes[0] assert 'groundTrack' in passes[0]
assert len(passes[0]['groundTrack']) == 60 assert len(passes[0]['groundTrack']) == 60
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -336,6 +355,7 @@ class TestPredictPasses:
assert passes[0]['quality'] == 'excellent' assert passes[0]['quality'] == 'excellent'
assert passes[0]['maxEl'] >= 60 assert passes[0]['maxEl'] >= 60
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -391,6 +411,7 @@ class TestPredictPasses:
assert passes[0]['quality'] == 'good' assert passes[0]['quality'] == 'good'
assert 30 <= passes[0]['maxEl'] < 60 assert 30 <= passes[0]['maxEl'] < 60
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -530,6 +551,7 @@ class TestPredictPasses:
predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
# Should not raise # Should not raise
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
@@ -605,6 +627,7 @@ class TestPredictPasses:
class TestPassDataStructure: class TestPassDataStructure:
"""Tests for pass data structure.""" """Tests for pass data structure."""
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
@patch('utils.weather_sat_predict.load') @patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES') @patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84') @patch('utils.weather_sat_predict.wgs84')
+39 -21
View File
@@ -11,9 +11,19 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from utils.weather_sat import WeatherSatImage from utils.weather_sat import WeatherSatImage
@pytest.fixture
def client(client):
"""Authenticated client for weather-sat route tests."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestWeatherSatRoutes: class TestWeatherSatRoutes:
"""Tests for weather satellite routes.""" """Tests for weather satellite routes."""
@@ -68,7 +78,8 @@ class TestWeatherSatRoutes:
"""POST /weather-sat/start successfully starts capture.""" """POST /weather-sat/start successfully starts capture."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('routes.weather_sat.queue.Queue'): patch('routes.weather_sat.queue.Queue'), \
patch('app.claim_sdr_device', return_value=None):
mock_decoder = MagicMock() mock_decoder = MagicMock()
mock_decoder.is_running = False mock_decoder.is_running = False
@@ -96,12 +107,12 @@ class TestWeatherSatRoutes:
assert data['mode'] == 'APT' assert data['mode'] == 'APT'
assert data['device'] == 0 assert data['device'] == 0
mock_decoder.start.assert_called_once_with( mock_decoder.start.assert_called_once()
satellite='NOAA-18', call_kwargs = mock_decoder.start.call_args[1]
device_index=0, assert call_kwargs['satellite'] == 'NOAA-18'
gain=40.0, assert call_kwargs['device_index'] == 0
bias_t=False, assert call_kwargs['gain'] == 40.0
) assert call_kwargs['bias_t'] is False
def test_start_capture_no_satdump(self, client): def test_start_capture_no_satdump(self, client):
"""POST /weather-sat/start returns error when SatDump unavailable.""" """POST /weather-sat/start returns error when SatDump unavailable."""
@@ -290,7 +301,8 @@ class TestWeatherSatRoutes:
def test_start_capture_start_failure(self, client): def test_start_capture_start_failure(self, client):
"""POST /weather-sat/start when decoder.start() fails.""" """POST /weather-sat/start when decoder.start() fails."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('app.claim_sdr_device', return_value=None):
mock_decoder = MagicMock() mock_decoder = MagicMock()
mock_decoder.is_running = False mock_decoder.is_running = False
@@ -409,7 +421,13 @@ class TestWeatherSatRoutes:
def test_test_decode_invalid_sample_rate(self, client): def test_test_decode_invalid_sample_rate(self, client):
"""POST /weather-sat/test-decode with invalid sample rate.""" """POST /weather-sat/test-decode with invalid sample rate."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('pathlib.Path.is_file', return_value=True), \
patch('pathlib.Path.resolve') as mock_resolve:
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_resolve.return_value = mock_path
mock_decoder = MagicMock() mock_decoder = MagicMock()
mock_decoder.is_running = False mock_decoder.is_running = False
@@ -558,7 +576,7 @@ class TestWeatherSatRoutes:
mock_decoder = MagicMock() mock_decoder = MagicMock()
mock_get.return_value = mock_decoder mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images/../../../etc/passwd') response = client.get('/weather-sat/images/bad!file@name.png')
assert response.status_code == 400 assert response.status_code == 400
data = response.get_json() data = response.get_json()
assert data['status'] == 'error' assert data['status'] == 'error'
@@ -647,7 +665,7 @@ class TestWeatherSatRoutes:
def test_get_passes_success(self, client): def test_get_passes_success(self, client):
"""GET /weather-sat/passes successfully predicts passes.""" """GET /weather-sat/passes successfully predicts passes."""
with patch('routes.weather_sat.predict_passes') as mock_predict: with patch('utils.weather_sat_predict.predict_passes') as mock_predict:
mock_predict.return_value = [ mock_predict.return_value = [
{ {
'id': 'NOAA-18_202401011200', 'id': 'NOAA-18_202401011200',
@@ -676,7 +694,7 @@ class TestWeatherSatRoutes:
def test_get_passes_with_options(self, client): def test_get_passes_with_options(self, client):
"""GET /weather-sat/passes with trajectory and ground track.""" """GET /weather-sat/passes with trajectory and ground track."""
with patch('routes.weather_sat.predict_passes') as mock_predict: with patch('utils.weather_sat_predict.predict_passes') as mock_predict:
mock_predict.return_value = [] mock_predict.return_value = []
response = client.get( response = client.get(
@@ -696,7 +714,7 @@ class TestWeatherSatRoutes:
def test_get_passes_import_error(self, client): def test_get_passes_import_error(self, client):
"""GET /weather-sat/passes when skyfield not installed.""" """GET /weather-sat/passes when skyfield not installed."""
with patch('routes.weather_sat.predict_passes', side_effect=ImportError): with patch('utils.weather_sat_predict.predict_passes', side_effect=ImportError):
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
assert response.status_code == 503 assert response.status_code == 503
data = response.get_json() data = response.get_json()
@@ -705,7 +723,7 @@ class TestWeatherSatRoutes:
def test_get_passes_prediction_error(self, client): def test_get_passes_prediction_error(self, client):
"""GET /weather-sat/passes when prediction fails.""" """GET /weather-sat/passes when prediction fails."""
with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')): with patch('utils.weather_sat_predict.predict_passes', side_effect=Exception('TLE error')):
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
assert response.status_code == 500 assert response.status_code == 500
data = response.get_json() data = response.get_json()
@@ -717,7 +735,7 @@ class TestWeatherSatScheduler:
def test_enable_schedule_success(self, client): def test_enable_schedule_success(self, client):
"""POST /weather-sat/schedule/enable enables scheduler.""" """POST /weather-sat/schedule/enable enables scheduler."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.enable.return_value = { mock_scheduler.enable.return_value = {
'enabled': True, 'enabled': True,
@@ -780,7 +798,7 @@ class TestWeatherSatScheduler:
def test_disable_schedule(self, client): def test_disable_schedule(self, client):
"""POST /weather-sat/schedule/disable disables scheduler.""" """POST /weather-sat/schedule/disable disables scheduler."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.disable.return_value = {'status': 'disabled'} mock_scheduler.disable.return_value = {'status': 'disabled'}
mock_get.return_value = mock_scheduler mock_get.return_value = mock_scheduler
@@ -792,7 +810,7 @@ class TestWeatherSatScheduler:
def test_schedule_status(self, client): def test_schedule_status(self, client):
"""GET /weather-sat/schedule/status returns scheduler status.""" """GET /weather-sat/schedule/status returns scheduler status."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.get_status.return_value = { mock_scheduler.get_status.return_value = {
'enabled': False, 'enabled': False,
@@ -813,7 +831,7 @@ class TestWeatherSatScheduler:
def test_schedule_passes(self, client): def test_schedule_passes(self, client):
"""GET /weather-sat/schedule/passes lists scheduled passes.""" """GET /weather-sat/schedule/passes lists scheduled passes."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.get_passes.return_value = [ mock_scheduler.get_passes.return_value = [
{ {
@@ -832,7 +850,7 @@ class TestWeatherSatScheduler:
def test_skip_pass_success(self, client): def test_skip_pass_success(self, client):
"""POST /weather-sat/schedule/skip/<id> skips a pass.""" """POST /weather-sat/schedule/skip/<id> skips a pass."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.skip_pass.return_value = True mock_scheduler.skip_pass.return_value = True
mock_get.return_value = mock_scheduler mock_get.return_value = mock_scheduler
@@ -845,7 +863,7 @@ class TestWeatherSatScheduler:
def test_skip_pass_not_found(self, client): def test_skip_pass_not_found(self, client):
"""POST /weather-sat/schedule/skip/<id> for non-existent pass.""" """POST /weather-sat/schedule/skip/<id> for non-existent pass."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.skip_pass.return_value = False mock_scheduler.skip_pass.return_value = False
mock_get.return_value = mock_scheduler mock_get.return_value = mock_scheduler
@@ -855,7 +873,7 @@ class TestWeatherSatScheduler:
def test_skip_pass_invalid_id(self, client): def test_skip_pass_invalid_id(self, client):
"""POST /weather-sat/schedule/skip/<id> with invalid ID.""" """POST /weather-sat/schedule/skip/<id> with invalid ID."""
response = client.post('/weather-sat/schedule/skip/../../../etc/passwd') response = client.post('/weather-sat/schedule/skip/invalid!pass@id')
assert response.status_code == 400 assert response.status_code == 400
data = response.get_json() data = response.get_json()
assert data['status'] == 'error' assert data['status'] == 'error'
+20 -15
View File
@@ -191,8 +191,10 @@ class TestWeatherSatScheduler:
'quality': 'good', 'quality': 'good',
} }
sp = ScheduledPass(pass_data) sp = ScheduledPass(pass_data)
sp._timer = MagicMock() mock_pass_timer = MagicMock()
sp._stop_timer = MagicMock() mock_stop_timer = MagicMock()
sp._timer = mock_pass_timer
sp._stop_timer = mock_stop_timer
scheduler._passes = [sp] scheduler._passes = [sp]
result = scheduler.disable() result = scheduler.disable()
@@ -200,8 +202,8 @@ class TestWeatherSatScheduler:
assert scheduler._enabled is False assert scheduler._enabled is False
assert scheduler._passes == [] assert scheduler._passes == []
mock_timer.cancel.assert_called_once() mock_timer.cancel.assert_called_once()
sp._timer.cancel.assert_called_once() mock_pass_timer.cancel.assert_called_once()
sp._stop_timer.cancel.assert_called_once() mock_stop_timer.cancel.assert_called_once()
assert result['status'] == 'disabled' assert result['status'] == 'disabled'
def test_skip_pass_success(self): def test_skip_pass_success(self):
@@ -223,7 +225,8 @@ class TestWeatherSatScheduler:
'quality': 'good', 'quality': 'good',
} }
sp = ScheduledPass(pass_data) sp = ScheduledPass(pass_data)
sp._timer = MagicMock() mock_pass_timer = MagicMock()
sp._timer = mock_pass_timer
scheduler._passes = [sp] scheduler._passes = [sp]
result = scheduler.skip_pass('NOAA-18_202401011200') result = scheduler.skip_pass('NOAA-18_202401011200')
@@ -231,7 +234,7 @@ class TestWeatherSatScheduler:
assert result is True assert result is True
assert sp.status == 'skipped' assert sp.status == 'skipped'
assert sp.skipped is True assert sp.skipped is True
sp._timer.cancel.assert_called_once() mock_pass_timer.cancel.assert_called_once()
event_cb.assert_called_once() event_cb.assert_called_once()
def test_skip_pass_not_found(self): def test_skip_pass_not_found(self):
@@ -531,9 +534,10 @@ class TestWeatherSatScheduler:
assert event_data['type'] == 'schedule_capture_skipped' assert event_data['type'] == 'schedule_capture_skipped'
assert event_data['reason'] == 'sdr_busy' assert event_data['reason'] == 'sdr_busy'
@patch('app.claim_sdr_device', return_value=None)
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder') @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer') @patch('threading.Timer')
def test_execute_capture_success(self, mock_timer, mock_get): def test_execute_capture_success(self, mock_timer, mock_get, mock_claim):
"""_execute_capture() should start capture.""" """_execute_capture() should start capture."""
scheduler = WeatherSatScheduler() scheduler = WeatherSatScheduler()
scheduler._enabled = True scheduler._enabled = True
@@ -570,18 +574,18 @@ class TestWeatherSatScheduler:
assert sp.status == 'capturing' assert sp.status == 'capturing'
mock_decoder.set_callback.assert_called_once_with(progress_cb) mock_decoder.set_callback.assert_called_once_with(progress_cb)
mock_decoder.start.assert_called_once_with( call_kwargs = mock_decoder.start.call_args[1]
satellite='NOAA-18', assert call_kwargs['satellite'] == 'NOAA-18'
device_index=0, assert call_kwargs['device_index'] == 0
gain=40.0, assert call_kwargs['gain'] == 40.0
bias_t=False, assert call_kwargs['bias_t'] is False
)
event_cb.assert_called_once() event_cb.assert_called_once()
event_data = event_cb.call_args[0][0] event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_start' assert event_data['type'] == 'schedule_capture_start'
@patch('app.claim_sdr_device', return_value=None)
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder') @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_start_failed(self, mock_get): def test_execute_capture_start_failed(self, mock_get, mock_claim):
"""_execute_capture() should handle start failure.""" """_execute_capture() should handle start failure."""
scheduler = WeatherSatScheduler() scheduler = WeatherSatScheduler()
scheduler._enabled = True scheduler._enabled = True
@@ -773,10 +777,11 @@ class TestUtcIsoParsing:
class TestSchedulerIntegration: class TestSchedulerIntegration:
"""Integration tests for scheduler.""" """Integration tests for scheduler."""
@patch('app.claim_sdr_device', return_value=None)
@patch('utils.weather_sat_predict.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder') @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer') @patch('threading.Timer')
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict): def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict, mock_claim):
"""Test complete scheduling cycle from enable to execute.""" """Test complete scheduling cycle from enable to execute."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
future_pass = { future_pass = {
+166 -21
View File
@@ -635,6 +635,142 @@ def init_db() -> None:
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''') ''')
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('57166', 'METEOR-M2-3', NULL, NULL, 1, 1)
''')
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('59051', 'METEOR-M2-4', NULL, NULL, 1, 1)
''')
# =====================================================================
# Ground Station Tables (automated observations, IQ recordings)
# =====================================================================
# Observation profiles — per-satellite capture configuration
conn.execute('''
CREATE TABLE IF NOT EXISTS observation_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
norad_id INTEGER UNIQUE NOT NULL,
name TEXT NOT NULL,
frequency_mhz REAL NOT NULL,
decoder_type TEXT NOT NULL DEFAULT 'fm',
tasks_json TEXT,
gain REAL DEFAULT 40.0,
bandwidth_hz INTEGER DEFAULT 200000,
min_elevation REAL DEFAULT 10.0,
enabled BOOLEAN DEFAULT 1,
record_iq BOOLEAN DEFAULT 0,
iq_sample_rate INTEGER DEFAULT 2400000,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Observation history — one row per captured pass
conn.execute('''
CREATE TABLE IF NOT EXISTS ground_station_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER,
norad_id INTEGER NOT NULL,
satellite TEXT NOT NULL,
aos_time TEXT,
los_time TEXT,
status TEXT DEFAULT 'scheduled',
output_path TEXT,
packets_decoded INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES observation_profiles(id) ON DELETE SET NULL
)
''')
# Per-observation events (packets decoded, Doppler updates, etc.)
conn.execute('''
CREATE TABLE IF NOT EXISTS ground_station_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observation_id INTEGER,
event_type TEXT NOT NULL,
payload_json TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE
)
''')
# SigMF recordings — one row per IQ recording file pair
conn.execute('''
CREATE TABLE IF NOT EXISTS sigmf_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observation_id INTEGER,
sigmf_data_path TEXT NOT NULL,
sigmf_meta_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
sample_rate INTEGER,
center_freq_hz INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE SET NULL
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS ground_station_outputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observation_id INTEGER,
norad_id INTEGER,
output_type TEXT NOT NULL,
backend TEXT,
file_path TEXT NOT NULL,
preview_path TEXT,
metadata_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS ground_station_decode_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observation_id INTEGER,
norad_id INTEGER,
backend TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
input_path TEXT,
output_dir TEXT,
error_message TEXT,
details_json TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE
)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_gs_observations_norad
ON ground_station_observations(norad_id, created_at)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_gs_events_observation
ON ground_station_events(observation_id, timestamp)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_gs_outputs_observation
ON ground_station_outputs(observation_id, created_at)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation
ON ground_station_decode_jobs(observation_id, created_at)
''')
# Lightweight schema migrations for existing installs.
profile_cols = {
row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)')
}
if 'tasks_json' not in profile_cols:
conn.execute('ALTER TABLE observation_profiles ADD COLUMN tasks_json TEXT')
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@@ -2336,28 +2472,37 @@ def add_tracked_satellite(
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int: def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
"""Insert many tracked satellites at once. Returns count of newly inserted.""" """Insert many tracked satellites at once. Returns count of newly inserted."""
added = 0 if not satellites_list:
return 0
rows = []
for sat in satellites_list:
try:
rows.append((
str(sat['norad_id']),
sat['name'],
sat.get('tle_line1'),
sat.get('tle_line2'),
int(sat.get('enabled', True)),
int(sat.get('builtin', False)),
))
except (KeyError, TypeError) as e:
logger.warning(f"Skipping malformed satellite entry: {e}")
norad_ids = [r[0] for r in rows]
placeholders = ','.join('?' * len(norad_ids))
count_sql = f'SELECT COUNT(*) FROM tracked_satellites WHERE norad_id IN ({placeholders})'
with get_db() as conn: with get_db() as conn:
for sat in satellites_list: before = conn.execute(count_sql, norad_ids).fetchone()[0]
try: conn.executemany(
cursor = conn.execute( 'INSERT OR IGNORE INTO tracked_satellites '
'INSERT OR IGNORE INTO tracked_satellites ' '(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' 'VALUES (?, ?, ?, ?, ?, ?)',
'VALUES (?, ?, ?, ?, ?, ?)', rows,
( )
str(sat['norad_id']), after = conn.execute(count_sql, norad_ids).fetchone()[0]
sat['name'], return after - before
sat.get('tle_line1'),
sat.get('tle_line2'),
int(sat.get('enabled', True)),
int(sat.get('builtin', False)),
),
)
if cursor.rowcount > 0:
added += 1
except (sqlite3.Error, KeyError) as e:
logger.warning(f"Error bulk-adding satellite: {e}")
return added
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool: def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
+195
View File
@@ -0,0 +1,195 @@
"""Generalised Doppler shift calculator for satellite observations.
Extracted from utils/sstv/sstv_decoder.py and generalised to accept any
satellite by name (looked up in the live TLE cache) or by raw TLE tuple.
The sstv_decoder module imports DopplerTracker and DopplerInfo from here.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from utils.logging import get_logger
logger = get_logger('intercept.doppler')
# Speed of light in m/s
SPEED_OF_LIGHT = 299_792_458.0
# Default Hz threshold before triggering a retune
DEFAULT_RETUNE_THRESHOLD_HZ = 500
@dataclass
class DopplerInfo:
"""Doppler shift information for a satellite observation."""
frequency_hz: float
shift_hz: float
range_rate_km_s: float
elevation: float
azimuth: float
timestamp: datetime
def to_dict(self) -> dict:
return {
'frequency_hz': self.frequency_hz,
'shift_hz': round(self.shift_hz, 1),
'range_rate_km_s': round(self.range_rate_km_s, 3),
'elevation': round(self.elevation, 1),
'azimuth': round(self.azimuth, 1),
'timestamp': self.timestamp.isoformat(),
}
class DopplerTracker:
"""Real-time Doppler shift calculator for satellite tracking.
Accepts a satellite by name (looked up in the live TLE cache, falling
back to static data) **or** a raw TLE tuple ``(name, line1, line2)``
passed via the constructor or via :meth:`update_tle`.
"""
def __init__(
self,
satellite_name: str = 'ISS',
tle_data: tuple[str, str, str] | None = None,
):
self._satellite_name = satellite_name
self._tle_data = tle_data
self._observer_lat: float | None = None
self._observer_lon: float | None = None
self._satellite = None
self._observer = None
self._ts = None
self._enabled = False
self._lock = threading.Lock()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def configure(self, latitude: float, longitude: float) -> bool:
"""Configure the tracker with an observer location.
Resolves TLE data, builds the skyfield objects, and marks the
tracker enabled. Returns True on success.
"""
try:
from skyfield.api import EarthSatellite, load, wgs84
except ImportError:
logger.warning("skyfield not available — Doppler tracking disabled")
return False
tle = self._resolve_tle()
if tle is None:
logger.error(f"No TLE data for satellite: {self._satellite_name}")
return False
try:
ts = load.timescale(builtin=True)
satellite = EarthSatellite(tle[1], tle[2], tle[0], ts)
observer = wgs84.latlon(latitude, longitude)
except Exception as e:
logger.error(f"Failed to configure DopplerTracker: {e}")
return False
with self._lock:
self._ts = ts
self._satellite = satellite
self._observer = observer
self._observer_lat = latitude
self._observer_lon = longitude
self._enabled = True
logger.info(
f"DopplerTracker configured for {self._satellite_name} "
f"at ({latitude}, {longitude})"
)
return True
def update_tle(self, tle_data: tuple[str, str, str]) -> bool:
"""Update TLE data and re-configure if already enabled."""
self._tle_data = tle_data
if (
self._enabled
and self._observer_lat is not None
and self._observer_lon is not None
):
return self.configure(self._observer_lat, self._observer_lon)
return True
@property
def is_enabled(self) -> bool:
return self._enabled
def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None:
"""Calculate the Doppler-corrected receive frequency.
Returns a :class:`DopplerInfo` or *None* if the tracker is not
enabled or the calculation fails.
"""
with self._lock:
if not self._enabled or self._satellite is None or self._observer is None:
return None
ts = self._ts
satellite = self._satellite
observer = self._observer
try:
t = ts.now()
difference = satellite - observer
topocentric = difference.at(t)
alt, az, distance = topocentric.altaz()
dt_seconds = 1.0
t_future = ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds))
topocentric_future = difference.at(t_future)
_, _, distance_future = topocentric_future.altaz()
range_rate_km_s = (distance_future.km - distance.km) / dt_seconds
nominal_freq_hz = nominal_freq_mhz * 1_000_000
doppler_factor = 1.0 - (range_rate_km_s * 1000.0 / SPEED_OF_LIGHT)
corrected_freq_hz = nominal_freq_hz * doppler_factor
shift_hz = corrected_freq_hz - nominal_freq_hz
return DopplerInfo(
frequency_hz=corrected_freq_hz,
shift_hz=shift_hz,
range_rate_km_s=range_rate_km_s,
elevation=alt.degrees,
azimuth=az.degrees,
timestamp=datetime.now(timezone.utc),
)
except Exception as e:
logger.error(f"Doppler calculation failed: {e}")
return None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _resolve_tle(self) -> tuple[str, str, str] | None:
"""Return the best available TLE tuple."""
if self._tle_data:
return self._tle_data
# Try the live TLE cache maintained by routes/satellite.py
try:
from routes.satellite import _tle_cache # type: ignore[import]
if _tle_cache:
tle = _tle_cache.get(self._satellite_name)
if tle:
return tle
except (ImportError, AttributeError):
pass
# Fall back to static bundled data
try:
from data.satellites import TLE_SATELLITES
return TLE_SATELLITES.get(self._satellite_name)
except ImportError:
return None
+12
View File
@@ -0,0 +1,12 @@
"""Ground station automation subpackage.
Provides unattended satellite observation, Doppler correction, IQ recording
(SigMF), parallel multi-decoder pipelines, live spectrum, and optional
antenna rotator control.
Public API::
from utils.ground_station.scheduler import get_ground_station_scheduler
from utils.ground_station.observation_profile import ObservationProfile
from utils.ground_station.iq_bus import IQBus
"""
@@ -0,0 +1 @@
"""IQ bus consumer implementations."""
+219
View File
@@ -0,0 +1,219 @@
"""FMDemodConsumer — demodulates FM from CU8 IQ and pipes PCM to a decoder.
Performs FM (or AM/USB/LSB) demodulation in-process using numpy the
same algorithm as the listening-post waterfall monitor. The resulting
int16 PCM is written to the stdin of a configurable decoder subprocess
(e.g. direwolf for AX.25 AFSK or multimon-ng for GMSK/POCSAG).
Decoded lines from the subprocess stdout are forwarded to an optional
``on_decoded`` callback.
"""
from __future__ import annotations
import subprocess
import threading
from typing import Callable
import numpy as np
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
from utils.waterfall_fft import cu8_to_complex
logger = get_logger('intercept.ground_station.fm_demod')
AUDIO_RATE = 48_000 # Hz — standard rate for direwolf / multimon-ng
class FMDemodConsumer:
"""CU8 IQ → FM demodulation → int16 PCM → decoder subprocess stdin."""
def __init__(
self,
decoder_cmd: list[str],
*,
modulation: str = 'fm',
on_decoded: Callable[[str], None] | None = None,
):
"""
Args:
decoder_cmd: Decoder command + args, e.g.
``['direwolf', '-r', '48000', '-']`` or
``['multimon-ng', '-t', 'raw', '-a', 'AFSK1200', '-']``.
modulation: ``'fm'``, ``'am'``, ``'usb'``, ``'lsb'``.
on_decoded: Callback invoked with each decoded line from stdout.
"""
self._decoder_cmd = decoder_cmd
self._modulation = modulation.lower()
self._on_decoded = on_decoded
self._proc: subprocess.Popen | None = None
self._stdout_thread: threading.Thread | None = None
self._center_mhz = 0.0
self._sample_rate = 0
self._rotator_phase = 0.0
# ------------------------------------------------------------------
# IQConsumer protocol
# ------------------------------------------------------------------
def on_start(
self,
center_mhz: float,
sample_rate: int,
*,
start_freq_mhz: float,
end_freq_mhz: float,
) -> None:
self._center_mhz = center_mhz
self._sample_rate = sample_rate
self._rotator_phase = 0.0
self._start_proc()
def on_chunk(self, raw: bytes) -> None:
if self._proc is None or self._proc.poll() is not None:
return
try:
pcm, self._rotator_phase = _demodulate(
raw,
sample_rate=self._sample_rate,
center_mhz=self._center_mhz,
monitor_freq_mhz=self._center_mhz, # decode on-center
modulation=self._modulation,
rotator_phase=self._rotator_phase,
)
if pcm and self._proc.stdin:
self._proc.stdin.write(pcm)
self._proc.stdin.flush()
except (BrokenPipeError, OSError):
pass # decoder exited
except Exception as e:
logger.debug(f"FMDemodConsumer on_chunk error: {e}")
def on_stop(self) -> None:
if self._proc:
safe_terminate(self._proc)
unregister_process(self._proc)
self._proc = None
if self._stdout_thread and self._stdout_thread.is_alive():
self._stdout_thread.join(timeout=2)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _start_proc(self) -> None:
import shutil
if not shutil.which(self._decoder_cmd[0]):
logger.warning(
f"FMDemodConsumer: decoder '{self._decoder_cmd[0]}' not found — disabled"
)
return
try:
self._proc = subprocess.Popen(
self._decoder_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
register_process(self._proc)
self._stdout_thread = threading.Thread(
target=self._read_stdout, daemon=True, name='fm-demod-stdout'
)
self._stdout_thread.start()
except Exception as e:
logger.error(f"FMDemodConsumer: failed to start decoder: {e}")
self._proc = None
def _read_stdout(self) -> None:
assert self._proc is not None
assert self._proc.stdout is not None
try:
for line in self._proc.stdout:
decoded = line.decode('utf-8', errors='replace').rstrip()
if decoded and self._on_decoded:
try:
self._on_decoded(decoded)
except Exception as e:
logger.debug(f"FMDemodConsumer callback error: {e}")
except Exception:
pass
# ---------------------------------------------------------------------------
# In-process FM demodulation (mirrors waterfall_websocket._demodulate_monitor_audio)
# ---------------------------------------------------------------------------
def _demodulate(
raw: bytes,
sample_rate: int,
center_mhz: float,
monitor_freq_mhz: float,
modulation: str,
rotator_phase: float,
) -> tuple[bytes | None, float]:
"""Demodulate CU8 IQ to int16 PCM.
Returns ``(pcm_bytes, next_rotator_phase)``.
"""
if len(raw) < 32 or sample_rate <= 0:
return None, float(rotator_phase)
samples = cu8_to_complex(raw)
fs = float(sample_rate)
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
nyquist = fs * 0.5
if abs(freq_offset_hz) > nyquist * 0.98:
return None, float(rotator_phase)
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
n = np.arange(samples.size, dtype=np.float64)
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
shifted = samples * rotator
mod = modulation.lower().strip()
target_bb = 48_000.0
pre_decim = max(1, int(fs // target_bb))
if pre_decim > 1:
usable = (shifted.size // pre_decim) * pre_decim
if usable < pre_decim:
return None, next_phase
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
fs1 = fs / pre_decim
if shifted.size < 16:
return None, next_phase
if mod == 'fm':
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
elif mod == 'am':
envelope = np.abs(shifted).astype(np.float32)
audio = envelope - float(np.mean(envelope))
elif mod == 'usb':
audio = np.real(shifted).astype(np.float32)
elif mod == 'lsb':
audio = -np.real(shifted).astype(np.float32)
else:
audio = np.real(shifted).astype(np.float32)
if audio.size < 8:
return None, next_phase
audio = audio - float(np.mean(audio))
# Resample to AUDIO_RATE
out_len = int(audio.size * AUDIO_RATE / fs1)
if out_len < 32:
return None, next_phase
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
audio = np.interp(x_new, x_old, audio).astype(np.float32)
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
if peak > 0:
audio = audio * min(20.0, 0.85 / peak)
pcm = np.clip(audio, -1.0, 1.0)
return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
@@ -0,0 +1,154 @@
"""GrSatConsumer — pipes CU8 IQ to gr_satellites for packet decoding.
``gr_satellites`` is a GNU Radio-based multi-satellite decoder
(https://github.com/daniestevez/gr-satellites). It accepts complex
float32 (cf32) IQ samples on stdin when invoked with ``--iq``.
This consumer converts CU8 cf32 via numpy and pipes the result to
``gr_satellites``. If the tool is not installed it silently stays
disabled.
Decoded JSON packets are forwarded to an optional ``on_decoded`` callback.
"""
from __future__ import annotations
import shutil
import subprocess
import threading
from typing import Callable
import numpy as np
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
logger = get_logger('intercept.ground_station.gr_satellites')
GR_SATELLITES_BIN = 'gr_satellites'
class GrSatConsumer:
"""CU8 IQ → cf32 → gr_satellites stdin → JSON packets."""
def __init__(
self,
satellite_name: str,
*,
on_decoded: Callable[[dict], None] | None = None,
):
"""
Args:
satellite_name: Satellite name as known to gr_satellites
(e.g. ``'NOAA 15'``, ``'ISS'``).
on_decoded: Callback invoked with each parsed JSON packet dict.
"""
self._satellite_name = satellite_name
self._on_decoded = on_decoded
self._proc: subprocess.Popen | None = None
self._stdout_thread: threading.Thread | None = None
self._sample_rate = 0
self._enabled = False
# ------------------------------------------------------------------
# IQConsumer protocol
# ------------------------------------------------------------------
def on_start(
self,
center_mhz: float,
sample_rate: int,
*,
start_freq_mhz: float,
end_freq_mhz: float,
) -> None:
self._sample_rate = sample_rate
if not shutil.which(GR_SATELLITES_BIN):
logger.info(
"gr_satellites not found — GrSatConsumer disabled. "
"Install via: pip install gr-satellites or apt install python3-gr-satellites"
)
self._enabled = False
return
self._start_proc(sample_rate)
def on_chunk(self, raw: bytes) -> None:
if not self._enabled or self._proc is None or self._proc.poll() is not None:
return
# Convert CU8 → cf32
try:
iq = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
cf32 = ((iq - 127.5) / 127.5).view(np.complex64)
if self._proc.stdin:
self._proc.stdin.write(cf32.tobytes())
self._proc.stdin.flush()
except (BrokenPipeError, OSError):
pass
except Exception as e:
logger.debug(f"GrSatConsumer on_chunk error: {e}")
def on_stop(self) -> None:
self._enabled = False
if self._proc:
safe_terminate(self._proc)
unregister_process(self._proc)
self._proc = None
if self._stdout_thread and self._stdout_thread.is_alive():
self._stdout_thread.join(timeout=2)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _start_proc(self, sample_rate: int) -> None:
import json as _json
cmd = [
GR_SATELLITES_BIN,
self._satellite_name,
'--samplerate', str(sample_rate),
'--iq',
'--json',
'-',
]
try:
self._proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
register_process(self._proc)
self._enabled = True
self._stdout_thread = threading.Thread(
target=self._read_stdout,
args=(_json,),
daemon=True,
name='gr-sat-stdout',
)
self._stdout_thread.start()
logger.info(f"GrSatConsumer started for '{self._satellite_name}'")
except Exception as e:
logger.error(f"GrSatConsumer: failed to start gr_satellites: {e}")
self._proc = None
self._enabled = False
def _read_stdout(self, _json) -> None:
assert self._proc is not None
assert self._proc.stdout is not None
try:
for line in self._proc.stdout:
text = line.decode('utf-8', errors='replace').rstrip()
if not text:
continue
if self._on_decoded:
try:
data = _json.loads(text)
except _json.JSONDecodeError:
data = {'raw': text}
try:
self._on_decoded(data)
except Exception as e:
logger.debug(f"GrSatConsumer callback error: {e}")
except Exception:
pass
@@ -0,0 +1,75 @@
"""SigMFConsumer — wraps SigMFWriter as an IQ bus consumer."""
from __future__ import annotations
from utils.logging import get_logger
from utils.sigmf import SigMFMetadata, SigMFWriter
logger = get_logger('intercept.ground_station.sigmf_consumer')
class SigMFConsumer:
"""IQ consumer that records CU8 chunks to a SigMF file pair."""
def __init__(
self,
metadata: SigMFMetadata,
on_complete: callable | None = None,
):
"""
Args:
metadata: Pre-populated SigMF metadata (satellite info, freq, etc.)
on_complete: Optional callback invoked with ``(meta_path, data_path)``
when the recording is closed.
"""
self._metadata = metadata
self._on_complete = on_complete
self._writer: SigMFWriter | None = None
# ------------------------------------------------------------------
# IQConsumer protocol
# ------------------------------------------------------------------
def on_start(
self,
center_mhz: float,
sample_rate: int,
*,
start_freq_mhz: float,
end_freq_mhz: float,
) -> None:
self._metadata.center_frequency_hz = center_mhz * 1e6
self._metadata.sample_rate = sample_rate
self._writer = SigMFWriter(self._metadata)
try:
self._writer.open()
except Exception as e:
logger.error(f"SigMFConsumer: failed to open writer: {e}")
self._writer = None
def on_chunk(self, raw: bytes) -> None:
if self._writer is None:
return
ok = self._writer.write_chunk(raw)
if not ok and self._writer.aborted:
logger.warning("SigMFConsumer: recording aborted (disk full)")
self._writer = None
def on_stop(self) -> None:
if self._writer is None:
return
result = self._writer.close()
self._writer = None
if result and self._on_complete:
try:
self._on_complete(*result)
except Exception as e:
logger.debug(f"SigMFConsumer on_complete error: {e}")
# ------------------------------------------------------------------
# Status
# ------------------------------------------------------------------
@property
def bytes_written(self) -> int:
return self._writer.bytes_written if self._writer else 0
+121
View File
@@ -0,0 +1,121 @@
"""WaterfallConsumer — converts CU8 IQ chunks into binary waterfall frames.
Frames are placed on an ``output_queue`` that the WebSocket endpoint
(``/ws/satellite_waterfall``) drains and sends to the browser.
Reuses :mod:`utils.waterfall_fft` for FFT processing so the wire format
is identical to the main listening-post waterfall.
"""
from __future__ import annotations
import queue
import time
from utils.logging import get_logger
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
logger = get_logger('intercept.ground_station.waterfall_consumer')
FFT_SIZE = 1024
AVG_COUNT = 4
FPS = 20
DB_MIN: float | None = None # auto-range
DB_MAX: float | None = None
class WaterfallConsumer:
"""IQ consumer that produces waterfall binary frames."""
def __init__(
self,
output_queue: queue.Queue | None = None,
fft_size: int = FFT_SIZE,
avg_count: int = AVG_COUNT,
fps: int = FPS,
db_min: float | None = DB_MIN,
db_max: float | None = DB_MAX,
):
self.output_queue: queue.Queue = output_queue or queue.Queue(maxsize=120)
self._fft_size = fft_size
self._avg_count = avg_count
self._fps = fps
self._db_min = db_min
self._db_max = db_max
self._center_mhz = 0.0
self._start_freq = 0.0
self._end_freq = 0.0
self._sample_rate = 0
self._buffer = b''
self._required_bytes = 0
self._frame_interval = 1.0 / max(1, fps)
self._last_frame_time = 0.0
# ------------------------------------------------------------------
# IQConsumer protocol
# ------------------------------------------------------------------
def on_start(
self,
center_mhz: float,
sample_rate: int,
*,
start_freq_mhz: float,
end_freq_mhz: float,
) -> None:
self._center_mhz = center_mhz
self._sample_rate = sample_rate
self._start_freq = start_freq_mhz
self._end_freq = end_freq_mhz
# How many IQ samples (pairs) we need for one FFT frame
required_samples = max(
self._fft_size * self._avg_count,
sample_rate // max(1, self._fps),
)
self._required_bytes = required_samples * 2 # 1 byte I + 1 byte Q
self._frame_interval = 1.0 / max(1, self._fps)
self._buffer = b''
self._last_frame_time = 0.0
def on_chunk(self, raw: bytes) -> None:
self._buffer += raw
now = time.monotonic()
if (now - self._last_frame_time) < self._frame_interval:
return
if len(self._buffer) < self._required_bytes:
return
chunk = self._buffer[-self._required_bytes:]
self._buffer = b''
self._last_frame_time = now
try:
samples = cu8_to_complex(chunk)
power_db = compute_power_spectrum(
samples, fft_size=self._fft_size, avg_count=self._avg_count
)
quantized = quantize_to_uint8(power_db, db_min=self._db_min, db_max=self._db_max)
frame = build_binary_frame(self._start_freq, self._end_freq, quantized)
except Exception as e:
logger.debug(f"WaterfallConsumer FFT error: {e}")
return
# Non-blocking enqueue: drop oldest if full
if self.output_queue.full():
try:
self.output_queue.get_nowait()
except queue.Empty:
pass
try:
self.output_queue.put_nowait(frame)
except queue.Full:
pass
def on_stop(self) -> None:
self._buffer = b''
+307
View File
@@ -0,0 +1,307 @@
"""IQ broadcast bus — single SDR producer, multiple consumers.
The :class:`IQBus` claims an SDR device, spawns a capture subprocess
(``rx_sdr`` / ``rtl_sdr``), reads raw CU8 bytes from stdout in a
producer thread, and calls :meth:`IQConsumer.on_chunk` on every
registered consumer for each chunk.
Consumers are responsible for their own internal buffering. The bus
does *not* block on slow consumers each consumer's ``on_chunk`` is
called in the producer thread, so consumers must be non-blocking.
"""
from __future__ import annotations
import shutil
import subprocess
import threading
import time
from typing import Protocol, runtime_checkable
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
logger = get_logger('intercept.ground_station.iq_bus')
CHUNK_SIZE = 65_536 # bytes per read (~27 ms @ 2.4 Msps CU8)
@runtime_checkable
class IQConsumer(Protocol):
"""Protocol for objects that receive raw CU8 chunks from the IQ bus."""
def on_chunk(self, raw: bytes) -> None:
"""Called with each raw CU8 chunk from the SDR. Must be fast."""
...
def on_start(
self,
center_mhz: float,
sample_rate: int,
*,
start_freq_mhz: float,
end_freq_mhz: float,
) -> None:
"""Called once when the bus starts, before the first chunk."""
...
def on_stop(self) -> None:
"""Called once when the bus stops (LOS or manual stop)."""
...
class _NoopConsumer:
"""Fallback used internally for isinstance checks."""
def on_chunk(self, raw: bytes) -> None:
pass
def on_start(self, center_mhz, sample_rate, *, start_freq_mhz, end_freq_mhz):
pass
def on_stop(self) -> None:
pass
class IQBus:
"""Single-SDR IQ capture bus with fan-out to multiple consumers."""
def __init__(
self,
*,
center_mhz: float,
sample_rate: int = 2_400_000,
gain: float | None = None,
device_index: int = 0,
sdr_type: str = 'rtlsdr',
ppm: int | None = None,
bias_t: bool = False,
):
self._center_mhz = center_mhz
self._sample_rate = sample_rate
self._gain = gain
self._device_index = device_index
self._sdr_type = sdr_type
self._ppm = ppm
self._bias_t = bias_t
self._consumers: list[IQConsumer] = []
self._consumers_lock = threading.Lock()
self._proc: subprocess.Popen | None = None
self._producer_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._running = False
self._current_freq_mhz = center_mhz
# ------------------------------------------------------------------
# Consumer management
# ------------------------------------------------------------------
def add_consumer(self, consumer: IQConsumer) -> None:
with self._consumers_lock:
if consumer not in self._consumers:
self._consumers.append(consumer)
def remove_consumer(self, consumer: IQConsumer) -> None:
with self._consumers_lock:
self._consumers = [c for c in self._consumers if c is not consumer]
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> tuple[bool, str]:
"""Start IQ capture. Returns (success, error_message)."""
if self._running:
return True, ''
try:
cmd = self._build_command(self._center_mhz)
except Exception as e:
return False, f'Failed to build IQ capture command: {e}'
if not shutil.which(cmd[0]):
return False, f'Required tool "{cmd[0]}" not found. Install SoapySDR (rx_sdr) or rtl-sdr.'
try:
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
register_process(self._proc)
except Exception as e:
return False, f'Failed to spawn IQ capture: {e}'
# Brief check that the process actually started
time.sleep(0.3)
if self._proc.poll() is not None:
stderr_out = ''
if self._proc.stderr:
try:
stderr_out = self._proc.stderr.read().decode('utf-8', errors='replace').strip()
except Exception:
pass
unregister_process(self._proc)
self._proc = None
detail = f': {stderr_out}' if stderr_out else ''
return False, f'IQ capture process exited immediately{detail}'
self._stop_event.clear()
self._running = True
span_mhz = self._sample_rate / 1e6
start_freq_mhz = self._center_mhz - span_mhz / 2
end_freq_mhz = self._center_mhz + span_mhz / 2
with self._consumers_lock:
for consumer in list(self._consumers):
try:
consumer.on_start(
self._center_mhz,
self._sample_rate,
start_freq_mhz=start_freq_mhz,
end_freq_mhz=end_freq_mhz,
)
except Exception as e:
logger.warning(f"Consumer on_start error: {e}")
self._producer_thread = threading.Thread(
target=self._producer_loop, daemon=True, name='iq-bus-producer'
)
self._producer_thread.start()
logger.info(
f"IQBus started: {self._center_mhz} MHz, sr={self._sample_rate}, "
f"device={self._sdr_type}:{self._device_index}"
)
return True, ''
def stop(self) -> None:
"""Stop IQ capture and notify all consumers."""
self._stop_event.set()
if self._proc:
safe_terminate(self._proc)
unregister_process(self._proc)
self._proc = None
if self._producer_thread and self._producer_thread.is_alive():
self._producer_thread.join(timeout=3)
self._running = False
with self._consumers_lock:
for consumer in list(self._consumers):
try:
consumer.on_stop()
except Exception as e:
logger.warning(f"Consumer on_stop error: {e}")
logger.info("IQBus stopped")
def retune(self, new_freq_mhz: float) -> tuple[bool, str]:
"""Retune by stopping and restarting the capture process."""
self._current_freq_mhz = new_freq_mhz
if not self._running:
return False, 'Not running'
# Stop the current process
self._stop_event.set()
if self._proc:
safe_terminate(self._proc)
unregister_process(self._proc)
self._proc = None
if self._producer_thread and self._producer_thread.is_alive():
self._producer_thread.join(timeout=2)
# Restart at new frequency
self._stop_event.clear()
try:
cmd = self._build_command(new_freq_mhz)
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
register_process(self._proc)
except Exception as e:
self._running = False
return False, f'Retune failed: {e}'
self._producer_thread = threading.Thread(
target=self._producer_loop, daemon=True, name='iq-bus-producer'
)
self._producer_thread.start()
logger.info(f"IQBus retuned to {new_freq_mhz:.6f} MHz")
return True, ''
@property
def running(self) -> bool:
return self._running
@property
def center_mhz(self) -> float:
return self._current_freq_mhz
@property
def sample_rate(self) -> int:
return self._sample_rate
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _producer_loop(self) -> None:
"""Read CU8 chunks from the subprocess and fan out to consumers."""
assert self._proc is not None
assert self._proc.stdout is not None
try:
while not self._stop_event.is_set():
if self._proc.poll() is not None:
logger.warning("IQBus: capture process exited unexpectedly")
break
raw = self._proc.stdout.read(CHUNK_SIZE)
if not raw:
break
with self._consumers_lock:
consumers = list(self._consumers)
for consumer in consumers:
try:
consumer.on_chunk(raw)
except Exception as e:
logger.warning(f"Consumer on_chunk error: {e}")
except Exception as e:
logger.error(f"IQBus producer loop error: {e}")
def _build_command(self, freq_mhz: float) -> list[str]:
"""Build the IQ capture command using the SDR factory."""
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRDevice
type_map = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'airspy': SDRType.AIRSPY,
'sdrplay': SDRType.SDRPLAY,
}
sdr_type = type_map.get(self._sdr_type.lower(), SDRType.RTL_SDR)
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
device = SDRDevice(
sdr_type=sdr_type,
index=self._device_index,
name=f'{sdr_type.value}-{self._device_index}',
serial='N/A',
driver=sdr_type.value,
capabilities=caps,
)
return builder.build_iq_capture_command(
device=device,
frequency_mhz=freq_mhz,
sample_rate=self._sample_rate,
gain=self._gain,
ppm=self._ppm,
bias_t=self._bias_t,
)
+475
View File
@@ -0,0 +1,475 @@
"""Meteor LRPT offline decode backend for ground-station observations."""
from __future__ import annotations
import json
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from utils.logging import get_logger
from utils.weather_sat import WeatherSatDecoder
logger = get_logger('intercept.ground_station.meteor_backend')
OUTPUT_ROOT = Path('instance/ground_station/weather_outputs')
DECODE_TIMEOUT_SECONDS = 30 * 60
_NORAD_TO_SAT_KEY = {
57166: 'METEOR-M2-3',
59051: 'METEOR-M2-4',
}
def resolve_meteor_satellite_key(norad_id: int, satellite_name: str) -> str | None:
if norad_id in _NORAD_TO_SAT_KEY:
return _NORAD_TO_SAT_KEY[norad_id]
upper = str(satellite_name or '').upper()
if 'M2-4' in upper:
return 'METEOR-M2-4'
if 'M2-3' in upper or 'METEOR' in upper:
return 'METEOR-M2-3'
return None
def launch_meteor_decode(
*,
obs_db_id: int | None,
norad_id: int,
satellite_name: str,
sample_rate: int,
data_path: Path,
emit_event,
register_output,
) -> None:
"""Run Meteor LRPT offline decode in a background thread."""
decode_job_id = _create_decode_job(
observation_id=obs_db_id,
norad_id=norad_id,
backend='meteor_lrpt',
input_path=data_path,
)
emit_event({
'type': 'weather_decode_queued',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'input_path': str(data_path),
})
t = threading.Thread(
target=_run_decode,
kwargs={
'decode_job_id': decode_job_id,
'obs_db_id': obs_db_id,
'norad_id': norad_id,
'satellite_name': satellite_name,
'sample_rate': sample_rate,
'data_path': data_path,
'emit_event': emit_event,
'register_output': register_output,
},
daemon=True,
name=f'gs-meteor-decode-{norad_id}',
)
t.start()
def _run_decode(
*,
decode_job_id: int | None,
obs_db_id: int | None,
norad_id: int,
satellite_name: str,
sample_rate: int,
data_path: Path,
emit_event,
register_output,
) -> None:
latest_status: dict[str, str | int | None] = {
'message': None,
'status': None,
'phase': None,
}
sat_key = resolve_meteor_satellite_key(norad_id, satellite_name)
if not sat_key:
_update_decode_job(
decode_job_id,
status='failed',
error_message='No Meteor satellite mapping is available for this observation.',
details={'reason': 'unknown_satellite_mapping'},
completed=True,
)
emit_event({
'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'message': 'No Meteor satellite mapping is available for this observation.',
})
return
output_dir = OUTPUT_ROOT / f'{norad_id}_{int(time.time())}'
decoder = WeatherSatDecoder(output_dir=output_dir)
if decoder.decoder_available is None:
_update_decode_job(
decode_job_id,
status='failed',
error_message='SatDump backend is not available for Meteor LRPT decode.',
details={'reason': 'backend_unavailable', 'output_dir': str(output_dir)},
completed=True,
)
emit_event({
'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'message': 'SatDump backend is not available for Meteor LRPT decode.',
})
return
def _progress_cb(progress):
latest_status['message'] = progress.message or latest_status.get('message')
latest_status['status'] = progress.status
latest_status['phase'] = progress.capture_phase or latest_status.get('phase')
progress_event = progress.to_dict()
progress_event.pop('type', None)
emit_event({
'type': 'weather_decode_progress',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
**progress_event,
})
decoder.set_callback(_progress_cb)
_update_decode_job(
decode_job_id,
status='decoding',
output_dir=output_dir,
details={
'sample_rate': sample_rate,
'input_path': str(data_path),
'satellite': satellite_name,
},
started=True,
)
emit_event({
'type': 'weather_decode_started',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'input_path': str(data_path),
})
ok, error = decoder.start_from_file(
satellite=sat_key,
input_file=data_path,
sample_rate=sample_rate,
)
if not ok:
details = _build_failure_details(
data_path=data_path,
sample_rate=sample_rate,
decoder=decoder,
latest_status=latest_status,
)
emit_event({
'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'message': error or details['message'],
'failure_reason': details['reason'],
'details': details,
})
_update_decode_job(
decode_job_id,
status='failed',
error_message=error or details['message'],
details=details,
completed=True,
)
return
started = time.time()
while decoder.is_running and (time.time() - started) < DECODE_TIMEOUT_SECONDS:
time.sleep(1.0)
if decoder.is_running:
decoder.stop()
details = _build_failure_details(
data_path=data_path,
sample_rate=sample_rate,
decoder=decoder,
latest_status=latest_status,
override_reason='timeout',
override_message='Meteor decode timed out.',
)
emit_event({
'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'message': details['message'],
'failure_reason': details['reason'],
'details': details,
})
_update_decode_job(
decode_job_id,
status='failed',
error_message=details['message'],
details=details,
completed=True,
)
return
images = decoder.get_images()
if not images:
details = _build_failure_details(
data_path=data_path,
sample_rate=sample_rate,
decoder=decoder,
latest_status=latest_status,
)
emit_event({
'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'message': details['message'],
'failure_reason': details['reason'],
'details': details,
})
_update_decode_job(
decode_job_id,
status='failed',
error_message=details['message'],
details=details,
completed=True,
)
return
outputs = []
for image in images:
metadata = {
'satellite': image.satellite,
'mode': image.mode,
'frequency': image.frequency,
'product': image.product,
'timestamp': image.timestamp.isoformat(),
'size_bytes': image.size_bytes,
}
output_id = register_output(
observation_id=obs_db_id,
norad_id=norad_id,
output_type='image',
backend='meteor_lrpt',
file_path=image.path,
preview_path=image.path,
metadata=metadata,
)
outputs.append({
'id': output_id,
'file_path': str(image.path),
'filename': image.filename,
'product': image.product,
})
completion_details = {
'sample_rate': sample_rate,
'input_path': str(data_path),
'output_dir': str(output_dir),
'output_count': len(outputs),
}
_update_decode_job(
decode_job_id,
status='complete',
details=completion_details,
completed=True,
)
emit_event({
'type': 'weather_decode_complete',
'decode_job_id': decode_job_id,
'norad_id': norad_id,
'satellite': satellite_name,
'backend': 'meteor_lrpt',
'outputs': outputs,
})
def _build_failure_details(
*,
data_path: Path,
sample_rate: int,
decoder: WeatherSatDecoder,
latest_status: dict[str, str | int | None],
override_reason: str | None = None,
override_message: str | None = None,
) -> dict[str, str | int | None]:
file_size = data_path.stat().st_size if data_path.exists() else 0
status = decoder.get_status()
last_error = str(status.get('last_error') or latest_status.get('message') or '').strip()
return_code = status.get('last_returncode')
if override_reason:
reason = override_reason
elif sample_rate < 200_000:
reason = 'sample_rate_too_low'
elif not data_path.exists():
reason = 'missing_recording'
elif file_size < 5_000_000:
reason = 'recording_too_small'
elif return_code not in (None, 0):
reason = 'satdump_failed'
elif 'samplerate' in last_error.lower() or 'sample rate' in last_error.lower():
reason = 'invalid_sample_rate'
elif 'not found' in last_error.lower():
reason = 'input_missing'
elif 'permission' in last_error.lower():
reason = 'permission_error'
else:
reason = 'no_imagery_produced'
if override_message:
message = override_message
elif reason == 'sample_rate_too_low':
message = f'Sample rate {sample_rate} Hz is too low for Meteor LRPT decoding.'
elif reason == 'missing_recording':
message = 'The recording file was not found when decode started.'
elif reason == 'recording_too_small':
message = (
f'Recording is very small ({_format_bytes(file_size)}); this usually means the pass '
'ended early or little usable IQ was captured.'
)
elif reason == 'satdump_failed':
message = last_error or f'SatDump exited with code {return_code}.'
elif reason == 'invalid_sample_rate':
message = last_error or 'SatDump rejected the recording sample rate.'
elif reason == 'input_missing':
message = last_error or 'Input recording was not accessible to the decoder.'
elif reason == 'permission_error':
message = last_error or 'Decoder could not access the recording or output path.'
else:
message = (
last_error or
'Decode completed without any image outputs. This usually indicates weak signal, '
'incorrect sample rate, or a SatDump pipeline mismatch.'
)
return {
'reason': reason,
'message': message,
'sample_rate': sample_rate,
'file_size_bytes': file_size,
'file_size_human': _format_bytes(file_size),
'last_error': last_error or None,
'last_returncode': return_code,
'capture_phase': status.get('capture_phase') or latest_status.get('phase'),
'input_path': str(data_path),
}
def _format_bytes(size_bytes: int) -> str:
if size_bytes < 1024:
return f'{size_bytes} B'
if size_bytes < 1024 * 1024:
return f'{size_bytes / 1024:.1f} KB'
if size_bytes < 1024 * 1024 * 1024:
return f'{size_bytes / (1024 * 1024):.1f} MB'
return f'{size_bytes / (1024 * 1024 * 1024):.2f} GB'
def _create_decode_job(
*,
observation_id: int | None,
norad_id: int,
backend: str,
input_path: Path,
) -> int | None:
try:
from utils.database import get_db
with get_db() as conn:
cur = conn.execute(
'''
INSERT INTO ground_station_decode_jobs
(observation_id, norad_id, backend, status, input_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
''',
(
observation_id,
norad_id,
backend,
'queued',
str(input_path),
_utcnow_iso(),
_utcnow_iso(),
),
)
return cur.lastrowid
except Exception as e:
logger.warning("Failed to create decode job: %s", e)
return None
def _update_decode_job(
decode_job_id: int | None,
*,
status: str,
output_dir: Path | None = None,
error_message: str | None = None,
details: dict | None = None,
started: bool = False,
completed: bool = False,
) -> None:
if decode_job_id is None:
return
try:
from utils.database import get_db
fields = ['status = ?', 'updated_at = ?']
values: list[object] = [status, _utcnow_iso()]
if output_dir is not None:
fields.append('output_dir = ?')
values.append(str(output_dir))
if error_message is not None:
fields.append('error_message = ?')
values.append(error_message)
if details is not None:
fields.append('details_json = ?')
values.append(json.dumps(details))
if started:
fields.append('started_at = ?')
values.append(_utcnow_iso())
if completed:
fields.append('completed_at = ?')
values.append(_utcnow_iso())
values.append(decode_job_id)
with get_db() as conn:
conn.execute(
f'''
UPDATE ground_station_decode_jobs
SET {", ".join(fields)}
WHERE id = ?
''',
values,
)
except Exception as e:
logger.warning("Failed to update decode job %s: %s", decode_job_id, e)
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
+215
View File
@@ -0,0 +1,215 @@
"""Observation profile dataclass and DB CRUD.
An ObservationProfile describes *how* to capture a particular satellite:
frequency, decoder type, gain, bandwidth, minimum elevation, and whether
to record raw IQ in SigMF format.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from utils.logging import get_logger
logger = get_logger('intercept.ground_station.profile')
VALID_TASK_TYPES = {
'telemetry_ax25',
'telemetry_gmsk',
'telemetry_bpsk',
'weather_meteor_lrpt',
'record_iq',
}
def legacy_decoder_to_tasks(decoder_type: str | None, record_iq: bool = False) -> list[str]:
decoder = (decoder_type or 'fm').lower()
tasks: list[str] = []
if decoder in ('fm', 'afsk'):
tasks.append('telemetry_ax25')
elif decoder == 'gmsk':
tasks.append('telemetry_gmsk')
elif decoder == 'bpsk':
tasks.append('telemetry_bpsk')
elif decoder == 'iq_only':
tasks.append('record_iq')
if record_iq and 'record_iq' not in tasks:
tasks.append('record_iq')
return tasks
def tasks_to_legacy_decoder(tasks: list[str]) -> str:
normalized = normalize_tasks(tasks)
if 'telemetry_bpsk' in normalized:
return 'bpsk'
if 'telemetry_gmsk' in normalized:
return 'gmsk'
if 'telemetry_ax25' in normalized:
return 'afsk'
return 'iq_only'
def normalize_tasks(tasks: list[str] | None) -> list[str]:
result: list[str] = []
for task in tasks or []:
value = str(task or '').strip().lower()
if value and value in VALID_TASK_TYPES and value not in result:
result.append(value)
return result
@dataclass
class ObservationProfile:
"""Per-satellite capture configuration."""
norad_id: int
name: str # Human-readable label
frequency_mhz: float
decoder_type: str # 'fm', 'afsk', 'bpsk', 'gmsk', 'iq_only'
gain: float = 40.0
bandwidth_hz: int = 200_000
min_elevation: float = 10.0
enabled: bool = True
record_iq: bool = False
iq_sample_rate: int = 2_400_000
tasks: list[str] = field(default_factory=list)
id: int | None = None
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
def to_dict(self) -> dict[str, Any]:
normalized_tasks = self.get_tasks()
return {
'id': self.id,
'norad_id': self.norad_id,
'name': self.name,
'frequency_mhz': self.frequency_mhz,
'decoder_type': self.decoder_type,
'legacy_decoder_type': self.decoder_type,
'gain': self.gain,
'bandwidth_hz': self.bandwidth_hz,
'min_elevation': self.min_elevation,
'enabled': self.enabled,
'record_iq': self.record_iq,
'iq_sample_rate': self.iq_sample_rate,
'tasks': normalized_tasks,
'created_at': self.created_at,
}
def get_tasks(self) -> list[str]:
tasks = normalize_tasks(self.tasks)
if not tasks:
tasks = legacy_decoder_to_tasks(self.decoder_type, self.record_iq)
if self.record_iq and 'record_iq' not in tasks:
tasks.append('record_iq')
if 'weather_meteor_lrpt' in tasks and 'record_iq' not in tasks:
tasks.append('record_iq')
return tasks
@classmethod
def from_row(cls, row) -> ObservationProfile:
tasks = []
raw_tasks = row.get('tasks_json', None)
if raw_tasks:
try:
tasks = normalize_tasks(json.loads(raw_tasks))
except (TypeError, ValueError, json.JSONDecodeError):
tasks = []
return cls(
id=row['id'],
norad_id=row['norad_id'],
name=row['name'],
frequency_mhz=row['frequency_mhz'],
decoder_type=row['decoder_type'],
gain=row['gain'],
bandwidth_hz=row['bandwidth_hz'],
min_elevation=row['min_elevation'],
enabled=bool(row['enabled']),
record_iq=bool(row['record_iq']),
iq_sample_rate=row['iq_sample_rate'],
tasks=tasks,
created_at=row['created_at'],
)
# ---------------------------------------------------------------------------
# DB CRUD
# ---------------------------------------------------------------------------
def list_profiles() -> list[ObservationProfile]:
"""Return all observation profiles from the database."""
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'SELECT * FROM observation_profiles ORDER BY created_at DESC'
).fetchall()
return [ObservationProfile.from_row(r) for r in rows]
def get_profile(norad_id: int) -> ObservationProfile | None:
"""Return the profile for a NORAD ID, or None if not found."""
from utils.database import get_db
with get_db() as conn:
row = conn.execute(
'SELECT * FROM observation_profiles WHERE norad_id = ?', (norad_id,)
).fetchone()
return ObservationProfile.from_row(row) if row else None
def save_profile(profile: ObservationProfile) -> ObservationProfile:
"""Insert or replace an observation profile. Returns the saved profile."""
from utils.database import get_db
normalized_tasks = profile.get_tasks()
profile.tasks = normalized_tasks
profile.record_iq = 'record_iq' in normalized_tasks
profile.decoder_type = tasks_to_legacy_decoder(normalized_tasks)
with get_db() as conn:
conn.execute('''
INSERT INTO observation_profiles
(norad_id, name, frequency_mhz, decoder_type, tasks_json, gain,
bandwidth_hz, min_elevation, enabled, record_iq,
iq_sample_rate, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(norad_id) DO UPDATE SET
name=excluded.name,
frequency_mhz=excluded.frequency_mhz,
decoder_type=excluded.decoder_type,
tasks_json=excluded.tasks_json,
gain=excluded.gain,
bandwidth_hz=excluded.bandwidth_hz,
min_elevation=excluded.min_elevation,
enabled=excluded.enabled,
record_iq=excluded.record_iq,
iq_sample_rate=excluded.iq_sample_rate
''', (
profile.norad_id,
profile.name,
profile.frequency_mhz,
profile.decoder_type,
json.dumps(normalized_tasks),
profile.gain,
profile.bandwidth_hz,
profile.min_elevation,
int(profile.enabled),
int(profile.record_iq),
profile.iq_sample_rate,
profile.created_at,
))
return get_profile(profile.norad_id) or profile
def delete_profile(norad_id: int) -> bool:
"""Delete a profile by NORAD ID. Returns True if a row was deleted."""
from utils.database import get_db
with get_db() as conn:
cur = conn.execute(
'DELETE FROM observation_profiles WHERE norad_id = ?', (norad_id,)
)
return cur.rowcount > 0
+932
View File
@@ -0,0 +1,932 @@
"""Ground station automated observation scheduler.
Watches enabled :class:`~utils.ground_station.observation_profile.ObservationProfile`
entries, predicts passes for each satellite, fires a capture at AOS, and
stops it at LOS.
During a capture:
* An :class:`~utils.ground_station.iq_bus.IQBus` claims the SDR device.
* Consumers are attached according to ``profile.decoder_type``:
- ``'iq_only'`` SigMFConsumer only (if ``record_iq`` is True).
- ``'fm'`` FMDemodConsumer (direwolf AX.25) + optional SigMF.
- ``'afsk'`` FMDemodConsumer (direwolf AX.25) + optional SigMF.
- ``'gmsk'`` FMDemodConsumer (multimon-ng) + optional SigMF.
- ``'bpsk'`` GrSatConsumer + optional SigMF.
* A WaterfallConsumer is always attached for the live spectrum panel.
* A Doppler correction thread retunes the IQ bus every 5 s if shift > threshold.
* A rotator control thread points the antenna (if rotctld is available).
"""
from __future__ import annotations
import json
import queue
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from utils.logging import get_logger
logger = get_logger('intercept.ground_station.scheduler')
# Env-configurable Doppler retune threshold (Hz)
try:
from config import GS_DOPPLER_THRESHOLD_HZ # type: ignore[import]
except (ImportError, AttributeError):
import os
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get('INTERCEPT_GS_DOPPLER_THRESHOLD_HZ', 500))
DOPPLER_INTERVAL_SECONDS = 5
SCHEDULE_REFRESH_MINUTES = 30
CAPTURE_BUFFER_SECONDS = 30
# ---------------------------------------------------------------------------
# Scheduled observation (state machine)
# ---------------------------------------------------------------------------
class ScheduledObservation:
"""A single scheduled pass for a profile."""
def __init__(
self,
profile_norad_id: int,
satellite_name: str,
aos_iso: str,
los_iso: str,
max_el: float,
):
self.id = str(uuid.uuid4())[:8]
self.profile_norad_id = profile_norad_id
self.satellite_name = satellite_name
self.aos_iso = aos_iso
self.los_iso = los_iso
self.max_el = max_el
self.status: str = 'scheduled'
self._start_timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None
@property
def aos_dt(self) -> datetime:
return _parse_utc_iso(self.aos_iso)
@property
def los_dt(self) -> datetime:
return _parse_utc_iso(self.los_iso)
def to_dict(self) -> dict[str, Any]:
return {
'id': self.id,
'norad_id': self.profile_norad_id,
'satellite': self.satellite_name,
'aos': self.aos_iso,
'los': self.los_iso,
'max_el': self.max_el,
'status': self.status,
}
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
class GroundStationScheduler:
"""Automated ground station observation scheduler."""
def __init__(self):
self._enabled = False
self._lock = threading.Lock()
self._observations: list[ScheduledObservation] = []
self._refresh_timer: threading.Timer | None = None
self._event_callback: Callable[[dict[str, Any]], None] | None = None
# Active capture state
self._active_obs: ScheduledObservation | None = None
self._active_iq_bus = None # IQBus instance
self._active_waterfall_consumer = None
self._doppler_thread: threading.Thread | None = None
self._doppler_stop = threading.Event()
self._active_profile = None # ObservationProfile
self._active_doppler_tracker = None # DopplerTracker
# Shared waterfall queue (consumed by /ws/satellite_waterfall)
self.waterfall_queue: queue.Queue = queue.Queue(maxsize=120)
# Observer location
self._lat: float = 0.0
self._lon: float = 0.0
self._device: int = 0
self._sdr_type: str = 'rtlsdr'
# ------------------------------------------------------------------
# Public control API
# ------------------------------------------------------------------
def set_event_callback(
self, callback: Callable[[dict[str, Any]], None]
) -> None:
self._event_callback = callback
def enable(
self,
lat: float,
lon: float,
device: int = 0,
sdr_type: str = 'rtlsdr',
) -> dict[str, Any]:
with self._lock:
self._lat = lat
self._lon = lon
self._device = device
self._sdr_type = sdr_type
self._enabled = True
self._refresh_schedule()
return self.get_status()
def disable(self) -> dict[str, Any]:
with self._lock:
self._enabled = False
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = None
for obs in self._observations:
if obs._start_timer:
obs._start_timer.cancel()
if obs._stop_timer:
obs._stop_timer.cancel()
self._observations.clear()
self._stop_active_capture(reason='scheduler_disabled')
return {'status': 'disabled'}
@property
def enabled(self) -> bool:
return self._enabled
def get_status(self) -> dict[str, Any]:
with self._lock:
active = self._active_obs.to_dict() if self._active_obs else None
return {
'enabled': self._enabled,
'observer': {'latitude': self._lat, 'longitude': self._lon},
'device': self._device,
'sdr_type': self._sdr_type,
'scheduled_count': sum(
1 for o in self._observations if o.status == 'scheduled'
),
'total_observations': len(self._observations),
'active_observation': active,
'waterfall_active': self._active_iq_bus is not None
and self._active_iq_bus.running,
}
def get_scheduled_observations(self) -> list[dict[str, Any]]:
with self._lock:
return [o.to_dict() for o in self._observations]
def trigger_manual(self, norad_id: int) -> tuple[bool, str]:
"""Immediately start a manual observation for the given NORAD ID."""
from utils.ground_station.observation_profile import get_profile
profile = get_profile(norad_id)
if not profile:
return False, f'No observation profile for NORAD {norad_id}'
obs = ScheduledObservation(
profile_norad_id=norad_id,
satellite_name=profile.name,
aos_iso=datetime.now(timezone.utc).isoformat(),
los_iso=(datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat(),
max_el=90.0,
)
self._execute_observation(obs)
return True, 'Manual observation started'
def stop_active(self) -> dict[str, Any]:
"""Stop the currently running observation."""
self._stop_active_capture(reason='manual_stop')
return self.get_status()
# ------------------------------------------------------------------
# Schedule management
# ------------------------------------------------------------------
def _refresh_schedule(self) -> None:
if not self._enabled:
return
from utils.ground_station.observation_profile import list_profiles
profiles = [p for p in list_profiles() if p.enabled]
if not profiles:
logger.info("Ground station scheduler: no enabled profiles")
self._arm_refresh_timer()
return
try:
passes_by_profile = self._predict_passes_for_profiles(profiles)
except Exception as e:
logger.error(f"Ground station scheduler: pass prediction failed: {e}")
self._arm_refresh_timer()
return
with self._lock:
# Cancel existing scheduled timers (keep active/complete)
for obs in self._observations:
if obs.status == 'scheduled':
if obs._start_timer:
obs._start_timer.cancel()
if obs._stop_timer:
obs._stop_timer.cancel()
history = [o for o in self._observations if o.status in ('complete', 'capturing', 'failed')]
self._observations = history
now = datetime.now(timezone.utc)
buf = CAPTURE_BUFFER_SECONDS
for obs in passes_by_profile:
capture_start = obs.aos_dt - timedelta(seconds=buf)
capture_end = obs.los_dt + timedelta(seconds=buf)
if capture_end <= now:
continue
if any(h.id == obs.id for h in history):
continue
delay = max(0.0, (capture_start - now).total_seconds())
obs._start_timer = threading.Timer(
delay, self._execute_observation, args=[obs]
)
obs._start_timer.daemon = True
obs._start_timer.start()
self._observations.append(obs)
scheduled = sum(1 for o in self._observations if o.status == 'scheduled')
logger.info(f"Ground station scheduler refreshed: {scheduled} observations scheduled")
self._arm_refresh_timer()
def _arm_refresh_timer(self) -> None:
if self._refresh_timer:
self._refresh_timer.cancel()
if not self._enabled:
return
self._refresh_timer = threading.Timer(
SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule
)
self._refresh_timer.daemon = True
self._refresh_timer.start()
def _predict_passes_for_profiles(
self, profiles: list
) -> list[ScheduledObservation]:
"""Predict passes for each profile and return ScheduledObservation list."""
from skyfield.api import load, wgs84
from utils.satellite_predict import predict_passes as _predict_passes
try:
ts = load.timescale(builtin=True)
except Exception:
from skyfield.api import load as _load
ts = _load.timescale(builtin=True)
observer = wgs84.latlon(self._lat, self._lon)
now = datetime.now(timezone.utc)
import datetime as _dt
t0 = ts.utc(now)
t1 = ts.utc(now + _dt.timedelta(hours=24))
observations: list[ScheduledObservation] = []
for profile in profiles:
tle = _find_tle_by_norad(profile.norad_id)
if tle is None:
logger.warning(
f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping"
)
continue
try:
passes = _predict_passes(
tle_data=tle,
observer=observer,
ts=ts,
t0=t0,
t1=t1,
min_el=profile.min_elevation,
include_trajectory=False,
include_ground_track=False,
)
except Exception as e:
logger.warning(f"Pass prediction failed for {profile.name}: {e}")
continue
for p in passes:
obs = ScheduledObservation(
profile_norad_id=profile.norad_id,
satellite_name=profile.name,
aos_iso=p.get('startTimeISO', ''),
los_iso=p.get('endTimeISO', ''),
max_el=float(p.get('maxEl', 0.0)),
)
observations.append(obs)
return observations
# ------------------------------------------------------------------
# Capture execution
# ------------------------------------------------------------------
def _execute_observation(self, obs: ScheduledObservation) -> None:
"""Called at AOS (+ buffer) to start IQ capture."""
if not self._enabled:
return
if obs.status == 'scheduled':
obs.status = 'capturing'
else:
return # already cancelled / complete
from utils.ground_station.observation_profile import get_profile
profile = get_profile(obs.profile_norad_id)
if not profile or not profile.enabled:
obs.status = 'failed'
return
# Claim SDR device
try:
import app as _app
err = _app.claim_sdr_device(self._device, 'ground_station_iq_bus', self._sdr_type)
if err:
logger.warning(f"Ground station: SDR busy — skipping {obs.satellite_name}: {err}")
obs.status = 'failed'
self._emit_event({'type': 'observation_skipped', 'observation': obs.to_dict(), 'reason': 'device_busy'})
return
except ImportError:
pass
# Create DB record
obs_db_id = _insert_observation_record(obs, profile)
# Build IQ bus
from utils.ground_station.iq_bus import IQBus
bus = IQBus(
center_mhz=profile.frequency_mhz,
sample_rate=profile.iq_sample_rate,
gain=profile.gain,
device_index=self._device,
sdr_type=self._sdr_type,
)
# Attach waterfall consumer (always)
from utils.ground_station.consumers.waterfall import WaterfallConsumer
wf_consumer = WaterfallConsumer(output_queue=self.waterfall_queue)
bus.add_consumer(wf_consumer)
# Attach decoder consumers
self._attach_decoder_consumers(bus, profile, obs_db_id, obs)
# Attach SigMF consumer when explicitly requested or required by tasks
if _profile_requires_iq_recording(profile):
self._attach_sigmf_consumer(bus, profile, obs_db_id)
# Start bus
ok, err_msg = bus.start()
if not ok:
logger.error(f"Ground station: failed to start IQBus for {obs.satellite_name}: {err_msg}")
obs.status = 'failed'
try:
import app as _app
_app.release_sdr_device(self._device, self._sdr_type)
except ImportError:
pass
self._emit_event({'type': 'observation_failed', 'observation': obs.to_dict(), 'reason': err_msg})
return
with self._lock:
self._active_obs = obs
self._active_iq_bus = bus
self._active_waterfall_consumer = wf_consumer
self._active_profile = profile
# Emit iq_bus_started SSE event (used by Phase 5 waterfall)
span_mhz = profile.iq_sample_rate / 1e6
self._emit_event({
'type': 'iq_bus_started',
'observation': obs.to_dict(),
'center_mhz': profile.frequency_mhz,
'span_mhz': span_mhz,
})
self._emit_event({'type': 'observation_started', 'observation': obs.to_dict()})
logger.info(f"Ground station: observation started for {obs.satellite_name} (NORAD {obs.profile_norad_id})")
# Start Doppler correction thread
self._start_doppler_thread(profile, obs)
# Schedule stop at LOS + buffer
now = datetime.now(timezone.utc)
stop_delay = (obs.los_dt + timedelta(seconds=CAPTURE_BUFFER_SECONDS) - now).total_seconds()
if stop_delay > 0:
obs._stop_timer = threading.Timer(
stop_delay, self._stop_active_capture, kwargs={'reason': 'los'}
)
obs._stop_timer.daemon = True
obs._stop_timer.start()
else:
self._stop_active_capture(reason='los_immediate')
def _stop_active_capture(self, *, reason: str = 'manual') -> None:
"""Stop the currently active capture and release the SDR device."""
with self._lock:
bus = self._active_iq_bus
obs = self._active_obs
self._active_iq_bus = None
self._active_obs = None
self._active_waterfall_consumer = None
self._active_profile = None
self._active_doppler_tracker = None
self._doppler_stop.set()
if bus and bus.running:
bus.stop()
if obs:
obs.status = 'complete'
_update_observation_status(obs, 'complete')
self._emit_event({
'type': 'observation_complete',
'observation': obs.to_dict(),
'reason': reason,
})
self._emit_event({'type': 'iq_bus_stopped', 'observation': obs.to_dict()})
try:
import app as _app
_app.release_sdr_device(self._device, self._sdr_type)
except ImportError:
pass
logger.info(f"Ground station: observation stopped ({reason})")
# ------------------------------------------------------------------
# Consumer attachment helpers
# ------------------------------------------------------------------
def _attach_decoder_consumers(self, bus, profile, obs_db_id: int | None, obs) -> None:
"""Attach consumers for all telemetry tasks on the profile."""
import shutil
tasks = _get_profile_tasks(profile)
if 'telemetry_ax25' in tasks:
if shutil.which('direwolf'):
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
consumer = FMDemodConsumer(
decoder_cmd=[
'direwolf', '-r', '48000', '-n', '1', '-b', '16', '-',
],
modulation='fm',
on_decoded=lambda line: self._on_packet_decoded(
line, obs_db_id, obs, source='direwolf'
),
)
bus.add_consumer(consumer)
logger.info("Ground station: attached direwolf AX.25 decoder")
else:
logger.warning("direwolf not found — AX.25 decoding disabled")
if 'telemetry_gmsk' in tasks:
if shutil.which('multimon-ng'):
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
consumer = FMDemodConsumer(
decoder_cmd=['multimon-ng', '-t', 'raw', '-a', 'GMSK', '-'],
modulation='fm',
on_decoded=lambda line: self._on_packet_decoded(
line, obs_db_id, obs, source='multimon-ng'
),
)
bus.add_consumer(consumer)
logger.info("Ground station: attached multimon-ng GMSK decoder")
else:
logger.warning("multimon-ng not found — GMSK decoding disabled")
if 'telemetry_bpsk' in tasks:
from utils.ground_station.consumers.gr_satellites import GrSatConsumer
consumer = GrSatConsumer(
satellite_name=profile.name,
on_decoded=lambda pkt: self._on_packet_decoded(
pkt,
obs_db_id,
obs,
source='gr_satellites',
),
)
bus.add_consumer(consumer)
def _attach_sigmf_consumer(self, bus, profile, obs_db_id: int | None) -> None:
"""Attach a SigMFConsumer for raw IQ recording."""
from utils.ground_station.consumers.sigmf_writer import SigMFConsumer
from utils.sigmf import SigMFMetadata
meta = SigMFMetadata(
sample_rate=profile.iq_sample_rate,
center_frequency_hz=profile.frequency_mhz * 1e6,
satellite_name=profile.name,
norad_id=profile.norad_id,
latitude=self._lat,
longitude=self._lon,
)
def _on_recording_complete(meta_path, data_path):
_insert_recording_record(obs_db_id, meta_path, data_path, profile)
self._emit_event({
'type': 'recording_complete',
'norad_id': profile.norad_id,
'data_path': str(data_path),
'meta_path': str(meta_path),
})
if 'weather_meteor_lrpt' in _get_profile_tasks(profile):
try:
from utils.ground_station.meteor_backend import launch_meteor_decode
launch_meteor_decode(
obs_db_id=obs_db_id,
norad_id=profile.norad_id,
satellite_name=profile.name,
sample_rate=profile.iq_sample_rate,
data_path=Path(data_path),
emit_event=self._emit_event,
register_output=_insert_output_record,
)
except Exception as e:
logger.warning(f"Failed to launch Meteor decode backend: {e}")
self._emit_event({
'type': 'weather_decode_failed',
'norad_id': profile.norad_id,
'satellite': profile.name,
'backend': 'meteor_lrpt',
'message': str(e),
})
consumer = SigMFConsumer(metadata=meta, on_complete=_on_recording_complete)
bus.add_consumer(consumer)
logger.info(f"Ground station: SigMF recording enabled for {profile.name}")
# ------------------------------------------------------------------
# Doppler correction (Phase 2)
# ------------------------------------------------------------------
def _start_doppler_thread(self, profile, obs: ScheduledObservation) -> None:
"""Start the Doppler tracking/retune thread for an active capture."""
from utils.doppler import DopplerTracker
tle = _find_tle_by_norad(profile.norad_id)
if tle is None:
logger.info(f"Ground station: no TLE for {profile.name} — Doppler disabled")
return
tracker = DopplerTracker(satellite_name=profile.name, tle_data=tle)
if not tracker.configure(self._lat, self._lon):
logger.info(f"Ground station: Doppler tracking not available for {profile.name}")
return
with self._lock:
self._active_doppler_tracker = tracker
self._doppler_stop.clear()
t = threading.Thread(
target=self._doppler_loop,
args=[profile, tracker],
daemon=True,
name='gs-doppler',
)
t.start()
self._doppler_thread = t
logger.info(f"Ground station: Doppler tracking started for {profile.name}")
def _doppler_loop(self, profile, tracker) -> None:
"""Periodically compute Doppler shift and retune if necessary."""
while not self._doppler_stop.wait(DOPPLER_INTERVAL_SECONDS):
with self._lock:
bus = self._active_iq_bus
if bus is None or not bus.running:
break
info = tracker.calculate(profile.frequency_mhz)
if info is None:
continue
# Retune if shift exceeds threshold
if abs(info.shift_hz) >= GS_DOPPLER_THRESHOLD_HZ:
corrected_mhz = info.frequency_hz / 1_000_000
logger.info(
f"Ground station: Doppler retune {info.shift_hz:+.1f} Hz → "
f"{corrected_mhz:.6f} MHz (el={info.elevation:.1f}°)"
)
bus.retune(corrected_mhz)
self._emit_event({
'type': 'doppler_update',
'norad_id': profile.norad_id,
**info.to_dict(),
})
# Rotator control (Phase 6)
try:
from utils.rotator import get_rotator
rotator = get_rotator()
if rotator.enabled:
rotator.point_to(info.azimuth, info.elevation)
except Exception:
pass
logger.debug("Ground station: Doppler loop exited")
# ------------------------------------------------------------------
# Packet / event callbacks
# ------------------------------------------------------------------
def _on_packet_decoded(
self,
payload,
obs_db_id: int | None,
obs: ScheduledObservation,
*,
source: str = 'decoder',
) -> None:
"""Handle a decoded packet payload from a decoder consumer."""
if payload is None or payload == '':
return
packet_event = _build_packet_event(payload, source)
_insert_event_record(obs_db_id, 'packet', json.dumps(packet_event))
self._emit_event({
'type': 'packet_decoded',
'norad_id': obs.profile_norad_id,
'satellite': obs.satellite_name,
**packet_event,
})
def _emit_event(self, event: dict[str, Any]) -> None:
if self._event_callback:
try:
self._event_callback(event)
except Exception as e:
logger.debug(f"Event callback error: {e}")
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None:
try:
from datetime import datetime, timezone
from utils.database import get_db
with get_db() as conn:
cur = conn.execute('''
INSERT INTO ground_station_observations
(profile_id, norad_id, satellite, aos_time, los_time, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
profile.id,
obs.profile_norad_id,
obs.satellite_name,
obs.aos_iso,
obs.los_iso,
'capturing',
datetime.now(timezone.utc).isoformat(),
))
return cur.lastrowid
except Exception as e:
logger.warning(f"Failed to insert observation record: {e}")
return None
def _update_observation_status(obs: ScheduledObservation, status: str) -> None:
try:
from utils.database import get_db
with get_db() as conn:
conn.execute(
'UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?',
(status, obs.profile_norad_id, 'capturing'),
)
except Exception as e:
logger.debug(f"Failed to update observation status: {e}")
def _insert_event_record(obs_db_id: int | None, event_type: str, payload: str) -> None:
if obs_db_id is None:
return
try:
from datetime import datetime, timezone
from utils.database import get_db
with get_db() as conn:
conn.execute('''
INSERT INTO ground_station_events (observation_id, event_type, payload_json, timestamp)
VALUES (?, ?, ?, ?)
''', (obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()))
except Exception as e:
logger.debug(f"Failed to insert event record: {e}")
def _get_profile_tasks(profile) -> list[str]:
get_tasks = getattr(profile, 'get_tasks', None)
if callable(get_tasks):
return get_tasks()
return []
def _profile_requires_iq_recording(profile) -> bool:
tasks = _get_profile_tasks(profile)
return bool(getattr(profile, 'record_iq', False) or 'record_iq' in tasks or 'weather_meteor_lrpt' in tasks)
def _build_packet_event(payload, source: str) -> dict[str, Any]:
event: dict[str, Any] = {
'source': source,
'data': payload if isinstance(payload, str) else json.dumps(payload),
'parsed': None,
}
if isinstance(payload, dict):
event['parsed'] = payload
event['protocol'] = payload.get('protocol') or payload.get('type') or source
return event
text = str(payload).strip()
event['data'] = text
parsed = None
if source == 'gr_satellites':
try:
candidate = json.loads(text)
if isinstance(candidate, dict):
parsed = candidate
except json.JSONDecodeError:
parsed = None
if parsed is None:
try:
import base64
from utils.satellite_telemetry import auto_parse
for token in text.replace(',', ' ').split():
cleaned = token.strip()
if not cleaned or len(cleaned) < 8:
continue
try:
raw = base64.b64decode(cleaned, validate=True)
except Exception:
continue
maybe = auto_parse(raw)
if maybe:
parsed = maybe
break
except Exception:
parsed = None
event['parsed'] = parsed
if isinstance(parsed, dict):
event['protocol'] = parsed.get('protocol') or source
return event
def _insert_recording_record(obs_db_id: int | None, meta_path: Path, data_path: Path, profile) -> None:
try:
from datetime import datetime, timezone
from utils.database import get_db
size = data_path.stat().st_size if data_path.exists() else 0
with get_db() as conn:
conn.execute('''
INSERT INTO sigmf_recordings
(observation_id, sigmf_data_path, sigmf_meta_path, size_bytes,
sample_rate, center_freq_hz, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
obs_db_id,
str(data_path),
str(meta_path),
size,
profile.iq_sample_rate,
int(profile.frequency_mhz * 1e6),
datetime.now(timezone.utc).isoformat(),
))
except Exception as e:
logger.warning(f"Failed to insert recording record: {e}")
def _insert_output_record(
*,
observation_id: int | None,
norad_id: int | None,
output_type: str,
backend: str,
file_path: Path,
preview_path: Path | None = None,
metadata: dict[str, Any] | None = None,
) -> int | None:
try:
from datetime import datetime, timezone
from utils.database import get_db
with get_db() as conn:
cur = conn.execute(
'''
INSERT INTO ground_station_outputs
(observation_id, norad_id, output_type, backend, file_path,
preview_path, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''',
(
observation_id,
norad_id,
output_type,
backend,
str(file_path),
str(preview_path) if preview_path else None,
json.dumps(metadata or {}),
datetime.now(timezone.utc).isoformat(),
),
)
return cur.lastrowid
except Exception as e:
logger.warning(f"Failed to insert output record: {e}")
return None
# ---------------------------------------------------------------------------
# TLE lookup helpers
# ---------------------------------------------------------------------------
def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
"""Search TLE cache for a given NORAD catalog number."""
# Try live cache first
sources = []
try:
from routes.satellite import _tle_cache # type: ignore[import]
if _tle_cache:
sources.append(_tle_cache)
except (ImportError, AttributeError):
pass
try:
from data.satellites import TLE_SATELLITES
sources.append(TLE_SATELLITES)
except ImportError:
pass
target_id = str(norad_id).zfill(5)
for source in sources:
for _key, tle in source.items():
if not isinstance(tle, (tuple, list)) or len(tle) < 3:
continue
line1 = str(tle[1])
# NORAD catalog number occupies chars 2-6 (0-indexed) of TLE line 1
if len(line1) > 7:
catalog_str = line1[2:7].strip()
if catalog_str == target_id:
return (str(tle[0]), str(tle[1]), str(tle[2]))
return None
# ---------------------------------------------------------------------------
# Timestamp parser (mirrors weather_sat_scheduler)
# ---------------------------------------------------------------------------
def _parse_utc_iso(value: str) -> datetime:
text = str(value).strip().replace('+00:00Z', 'Z')
if text.endswith('Z'):
text = text[:-1] + '+00:00'
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
_scheduler: GroundStationScheduler | None = None
_scheduler_lock = threading.Lock()
def get_ground_station_scheduler() -> GroundStationScheduler:
"""Get or create the global ground station scheduler."""
global _scheduler
if _scheduler is None:
with _scheduler_lock:
if _scheduler is None:
_scheduler = GroundStationScheduler()
return _scheduler
+194
View File
@@ -0,0 +1,194 @@
"""Hamlib rotctld TCP client for antenna rotator control.
Communicates with a running ``rotctld`` daemon over TCP using the simple
line-based Hamlib protocol::
Client ``P <azimuth> <elevation>\\n``
Server ``RPRT 0\\n`` (success)
If ``rotctld`` is not reachable the controller silently operates in a
disabled state the rest of the system functions normally.
Usage::
rotator = get_rotator()
if rotator.connect('127.0.0.1', 4533):
rotator.point_to(az=180.0, el=30.0)
rotator.park()
rotator.disconnect()
"""
from __future__ import annotations
import socket
import threading
from utils.logging import get_logger
logger = get_logger('intercept.rotator')
DEFAULT_HOST = '127.0.0.1'
DEFAULT_PORT = 4533
DEFAULT_TIMEOUT = 2.0 # seconds
class RotatorController:
"""Thin wrapper around the rotctld TCP protocol."""
def __init__(self):
self._sock: socket.socket | None = None
self._lock = threading.Lock()
self._host = DEFAULT_HOST
self._port = DEFAULT_PORT
self._enabled = False
self._current_az: float = 0.0
self._current_el: float = 0.0
# ------------------------------------------------------------------
# Connection management
# ------------------------------------------------------------------
def connect(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
"""Connect to rotctld. Returns True on success."""
with self._lock:
self._host = host
self._port = port
try:
s = socket.create_connection((host, port), timeout=DEFAULT_TIMEOUT)
s.settimeout(DEFAULT_TIMEOUT)
self._sock = s
self._enabled = True
logger.info(f"Rotator connected to rotctld at {host}:{port}")
return True
except OSError as e:
logger.warning(f"Could not connect to rotctld at {host}:{port}: {e}")
self._sock = None
self._enabled = False
return False
def disconnect(self) -> None:
"""Close the TCP connection."""
with self._lock:
if self._sock:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._enabled = False
logger.info("Rotator disconnected")
# ------------------------------------------------------------------
# Commands
# ------------------------------------------------------------------
def point_to(self, az: float, el: float) -> bool:
"""Send a ``P`` (set position) command.
Azimuth and elevation are clamped to valid ranges before sending.
Returns True if the command was acknowledged.
"""
az = max(0.0, min(360.0, float(az)))
el = max(0.0, min(90.0, float(el)))
ok = self._send_command(f'P {az:.1f} {el:.1f}')
if ok:
self._current_az = az
self._current_el = el
return ok
def park(self) -> bool:
"""Send rotator to park position (0° az, 0° el)."""
return self.point_to(0.0, 0.0)
def get_position(self) -> tuple[float, float] | None:
"""Query current position. Returns (az, el) or None on failure."""
with self._lock:
if not self._enabled or self._sock is None:
return None
try:
self._sock.sendall(b'p\n')
resp = self._recv_line()
if resp and 'RPRT' not in resp:
parts = resp.split()
if len(parts) >= 2:
return float(parts[0]), float(parts[1])
except Exception as e:
logger.warning(f"Rotator get_position failed: {e}")
self._enabled = False
self._sock = None
return None
# ------------------------------------------------------------------
# Status
# ------------------------------------------------------------------
@property
def enabled(self) -> bool:
return self._enabled
def get_status(self) -> dict:
return {
'enabled': self._enabled,
'host': self._host,
'port': self._port,
'current_az': self._current_az,
'current_el': self._current_el,
}
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _send_command(self, cmd: str) -> bool:
with self._lock:
if not self._enabled or self._sock is None:
return False
try:
self._sock.sendall((cmd + '\n').encode())
resp = self._recv_line()
if resp and 'RPRT 0' in resp:
return True
logger.warning(f"Rotator unexpected response to '{cmd}': {resp!r}")
return False
except Exception as e:
logger.warning(f"Rotator command '{cmd}' failed: {e}")
self._enabled = False
try:
self._sock.close()
except OSError:
pass
self._sock = None
return False
def _recv_line(self, max_bytes: int = 256) -> str:
"""Read until newline (already holding _lock)."""
buf = b''
assert self._sock is not None
while len(buf) < max_bytes:
c = self._sock.recv(1)
if not c:
break
buf += c
if c == b'\n':
break
return buf.decode('ascii', errors='replace').strip()
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
_rotator: RotatorController | None = None
_rotator_lock = threading.Lock()
def get_rotator() -> RotatorController:
"""Get or create the global rotator controller instance."""
global _rotator
if _rotator is None:
with _rotator_lock:
if _rotator is None:
_rotator = RotatorController()
return _rotator
+208
View File
@@ -0,0 +1,208 @@
"""Shared satellite pass prediction utility.
Used by both the satellite tracking dashboard and the weather satellite scheduler.
Uses Skyfield's find_events() for accurate AOS/TCA/LOS event detection.
"""
from __future__ import annotations
from typing import Any
from utils.logging import get_logger
logger = get_logger('intercept.satellite_predict')
def predict_passes(
tle_data: tuple,
observer, # skyfield wgs84.latlon object
ts, # skyfield timescale
t0, # skyfield Time start
t1, # skyfield Time end
min_el: float = 10.0,
include_trajectory: bool = True,
include_ground_track: bool = True,
) -> list[dict[str, Any]]:
"""Predict satellite passes over an observer location.
Args:
tle_data: (name, line1, line2) tuple
observer: Skyfield wgs84.latlon observer
ts: Skyfield timescale
t0: Start time (Skyfield Time)
t1: End time (Skyfield Time)
min_el: Minimum peak elevation in degrees to include pass
include_trajectory: Include 30-point az/el trajectory for polar plot
include_ground_track: Include 60-point lat/lon ground track for map
Returns:
List of pass dicts sorted by AOS time. Each dict contains:
aosTime, aosAz, aosEl,
tcaTime, tcaEl, tcaAz,
losTime, losAz, losEl,
duration (minutes, float),
startTime (human-readable UTC),
startTimeISO (ISO string),
endTimeISO (ISO string),
maxEl (float, same as tcaEl),
trajectory (list of {az, el} if include_trajectory),
groundTrack (list of {lat, lon} if include_ground_track)
"""
from skyfield.api import EarthSatellite, wgs84
# Filter decaying satellites by checking ndot from TLE line1 chars 33-43
try:
line1 = tle_data[1]
ndot_str = line1[33:43].strip()
ndot = float(ndot_str)
if abs(ndot) > 0.01:
logger.debug(
'Skipping decaying satellite %s (ndot=%s)', tle_data[0], ndot
)
return []
except (ValueError, IndexError):
# Don't skip on parse error
pass
# Create EarthSatellite object
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
except Exception as exc:
logger.debug('Failed to create EarthSatellite for %s: %s', tle_data[0], exc)
return []
# Find events using Skyfield's native find_events()
# Event types: 0=AOS, 1=TCA, 2=LOS
try:
times, events = satellite.find_events(
observer, t0, t1, altitude_degrees=min_el
)
except Exception as exc:
logger.debug('find_events failed for %s: %s', tle_data[0], exc)
return []
# Group events into AOS->TCA->LOS triplets
passes = []
i = 0
total = len(events)
# Skip any leading non-AOS events (satellite already above horizon at t0)
while i < total and events[i] != 0:
i += 1
while i < total:
# Expect AOS (0)
if events[i] != 0:
i += 1
continue
aos_time = times[i]
i += 1
# Collect TCA and LOS, watching for premature next AOS
tca_time = None
los_time = None
while i < total and events[i] != 0:
if events[i] == 1:
tca_time = times[i]
elif events[i] == 2:
los_time = times[i]
i += 1
# Must have both AOS and LOS to form a valid pass
if los_time is None:
# Incomplete pass — skip
continue
# If TCA is missing, derive from midpoint between AOS and LOS
if tca_time is None:
aos_tt = aos_time.tt
los_tt = los_time.tt
tca_time = ts.tt_jd((aos_tt + los_tt) / 2.0)
# Compute topocentric positions at AOS, TCA, LOS
try:
aos_topo = (satellite - observer).at(aos_time)
tca_topo = (satellite - observer).at(tca_time)
los_topo = (satellite - observer).at(los_time)
aos_alt, aos_az, _ = aos_topo.altaz()
tca_alt, tca_az, _ = tca_topo.altaz()
los_alt, los_az, _ = los_topo.altaz()
aos_dt = aos_time.utc_datetime()
tca_dt = tca_time.utc_datetime()
los_dt = los_time.utc_datetime()
duration = (los_dt - aos_dt).total_seconds() / 60.0
pass_dict: dict[str, Any] = {
'aosTime': aos_dt.isoformat(),
'aosAz': round(float(aos_az.degrees), 1),
'aosEl': round(float(aos_alt.degrees), 1),
'tcaTime': tca_dt.isoformat(),
'tcaAz': round(float(tca_az.degrees), 1),
'tcaEl': round(float(tca_alt.degrees), 1),
'losTime': los_dt.isoformat(),
'losAz': round(float(los_az.degrees), 1),
'losEl': round(float(los_alt.degrees), 1),
'duration': round(duration, 1),
# Backwards-compatible fields
'startTime': aos_dt.strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': aos_dt.isoformat(),
'endTimeISO': los_dt.isoformat(),
'maxEl': round(float(tca_alt.degrees), 1),
}
# Build 30-point az/el trajectory for polar plot
if include_trajectory:
trajectory = []
for step in range(30):
frac = step / 29.0
t_pt = ts.tt_jd(
aos_time.tt + frac * (los_time.tt - aos_time.tt)
)
try:
pt_alt, pt_az, _ = (satellite - observer).at(t_pt).altaz()
trajectory.append({
'az': round(float(pt_az.degrees), 1),
'el': round(float(max(0.0, pt_alt.degrees)), 1),
})
except Exception as pt_exc:
logger.debug(
'Trajectory point error for %s: %s', tle_data[0], pt_exc
)
pass_dict['trajectory'] = trajectory
# Build 60-point lat/lon ground track for map
if include_ground_track:
ground_track = []
for step in range(60):
frac = step / 59.0
t_pt = ts.tt_jd(
aos_time.tt + frac * (los_time.tt - aos_time.tt)
)
try:
geocentric = satellite.at(t_pt)
subpoint = wgs84.subpoint(geocentric)
ground_track.append({
'lat': round(float(subpoint.latitude.degrees), 4),
'lon': round(float(subpoint.longitude.degrees), 4),
})
except Exception as gt_exc:
logger.debug(
'Ground track point error for %s: %s', tle_data[0], gt_exc
)
pass_dict['groundTrack'] = ground_track
passes.append(pass_dict)
except Exception as exc:
logger.debug(
'Failed to compute pass details for %s: %s', tle_data[0], exc
)
continue
passes.sort(key=lambda p: p['startTimeISO'])
return passes
+435
View File
@@ -0,0 +1,435 @@
"""Satellite telemetry packet parsers.
Provides pure-Python decoders for common amateur/CubeSat protocols:
- AX.25 (callsign-addressed frames)
- CSP (CubeSat Space Protocol)
- CCSDS TM (space packet primary header)
Also provides a PayloadAnalyzer that generates multi-interpretation
views of raw binary data (hex dump, float32, uint16/32, strings).
"""
from __future__ import annotations
import math
import string
import struct
from datetime import datetime
# ---------------------------------------------------------------------------
# AX.25 parser
# ---------------------------------------------------------------------------
def _decode_ax25_callsign(addr_bytes: bytes) -> str:
"""Decode a 7-byte AX.25 address field into a 'CALL-SSID' string.
The first 6 bytes encode the callsign (each ASCII character left-shifted
by 1 bit). The 7th byte encodes the SSID in bits 4-1.
Args:
addr_bytes: Exactly 7 bytes of raw address data.
Returns:
A callsign string such as ``"N0CALL-3"`` or ``"N0CALL"`` (no suffix
when SSID is 0).
"""
callsign = "".join(chr(b >> 1) for b in addr_bytes[:6]).rstrip()
ssid = (addr_bytes[6] >> 1) & 0x0F
return f"{callsign}-{ssid}" if ssid else callsign
def parse_ax25(data: bytes) -> dict | None:
"""Parse an AX.25 frame from raw bytes.
Decodes destination and source callsigns, optional repeater addresses,
control byte, optional PID byte, and payload.
Args:
data: Raw bytes of the AX.25 frame (without HDLC flags or FCS).
Returns:
A dict with parsed fields or ``None`` if the frame is too short or
cannot be decoded.
"""
try:
# Minimum: 7 (dest) + 7 (src) + 1 (control) = 15 bytes
if len(data) < 15:
return None
destination = _decode_ax25_callsign(data[0:7])
source = _decode_ax25_callsign(data[7:14])
# Walk repeater addresses. The H-bit (LSB of byte 6 in each address)
# being set means this is the last address in the chain.
offset = 14 # byte index of the last byte in the source field
repeaters: list[str] = []
if not (data[offset] & 0x01):
# More addresses follow; read up to 8 repeaters.
for _ in range(8):
rep_start = offset + 1
rep_end = rep_start + 7
if rep_end > len(data):
break
repeaters.append(_decode_ax25_callsign(data[rep_start:rep_end]))
offset = rep_end - 1 # last byte of this repeater field
if data[offset] & 0x01:
# H-bit set — this was the final address
break
# Control byte follows the last address field
ctrl_offset = offset + 1
if ctrl_offset >= len(data):
return None
control = data[ctrl_offset]
payload_offset = ctrl_offset + 1
# PID byte is present for I-frames (bits 0-1 == 0b00) and
# UI-frames (bits 0-5 == 0b000011). More generally: absent only
# for pure unnumbered frames where (control & 0x03) == 0x03 AND
# control is not 0x03 itself (UI).
pid: int | None = None
is_unnumbered = (control & 0x03) == 0x03
is_ui = control == 0x03
if not is_unnumbered or is_ui:
if payload_offset < len(data):
pid = data[payload_offset]
payload_offset += 1
payload = data[payload_offset:]
return {
"protocol": "AX.25",
"destination": destination,
"source": source,
"repeaters": repeaters,
"control": control,
"pid": pid,
"payload": payload,
"payload_hex": payload.hex(),
"payload_length": len(payload),
}
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# CSP parser
# ---------------------------------------------------------------------------
def parse_csp(data: bytes) -> dict | None:
"""Parse a CSP v1 (CubeSat Space Protocol) header.
The first 4 bytes form a big-endian 32-bit header word with the
following bit layout::
bits 31-27 priority (5 bits)
bits 26-22 source (5 bits)
bits 21-17 destination (5 bits)
bits 16-12 dest_port (5 bits)
bits 11-6 src_port (6 bits)
bits 5-0 flags (6 bits)
Args:
data: Raw bytes starting from the CSP header.
Returns:
A dict with parsed CSP fields and payload, or ``None`` on failure.
"""
try:
if len(data) < 4:
return None
header: int = struct.unpack(">I", data[:4])[0]
priority = (header >> 27) & 0x1F
source = (header >> 22) & 0x1F
destination = (header >> 17) & 0x1F
dest_port = (header >> 12) & 0x1F
src_port = (header >> 6) & 0x3F
raw_flags = header & 0x3F
flags = {
"frag": bool(raw_flags & 0x10),
"hmac": bool(raw_flags & 0x08),
"xtea": bool(raw_flags & 0x04),
"rdp": bool(raw_flags & 0x02),
"crc": bool(raw_flags & 0x01),
}
payload = data[4:]
return {
"protocol": "CSP",
"priority": priority,
"source": source,
"destination": destination,
"dest_port": dest_port,
"src_port": src_port,
"flags": flags,
"payload": payload,
"payload_hex": payload.hex(),
"payload_length": len(payload),
}
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# CCSDS parser
# ---------------------------------------------------------------------------
def parse_ccsds(data: bytes) -> dict | None:
"""Parse a CCSDS Space Packet primary header (6 bytes).
Header layout::
bytes 0-1: version (3 bits) | packet_type (1 bit) |
secondary_header_flag (1 bit) | APID (11 bits)
bytes 2-3: sequence_flags (2 bits) | sequence_count (14 bits)
bytes 4-5: data_length field (16 bits, = actual_payload_length - 1)
Args:
data: Raw bytes starting from the CCSDS primary header.
Returns:
A dict with parsed CCSDS fields and payload, or ``None`` on failure.
"""
try:
if len(data) < 6:
return None
word0: int = struct.unpack(">H", data[0:2])[0]
word1: int = struct.unpack(">H", data[2:4])[0]
word2: int = struct.unpack(">H", data[4:6])[0]
version = (word0 >> 13) & 0x07
packet_type = (word0 >> 12) & 0x01
secondary_header_flag = bool((word0 >> 11) & 0x01)
apid = word0 & 0x07FF
sequence_flags = (word1 >> 14) & 0x03
sequence_count = word1 & 0x3FFF
data_length = word2 # raw field; actual user data bytes = data_length + 1
payload = data[6:]
return {
"protocol": "CCSDS_TM",
"version": version,
"packet_type": packet_type,
"secondary_header": secondary_header_flag,
"apid": apid,
"sequence_flags": sequence_flags,
"sequence_count": sequence_count,
"data_length": data_length,
"payload": payload,
"payload_hex": payload.hex(),
"payload_length": len(payload),
}
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# Payload analyzer
# ---------------------------------------------------------------------------
_PRINTABLE = set(string.printable) - set("\t\n\r\x0b\x0c")
def _hex_dump(data: bytes) -> str:
"""Format bytes as an annotated hex dump, 16 bytes per line.
Each line is formatted as::
OOOO: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX ASCII
where ``OOOO`` is the hex offset and ``ASCII`` shows printable characters
(non-printable replaced with ``'.'``).
Args:
data: Bytes to format.
Returns:
Multi-line hex dump string (trailing newline on each line).
"""
lines: list[str] = []
for row in range(0, len(data), 16):
chunk = data[row : row + 16]
# Build groups of 4 bytes separated by two spaces
groups: list[str] = []
for g in range(0, 16, 4):
group_bytes = chunk[g : g + 4]
groups.append(" ".join(f"{b:02X}" for b in group_bytes))
hex_part = " ".join(groups)
# Pad to fixed width: 16 bytes × 3 chars - 1 space + 3 group separators
# Maximum width: 11+2+11+2+11+2+11 = 50 chars; pad to 50
hex_part = hex_part.ljust(50)
ascii_part = "".join(chr(b) if chr(b) in _PRINTABLE else "." for b in chunk)
lines.append(f"{row:04X}: {hex_part} {ascii_part}\n")
return "".join(lines)
def _extract_strings(data: bytes, min_len: int = 3) -> list[str]:
"""Extract runs of printable ASCII characters of at least ``min_len``."""
results: list[str] = []
current: list[str] = []
for b in data:
ch = chr(b)
if ch in _PRINTABLE:
current.append(ch)
else:
if len(current) >= min_len:
results.append("".join(current))
current = []
if len(current) >= min_len:
results.append("".join(current))
return results
def analyze_payload(data: bytes) -> dict:
"""Generate a multi-interpretation analysis of raw bytes.
Produces a hex dump, several numeric/string interpretations, and a
list of heuristic observations about plausible sensor values.
Args:
data: Raw bytes to analyze.
Returns:
A dict containing ``hex_dump``, ``length``, ``interpretations``,
and ``heuristics`` keys. Never raises an exception.
"""
try:
hex_dump = _hex_dump(data)
length = len(data)
# --- float32 (little-endian) ---
float32_values: list[float] = []
for i in range(0, length - 3, 4):
(val,) = struct.unpack_from("<f", data, i)
if not math.isnan(val) and abs(val) <= 1e9:
float32_values.append(val)
# --- uint16 little-endian ---
uint16_values: list[int] = []
for i in range(0, length - 1, 2):
(val,) = struct.unpack_from("<H", data, i)
uint16_values.append(val)
# --- uint32 little-endian ---
uint32_values: list[int] = []
for i in range(0, length - 3, 4):
(val,) = struct.unpack_from("<I", data, i)
uint32_values.append(val)
# --- printable string runs ---
strings = _extract_strings(data, min_len=3)
interpretations = {
"float32": float32_values,
"uint16_le": uint16_values,
"uint32_le": uint32_values,
"strings": strings,
}
# --- heuristics ---
heuristics: list[str] = []
used_as_voltage: set[int] = set()
for idx, v in enumerate(float32_values):
# Voltage: small positive float
if 0.0 < v < 10.0:
heuristics.append(f"Possible voltage: {v:.3f} V (index {idx})")
used_as_voltage.add(idx)
for idx, v in enumerate(float32_values):
# Temperature: plausible range, not already flagged as voltage, not zero
if -50.0 < v < 120.0 and idx not in used_as_voltage and v != 0.0:
heuristics.append(f"Possible temperature: {v:.1f}°C (index {idx})")
for idx, v in enumerate(float32_values):
# Current: small positive float not already flagged as voltage
if 0.0 < v < 5.0 and idx not in used_as_voltage:
heuristics.append(f"Possible current: {v:.3f} A (index {idx})")
for idx, v in enumerate(float32_values):
# Unix timestamp: plausible range (roughly 20012033)
if 1_000_000_000.0 < v < 2_000_000_000.0:
ts = datetime.utcfromtimestamp(v)
heuristics.append(f"Possible Unix timestamp: {ts} (index {idx})")
return {
"hex_dump": hex_dump,
"length": length,
"interpretations": interpretations,
"heuristics": heuristics,
}
except Exception: # noqa: BLE001
# Guarantee a safe return even on completely malformed input
return {
"hex_dump": "",
"length": len(data) if isinstance(data, (bytes, bytearray)) else 0,
"interpretations": {"float32": [], "uint16_le": [], "uint32_le": [], "strings": []},
"heuristics": [],
}
# ---------------------------------------------------------------------------
# Auto-parser
# ---------------------------------------------------------------------------
def auto_parse(data: bytes) -> dict:
"""Attempt to decode a packet using each supported protocol in turn.
Tries parsers in priority order: CSP CCSDS AX.25. Returns the
first successful parse merged with a ``payload_analysis`` key produced
by :func:`analyze_payload`.
Args:
data: Raw bytes of the packet.
Returns:
A dict with parsed protocol fields plus ``payload_analysis``, or a
fallback dict with ``protocol: 'unknown'`` and a top-level
``analysis`` key if no parser succeeds.
"""
# CSP: 4-byte header minimum
if len(data) >= 4:
result = parse_csp(data)
if result is not None:
result["payload_analysis"] = analyze_payload(result["payload"])
return result
# CCSDS: 6-byte header minimum
if len(data) >= 6:
result = parse_ccsds(data)
if result is not None:
result["payload_analysis"] = analyze_payload(result["payload"])
return result
# AX.25: 15-byte frame minimum
if len(data) >= 15:
result = parse_ax25(data)
if result is not None:
result["payload_analysis"] = analyze_payload(result["payload"])
return result
# Nothing matched — return a raw analysis
return {
"protocol": "unknown",
"raw_hex": data.hex(),
"analysis": analyze_payload(data),
}
+258
View File
@@ -0,0 +1,258 @@
"""SatNOGS transmitter data.
Fetches downlink/uplink frequency data from the SatNOGS database,
keyed by NORAD ID. Cached for 24 hours to avoid hammering the API.
"""
from __future__ import annotations
import json
import threading
import time
import urllib.request
from utils.logging import get_logger
logger = get_logger("intercept.satnogs")
# ---------------------------------------------------------------------------
# Module-level cache
# ---------------------------------------------------------------------------
_transmitters: dict[int, list[dict]] = {}
_fetched_at: float = 0.0
_CACHE_TTL = 86400 # 24 hours in seconds
_fetch_lock = threading.Lock()
_prefetch_started = False
_SATNOGS_URL = "https://db.satnogs.org/api/transmitters/?format=json"
_REQUEST_TIMEOUT = 6 # seconds
_BUILTIN_TRANSMITTERS: dict[int, list[dict]] = {
25544: [
{
"description": "APRS digipeater",
"downlink_low": 145.825,
"downlink_high": 145.825,
"uplink_low": None,
"uplink_high": None,
"mode": "FM AX.25",
"baud": 1200,
"status": "active",
"type": "beacon",
"service": "Packet",
},
{
"description": "SSTV events",
"downlink_low": 145.800,
"downlink_high": 145.800,
"uplink_low": None,
"uplink_high": None,
"mode": "FM",
"baud": None,
"status": "active",
"type": "image",
"service": "SSTV",
},
],
40069: [
{
"description": "Meteor LRPT weather downlink",
"downlink_low": 137.900,
"downlink_high": 137.900,
"uplink_low": None,
"uplink_high": None,
"mode": "LRPT",
"baud": 72000,
"status": "active",
"type": "image",
"service": "Weather",
},
],
57166: [
{
"description": "Meteor LRPT weather downlink",
"downlink_low": 137.900,
"downlink_high": 137.900,
"uplink_low": None,
"uplink_high": None,
"mode": "LRPT",
"baud": 72000,
"status": "active",
"type": "image",
"service": "Weather",
},
],
59051: [
{
"description": "Meteor LRPT weather downlink",
"downlink_low": 137.900,
"downlink_high": 137.900,
"uplink_low": None,
"uplink_high": None,
"mode": "LRPT",
"baud": 72000,
"status": "active",
"type": "image",
"service": "Weather",
},
],
}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _hz_to_mhz(value: float | int | None) -> float | None:
"""Convert a frequency in Hz to MHz, returning None if value is None."""
if value is None:
return None
return float(value) / 1_000_000.0
def _safe_float(value: object) -> float | None:
"""Return a float or None, silently swallowing conversion errors."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fetch_transmitters() -> dict[int, list[dict]]:
"""Fetch transmitter records from the SatNOGS database API.
Makes a single HTTP GET to the SatNOGS transmitters endpoint, groups
results by NORAD catalogue ID, and converts all frequency fields from
Hz to MHz.
Returns:
A dict mapping NORAD ID (int) to a list of transmitter dicts.
Returns an empty dict on any network or parse error.
"""
try:
logger.info("Fetching SatNOGS transmitter data from %s", _SATNOGS_URL)
with urllib.request.urlopen(_SATNOGS_URL, timeout=_REQUEST_TIMEOUT) as resp:
raw = resp.read()
records: list[dict] = json.loads(raw)
grouped: dict[int, list[dict]] = {}
for item in records:
norad_id = item.get("norad_cat_id")
if norad_id is None:
continue
norad_id = int(norad_id)
entry: dict = {
"description": str(item.get("description") or ""),
"downlink_low": _hz_to_mhz(_safe_float(item.get("downlink_low"))),
"downlink_high": _hz_to_mhz(_safe_float(item.get("downlink_high"))),
"uplink_low": _hz_to_mhz(_safe_float(item.get("uplink_low"))),
"uplink_high": _hz_to_mhz(_safe_float(item.get("uplink_high"))),
"mode": str(item.get("mode") or ""),
"baud": _safe_float(item.get("baud")),
"status": str(item.get("status") or ""),
"type": str(item.get("type") or ""),
"service": str(item.get("service") or ""),
}
grouped.setdefault(norad_id, []).append(entry)
logger.info(
"SatNOGS fetch complete: %d satellites with transmitter data",
len(grouped),
)
return grouped
except Exception as exc: # noqa: BLE001
logger.warning("Failed to fetch SatNOGS transmitter data: %s", exc)
return {}
def get_transmitters(norad_id: int) -> list[dict]:
"""Return cached transmitter records for a given NORAD catalogue ID.
Refreshes the in-memory cache from the SatNOGS API when the cache is
empty or older than ``_CACHE_TTL`` seconds (24 hours).
Args:
norad_id: The NORAD catalogue ID of the satellite.
Returns:
A (possibly empty) list of transmitter dicts for that satellite.
"""
global _transmitters, _fetched_at # noqa: PLW0603
sat_id = int(norad_id)
age = time.time() - _fetched_at
# Fast path: serve warm cache immediately.
if _transmitters and age <= _CACHE_TTL:
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
# Avoid blocking the UI behind a long-running background refresh.
if not _fetch_lock.acquire(blocking=False):
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
try:
age = time.time() - _fetched_at
if not _transmitters or age > _CACHE_TTL:
fetched = fetch_transmitters()
if fetched:
_transmitters = fetched
_fetched_at = time.time()
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
finally:
_fetch_lock.release()
def refresh_transmitters() -> int:
"""Force-refresh the transmitter cache regardless of TTL.
Returns:
The number of satellites (unique NORAD IDs) with transmitter data
after the refresh.
"""
global _transmitters, _fetched_at # noqa: PLW0603
with _fetch_lock:
fetched = fetch_transmitters()
if fetched:
_transmitters = fetched
_fetched_at = time.time()
return len(_transmitters)
def prefetch_transmitters() -> None:
"""Kick off a background thread to warm the transmitter cache at startup.
Safe to call multiple times only spawns one thread.
"""
global _prefetch_started # noqa: PLW0603
with _fetch_lock:
if _prefetch_started:
return
_prefetch_started = True
def _run() -> None:
logger.info("Pre-fetching SatNOGS transmitter data in background...")
global _transmitters, _fetched_at # noqa: PLW0603
data = fetch_transmitters()
with _fetch_lock:
_transmitters = data
_fetched_at = time.time()
logger.info("SatNOGS prefetch complete: %d satellites cached", len(data))
t = threading.Thread(target=_run, name="satnogs-prefetch", daemon=True)
t.start()
+36 -4
View File
@@ -46,6 +46,35 @@ def _rtl_tool_supports_bias_t(tool_path: str) -> bool:
return False return False
def enable_bias_t_via_rtl_biast(device_index: int = 0) -> bool:
"""Enable bias-t power using rtl_biast (RTL-SDR Blog drivers).
Runs rtl_biast to set the bias-t register on the device, then exits.
The setting persists across device opens until the device is reset.
Returns True if bias-t was enabled successfully.
"""
rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast'
try:
result = subprocess.run(
[rtl_biast_path, '-b', '1', '-d', str(device_index)],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logger.info(f"Bias-t enabled via rtl_biast on device {device_index}")
return True
logger.warning(f"rtl_biast failed (exit {result.returncode}): {result.stderr.strip()}")
return False
except FileNotFoundError:
logger.warning("rtl_biast not found — install RTL-SDR Blog drivers for bias-t support")
return False
except Exception as e:
logger.warning(f"Failed to enable bias-t via rtl_biast: {e}")
return False
def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None: def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None:
"""Detect the correct bias-t flag for the installed dump1090 variant. """Detect the correct bias-t flag for the installed dump1090 variant.
@@ -197,10 +226,13 @@ class RTLSDRCommandBuilder(CommandBuilder):
if bias_t_flag: if bias_t_flag:
cmd.append(bias_t_flag) cmd.append(bias_t_flag)
else: else:
logger.warning( # Fallback: use rtl_biast to set bias-t before starting dump1090
f"Bias-t requested but {dump1090_path} does not support it. " if not enable_bias_t_via_rtl_biast(device.index):
"Consider using dump1090-fa or readsb for bias-t support." logger.warning(
) f"Bias-t requested but {dump1090_path} does not support it "
"and rtl_biast is not available. Install RTL-SDR Blog drivers "
"or use dump1090-fa/readsb for bias-t support."
)
return cmd return cmd
+208
View File
@@ -0,0 +1,208 @@
"""SigMF metadata and writer for IQ recordings.
Writes raw CU8 I/Q data to ``.sigmf-data`` files and companion
``.sigmf-meta`` JSON metadata files conforming to the SigMF spec v1.x.
Output directory: ``instance/ground_station/recordings/``
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from utils.logging import get_logger
logger = get_logger('intercept.sigmf')
# Abort recording if less than this many bytes are free on the disk
DEFAULT_MIN_FREE_BYTES = 500 * 1024 * 1024 # 500 MB
OUTPUT_DIR = Path('instance/ground_station/recordings')
@dataclass
class SigMFMetadata:
"""SigMF metadata block.
Covers the fields most relevant for ground-station recordings. The
``global`` block is always written; an ``annotations`` list is built
incrementally if callers add annotation events.
"""
sample_rate: int
center_frequency_hz: float
datatype: str = 'cu8' # unsigned 8-bit I/Q (rtlsdr native)
description: str = ''
author: str = 'INTERCEPT ground station'
recorder: str = 'INTERCEPT'
hw: str = ''
norad_id: int = 0
satellite_name: str = ''
latitude: float = 0.0
longitude: float = 0.0
annotations: list[dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
global_block: dict[str, Any] = {
'core:datatype': self.datatype,
'core:sample_rate': self.sample_rate,
'core:version': '1.0.0',
'core:recorder': self.recorder,
}
if self.description:
global_block['core:description'] = self.description
if self.author:
global_block['core:author'] = self.author
if self.hw:
global_block['core:hw'] = self.hw
if self.latitude or self.longitude:
global_block['core:geolocation'] = {
'type': 'Point',
'coordinates': [self.longitude, self.latitude],
}
captures = [
{
'core:sample_start': 0,
'core:frequency': self.center_frequency_hz,
'core:datetime': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
}
]
return {
'global': global_block,
'captures': captures,
'annotations': self.annotations,
}
class SigMFWriter:
"""Streams raw CU8 IQ bytes to a SigMF recording pair."""
def __init__(
self,
metadata: SigMFMetadata,
output_dir: Path | str | None = None,
stem: str | None = None,
min_free_bytes: int = DEFAULT_MIN_FREE_BYTES,
):
self._metadata = metadata
self._output_dir = Path(output_dir) if output_dir else OUTPUT_DIR
self._stem = stem or _default_stem(metadata)
self._min_free_bytes = min_free_bytes
self._data_path: Path | None = None
self._meta_path: Path | None = None
self._data_file = None
self._bytes_written = 0
self._aborted = False
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def open(self) -> None:
"""Create output directory and open the data file for writing."""
self._output_dir.mkdir(parents=True, exist_ok=True)
self._data_path = self._output_dir / f'{self._stem}.sigmf-data'
self._meta_path = self._output_dir / f'{self._stem}.sigmf-meta'
self._data_file = open(self._data_path, 'wb')
self._bytes_written = 0
self._aborted = False
logger.info(f"SigMFWriter opened: {self._data_path}")
def write_chunk(self, raw: bytes) -> bool:
"""Write a chunk of raw CU8 bytes.
Returns False (and sets ``aborted``) if disk space drops below
the minimum threshold.
"""
if self._aborted or self._data_file is None:
return False
# Check free space before writing
try:
usage = shutil.disk_usage(self._output_dir)
if usage.free < self._min_free_bytes:
logger.warning(
f"SigMF recording aborted — disk free "
f"({usage.free // (1024**2)} MB) below "
f"{self._min_free_bytes // (1024**2)} MB threshold"
)
self._aborted = True
self._data_file.close()
self._data_file = None
return False
except Exception:
pass
self._data_file.write(raw)
self._bytes_written += len(raw)
return True
def close(self) -> tuple[Path, Path] | None:
"""Flush data, write .sigmf-meta, close file.
Returns ``(meta_path, data_path)`` on success, *None* if never
opened or already aborted before any data was written.
"""
if self._data_file is not None:
try:
self._data_file.flush()
self._data_file.close()
except Exception:
pass
self._data_file = None
if self._data_path is None or self._meta_path is None:
return None
if self._bytes_written == 0 and not self._aborted:
# Nothing written — clean up empty file
self._data_path.unlink(missing_ok=True)
return None
try:
meta_dict = self._metadata.to_dict()
self._meta_path.write_text(
json.dumps(meta_dict, indent=2), encoding='utf-8'
)
except Exception as e:
logger.error(f"Failed to write SigMF metadata: {e}")
logger.info(
f"SigMFWriter closed: {self._bytes_written} bytes → {self._data_path}"
)
return self._meta_path, self._data_path
@property
def bytes_written(self) -> int:
return self._bytes_written
@property
def aborted(self) -> bool:
return self._aborted
@property
def data_path(self) -> Path | None:
return self._data_path
@property
def meta_path(self) -> Path | None:
return self._meta_path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_stem(meta: SigMFMetadata) -> str:
ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
sat = (meta.satellite_name or 'unknown').replace(' ', '_').replace('/', '-')
freq_khz = int(meta.center_frequency_hz / 1000)
return f'{ts}_{sat}_{freq_khz}kHz'
+4
View File
@@ -152,6 +152,10 @@ def sse_stream_fanout(
) )
last_keepalive = time.time() last_keepalive = time.time()
# Send an immediate keepalive so the browser receives response headers
# right away (Werkzeug dev server buffers headers until first body byte).
yield format_sse({'type': 'keepalive'})
try: try:
while True: while True:
if stop_check and stop_check(): if stop_check and stop_check():
+15 -110
View File
@@ -3,8 +3,8 @@
Provides the SSTVDecoder class that manages the full pipeline: Provides the SSTVDecoder class that manages the full pipeline:
rtl_fm subprocess -> audio stream -> VIS detection -> image decoding -> PNG output. rtl_fm subprocess -> audio stream -> VIS detection -> image decoding -> PNG output.
Also contains DopplerTracker and supporting dataclasses migrated from the DopplerTracker and DopplerInfo live in utils/doppler.py and are re-exported
original monolithic utils/sstv.py. here for backwards compatibility.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,15 +16,20 @@ import subprocess
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
import numpy as np import numpy as np
# DopplerTracker/DopplerInfo now live in the shared utils/doppler module.
# Import them here so existing code that does
# ``from utils.sstv.sstv_decoder import DopplerTracker``
# continues to work unchanged.
from utils.doppler import DopplerInfo, DopplerTracker # noqa: F401
from utils.logging import get_logger from utils.logging import get_logger
from .constants import ISS_SSTV_FREQ, SAMPLE_RATE, SPEED_OF_LIGHT from .constants import ISS_SSTV_FREQ, SAMPLE_RATE
from .dsp import goertzel_mag, normalize_audio from .dsp import goertzel_mag, normalize_audio
from .image_decoder import SSTVImageDecoder from .image_decoder import SSTVImageDecoder
from .modes import get_mode from .modes import get_mode
@@ -42,25 +47,10 @@ except ImportError:
# Dataclasses # Dataclasses
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass # DopplerInfo is now defined in utils/doppler and imported at the top of
class DopplerInfo: # this module. The re-export keeps any code that does
"""Doppler shift information.""" # from utils.sstv.sstv_decoder import DopplerInfo
frequency_hz: float # working without changes.
shift_hz: float
range_rate_km_s: float
elevation: float
azimuth: float
timestamp: datetime
def to_dict(self) -> dict:
return {
'frequency_hz': self.frequency_hz,
'shift_hz': round(self.shift_hz, 1),
'range_rate_km_s': round(self.range_rate_km_s, 3),
'elevation': round(self.elevation, 1),
'azimuth': round(self.azimuth, 1),
'timestamp': self.timestamp.isoformat(),
}
@dataclass @dataclass
@@ -133,93 +123,8 @@ def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> l
return packed.tolist() return packed.tolist()
# --------------------------------------------------------------------------- # DopplerTracker is now imported from utils/doppler at the top of this module.
# DopplerTracker # Nothing to define here.
# ---------------------------------------------------------------------------
class DopplerTracker:
"""Real-time Doppler shift calculator for satellite tracking.
Uses skyfield to calculate the range rate between observer and satellite,
then computes the Doppler-shifted receive frequency.
"""
def __init__(self, satellite_name: str = 'ISS'):
self._satellite_name = satellite_name
self._observer_lat: float | None = None
self._observer_lon: float | None = None
self._satellite = None
self._observer = None
self._ts = None
self._enabled = False
def configure(self, latitude: float, longitude: float) -> bool:
"""Configure the Doppler tracker with observer location."""
try:
from skyfield.api import EarthSatellite, load, wgs84
from data.satellites import TLE_SATELLITES
tle_data = TLE_SATELLITES.get(self._satellite_name)
if not tle_data:
logger.error(f"No TLE data for satellite: {self._satellite_name}")
return False
self._ts = load.timescale()
self._satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], self._ts)
self._observer = wgs84.latlon(latitude, longitude)
self._observer_lat = latitude
self._observer_lon = longitude
self._enabled = True
logger.info(f"Doppler tracker configured for {self._satellite_name} at ({latitude}, {longitude})")
return True
except ImportError:
logger.warning("skyfield not available - Doppler tracking disabled")
return False
except Exception as e:
logger.error(f"Failed to configure Doppler tracker: {e}")
return False
@property
def is_enabled(self) -> bool:
return self._enabled
def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None:
"""Calculate current Doppler-shifted frequency."""
if not self._enabled or not self._satellite or not self._observer:
return None
try:
t = self._ts.now()
difference = self._satellite - self._observer
topocentric = difference.at(t)
alt, az, distance = topocentric.altaz()
dt_seconds = 1.0
t_future = self._ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds))
topocentric_future = difference.at(t_future)
_, _, distance_future = topocentric_future.altaz()
range_rate_km_s = (distance_future.km - distance.km) / dt_seconds
nominal_freq_hz = nominal_freq_mhz * 1_000_000
doppler_factor = 1 - (range_rate_km_s * 1000 / SPEED_OF_LIGHT)
corrected_freq_hz = nominal_freq_hz * doppler_factor
shift_hz = corrected_freq_hz - nominal_freq_hz
return DopplerInfo(
frequency_hz=corrected_freq_hz,
shift_hz=shift_hz,
range_rate_km_s=range_rate_km_s,
elevation=alt.degrees,
azimuth=az.degrees,
timestamp=datetime.now(timezone.utc)
)
except Exception as e:
logger.error(f"Doppler calculation failed: {e}")
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+33 -17
View File
@@ -1,16 +1,13 @@
"""Weather Satellite decoder for NOAA APT and Meteor LRPT imagery. """Weather satellite decoder focused on Meteor LRPT workflows.
Provides automated capture and decoding of weather satellite images using SatDump. Provides automated capture and decoding of weather imagery using SatDump.
Supported satellites: Active satellites:
- NOAA-15: 137.620 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- NOAA-18: 137.9125 MHz (APT) [DEFUNCT - decommissioned Jun 2025]
- NOAA-19: 137.100 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- Meteor-M2-3: 137.900 MHz (LRPT) - Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT) - Meteor-M2-4: 137.900 MHz (LRPT)
Uses SatDump CLI for live SDR capture and decoding, with fallback to Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
rtl_fm capture for manual decoding when SatDump is unavailable. and historical metadata, but they are no longer active operational targets.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,8 +31,15 @@ from utils.process import register_process, safe_terminate
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ALLOWED_OFFLINE_INPUT_DIRS = (
PROJECT_ROOT / 'data',
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
)
# Weather satellite definitions
# Weather satellite definitions.
# NOAA APT entries are retained as inactive compatibility metadata.
WEATHER_SATELLITES = { WEATHER_SATELLITES = {
'NOAA-15': { 'NOAA-15': {
'name': 'NOAA 15', 'name': 'NOAA 15',
@@ -152,8 +156,8 @@ class CaptureProgress:
class WeatherSatDecoder: class WeatherSatDecoder:
"""Weather satellite decoder using SatDump CLI. """Weather satellite decoder using SatDump CLI.
Manages live SDR capture and decoding of NOAA APT and Meteor LRPT Manages live SDR capture and offline decode for the active Meteor LRPT
satellite transmissions. workflow, while preserving compatibility with older weather-sat metadata.
""" """
def __init__(self, output_dir: str | Path | None = None): def __init__(self, output_dir: str | Path | None = None):
@@ -177,6 +181,8 @@ class WeatherSatDecoder:
self._capture_output_dir: Path | None = None self._capture_output_dir: Path | None = None
self._on_complete_callback: Callable[[], None] | None = None self._on_complete_callback: Callable[[], None] | None = None
self._capture_phase: str = 'idle' self._capture_phase: str = 'idle'
self._last_error_message: str = ''
self._last_process_returncode: int | None = None
# Ensure output directory exists # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True) self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -245,7 +251,7 @@ class WeatherSatDecoder:
No SDR hardware is required SatDump runs in offline mode. No SDR hardware is required SatDump runs in offline mode.
Args: Args:
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3') satellite: Satellite key (for example ``'METEOR-M2-3'``)
input_file: Path to IQ baseband or WAV audio file input_file: Path to IQ baseband or WAV audio file
sample_rate: Sample rate of the recording in Hz sample_rate: Sample rate of the recording in Hz
@@ -277,13 +283,13 @@ class WeatherSatDecoder:
input_path = Path(input_file) input_path = Path(input_file)
# Security: restrict to data directory # Security: restrict offline decode inputs to application-owned
allowed_base = Path(__file__).resolve().parent.parent / 'data' # capture directories so external paths cannot be injected.
try: try:
resolved = input_path.resolve() resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base): if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
logger.warning(f"Path traversal blocked in start_from_file: {input_file}") logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
msg = 'Input file must be under the data/ directory' msg = 'Input file must be under INTERCEPT data or ground-station recordings'
self._emit_progress(CaptureProgress( self._emit_progress(CaptureProgress(
status='error', status='error',
message=msg, message=msg,
@@ -312,6 +318,8 @@ class WeatherSatDecoder:
self._device_index = -1 # Offline decode does not claim an SDR device self._device_index = -1 # Offline decode does not claim an SDR device
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'decoding' self._capture_phase = 'decoding'
self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear() self._stop_event.clear()
try: try:
@@ -360,7 +368,7 @@ class WeatherSatDecoder:
"""Start weather satellite capture and decode. """Start weather satellite capture and decode.
Args: Args:
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3') satellite: Satellite key (for example ``'METEOR-M2-3'``)
device_index: RTL-SDR device index device_index: RTL-SDR device index
gain: SDR gain in dB gain: SDR gain in dB
sample_rate: Sample rate in Hz sample_rate: Sample rate in Hz
@@ -406,6 +414,8 @@ class WeatherSatDecoder:
self._device_index = device_index self._device_index = device_index
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'tuning' self._capture_phase = 'tuning'
self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear() self._stop_event.clear()
try: try:
@@ -887,6 +897,7 @@ class WeatherSatDecoder:
process.kill() process.kill()
process.wait() process.wait()
retcode = process.returncode if process else None retcode = process.returncode if process else None
self._last_process_returncode = retcode
if retcode and retcode != 0: if retcode and retcode != 0:
self._capture_phase = 'error' self._capture_phase = 'error'
self._emit_progress(CaptureProgress( self._emit_progress(CaptureProgress(
@@ -1134,6 +1145,8 @@ class WeatherSatDecoder:
def _emit_progress(self, progress: CaptureProgress) -> None: def _emit_progress(self, progress: CaptureProgress) -> None:
"""Emit progress update to callback.""" """Emit progress update to callback."""
if progress.status == 'error' and progress.message:
self._last_error_message = str(progress.message)
if self._callback: if self._callback:
try: try:
self._callback(progress) self._callback(progress)
@@ -1153,8 +1166,11 @@ class WeatherSatDecoder:
'satellite': self._current_satellite, 'satellite': self._current_satellite,
'frequency': self._current_frequency, 'frequency': self._current_frequency,
'mode': self._current_mode, 'mode': self._current_mode,
'capture_phase': self._capture_phase,
'elapsed_seconds': elapsed, 'elapsed_seconds': elapsed,
'image_count': len(self._images), 'image_count': len(self._images),
'last_error': self._last_error_message,
'last_returncode': self._last_process_returncode,
} }
+170 -160
View File
@@ -1,38 +1,45 @@
"""Weather satellite pass prediction utility. """Weather satellite pass prediction utility.
Shared prediction logic used by both the API endpoint and the auto-scheduler. Self-contained pass prediction for NOAA/Meteor weather satellites. Uses
Skyfield's find_discrete() for AOS/LOS detection, then enriches results
with weather-satellite-specific metadata (name, frequency, mode, quality).
""" """
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import time
from typing import Any from typing import Any
from skyfield.api import EarthSatellite, load, wgs84
from skyfield.searchlib import find_discrete
from data.satellites import TLE_SATELLITES
from utils.logging import get_logger from utils.logging import get_logger
from utils.weather_sat import WEATHER_SATELLITES from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict') logger = get_logger('intercept.weather_sat_predict')
# Cache skyfield timescale to avoid re-downloading/re-parsing per request # Live TLE cache — populated by routes/satellite.py at startup.
_cached_timescale = None # Module-level so tests can patch it with patch('utils.weather_sat_predict._tle_cache', ...).
_tle_cache: dict = {}
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
_cached_timescale = load.timescale()
return _cached_timescale
def _format_utc_iso(dt: datetime.datetime) -> str: def _format_utc_iso(dt: datetime.datetime) -> str:
"""Return an ISO8601 UTC timestamp with a single timezone designator.""" """Format a datetime as a UTC ISO 8601 string ending with 'Z'.
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc) Handles both aware (UTC) and naive (assumed UTC) datetimes, producing a
else: consistent ``YYYY-MM-DDTHH:MM:SSZ`` string without ``+00:00`` suffixes.
dt = dt.astimezone(datetime.timezone.utc) """
return dt.isoformat().replace('+00:00', 'Z') if dt.tzinfo is not None:
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
def _get_tle_source() -> dict:
"""Return the best available TLE source (live cache preferred over static data)."""
if _tle_cache:
return _tle_cache
return TLE_SATELLITES
def predict_passes( def predict_passes(
@@ -49,170 +56,173 @@ def predict_passes(
lat: Observer latitude (-90 to 90) lat: Observer latitude (-90 to 90)
lon: Observer longitude (-180 to 180) lon: Observer longitude (-180 to 180)
hours: Hours ahead to predict (1-72) hours: Hours ahead to predict (1-72)
min_elevation: Minimum max elevation in degrees (0-90) min_elevation: Minimum peak elevation in degrees (0-90)
include_trajectory: Include az/el trajectory points (30 points) include_trajectory: Include 30-point az/el trajectory for polar plot
include_ground_track: Include lat/lon ground track points (60 points) include_ground_track: Include 60-point lat/lon ground track for map
Returns: Returns:
List of pass dicts sorted by start time. List of pass dicts sorted by start time, each containing:
id, satellite, name, frequency, mode, startTime, startTimeISO,
Raises: endTimeISO, maxEl, maxElAz, riseAz, setAz, duration, quality,
ImportError: If skyfield is not installed. and optionally trajectory/groundTrack.
""" """
from skyfield.almanac import find_discrete # Raise ImportError early if skyfield has been disabled (e.g., in tests that
from skyfield.api import EarthSatellite, wgs84 # patch sys.modules to simulate skyfield being unavailable).
import skyfield # noqa: F401
from data.satellites import TLE_SATELLITES ts = load.timescale(builtin=True)
# Use live TLE cache from satellite module if available (refreshed from CelesTrak).
# Cache the reference locally so repeated calls don't re-import each time.
tle_source = TLE_SATELLITES
if not hasattr(predict_passes, '_tle_ref') or \
(time.time() - getattr(predict_passes, '_tle_ref_ts', 0)) > 3600:
try:
from routes.satellite import _tle_cache
if _tle_cache:
predict_passes._tle_ref = _tle_cache
predict_passes._tle_ref_ts = time.time()
except ImportError:
pass
if hasattr(predict_passes, '_tle_ref') and predict_passes._tle_ref:
tle_source = predict_passes._tle_ref
ts = _get_timescale()
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
t0 = ts.now() t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours)) t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
tle_source = _get_tle_source()
all_passes: list[dict[str, Any]] = [] all_passes: list[dict[str, Any]] = []
for sat_key, sat_info in WEATHER_SATELLITES.items(): for sat_key, sat_info in WEATHER_SATELLITES.items():
if not sat_info['active']: if not sat_info['active']:
continue continue
tle_data = tle_source.get(sat_info['tle_key'])
if not tle_data:
continue
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
def above_horizon(t, _sat=satellite):
diff = _sat - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1 / 720
try: try:
times, events = find_discrete(t0, t1, above_horizon) tle_data = tle_source.get(sat_info['tle_key'])
except Exception: if not tle_data:
continue continue
i = 0 satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
while i < len(times): diff = satellite - observer
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)): def above_horizon(t, _diff=diff, _el=min_elevation):
if not events[j]: # Setting alt, _, _ = _diff.at(t).altaz()
set_time = times[j] return alt.degrees > _el
i = j
break
else:
i += 1
continue
if set_time is None: above_horizon.rough_period = 0.5 # Approximate orbital period in days
i += 1
continue
rise_dt = rise_time.utc_datetime() times, is_rising = find_discrete(t0, t1, above_horizon)
set_dt = set_time.utc_datetime()
duration_seconds = (
set_dt - rise_dt
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
# Calculate max elevation (always) and trajectory points (only if requested) rise_t = None
max_el = 0.0 for t, rising in zip(times, is_rising):
max_el_az = 0.0 if rising:
trajectory: list[dict[str, float]] = [] rise_t = t
num_traj_points = 30 elif rise_t is not None:
_process_pass(
for k in range(num_traj_points): sat_key, sat_info, satellite, diff, ts,
frac = k / (num_traj_points - 1) rise_t, t, min_elevation,
t_point = ts.utc( include_trajectory, include_ground_track,
rise_time.utc_datetime() all_passes,
+ datetime.timedelta(seconds=duration_seconds * frac)
) )
diff = satellite - observer rise_t = None
topocentric = diff.at(t_point)
alt, az, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
max_el_az = az.degrees
if include_trajectory:
trajectory.append({
'el': float(max(0, alt.degrees)),
'az': float(az.degrees),
})
if max_el < min_elevation: except Exception as exc:
i += 1 logger.debug('Error predicting passes for %s: %s', sat_key, exc)
continue continue
# Rise/set azimuths
rise_topo = (satellite - observer).at(rise_time)
_, rise_az, _ = rise_topo.altaz()
set_topo = (satellite - observer).at(set_time)
_, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = {
'id': f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}",
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': _format_utc_iso(rise_dt),
'endTimeISO': _format_utc_iso(set_dt),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),
'setAz': round(set_az.degrees, 1),
'duration': duration_minutes,
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
}
if include_trajectory:
pass_data['trajectory'] = trajectory
if include_ground_track:
ground_track: list[dict[str, float]] = []
for k in range(60):
frac = k / 59
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
geocentric = satellite.at(t_point)
subpoint = wgs84.subpoint(geocentric)
ground_track.append({
'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees),
})
pass_data['groundTrack'] = ground_track
all_passes.append(pass_data)
i += 1
all_passes.sort(key=lambda p: p['startTimeISO']) all_passes.sort(key=lambda p: p['startTimeISO'])
return all_passes return all_passes
def _process_pass(
sat_key: str,
sat_info: dict,
satellite,
diff,
ts,
rise_t,
set_t,
min_elevation: float,
include_trajectory: bool,
include_ground_track: bool,
all_passes: list,
) -> None:
"""Sample a rise/set interval, build the pass dict, append to all_passes."""
rise_dt = rise_t.utc_datetime()
set_dt = set_t.utc_datetime()
duration_secs = (set_dt - rise_dt).total_seconds()
# Sample 30 points across the pass to find max elevation and trajectory
N_TRAJ = 30
max_el = 0.0
max_el_az = 0.0
traj_points = []
for i in range(N_TRAJ):
frac = i / (N_TRAJ - 1) if N_TRAJ > 1 else 0.0
t_pt = ts.tt_jd(rise_t.tt + frac * (set_t.tt - rise_t.tt))
try:
topo = diff.at(t_pt)
alt, az, _ = topo.altaz()
el = float(alt.degrees)
az_deg = float(az.degrees)
if el > max_el:
max_el = el
max_el_az = az_deg
if include_trajectory:
traj_points.append({'az': round(az_deg, 1), 'el': round(max(0.0, el), 1)})
except Exception:
pass
# Filter passes that never reach min_elevation
if max_el < min_elevation:
return
# AOS and LOS azimuths
try:
rise_az = float(diff.at(rise_t).altaz()[1].degrees)
except Exception:
rise_az = 0.0
try:
set_az = float(diff.at(set_t).altaz()[1].degrees)
except Exception:
set_az = 0.0
aos_iso = _format_utc_iso(rise_dt)
try:
pass_id = f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}"
except Exception:
pass_id = f"{sat_key}_{aos_iso}"
pass_dict: dict[str, Any] = {
'id': pass_id,
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': aos_iso,
'endTimeISO': _format_utc_iso(set_dt),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az, 1),
'setAz': round(set_az, 1),
'duration': round(duration_secs, 1),
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
# Backwards-compatible aliases used by weather_sat_scheduler and the frontend
'aosAz': round(rise_az, 1),
'losAz': round(set_az, 1),
'tcaAz': round(max_el_az, 1),
}
if include_trajectory:
pass_dict['trajectory'] = traj_points
if include_ground_track:
ground_track = []
N_TRACK = 60
for i in range(N_TRACK):
frac = i / (N_TRACK - 1) if N_TRACK > 1 else 0.0
t_pt = ts.tt_jd(rise_t.tt + frac * (set_t.tt - rise_t.tt))
try:
geocentric = satellite.at(t_pt)
subpoint = wgs84.subpoint(geocentric)
ground_track.append({
'lat': round(float(subpoint.latitude.degrees), 4),
'lon': round(float(subpoint.longitude.degrees), 4),
})
except Exception:
pass
pass_dict['groundTrack'] = ground_track
all_passes.append(pass_dict)
+9
View File
@@ -34,6 +34,7 @@ from .constants import (
SCAN_MODE_QUICK, SCAN_MODE_QUICK,
TOOL_TIMEOUT_DETECT, TOOL_TIMEOUT_DETECT,
WIFI_EMA_ALPHA, WIFI_EMA_ALPHA,
get_band_from_channel,
get_proximity_band, get_proximity_band,
get_signal_band, get_signal_band,
get_vendor_from_mac, get_vendor_from_mac,
@@ -821,6 +822,8 @@ class UnifiedWiFiScanner:
cmd.extend(['--band', 'bg']) cmd.extend(['--band', 'bg'])
elif band == '5': elif band == '5':
cmd.extend(['--band', 'a']) cmd.extend(['--band', 'a'])
else:
cmd.extend(['--band', 'abg'])
cmd.append(interface) cmd.append(interface)
@@ -958,6 +961,12 @@ class UnifiedWiFiScanner:
ap.last_seen = now ap.last_seen = now
ap.seen_count += 1 ap.seen_count += 1
# Update channel/band if now known (airodump-ng may report -1 or 0 before resolving)
if obs.channel and not ap.channel:
ap.channel = obs.channel
ap.frequency_mhz = obs.frequency_mhz
ap.band = get_band_from_channel(obs.channel)
# Update ESSID if revealed # Update ESSID if revealed
if obs.essid and ap.is_hidden: if obs.essid and ap.is_hidden:
ap.revealed_essid = obs.essid ap.revealed_essid = obs.essid